v2.0.5-rc (#41)
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				BlockStorage/repertory/pipeline/head This commit looks good
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	BlockStorage/repertory/pipeline/head This commit looks good
				
			Reviewed-on: #41
This commit is contained in:
		
							
								
								
									
										8
									
								
								web/repertory/.cspell/words.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								web/repertory/.cspell/words.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| autofocus | ||||
| canvaskit | ||||
| cupertino | ||||
| cupertinoicons | ||||
| fromargb | ||||
| onetwothree | ||||
| renterd | ||||
| rocksdb | ||||
							
								
								
									
										47
									
								
								web/repertory/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								web/repertory/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| # Miscellaneous | ||||
| *.class | ||||
| *.log | ||||
| *.pyc | ||||
| *.swp | ||||
| .DS_Store | ||||
| .atom/ | ||||
| .build/ | ||||
| .buildlog/ | ||||
| .history | ||||
| .svn/ | ||||
| .swiftpm/ | ||||
| migrate_working_dir/ | ||||
|  | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
| *.iws | ||||
| .idea/ | ||||
|  | ||||
| # The .vscode folder contains launch configuration and tasks you configure in | ||||
| # VS Code which you may wish to be included in version control, so this line | ||||
| # is commented out by default. | ||||
| #.vscode/ | ||||
|  | ||||
| # Flutter/Dart/Pub related | ||||
| **/doc/api/ | ||||
| **/ios/Flutter/.last_build_id | ||||
| .dart_tool/ | ||||
| .flutter-plugins | ||||
| .flutter-plugins-dependencies | ||||
| .pub-cache/ | ||||
| .pub/ | ||||
| /build/ | ||||
|  | ||||
| # Symbolication related | ||||
| app.*.symbols | ||||
|  | ||||
| # Obfuscation related | ||||
| app.*.map.json | ||||
|  | ||||
| # Android Studio will place build artifacts here | ||||
| /android/app/debug | ||||
| /android/app/profile | ||||
| /android/app/release | ||||
| .flutter-companion | ||||
| pubspec.lock | ||||
							
								
								
									
										30
									
								
								web/repertory/.metadata
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/repertory/.metadata
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # This file tracks properties of this Flutter project. | ||||
| # Used by Flutter tool to assess capabilities and perform upgrades etc. | ||||
| # | ||||
| # This file should be version controlled and should not be manually edited. | ||||
|  | ||||
| version: | ||||
|   revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" | ||||
|   channel: "stable" | ||||
|  | ||||
| project_type: app | ||||
|  | ||||
| # Tracks metadata for the flutter migrate command | ||||
| migration: | ||||
|   platforms: | ||||
|     - platform: root | ||||
|       create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 | ||||
|       base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 | ||||
|     - platform: web | ||||
|       create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 | ||||
|       base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 | ||||
|  | ||||
|   # User provided section | ||||
|  | ||||
|   # List of Local paths (relative to this file) that should be | ||||
|   # ignored by the migrate tool. | ||||
|   # | ||||
|   # Files that are not part of the templates will be ignored by default. | ||||
|   unmanaged_files: | ||||
|     - 'lib/main.dart' | ||||
|     - 'ios/Runner.xcodeproj/project.pbxproj' | ||||
							
								
								
									
										16
									
								
								web/repertory/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/repertory/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # repertory | ||||
|  | ||||
| A new Flutter project. | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| This project is a starting point for a Flutter application. | ||||
|  | ||||
| A few resources to get you started if this is your first Flutter project: | ||||
|  | ||||
| - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) | ||||
| - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) | ||||
|  | ||||
| For help getting started with Flutter development, view the | ||||
| [online documentation](https://docs.flutter.dev/), which offers tutorials, | ||||
| samples, guidance on mobile development, and a full API reference. | ||||
							
								
								
									
										28
									
								
								web/repertory/analysis_options.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								web/repertory/analysis_options.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| # This file configures the analyzer, which statically analyzes Dart code to | ||||
| # check for errors, warnings, and lints. | ||||
| # | ||||
| # The issues identified by the analyzer are surfaced in the UI of Dart-enabled | ||||
| # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be | ||||
| # invoked from the command line by running `flutter analyze`. | ||||
|  | ||||
| # The following line activates a set of recommended lints for Flutter apps, | ||||
| # packages, and plugins designed to encourage good coding practices. | ||||
| include: package:flutter_lints/flutter.yaml | ||||
|  | ||||
| linter: | ||||
|   # The lint rules applied to this project can be customized in the | ||||
|   # section below to disable rules from the `package:flutter_lints/flutter.yaml` | ||||
|   # included above or to enable additional rules. A list of all available lints | ||||
|   # and their documentation is published at https://dart.dev/lints. | ||||
|   # | ||||
|   # Instead of disabling a lint rule for the entire project in the | ||||
|   # section below, it can also be suppressed for a single line of code | ||||
|   # or a specific dart file by using the `// ignore: name_of_lint` and | ||||
|   # `// ignore_for_file: name_of_lint` syntax on the line or in the file | ||||
|   # producing the lint. | ||||
|   rules: | ||||
|     # avoid_print: false  # Uncomment to disable the `avoid_print` rule | ||||
|     # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule | ||||
|  | ||||
| # Additional information about this file can be found at | ||||
| # https://dart.dev/guides/language/analysis-options | ||||
							
								
								
									
										24
									
								
								web/repertory/lib/constants.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								web/repertory/lib/constants.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import 'package:flutter/material.dart' show GlobalKey, NavigatorState; | ||||
| import 'package:sodium_libs/sodium_libs.dart'; | ||||
|  | ||||
| const addMountTitle = 'Add New Mount'; | ||||
| const appLogonTitle = 'Repertory Portal Login'; | ||||
| const appSettingsTitle = 'Portal Settings'; | ||||
| const appTitle = 'Repertory Management Portal'; | ||||
| const logonWidth = 300.0; | ||||
| const databaseTypeList = ['rocksdb', 'sqlite']; | ||||
| const downloadTypeList = ['default', 'direct', 'ring_buffer']; | ||||
| const eventLevelList = ['critical', 'error', 'warn', 'info', 'debug', 'trace']; | ||||
| const padding = 15.0; | ||||
| const protocolTypeList = ['http', 'https']; | ||||
| const providerTypeList = ['Encrypt', 'Remote', 'S3', 'Sia']; | ||||
| const ringBufferSizeList = ['128', '256', '512', '1024', '2048']; | ||||
|  | ||||
| final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); | ||||
|  | ||||
| Sodium? _sodium; | ||||
| void setSodium(Sodium sodium) { | ||||
|   _sodium = sodium; | ||||
| } | ||||
|  | ||||
| Sodium get sodium => _sodium!; | ||||
							
								
								
									
										402
									
								
								web/repertory/lib/helpers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										402
									
								
								web/repertory/lib/helpers.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,402 @@ | ||||
