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