| import 'package:convert/convert.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:sodium_libs/sodium_libs.dart' show SecureKey, StringX; | ||||
|  | ||||
| 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': 'default'}, | ||||
|       }; | ||||
|   } | ||||
|  | ||||
|   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 basic authentication password"; | ||||
|     case 'ApiUser': | ||||
|       return "HTTP basic 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( | ||||
|   context, | ||||
|   List<String> available, { | ||||
|   bool allowEmpty = false, | ||||
|   String? location, | ||||
| }) async { | ||||
|   String? currentLocation = location; | ||||
|   final controller = TextEditingController(text: currentLocation); | ||||
|   return await showDialog( | ||||
|     context: context, | ||||
|     builder: (context) { | ||||
|       return 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), | ||||
|                     ) | ||||
|                     : DropdownButton<String>( | ||||
|                       hint: const Text("Select drive"), | ||||
|                       value: currentLocation, | ||||
|                       onChanged: | ||||
|                           (value) => setState(() => currentLocation = value), | ||||
|                       items: | ||||
|                           available.map<DropdownMenuItem<String>>((item) { | ||||
|                             return DropdownMenuItem<String>( | ||||
|                               value: item, | ||||
|                               child: Text(item), | ||||
|                             ); | ||||
|                           }).toList(), | ||||
|                     ), | ||||
|             title: const Text('Mount Location', textAlign: TextAlign.center), | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										126
									
								
								web/repertory/lib/main.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								web/repertory/lib/main.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/models/mount.dart'; | ||||
| import 'package:repertory/models/mount_list.dart'; | ||||
| import 'package:repertory/screens/add_mount_screen.dart'; | ||||
| import 'package:repertory/screens/auth_screen.dart'; | ||||
| import 'package:repertory/screens/edit_mount_screen.dart'; | ||||
| import 'package:repertory/screens/edit_settings_screen.dart'; | ||||
| import 'package:repertory/screens/home_screen.dart'; | ||||
| import 'package:sodium_libs/sodium_libs.dart' show SodiumInit; | ||||
|  | ||||
| void main() async { | ||||
|   try { | ||||
|     constants.setSodium(await SodiumInit.init()); | ||||
|   } catch (e) { | ||||
|     debugPrint('$e'); | ||||
|   } | ||||
|  | ||||
|   final auth = Auth(); | ||||
|   runApp( | ||||
|     MultiProvider( | ||||
|       providers: [ | ||||
|         ChangeNotifierProvider(create: (_) => auth), | ||||
|         ChangeNotifierProvider(create: (_) => MountList(auth)), | ||||
|       ], | ||||
|       child: const MyApp(), | ||||
|     ), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class MyApp extends StatefulWidget { | ||||
|   const MyApp({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<MyApp> createState() => _MyAppState(); | ||||
| } | ||||
|  | ||||
| class _MyAppState extends State<MyApp> { | ||||
|   @override | ||||
|   Widget build(context) { | ||||
|     final snackBarTheme = SnackBarThemeData( | ||||
|       width: MediaQuery.of(context).size.width * 0.50, | ||||
|       behavior: SnackBarBehavior.floating, | ||||
|     ); | ||||
|  | ||||
|     return MaterialApp( | ||||
|       navigatorKey: constants.navigatorKey, | ||||
|       themeMode: ThemeMode.dark, | ||||
|       darkTheme: ThemeData( | ||||
|         useMaterial3: true, | ||||
|         brightness: Brightness.dark, | ||||
|         colorScheme: ColorScheme.fromSeed( | ||||
|           brightness: Brightness.dark, | ||||
|           onSurface: Colors.white70, | ||||
|           seedColor: Colors.deepOrange, | ||||
|           surface: Color.fromARGB(255, 32, 33, 36), | ||||
|           surfaceContainerLow: Color.fromARGB(255, 41, 42, 45), | ||||
|         ), | ||||
|         snackBarTheme: snackBarTheme, | ||||
|       ), | ||||
|       title: constants.appTitle, | ||||
|       initialRoute: '/auth', | ||||
|       routes: { | ||||
|         '/': | ||||
|             (context) => | ||||
|                 const AuthCheck(child: HomeScreen(title: constants.appTitle)), | ||||
|         '/add': | ||||
|             (context) => const AuthCheck( | ||||
|               child: AddMountScreen(title: constants.addMountTitle), | ||||
|             ), | ||||
|         '/auth': (context) => const AuthScreen(title: constants.appTitle), | ||||
|         '/settings': | ||||
|             (context) => const AuthCheck( | ||||
|               child: EditSettingsScreen(title: constants.appSettingsTitle), | ||||
|             ), | ||||
|       }, | ||||
|       onGenerateRoute: (settings) { | ||||
|         if (settings.name != '/edit') { | ||||
|           return null; | ||||
|         } | ||||
|  | ||||
|         final mount = settings.arguments as Mount; | ||||
|         return MaterialPageRoute( | ||||
|           builder: (context) { | ||||
|             return AuthCheck( | ||||
|               child: EditMountScreen( | ||||
|                 mount: mount, | ||||
|                 title: | ||||
|                     '${mount.provider} [${formatMountName(mount.type, mount.name)}] Settings', | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AuthCheck extends StatelessWidget { | ||||
|   final Widget child; | ||||
|   const AuthCheck({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Consumer<Auth>( | ||||
|       builder: (context, auth, __) { | ||||
|         if (!auth.authenticated) { | ||||
|           Future.delayed(Duration(milliseconds: 1), () { | ||||
|             if (constants.navigatorKey.currentContext == null) { | ||||
|               return; | ||||
|             } | ||||
|             Navigator.of( | ||||
|               constants.navigatorKey.currentContext!, | ||||
|             ).pushNamedAndRemoveUntil('/auth', (Route<dynamic> route) => false); | ||||
|           }); | ||||
|           return child; | ||||
|         } | ||||
|  | ||||
|         return child; | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										64
									
								
								web/repertory/lib/models/auth.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								web/repertory/lib/models/auth.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/mount_list.dart'; | ||||
| import 'package:sodium_libs/sodium_libs.dart'; | ||||
|  | ||||
| class Auth with ChangeNotifier { | ||||
|   bool _authenticated = false; | ||||
|   SecureKey _key = SecureKey.random(constants.sodium, 32); | ||||
|   String _user = ""; | ||||
|   MountList? mountList; | ||||
|  | ||||
|   bool get authenticated => _authenticated; | ||||
|   SecureKey get key => _key; | ||||
|  | ||||
|   Future<void> authenticate(String user, String password) async { | ||||
|     final sodium = constants.sodium; | ||||
|  | ||||
|     final keyHash = sodium.crypto.genericHash( | ||||
|       outLen: sodium.crypto.aeadXChaCha20Poly1305IETF.keyBytes, | ||||
|       message: Uint8List.fromList(password.toCharArray()), | ||||
|     ); | ||||
|  | ||||
|     _authenticated = true; | ||||
|     _key = SecureKey.fromList(sodium, keyHash); | ||||
|     _user = user; | ||||
|  | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<String> createAuth() async { | ||||
|     try { | ||||
|       final response = await http.get( | ||||
|         Uri.parse(Uri.encodeFull('${getBaseUri()}/api/v1/nonce')), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         logoff(); | ||||
|         return ""; | ||||
|       } | ||||
|  | ||||
|       final nonce = jsonDecode(response.body)["nonce"]; | ||||
|       return encryptValue('${_user}_$nonce', key); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     return ""; | ||||
|   } | ||||
|  | ||||
|   void logoff() { | ||||
|     _authenticated = false; | ||||
|     _key = SecureKey.random(constants.sodium, 32); | ||||
|     _user = ""; | ||||
|  | ||||
|     notifyListeners(); | ||||
|  | ||||
|     mountList?.clear(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										285
									
								
								web/repertory/lib/models/mount.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								web/repertory/lib/models/mount.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,285 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/models/mount_list.dart'; | ||||
| import 'package:repertory/types/mount_config.dart'; | ||||
|  | ||||
| class Mount with ChangeNotifier { | ||||
|   final Auth _auth; | ||||
|   final MountConfig mountConfig; | ||||
|   final MountList? _mountList; | ||||
|   bool _isMounting = false; | ||||
|   bool _isRefreshing = false; | ||||
|  | ||||
|   Mount(this._auth, this.mountConfig, this._mountList, {isAdd = false}) { | ||||
|     if (isAdd) { | ||||
|       return; | ||||
|     } | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   String? get bucket => mountConfig.bucket; | ||||
|   String get id => '${type}_$name'; | ||||
|   bool? get mounted => mountConfig.mounted; | ||||
|   String get name => mountConfig.name; | ||||
|   String get path => mountConfig.path; | ||||
|   String get provider => mountConfig.provider; | ||||
|   String get type => mountConfig.type; | ||||
|  | ||||
|   Future<void> _fetch() async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/mount?auth=$auth&name=$name&type=$type', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         _mountList?.reset(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (_isMounting) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       mountConfig.updateSettings(jsonDecode(response.body)); | ||||
|       notifyListeners(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetchStatus() async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/mount_status?auth=$auth&name=$name&type=$type', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         _mountList?.reset(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (_isMounting) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       mountConfig.updateStatus(jsonDecode(response.body)); | ||||
|       notifyListeners(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> setMountLocation(String location) async { | ||||
|     try { | ||||
|       mountConfig.path = location; | ||||
|  | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.put( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/mount_location?auth=$auth&name=$name&type=$type&location=$location', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         _mountList?.reset(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       return refresh(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<String>> getAvailableLocations() async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull('${getBaseUri()}/api/v1/locations?auth=$auth'), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return <String>[]; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return <String>[]; | ||||
|       } | ||||
|  | ||||
|       return (jsonDecode(response.body) as List).cast<String>(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     return <String>[]; | ||||
|   } | ||||
|  | ||||
|   Future<String?> getMountLocation() async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/mount_location?auth=$auth&name=$name&type=$type', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|       final location = jsonDecode(response.body)['Location'] as String; | ||||
|       return location.trim().isEmpty ? null : location; | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   Future<bool> mount(bool unmount, {String? location}) async { | ||||
|     try { | ||||
|       _isMounting = true; | ||||
|  | ||||
|       mountConfig.mounted = null; | ||||
|       notifyListeners(); | ||||
|  | ||||
|       var count = 0; | ||||
|       while (_isRefreshing && count++ < 10) { | ||||
|         await Future.delayed(Duration(seconds: 1)); | ||||
|       } | ||||
|  | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.post( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/mount?auth=$auth&unmount=$unmount&name=$name&type=$type&location=$location', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         displayAuthError(_auth); | ||||
|         _auth.logoff(); | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         _isMounting = false; | ||||
|         _mountList?.reset(); | ||||
|         return true; | ||||
|       } | ||||
|  | ||||
|       final badLocation = (!unmount && response.statusCode == 500); | ||||
|       if (badLocation) { | ||||
|         mountConfig.path = ""; | ||||
|       } | ||||
|  | ||||
|       await refresh(force: true); | ||||
|       _isMounting = false; | ||||
|  | ||||
|       return !badLocation; | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     _isMounting = false; | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   Future<void> refresh({bool force = false}) async { | ||||
|     if (!force && (_isMounting || _isRefreshing)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     _isRefreshing = true; | ||||
|  | ||||
|     try { | ||||
|       await _fetch(); | ||||
|       await _fetchStatus(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     _isRefreshing = false; | ||||
|   } | ||||
|  | ||||
|   Future<void> setValue(String key, String value) async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.put( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/set_value_by_name?auth=$auth&name=$name&type=$type&key=$key&value=$value', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         _auth.logoff(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         _mountList?.reset(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       return refresh(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										201
									
								
								web/repertory/lib/models/mount_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								web/repertory/lib/models/mount_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart' show ModalRoute; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/models/mount.dart'; | ||||
| import 'package:repertory/types/mount_config.dart'; | ||||
|  | ||||
| class MountList with ChangeNotifier { | ||||
|   final Auth _auth; | ||||
|  | ||||
|   MountList(this._auth) { | ||||
|     _auth.mountList = this; | ||||
|     _auth.addListener(() { | ||||
|       if (_auth.authenticated) { | ||||
|         _fetch(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   List<Mount> _mountList = []; | ||||
|  | ||||
|   Auth get auth => _auth; | ||||
|  | ||||
|   UnmodifiableListView<Mount> get items => | ||||
|       UnmodifiableListView<Mount>(_mountList); | ||||
|  | ||||
|   bool hasBucketName(String mountType, String bucket, {String? excludeName}) { | ||||
|     final list = items.where( | ||||
|       (item) => item.type.toLowerCase() == mountType.toLowerCase(), | ||||
|     ); | ||||
|  | ||||
|     return (excludeName == null | ||||
|                 ? list | ||||
|                 : list.whereNot( | ||||
|                   (item) => | ||||
|                       item.name.toLowerCase() == excludeName.toLowerCase(), | ||||
|                 )) | ||||
|             .firstWhereOrNull((Mount item) { | ||||
|               return item.bucket != null && | ||||
|                   item.bucket!.toLowerCase() == bucket.toLowerCase(); | ||||
|             }) != | ||||
|         null; | ||||
|   } | ||||
|  | ||||
|   bool hasConfigName(String name) { | ||||
|     return items.firstWhereOrNull( | ||||
|           (item) => item.name.toLowerCase() == name.toLowerCase(), | ||||
|         ) != | ||||
|         null; | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetch() async { | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse('${getBaseUri()}/api/v1/mount_list?auth=$auth'), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         displayAuthError(_auth); | ||||
|         _auth.logoff(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode == 404) { | ||||
|         reset(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       List<Mount> nextList = []; | ||||
|  | ||||
|       jsonDecode(response.body).forEach((type, value) { | ||||
|         nextList.addAll( | ||||
|           value | ||||
|               .map( | ||||
|                 (name) => | ||||
|                     Mount(_auth, MountConfig(type: type, name: name), this), | ||||
|               ) | ||||
|               .toList(), | ||||
|         ); | ||||
|       }); | ||||
|       _sort(nextList); | ||||
|       _mountList = nextList; | ||||
|  | ||||
|       notifyListeners(); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _sort(list) { | ||||
|     list.sort((a, b) { | ||||
|       final res = a.type.compareTo(b.type); | ||||
|       if (res != 0) { | ||||
|         return res; | ||||
|       } | ||||
|  | ||||
|       return a.name.compareTo(b.name); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<bool> add( | ||||
|     String type, | ||||
|     String name, | ||||
|     Map<String, dynamic> settings, | ||||
|   ) async { | ||||
|     var ret = false; | ||||
|  | ||||
|     var apiPort = settings['ApiPort'] ?? 10000; | ||||
|     for (var mount in _mountList) { | ||||
|       var port = mount.mountConfig.settings['ApiPort'] as int?; | ||||
|       if (port != null) { | ||||
|         apiPort = max(apiPort, port + 1); | ||||
|       } | ||||
|     } | ||||
|     settings["ApiPort"] = apiPort; | ||||
|  | ||||
|     displayError() { | ||||
|       if (constants.navigatorKey.currentContext == null) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       displayErrorMessage( | ||||
|         constants.navigatorKey.currentContext!, | ||||
|         'Add mount failed. Please try again.', | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       final auth = await _auth.createAuth(); | ||||
|       final map = await convertAllToString( | ||||
|         jsonDecode(jsonEncode(settings)), | ||||
|         _auth.key, | ||||
|       ); | ||||
|       final response = await http.post( | ||||
|         Uri.parse( | ||||
|           Uri.encodeFull( | ||||
|             '${getBaseUri()}/api/v1/add_mount?auth=$auth&name=$name&type=$type&config=${jsonEncode(map)}', | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       switch (response.statusCode) { | ||||
|         case 200: | ||||
|           ret = true; | ||||
|           break; | ||||
|         case 401: | ||||
|           displayAuthError(_auth); | ||||
|           _auth.logoff(); | ||||
|           break; | ||||
|         case 404: | ||||
|           reset(); | ||||
|           break; | ||||
|         default: | ||||
|           displayError(); | ||||
|           break; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|       displayError(); | ||||
|     } | ||||
|  | ||||
|     if (ret) { | ||||
|       await _fetch(); | ||||
|     } | ||||
|  | ||||
|     return ret; | ||||
|   } | ||||
|  | ||||
|   void clear() { | ||||
|     _mountList = []; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> reset() async { | ||||
|     if (constants.navigatorKey.currentContext == null || | ||||
|         ModalRoute.of(constants.navigatorKey.currentContext!)?.settings.name != | ||||
|             '/') { | ||||
|       await constants.navigatorKey.currentState?.pushReplacementNamed('/'); | ||||
|     } | ||||
|  | ||||
|     displayErrorMessage( | ||||
|       constants.navigatorKey.currentContext!, | ||||
|       'Mount removed externally. Reloading...', | ||||
|     ); | ||||
|  | ||||
|     clear(); | ||||
|  | ||||
|     return _fetch(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										245
									
								
								web/repertory/lib/screens/add_mount_screen.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								web/repertory/lib/screens/add_mount_screen.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,245 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/models/mount.dart'; | ||||
| import 'package:repertory/models/mount_list.dart'; | ||||
| import 'package:repertory/types/mount_config.dart'; | ||||
| import 'package:repertory/widgets/mount_settings.dart'; | ||||
|  | ||||
| class AddMountScreen extends StatefulWidget { | ||||
|   final String title; | ||||
|   const AddMountScreen({super.key, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<AddMountScreen> createState() => _AddMountScreenState(); | ||||
| } | ||||
|  | ||||
| class _AddMountScreenState extends State<AddMountScreen> { | ||||
|   Mount? _mount; | ||||
|   final _mountNameController = TextEditingController(); | ||||
|   String _mountType = ""; | ||||
|   final Map<String, Map<String, dynamic>> _settings = { | ||||
|     "": {}, | ||||
|     "Encrypt": createDefaultSettings("Encrypt"), | ||||
|     "Remote": createDefaultSettings("Remote"), | ||||
|     "S3": createDefaultSettings("S3"), | ||||
|     "Sia": createDefaultSettings("Sia"), | ||||
|   }; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         title: Text(widget.title), | ||||
|         actions: [ | ||||
|           Consumer<Auth>( | ||||
|             builder: (context, auth, _) { | ||||
|               return IconButton( | ||||
|                 icon: const Icon(Icons.logout), | ||||
|                 onPressed: () => auth.logoff(), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       body: Padding( | ||||
|         padding: const EdgeInsets.all(constants.padding), | ||||
|         child: Consumer<Auth>( | ||||
|           builder: (context, auth, _) { | ||||
|             return Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               mainAxisAlignment: MainAxisAlignment.start, | ||||
|               mainAxisSize: MainAxisSize.max, | ||||
|               children: [ | ||||
|                 Card( | ||||
|                   margin: EdgeInsets.all(0.0), | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(constants.padding), | ||||
|                     child: Row( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       mainAxisAlignment: MainAxisAlignment.start, | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         const Text('Provider Type'), | ||||
|                         const SizedBox(width: constants.padding), | ||||
|                         DropdownButton<String>( | ||||
|                           autofocus: true, | ||||
|                           value: _mountType, | ||||
|                           onChanged: | ||||
|                               (mountType) => | ||||
|                                   _handleChange(auth, mountType ?? ''), | ||||
|                           items: | ||||
|                               constants.providerTypeList | ||||
|                                   .map<DropdownMenuItem<String>>((item) { | ||||
|                                     return DropdownMenuItem<String>( | ||||
|                                       value: item, | ||||
|                                       child: Text(item), | ||||
|                                     ); | ||||
|                                   }) | ||||
|                                   .toList(), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 if (_mountType.isNotEmpty && _mountType != 'Remote') | ||||
|                   const SizedBox(height: constants.padding), | ||||
|                 if (_mountType.isNotEmpty && _mountType != 'Remote') | ||||
|                   Card( | ||||
|                     margin: EdgeInsets.all(0.0), | ||||
|                     child: Padding( | ||||
|                       padding: const EdgeInsets.all(constants.padding), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         mainAxisAlignment: MainAxisAlignment.start, | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           const Text('Configuration Name'), | ||||
|                           const SizedBox(width: constants.padding), | ||||
|                           TextField( | ||||
|                             autofocus: true, | ||||
|                             controller: _mountNameController, | ||||
|                             keyboardType: TextInputType.text, | ||||
|                             inputFormatters: [ | ||||
|                               FilteringTextInputFormatter.deny(RegExp(r'\s')), | ||||
|                             ], | ||||
|                             onChanged: (_) => _handleChange(auth, _mountType), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 if (_mount != null) const SizedBox(height: constants.padding), | ||||
|                 if (_mount != null) | ||||
|                   Expanded( | ||||
|                     child: Card( | ||||
|                       margin: EdgeInsets.all(0.0), | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.all(constants.padding), | ||||
|                         child: MountSettingsWidget( | ||||
|                           isAdd: true, | ||||
|                           mount: _mount!, | ||||
|                           settings: _settings[_mountType]!, | ||||
|                           showAdvanced: false, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 if (_mount != null) const SizedBox(height: constants.padding), | ||||
|                 if (_mount != null) | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       ElevatedButton.icon( | ||||
|                         label: const Text('Add'), | ||||
|                         icon: const Icon(Icons.add), | ||||
|                         onPressed: () async { | ||||
|                           final mountList = Provider.of<MountList>(context); | ||||
|  | ||||
|                           List<String> failed = []; | ||||
|                           if (!validateSettings( | ||||
|                             _settings[_mountType]!, | ||||
|                             failed, | ||||
|                           )) { | ||||
|                             for (var key in failed) { | ||||
|                               displayErrorMessage( | ||||
|                                 context, | ||||
|                                 "Setting '$key' is not valid", | ||||
|                               ); | ||||
|                             } | ||||
|                             return; | ||||
|                           } | ||||
|  | ||||
|                           if (mountList.hasConfigName( | ||||
|                             _mountNameController.text, | ||||
|                           )) { | ||||
|                             return displayErrorMessage( | ||||
|                               context, | ||||
|                               "Configuration name '${_mountNameController.text}' already exists", | ||||
|                             ); | ||||
|                           } | ||||
|  | ||||
|                           if (_mountType == "Sia" || _mountType == "S3") { | ||||
|                             final bucket = | ||||
|                                 _settings[_mountType]!["${_mountType}Config"]["Bucket"] | ||||
|                                     as String; | ||||
|                             if (mountList.hasBucketName(_mountType, bucket)) { | ||||
|                               return displayErrorMessage( | ||||
|                                 context, | ||||
|                                 "Bucket '$bucket' already exists", | ||||
|                               ); | ||||
|                             } | ||||
|                           } | ||||
|  | ||||
|                           final success = await mountList.add( | ||||
|                             _mountType, | ||||
|                             _mountType == 'Remote' | ||||
|                                 ? '${_settings[_mountType]!['RemoteConfig']['HostNameOrIp']}_${_settings[_mountType]!['RemoteConfig']['ApiPort']}' | ||||
|                                 : _mountNameController.text, | ||||
|                             _settings[_mountType]!, | ||||
|                           ); | ||||
|  | ||||
|                           if (!success || !context.mounted) { | ||||
|                             return; | ||||
|                           } | ||||
|  | ||||
|                           Navigator.pop(context); | ||||
|                         }, | ||||
|                       ), | ||||
|                       if (_mountType == 'Sia' || _mountType == 'S3') ...[ | ||||
|                         const SizedBox(width: constants.padding), | ||||
|                         ElevatedButton.icon( | ||||
|                           label: const Text('Test'), | ||||
|                           icon: const Icon(Icons.check), | ||||
|                           onPressed: () async {}, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ], | ||||
|                   ), | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _handleChange(Auth auth, String mountType) { | ||||
|     setState(() { | ||||
|       final changed = _mountType != mountType; | ||||
|  | ||||
|       _mountType = mountType; | ||||
|       if (_mountType == 'Remote') { | ||||
|         _mountNameController.text = 'remote'; | ||||
|       } else if (changed) { | ||||
|         _mountNameController.text = mountType == 'Sia' ? 'default' : ''; | ||||
|       } | ||||
|  | ||||
|       _mount = | ||||
|           (_mountNameController.text.isEmpty) | ||||
|               ? null | ||||
|               : Mount( | ||||
|                 auth, | ||||
|                 MountConfig( | ||||
|                   name: _mountNameController.text, | ||||
|                   settings: _settings[mountType], | ||||
|                   type: mountType, | ||||
|                 ), | ||||
|                 null, | ||||
|                 isAdd: true, | ||||
|               ); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										116
									
								
								web/repertory/lib/screens/auth_screen.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								web/repertory/lib/screens/auth_screen.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
|  | ||||
| class AuthScreen extends StatefulWidget { | ||||
|   final String title; | ||||
|   const AuthScreen({super.key, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<AuthScreen> createState() => _AuthScreenState(); | ||||
| } | ||||
|  | ||||
| class _AuthScreenState extends State<AuthScreen> { | ||||
|   bool _enabled = true; | ||||
|   final _passwordController = TextEditingController(); | ||||
|   final _userController = TextEditingController(); | ||||
|  | ||||
|   @override | ||||
|   Widget build(context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         title: Text(widget.title), | ||||
|       ), | ||||
|       body: Consumer<Auth>( | ||||
|         builder: (context, auth, _) { | ||||
|           if (auth.authenticated) { | ||||
|             Future.delayed(Duration(milliseconds: 1), () { | ||||
|               if (constants.navigatorKey.currentContext == null) { | ||||
|                 return; | ||||
|               } | ||||
|  | ||||
|               Navigator.of( | ||||
|                 constants.navigatorKey.currentContext!, | ||||
|               ).pushNamedAndRemoveUntil('/', (Route<dynamic> route) => false); | ||||
|             }); | ||||
|             return SizedBox.shrink(); | ||||
|           } | ||||
|  | ||||
|           createLoginHandler() { | ||||
|             return _enabled | ||||
|                 ? () async { | ||||
|                   setState(() => _enabled = false); | ||||
|                   await auth.authenticate( | ||||
|                     _userController.text, | ||||
|                     _passwordController.text, | ||||
|                   ); | ||||
|                   setState(() => _enabled = true); | ||||
|                 } | ||||
|                 : null; | ||||
|           } | ||||
|  | ||||
|           return Center( | ||||
|             child: Card( | ||||
|               child: Padding( | ||||
|                 padding: const EdgeInsets.all(constants.padding), | ||||
|                 child: SizedBox( | ||||
|                   width: constants.logonWidth, | ||||
|                   child: Column( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     mainAxisAlignment: MainAxisAlignment.start, | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         constants.appLogonTitle, | ||||
|                         textAlign: TextAlign.center, | ||||
|                         style: Theme.of(context).textTheme.titleLarge, | ||||
|                       ), | ||||
|                       const SizedBox(height: constants.padding), | ||||
|                       TextField( | ||||
|                         autofocus: true, | ||||
|                         decoration: InputDecoration(labelText: 'Username'), | ||||
|                         controller: _userController, | ||||
|                         textInputAction: TextInputAction.next, | ||||
|                       ), | ||||
|                       const SizedBox(height: constants.padding), | ||||
|                       TextField( | ||||
|                         obscureText: true, | ||||
|                         decoration: InputDecoration(labelText: 'Password'), | ||||
|                         controller: _passwordController, | ||||
|                         textInputAction: TextInputAction.go, | ||||
|                         onSubmitted: (_) { | ||||
|                           final handler = createLoginHandler(); | ||||
|                           if (handler == null) { | ||||
|                             return; | ||||
|                           } | ||||
|  | ||||
|                           handler(); | ||||
|                         }, | ||||
|                       ), | ||||
|                       const SizedBox(height: constants.padding), | ||||
|                       ElevatedButton( | ||||
|                         onPressed: createLoginHandler(), | ||||
|                         child: const Text('Login'), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										70
									
								
								web/repertory/lib/screens/edit_mount_screen.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								web/repertory/lib/screens/edit_mount_screen.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/models/mount.dart'; | ||||
| import 'package:repertory/widgets/mount_settings.dart'; | ||||
|  | ||||
| class EditMountScreen extends StatefulWidget { | ||||
|   final Mount mount; | ||||
|   final String title; | ||||
|   const EditMountScreen({super.key, required this.mount, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<EditMountScreen> createState() => _EditMountScreenState(); | ||||
| } | ||||
|  | ||||
| class _EditMountScreenState extends State<EditMountScreen> { | ||||
|   bool _showAdvanced = false; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         title: Text(widget.title), | ||||
|         actions: [ | ||||
|           Row( | ||||
|             children: [ | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   const Text("Advanced"), | ||||
|                   IconButton( | ||||
|                     icon: Icon( | ||||
|                       _showAdvanced ? Icons.toggle_on : Icons.toggle_off, | ||||
|                     ), | ||||
|                     onPressed: | ||||
|                         () => setState(() => _showAdvanced = !_showAdvanced), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               Consumer<Auth>( | ||||
|                 builder: (context, auth, _) { | ||||
|                   return IconButton( | ||||
|                     icon: const Icon(Icons.logout), | ||||
|                     onPressed: () => auth.logoff(), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       body: MountSettingsWidget( | ||||
|         mount: widget.mount, | ||||
|         settings: jsonDecode(jsonEncode(widget.mount.mountConfig.settings)), | ||||
|         showAdvanced: _showAdvanced, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										87
									
								
								web/repertory/lib/screens/edit_settings_screen.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								web/repertory/lib/screens/edit_settings_screen.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| import 'dart:convert' show jsonDecode, jsonEncode; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/widgets/ui_settings.dart'; | ||||
|  | ||||
| class EditSettingsScreen extends StatefulWidget { | ||||
|   final String title; | ||||
|   const EditSettingsScreen({super.key, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<EditSettingsScreen> createState() => _EditSettingsScreenState(); | ||||
| } | ||||
|  | ||||
| class _EditSettingsScreenState extends State<EditSettingsScreen> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         title: Text(widget.title), | ||||
|         actions: [ | ||||
|           Consumer<Auth>( | ||||
|             builder: (context, auth, _) { | ||||
|               return IconButton( | ||||
|                 icon: const Icon(Icons.logout), | ||||
|                 onPressed: () => auth.logoff(), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       body: FutureBuilder( | ||||
|         builder: (context, snapshot) { | ||||
|           if (!snapshot.hasData) { | ||||
|             return Center(child: CircularProgressIndicator()); | ||||
|           } | ||||
|  | ||||
|           return UISettingsWidget( | ||||
|             origSettings: jsonDecode(jsonEncode(snapshot.requireData)), | ||||
|             settings: snapshot.requireData, | ||||
|             showAdvanced: false, | ||||
|           ); | ||||
|         }, | ||||
|         future: _grabSettings(), | ||||
|         initialData: <String, dynamic>{}, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<Map<String, dynamic>> _grabSettings() async { | ||||
|     try { | ||||
|       final authProvider = Provider.of<Auth>(context, listen: false); | ||||
|       final auth = await authProvider.createAuth(); | ||||
|       final response = await http.get( | ||||
|         Uri.parse('${getBaseUri()}/api/v1/settings?auth=$auth'), | ||||
|       ); | ||||
|  | ||||
|       if (response.statusCode == 401) { | ||||
|         authProvider.logoff(); | ||||
|         return {}; | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         return {}; | ||||
|       } | ||||
|  | ||||
|       return jsonDecode(response.body); | ||||
|     } catch (e) { | ||||
|       debugPrint('$e'); | ||||
|     } | ||||
|  | ||||
|     return {}; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										57
									
								
								web/repertory/lib/screens/home_screen.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/repertory/lib/screens/home_screen.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| 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/widgets/mount_list_widget.dart'; | ||||
|  | ||||
| class HomeScreen extends StatefulWidget { | ||||
|   final String title; | ||||
|   const HomeScreen({super.key, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<HomeScreen> createState() => _HomeScreeState(); | ||||
| } | ||||
|  | ||||
| class _HomeScreeState extends State<HomeScreen> { | ||||
|   @override | ||||
|   Widget build(context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         leading: IconButton( | ||||
|           onPressed: () => Navigator.pushNamed(context, '/settings'), | ||||
|           icon: const Icon(Icons.storage), | ||||
|         ), | ||||
|         title: Text(widget.title), | ||||
|         actions: [ | ||||
|           Consumer<Auth>( | ||||
|             builder: (context, auth, _) { | ||||
|               return IconButton( | ||||
|                 icon: const Icon(Icons.logout), | ||||
|                 onPressed: () => auth.logoff(), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       body: Padding( | ||||
|         padding: const EdgeInsets.all(constants.padding), | ||||
|         child: MountListWidget(), | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: () => Navigator.pushNamed(context, '/add'), | ||||
|         tooltip: 'Add Mount', | ||||
|         child: const Icon(Icons.add), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										390
									
								
								web/repertory/lib/settings.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										390
									
								
								web/repertory/lib/settings.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,390 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart' show Validator, displayErrorMessage; | ||||
| import 'package:settings_ui/settings_ui.dart'; | ||||
|  | ||||
| void createBooleanSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
|   IconData icon = Icons.quiz, | ||||
| }) { | ||||
|   if (!isAdvanced || showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.switchTile( | ||||
|         leading: Icon(icon), | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         initialValue: (value as bool), | ||||
|         onPressed: (_) => setState(() => settings[key] = !value), | ||||
|         onToggle: (bool nextValue) { | ||||
|           setState(() => settings[key] = nextValue); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void createIntListSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   List<String> valueList, | ||||
|   defaultValue, | ||||
|   IconData icon, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
| }) { | ||||
|   if (!isAdvanced || widget.showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.navigation( | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         leading: Icon(icon), | ||||
|         value: DropdownButton<String>( | ||||
|           value: value.toString(), | ||||
|           onChanged: (newValue) { | ||||
|             setState( | ||||
|               () => | ||||
|                   settings[key] = int.parse( | ||||
|                     newValue ?? defaultValue.toString(), | ||||
|                   ), | ||||
|             ); | ||||
|           }, | ||||
|           items: | ||||
|               valueList.map<DropdownMenuItem<String>>((item) { | ||||
|                 return DropdownMenuItem<String>(value: item, child: Text(item)); | ||||
|               }).toList(), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void createIntSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
|   IconData icon = Icons.onetwothree, | ||||
|   List<Validator> validators = const [], | ||||
| }) { | ||||
|   if (!isAdvanced || widget.showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.navigation( | ||||
|         leading: Icon(icon), | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         value: Text(value.toString()), | ||||
|         onPressed: (_) { | ||||
|           String updatedValue = value.toString(); | ||||
|           showDialog( | ||||
|             context: context, | ||||
|             builder: (context) { | ||||
|               return AlertDialog( | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     child: const Text('Cancel'), | ||||
|                     onPressed: () => Navigator.of(context).pop(), | ||||
|                   ), | ||||
|                   TextButton( | ||||
|                     child: const Text('OK'), | ||||
|                     onPressed: () { | ||||
|                       final result = validators.firstWhereOrNull( | ||||
|                         (validator) => !validator(updatedValue), | ||||
|                       ); | ||||
|                       if (result != null) { | ||||
|                         return displayErrorMessage( | ||||
|                           context, | ||||
|                           "Setting '$key' is not valid", | ||||
|                         ); | ||||
|                       } | ||||
|                       setState(() => settings[key] = int.parse(updatedValue)); | ||||
|                       Navigator.of(context).pop(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|                 content: TextField( | ||||
|                   autofocus: true, | ||||
|                   controller: TextEditingController(text: updatedValue), | ||||
|                   inputFormatters: [FilteringTextInputFormatter.digitsOnly], | ||||
|                   keyboardType: TextInputType.number, | ||||
|                   onChanged: (nextValue) => updatedValue = nextValue, | ||||
|                 ), | ||||
|                 title: createSettingTitle(context, key, description), | ||||
|               ); | ||||
|             }, | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void createPasswordSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
|   IconData icon = Icons.password, | ||||
|   List<Validator> validators = const [], | ||||
| }) { | ||||
|   if (!isAdvanced || widget.showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.navigation( | ||||
|         leading: Icon(icon), | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         value: Text('*' * (value as String).length), | ||||
|         onPressed: (_) { | ||||
|           String updatedValue1 = value; | ||||
|           String updatedValue2 = value; | ||||
|           bool hidePassword1 = true; | ||||
|           bool hidePassword2 = true; | ||||
|           showDialog( | ||||
|             context: context, | ||||
|             builder: (context) { | ||||
|               return StatefulBuilder( | ||||
|                 builder: (context, setDialogState) { | ||||
|                   return AlertDialog( | ||||
|                     actions: [ | ||||
|                       TextButton( | ||||
|                         child: const Text('Cancel'), | ||||
|                         onPressed: () => Navigator.of(context).pop(), | ||||
|                       ), | ||||
|                       TextButton( | ||||
|                         child: const Text('OK'), | ||||
|                         onPressed: () { | ||||
|                           if (updatedValue1 != updatedValue2) { | ||||
|                             return displayErrorMessage( | ||||
|                               context, | ||||
|                               "Setting '$key' does not match", | ||||
|                             ); | ||||
|                           } | ||||
|  | ||||
|                           final result = validators.firstWhereOrNull( | ||||
|                             (validator) => !validator(updatedValue1), | ||||
|                           ); | ||||
|                           if (result != null) { | ||||
|                             return displayErrorMessage( | ||||
|                               context, | ||||
|                               "Setting '$key' is not valid", | ||||
|                             ); | ||||
|                           } | ||||
|  | ||||
|                           setState(() => settings[key] = updatedValue1); | ||||
|                           Navigator.of(context).pop(); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ], | ||||
|                     content: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       mainAxisAlignment: MainAxisAlignment.start, | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                               child: TextField( | ||||
|                                 autofocus: true, | ||||
|                                 controller: TextEditingController( | ||||
|                                   text: updatedValue1, | ||||
|                                 ), | ||||
|                                 obscureText: hidePassword1, | ||||
|                                 obscuringCharacter: '*', | ||||
|                                 onChanged: (value) => updatedValue1 = value, | ||||
|                               ), | ||||
|                             ), | ||||
|                             IconButton( | ||||
|                               onPressed: | ||||
|                                   () => setDialogState( | ||||
|                                     () => hidePassword1 = !hidePassword1, | ||||
|                                   ), | ||||
|                               icon: Icon( | ||||
|                                 hidePassword1 | ||||
|                                     ? Icons.visibility | ||||
|                                     : Icons.visibility_off, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         const SizedBox(height: constants.padding), | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                               child: TextField( | ||||
|                                 autofocus: false, | ||||
|                                 controller: TextEditingController( | ||||
|                                   text: updatedValue2, | ||||
|                                 ), | ||||
|                                 obscureText: hidePassword2, | ||||
|                                 obscuringCharacter: '*', | ||||
|                                 onChanged: (value) => updatedValue2 = value, | ||||
|                               ), | ||||
|                             ), | ||||
|                             IconButton( | ||||
|                               onPressed: | ||||
|                                   () => setDialogState( | ||||
|                                     () => hidePassword2 = !hidePassword2, | ||||
|                                   ), | ||||
|                               icon: Icon( | ||||
|                                 hidePassword2 | ||||
|                                     ? Icons.visibility | ||||
|                                     : Icons.visibility_off, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                     title: createSettingTitle(context, key, description), | ||||
|                   ); | ||||
|                 }, | ||||
|               ); | ||||
|             }, | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Widget createSettingTitle(context, String key, String? description) { | ||||
|   if (description == null) { | ||||
|     return Text(key); | ||||
|   } | ||||
|  | ||||
|   return Column( | ||||
|     mainAxisAlignment: MainAxisAlignment.start, | ||||
|     mainAxisSize: MainAxisSize.min, | ||||
|     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|     children: [ | ||||
|       Text(key, textAlign: TextAlign.start), | ||||
|       Text( | ||||
|         description, | ||||
|         style: Theme.of(context).textTheme.titleSmall, | ||||
|         textAlign: TextAlign.start, | ||||
|       ), | ||||
|     ], | ||||
|   ); | ||||
| } | ||||
|  | ||||
| void createStringListSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   List<String> valueList, | ||||
|   IconData icon, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
| }) { | ||||
|   if (!isAdvanced || widget.showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.navigation( | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         leading: Icon(icon), | ||||
|         value: DropdownButton<String>( | ||||
|           value: value, | ||||
|           onChanged: (newValue) => setState(() => settings[key] = newValue), | ||||
|           items: | ||||
|               valueList.map<DropdownMenuItem<String>>((item) { | ||||
|                 return DropdownMenuItem<String>(value: item, child: Text(item)); | ||||
|               }).toList(), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void createStringSetting( | ||||
|   context, | ||||
|   List<Widget> list, | ||||
|   Map<String, dynamic> settings, | ||||
|   String key, | ||||
|   value, | ||||
|   IconData icon, | ||||
|   bool isAdvanced, | ||||
|   bool showAdvanced, | ||||
|   widget, | ||||
|   Function setState, { | ||||
|   String? description, | ||||
|   List<Validator> validators = const [], | ||||
| }) { | ||||
|   if (!isAdvanced || widget.showAdvanced) { | ||||
|     list.add( | ||||
|       SettingsTile.navigation( | ||||
|         leading: Icon(icon), | ||||
|         title: createSettingTitle(context, key, description), | ||||
|         value: Text(value), | ||||
|         onPressed: (_) { | ||||
|           String updatedValue = value; | ||||
|           showDialog( | ||||
|             context: context, | ||||
|             builder: (context) { | ||||
|               return AlertDialog( | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     child: const Text('Cancel'), | ||||
|                     onPressed: () => Navigator.of(context).pop(), | ||||
|                   ), | ||||
|                   TextButton( | ||||
|                     child: const Text('OK'), | ||||
|                     onPressed: () { | ||||
|                       final result = validators.firstWhereOrNull( | ||||
|                         (validator) => !validator(updatedValue), | ||||
|                       ); | ||||
|                       if (result != null) { | ||||
|                         return displayErrorMessage( | ||||
|                           context, | ||||
|                           "Setting '$key' is not valid", | ||||
|                         ); | ||||
|                       } | ||||
|                       setState(() => settings[key] = updatedValue); | ||||
|                       Navigator.of(context).pop(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|                 content: TextField( | ||||
|                   autofocus: true, | ||||
|                   controller: TextEditingController(text: updatedValue), | ||||
|                   inputFormatters: [ | ||||
|                     FilteringTextInputFormatter.deny(RegExp(r'\s')), | ||||
|                   ], | ||||
|                   onChanged: (value) => updatedValue = value, | ||||
|                 ), | ||||
|                 title: createSettingTitle(context, key, description), | ||||
|               ); | ||||
|             }, | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										33
									
								
								web/repertory/lib/types/mount_config.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								web/repertory/lib/types/mount_config.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:repertory/helpers.dart' show initialCaps; | ||||
|  | ||||
| class MountConfig { | ||||
|   bool? mounted; | ||||
|   final String _name; | ||||
|   String path = ''; | ||||
|   Map<String, dynamic> _settings = {}; | ||||
|   final String _type; | ||||
|   MountConfig({required name, required type, Map<String, dynamic>? settings}) | ||||
|     : _name = name, | ||||
|       _type = type { | ||||
|     if (settings != null) { | ||||
|       _settings = settings; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String? get bucket => _settings['${provider}Config']?["Bucket"] as String; | ||||
|   String get name => _name; | ||||
|   String get provider => initialCaps(_type); | ||||
|   UnmodifiableMapView<String, dynamic> get settings => | ||||
|       UnmodifiableMapView<String, dynamic>(_settings); | ||||
|   String get type => _type; | ||||
|  | ||||
|   void updateSettings(Map<String, dynamic> settings) { | ||||
|     _settings = settings; | ||||
|   } | ||||
|  | ||||
|   void updateStatus(Map<String, dynamic> status) { | ||||
|     path = status['Location'] as String; | ||||
|     mounted = status['Active'] as bool; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										35
									
								
								web/repertory/lib/widgets/mount_list_widget.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/repertory/lib/widgets/mount_list_widget.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/models/mount_list.dart'; | ||||
| import 'package:repertory/widgets/mount_widget.dart'; | ||||
|  | ||||
| class MountListWidget extends StatelessWidget { | ||||
|   const MountListWidget({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Consumer<MountList>( | ||||
|       builder: (context, MountList mountList, _) { | ||||
|         return ListView.builder( | ||||
|           itemBuilder: (context, idx) { | ||||
|             return ChangeNotifierProvider( | ||||
|               create: (context) => mountList.items[idx], | ||||
|               key: ValueKey(mountList.items[idx].id), | ||||
|               child: Padding( | ||||
|                 padding: EdgeInsets.only( | ||||
|                   bottom: | ||||
|                       idx == mountList.items.length - 1 | ||||
|                           ? 0.0 | ||||
|                           : constants.padding, | ||||
|                 ), | ||||
|                 child: const MountWidget(), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|           itemCount: mountList.items.length, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1025
									
								
								web/repertory/lib/widgets/mount_settings.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1025
									
								
								web/repertory/lib/widgets/mount_settings.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										206
									
								
								web/repertory/lib/widgets/mount_widget.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								web/repertory/lib/widgets/mount_widget.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart'; | ||||
| import 'package:repertory/models/mount.dart'; | ||||
|  | ||||
| class MountWidget extends StatefulWidget { | ||||
|   const MountWidget({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<MountWidget> createState() => _MountWidgetState(); | ||||
| } | ||||
|  | ||||
| class _MountWidgetState extends State<MountWidget> { | ||||
|   bool _enabled = true; | ||||
|   bool _editEnabled = true; | ||||
|   Timer? _timer; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|       margin: const EdgeInsets.all(0.0), | ||||
|       child: Consumer<Mount>( | ||||
|         builder: (context, Mount mount, _) { | ||||
|           final textColor = Theme.of(context).colorScheme.onSurface; | ||||
|           final subTextColor = | ||||
|               Theme.of(context).brightness == Brightness.dark | ||||
|                   ? Colors.white38 | ||||
|                   : Colors.black87; | ||||
|  | ||||
|           final nameText = SelectableText( | ||||
|             formatMountName(mount.type, mount.name), | ||||
|             style: TextStyle(color: subTextColor), | ||||
|           ); | ||||
|  | ||||
|           return ListTile( | ||||
|             isThreeLine: true, | ||||
|             leading: IconButton( | ||||
|               icon: Icon(Icons.settings, color: textColor), | ||||
|               onPressed: | ||||
|                   () => Navigator.pushNamed(context, '/edit', arguments: mount), | ||||
|             ), | ||||
|             subtitle: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 nameText, | ||||
|                 SelectableText( | ||||
|                   mount.path.isEmpty && mount.mounted == null | ||||
|                       ? 'loading...' | ||||
|                       : mount.path.isEmpty | ||||
|                       ? '<mount location not set>' | ||||
|                       : mount.path, | ||||
|                   style: TextStyle(color: subTextColor), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             title: SelectableText( | ||||
|               mount.provider, | ||||
|               style: TextStyle(color: textColor, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             trailing: Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.end, | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 if (mount.mounted != null && !mount.mounted!) | ||||
|                   IconButton( | ||||
|                     icon: const Icon(Icons.edit), | ||||
|                     color: subTextColor, | ||||
|                     tooltip: 'Edit mount location', | ||||
|                     onPressed: () async { | ||||
|                       setState(() => _editEnabled = false); | ||||
|                       final available = await mount.getAvailableLocations(); | ||||
|                       if (context.mounted) { | ||||
|                         final location = await editMountLocation( | ||||
|                           context, | ||||
|                           available, | ||||
|                           location: mount.path, | ||||
|                         ); | ||||
|                         if (location != null) { | ||||
|                           await mount.setMountLocation(location); | ||||
|                         } | ||||
|                       } | ||||
|                       setState(() => _editEnabled = true); | ||||
|                     }, | ||||
|                   ), | ||||
|                 IconButton( | ||||
|                   icon: Icon( | ||||
|                     mount.mounted == null | ||||
|                         ? Icons.hourglass_top | ||||
|                         : mount.mounted! | ||||
|                         ? Icons.toggle_on | ||||
|                         : Icons.toggle_off, | ||||
|                     color: | ||||
|                         mount.mounted ?? false | ||||
|                             ? Color.fromARGB(255, 163, 96, 76) | ||||
|                             : subTextColor, | ||||
|                   ), | ||||
|                   tooltip: | ||||
|                       mount.mounted == null | ||||
|                           ? '' | ||||
|                           : mount.mounted! | ||||
|                           ? 'Unmount' | ||||
|                           : 'Mount', | ||||
|                   onPressed: _createMountHandler(context, mount), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   VoidCallback? _createMountHandler(context, Mount mount) { | ||||
|     return _enabled && mount.mounted != null | ||||
|         ? () async { | ||||
|           if (mount.mounted == null) { | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           final mounted = mount.mounted!; | ||||
|  | ||||
|           setState(() { | ||||
|             _enabled = false; | ||||
|           }); | ||||
|  | ||||
|           final location = await _getMountLocation(context, mount); | ||||
|  | ||||
|           cleanup() { | ||||
|             setState(() { | ||||
|               _enabled = true; | ||||
|             }); | ||||
|           } | ||||
|  | ||||
|           if (!mounted && location == null) { | ||||
|             displayErrorMessage(context, "Mount location is not set"); | ||||
|             return cleanup(); | ||||
|           } | ||||
|  | ||||
|           final success = await mount.mount(mounted, location: location); | ||||
|           if (success || | ||||
|               mounted || | ||||
|               constants.navigatorKey.currentContext == null || | ||||
|               !constants.navigatorKey.currentContext!.mounted) { | ||||
|             return cleanup(); | ||||
|           } | ||||
|  | ||||
|           displayErrorMessage( | ||||
|             context, | ||||
|             "Mount location is not available: $location", | ||||
|           ); | ||||
|           cleanup(); | ||||
|         } | ||||
|         : null; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _timer?.cancel(); | ||||
|     _timer = null; | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   Future<String?> _getMountLocation(context, Mount mount) async { | ||||
|     if (mount.mounted ?? false) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     if (mount.path.isNotEmpty) { | ||||
|       return mount.path; | ||||
|     } | ||||
|  | ||||
|     String? location = await mount.getMountLocation(); | ||||
|     if (location != null) { | ||||
|       return location; | ||||
|     } | ||||
|  | ||||
|     if (!context.mounted) { | ||||
|       return location; | ||||
|     } | ||||
|  | ||||
|     return editMountLocation(context, await mount.getAvailableLocations()); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _timer = Timer.periodic(const Duration(seconds: 5), (_) { | ||||
|       Provider.of<Mount>(context, listen: false).refresh(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										155
									
								
								web/repertory/lib/widgets/ui_settings.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								web/repertory/lib/widgets/ui_settings.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| import 'dart:convert' show jsonEncode; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart' as http; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:repertory/constants.dart' as constants; | ||||
| import 'package:repertory/helpers.dart' | ||||
|     show | ||||
|         convertAllToString, | ||||
|         displayAuthError, | ||||
|         getBaseUri, | ||||
|         getChanged, | ||||
|         getSettingDescription, | ||||
|         getSettingValidators, | ||||
|         trimNotEmptyValidator; | ||||
| import 'package:repertory/models/auth.dart'; | ||||
| import 'package:repertory/settings.dart'; | ||||
| import 'package:settings_ui/settings_ui.dart'; | ||||
|  | ||||
| class UISettingsWidget extends StatefulWidget { | ||||
|   final bool showAdvanced; | ||||
|   final Map<String, dynamic> settings; | ||||
|   final Map<String, dynamic> origSettings; | ||||
|   const UISettingsWidget({ | ||||
|     super.key, | ||||
|     required this.origSettings, | ||||
|     required this.settings, | ||||
|     required this.showAdvanced, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<UISettingsWidget> createState() => _UISettingsWidgetState(); | ||||
| } | ||||
|  | ||||
| class _UISettingsWidgetState extends State<UISettingsWidget> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     List<SettingsTile> commonSettings = []; | ||||
|  | ||||
|     widget.settings.forEach((key, value) { | ||||
|       switch (key) { | ||||
|         case 'ApiPassword': | ||||
|           { | ||||
|             createPasswordSetting( | ||||
|               context, | ||||
|               commonSettings, | ||||
|               widget.settings, | ||||
|               key, | ||||
|               value, | ||||
|               false, | ||||
|               widget.showAdvanced, | ||||
|               widget, | ||||
|               setState, | ||||
|               description: getSettingDescription(key), | ||||
|               validators: getSettingValidators(key), | ||||
|             ); | ||||
|           } | ||||
|           break; | ||||
|         case 'ApiPort': | ||||
|           { | ||||
|             createIntSetting( | ||||
|               context, | ||||
|               commonSettings, | ||||
|               widget.settings, | ||||
|               key, | ||||
|               value, | ||||
|               false, | ||||
|               widget.showAdvanced, | ||||
|               widget, | ||||
|               setState, | ||||
|               description: getSettingDescription(key), | ||||
|               validators: getSettingValidators(key), | ||||
|             ); | ||||
|           } | ||||
|           break; | ||||
|         case 'ApiUser': | ||||
|           { | ||||
|             createStringSetting( | ||||
|               context, | ||||
|               commonSettings, | ||||
|               widget.settings, | ||||
|               key, | ||||
|               value, | ||||
|               Icons.person, | ||||
|               false, | ||||
|               widget.showAdvanced, | ||||
|               widget, | ||||
|               setState, | ||||
|               description: getSettingDescription(key), | ||||
|               validators: [...getSettingValidators(key), trimNotEmptyValidator], | ||||
|             ); | ||||
|           } | ||||
|           break; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return SettingsList( | ||||
|       shrinkWrap: false, | ||||
|       sections: [ | ||||
|         SettingsSection(title: const Text('Settings'), tiles: commonSettings), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     final settings = getChanged(widget.origSettings, widget.settings); | ||||
|     if (settings.isNotEmpty) { | ||||
|       final key = | ||||
|           Provider.of<Auth>( | ||||
|             constants.navigatorKey.currentContext!, | ||||
|             listen: false, | ||||
|           ).key; | ||||
|       convertAllToString(settings, key) | ||||
|           .then((map) async { | ||||
|             try { | ||||
|               final authProvider = Provider.of<Auth>( | ||||
|                 constants.navigatorKey.currentContext!, | ||||
|                 listen: false, | ||||
|               ); | ||||
|  | ||||
|               final auth = await authProvider.createAuth(); | ||||
|               final response = await http.put( | ||||
|                 Uri.parse( | ||||
|                   Uri.encodeFull( | ||||
|                     '${getBaseUri()}/api/v1/settings?auth=$auth&data=${jsonEncode(map)}', | ||||
|                   ), | ||||
|                 ), | ||||
|               ); | ||||
|  | ||||
|               if (response.statusCode == 401) { | ||||
|                 displayAuthError(authProvider); | ||||
|                 authProvider.logoff(); | ||||
|               } | ||||
|             } catch (e) { | ||||
|               debugPrint('$e'); | ||||
|             } | ||||
|           }) | ||||
|           .catchError((e) { | ||||
|             debugPrint('$e'); | ||||
|           }); | ||||
|     } | ||||
|  | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void setState(VoidCallback fn) { | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     super.setState(fn); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										95
									
								
								web/repertory/pubspec.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								web/repertory/pubspec.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| name: repertory | ||||
| description: "Repertory Management Portal" | ||||
| # The following line prevents the package from being accidentally published to | ||||
| # pub.dev using `flutter pub publish`. This is preferred for private packages. | ||||
| publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
|  | ||||
| # The following defines the version and build number for your application. | ||||
| # A version number is three numbers separated by dots, like 1.2.43 | ||||
| # followed by an optional build number separated by a +. | ||||
| # Both the version and the builder number may be overridden in flutter | ||||
| # build by specifying --build-name and --build-number, respectively. | ||||
| # In Android, build-name is used as versionName while build-number used as versionCode. | ||||
| # Read more about Android versioning at https://developer.android.com/studio/publish/versioning | ||||
| # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. | ||||
| # Read more about iOS versioning at | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 1.0.0+1 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.7.0 | ||||
|  | ||||
| # Dependencies specify other packages that your package needs in order to work. | ||||
| # To automatically upgrade your package dependencies to the latest versions | ||||
| # consider running `flutter pub upgrade --major-versions`. Alternatively, | ||||
| # dependencies can be manually updated by changing the version numbers below to | ||||
| # the latest version available on pub.dev. To see which dependencies have newer | ||||
| # versions available, run `flutter pub outdated`. | ||||
| dependencies: | ||||
|   flutter: | ||||
|     sdk: flutter | ||||
|  | ||||
|   # The following adds the Cupertino Icons font to your application. | ||||
|   # Use with the CupertinoIcons class for iOS style icons. | ||||
|   cupertino_icons: ^1.0.8 | ||||
|   collection: ^1.19.1 | ||||
|   http: ^1.3.0 | ||||
|   provider: ^6.1.2 | ||||
|   settings_ui: ^2.0.2 | ||||
|   sodium_libs: ^3.4.4+1 | ||||
|   convert: ^3.1.2 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
|  | ||||
|   # The "flutter_lints" package below contains a set of recommended lints to | ||||
|   # encourage good coding practices. The lint set provided by the package is | ||||
|   # activated in the `analysis_options.yaml` file located at the root of your | ||||
|   # package. See that file for information about deactivating specific lint | ||||
|   # rules and activating additional ones. | ||||
|   flutter_lints: ^5.0.0 | ||||
|  | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
|  | ||||
| # The following section is specific to Flutter packages. | ||||
| flutter: | ||||
|  | ||||
|   # The following line ensures that the Material Icons font is | ||||
|   # included with your application, so that you can use the icons in | ||||
|   # the material Icons class. | ||||
|   uses-material-design: true | ||||
|  | ||||
|   # To add assets to your application, add an assets section, like this: | ||||
|   # assets: | ||||
|   #   - images/a_dot_burr.jpeg | ||||
|   #   - images/a_dot_ham.jpeg | ||||
|  | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/to/resolution-aware-images | ||||
|  | ||||
|   # For details regarding adding assets from package dependencies, see | ||||
|   # https://flutter.dev/to/asset-from-package | ||||
|  | ||||
|   # To add custom fonts to your application, add a fonts section here, | ||||
|   # in this "flutter" section. Each entry in this list should have a | ||||
|   # "family" key with the font family name, and a "fonts" key with a | ||||
|   # list giving the asset and other descriptors for the font. For | ||||
|   # example: | ||||
|   # fonts: | ||||
|   #   - family: Schyler | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/Schyler-Regular.ttf | ||||
|   #       - asset: fonts/Schyler-Italic.ttf | ||||
|   #         style: italic | ||||
|   #   - family: Trajan Pro | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/TrajanPro.ttf | ||||
|   #       - asset: fonts/TrajanPro_Bold.ttf | ||||
|   #         weight: 700 | ||||
|   # | ||||
|   # For details regarding fonts from package dependencies, | ||||
|   # see https://flutter.dev/to/font-from-package | ||||
							
								
								
									
										30
									
								
								web/repertory/test/widget_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/repertory/test/widget_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| // This is a basic Flutter widget test. | ||||
| // | ||||
| // To perform an interaction with a widget in your test, use the WidgetTester | ||||
| // utility in the flutter_test package. For example, you can send tap and scroll | ||||
| // gestures. You can also use WidgetTester to find child widgets in the widget | ||||
| // tree, read text, and verify that the values of widget properties are correct. | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_test/flutter_test.dart'; | ||||
|  | ||||
| import 'package:repertory/main.dart'; | ||||
|  | ||||
| void main() { | ||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||
|     // Build our app and trigger a frame. | ||||
|     await tester.pumpWidget(const MyApp()); | ||||
|  | ||||
|     // Verify that our counter starts at 0. | ||||
|     expect(find.text('0'), findsOneWidget); | ||||
|     expect(find.text('1'), findsNothing); | ||||
|  | ||||
|     // Tap the '+' icon and trigger a frame. | ||||
|     await tester.tap(find.byIcon(Icons.add)); | ||||
|     await tester.pump(); | ||||
|  | ||||
|     // Verify that our counter has incremented. | ||||
|     expect(find.text('0'), findsNothing); | ||||
|     expect(find.text('1'), findsOneWidget); | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								web/repertory/web/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/repertory/web/favicon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 917 B | 
							
								
								
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-192.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 5.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-512.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 8.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-maskable-192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-maskable-192.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 5.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-maskable-512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/repertory/web/icons/Icon-maskable-512.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										44
									
								
								web/repertory/web/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								web/repertory/web/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <!-- | ||||
|       If you are serving your web app in a path other than the root, change the | ||||
|       href value below to reflect the base path you are serving from. | ||||
|  | ||||
|       The path provided below has to start and end with a slash "/" in order for | ||||
|       it to work correctly. | ||||
|  | ||||
|       For more details: | ||||
|       * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | ||||
|  | ||||
|       This is a placeholder for base href that will be replaced by the value of | ||||
|       the `--base-href` argument provided to `flutter build`. | ||||
|     --> | ||||
|     <base href="$FLUTTER_BASE_HREF"> | ||||
|  | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||||
|     <meta name="description" content="A new Flutter project."> | ||||
|  | ||||
|     <!-- iOS meta tags & icons --> | ||||
|     <meta name="mobile-web-app-capable" content="yes"> | ||||
|     <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||||
|     <meta name="apple-mobile-web-app-title" content="repertory"> | ||||
|     <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||||
|  | ||||
|     <!-- Favicon --> | ||||
|     <link rel="icon" type="image/png" href="favicon.png"> | ||||
|  | ||||
|     <title>repertory</title> | ||||
|     <link rel="manifest" href="manifest.json"> | ||||
|     <script type="text/javascript" src="sodium.js" async="true"></script> | ||||
|   </head> | ||||
|   <body> | ||||
|     <script> | ||||
|       window.flutterConfiguration = { | ||||
|           canvasKitBaseUrl: "/canvaskit/" | ||||
|       }; | ||||
|     </script> | ||||
|     <script src="flutter_bootstrap.js" async=""></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										35
									
								
								web/repertory/web/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/repertory/web/manifest.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| { | ||||
|     "name": "repertory", | ||||
|     "short_name": "repertory", | ||||
|     "start_url": ".", | ||||
|     "display": "standalone", | ||||
|     "background_color": "#0175C2", | ||||
|     "theme_color": "#0175C2", | ||||
|     "description": "A new Flutter project.", | ||||
|     "orientation": "portrait-primary", | ||||
|     "prefer_related_applications": false, | ||||
|     "icons": [ | ||||
|         { | ||||
|             "src": "icons/Icon-192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-512.png", | ||||
|             "sizes": "512x512", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-maskable-192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png", | ||||
|             "purpose": "maskable" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-maskable-512.png", | ||||
|             "sizes": "512x512", | ||||
|             "type": "image/png", | ||||
|             "purpose": "maskable" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										1
									
								
								web/repertory/web/sodium.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/repertory/web/sodium.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
		Reference in New Issue
	
	Block a user