extra form validation and connection list done
This commit is contained in:
parent
bd8884787d
commit
789cea13a1
5 changed files with 281 additions and 74 deletions
|
@ -1,13 +1,16 @@
|
||||||
import 'package:file_selector/file_selector.dart';
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:fluxcloud/user.dart';
|
import 'package:fluxcloud/connection.dart';
|
||||||
|
|
||||||
class AddServerModal extends StatefulWidget {
|
class AddServerModal extends StatefulWidget {
|
||||||
const AddServerModal({
|
const AddServerModal({
|
||||||
super.key,
|
super.key, required this.updateConnectionList, this.initialState,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final Function updateConnectionList;
|
||||||
|
final Connection? initialState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddServerModal> createState() => _AddServerModalState();
|
State<AddServerModal> createState() => _AddServerModalState();
|
||||||
}
|
}
|
||||||
|
@ -21,9 +24,23 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
|
|
||||||
bool _fileSelected = false;
|
bool _fileSelected = false;
|
||||||
bool _showPassword = false;
|
bool _showPassword = false;
|
||||||
|
bool _showPrivateKey = false;
|
||||||
|
bool _isPrivateKeyValid = false;
|
||||||
|
|
||||||
final Connection _connection = Connection();
|
final Connection _connection = Connection();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.initialState != null) {
|
||||||
|
_connection.isEncryptionEnabled = widget.initialState?.isEncryptionEnabled;
|
||||||
|
_connection.isDefault = widget.initialState?.isDefault;
|
||||||
|
_privateKeyFileController.text = widget.initialState?.privateKey ?? '';
|
||||||
|
_passwordController.text = widget.initialState?.password ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
@ -41,11 +58,12 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Add Connection',
|
widget.initialState == null ? 'Add Connection' : 'Edit Connection',
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
style: TextStyle(fontSize: 18),
|
style: TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
initialValue: widget.initialState?.host,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Host',
|
labelText: 'Host',
|
||||||
),
|
),
|
||||||
|
@ -61,6 +79,7 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
onSaved: (value) => _connection.host = value,
|
onSaved: (value) => _connection.host = value,
|
||||||
),
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
initialValue: widget.initialState?.port.toString(),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Port',
|
labelText: 'Port',
|
||||||
|
@ -77,6 +96,7 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
onSaved: (value) => _connection.port = int.parse(value!),
|
onSaved: (value) => _connection.port = int.parse(value!),
|
||||||
),
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
initialValue: widget.initialState?.username,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Username',
|
labelText: 'Username',
|
||||||
),
|
),
|
||||||
|
@ -85,30 +105,69 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
),
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _privateKeyFileController,
|
controller: _privateKeyFileController,
|
||||||
|
obscureText: !_showPrivateKey,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final XFile? result = await openFile();
|
final XFile? file = await openFile();
|
||||||
if (result != null) {
|
if (file != null) {
|
||||||
setState(() => _fileSelected = true);
|
const knownHeaders = [
|
||||||
_privateKeyFileController.text = result.name;
|
'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||||
_connection.privateKeyFilePath = result.path;
|
'-----BEGIN RSA PRIVATE KEY-----',
|
||||||
|
'-----BEGIN DSA PRIVATE KEY-----',
|
||||||
|
'-----BEGIN EC PRIVATE KEY-----',
|
||||||
|
'-----BEGIN PRIVATE KEY-----',
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
final privateKey = await file.readAsString();
|
||||||
|
if (knownHeaders.any((h) => privateKey.startsWith(h))) {
|
||||||
|
setState(() {
|
||||||
|
_fileSelected = true;
|
||||||
|
_showPrivateKey = false;
|
||||||
|
_privateKeyFileController.text = privateKey;
|
||||||
|
});
|
||||||
|
_isPrivateKeyValid = true;
|
||||||
|
_connection.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_isPrivateKeyValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_fileSelected = true;
|
||||||
|
_showPrivateKey = true;
|
||||||
|
_privateKeyFileController.text = 'Invalid private key file';
|
||||||
|
});
|
||||||
|
_isPrivateKeyValid = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Private Key File',
|
labelText: 'Private Key File',
|
||||||
suffixIcon: _fileSelected ? IconButton(
|
suffixIcon: _fileSelected ? Row(
|
||||||
onPressed: () => setState(() {
|
mainAxisSize: MainAxisSize.min,
|
||||||
_fileSelected = false;
|
children: [
|
||||||
_privateKeyFileController.clear();
|
IconButton(
|
||||||
}),
|
onPressed: () => setState(() => _showPrivateKey = !_showPrivateKey),
|
||||||
icon: Icon(Icons.remove)
|
icon: _showPrivateKey ? Icon(Icons.visibility) : Icon(Icons.visibility_off)
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_fileSelected = false;
|
||||||
|
_privateKeyFileController.clear();
|
||||||
|
}),
|
||||||
|
icon: Icon(Icons.remove)
|
||||||
|
),
|
||||||
|
],
|
||||||
) : null
|
) : null
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
// TODO: validate the file to see if it is valid private key
|
|
||||||
if (_privateKeyFileController.text.isEmpty && _passwordController.text.isEmpty) {
|
if (_privateKeyFileController.text.isEmpty && _passwordController.text.isEmpty) {
|
||||||
return 'At least provide one of these';
|
return 'At least provide one of these';
|
||||||
}
|
}
|
||||||
|
if (!_isPrivateKeyValid) {
|
||||||
|
return 'Invalid private key file';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -130,7 +189,18 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
},
|
},
|
||||||
onSaved: (value) => _connection.password = value,
|
onSaved: (value) => _connection.password = value,
|
||||||
),
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text('Enable Encryption'),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadiusGeometry.circular(10)),
|
||||||
|
value: _connection.isEncryptionEnabled ?? true,
|
||||||
|
onChanged: (value) => setState(() => _connection.isEncryptionEnabled = value),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text('Set as Default Connection'),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadiusGeometry.circular(10)),
|
||||||
|
value: _connection.isDefault ?? false,
|
||||||
|
onChanged: (value) => setState(() => _connection.isDefault = value),
|
||||||
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (_formKey.currentState?.validate() == true) {
|
if (_formKey.currentState?.validate() == true) {
|
||||||
|
@ -138,12 +208,13 @@ class _AddServerModalState extends State<AddServerModal> {
|
||||||
assert(_connection.assertComplete());
|
assert(_connection.assertComplete());
|
||||||
final storage = FlutterSecureStorage();
|
final storage = FlutterSecureStorage();
|
||||||
await storage.write(key: _connection.host!, value: _connection.toJson);
|
await storage.write(key: _connection.host!, value: _connection.toJson);
|
||||||
|
widget.updateConnectionList();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text('Add Connection')
|
child: Text(widget.initialState == null ? 'Add Connection' : 'Save')
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
44
lib/connection.dart
Normal file
44
lib/connection.dart
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class Connection {
|
||||||
|
String? host;
|
||||||
|
int? port;
|
||||||
|
String? username;
|
||||||
|
String? privateKey;
|
||||||
|
String? password;
|
||||||
|
bool? isEncryptionEnabled;
|
||||||
|
bool? isDefault;
|
||||||
|
|
||||||
|
Connection() : isEncryptionEnabled = true, isDefault = false;
|
||||||
|
|
||||||
|
Connection.fromJson(Map<String, dynamic> json) {
|
||||||
|
host = json['host'];
|
||||||
|
port = json['port'];
|
||||||
|
username = json['username'];
|
||||||
|
privateKey = json['privateKeyFilePath'];
|
||||||
|
password = json['password'];
|
||||||
|
isEncryptionEnabled = json['isEncryptionEnabled'];
|
||||||
|
isDefault = json['isDefault'];
|
||||||
|
}
|
||||||
|
|
||||||
|
String get toJson => jsonEncode({
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'username': username,
|
||||||
|
'privateKeyFilePath': privateKey,
|
||||||
|
'password': password,
|
||||||
|
'isEncryptionEnabled': isEncryptionEnabled,
|
||||||
|
'isDefault': isDefault
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
bool assertComplete() {
|
||||||
|
assert(host != null);
|
||||||
|
assert(port != null);
|
||||||
|
assert(username != null);
|
||||||
|
assert(privateKey != null || password != null);
|
||||||
|
assert(isEncryptionEnabled != null);
|
||||||
|
assert(isDefault != null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluxcloud/add_server_modal.dart';
|
import 'package:fluxcloud/sftp_connection_list.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MainApp());
|
runApp(const MainApp());
|
||||||
|
@ -23,26 +23,7 @@ class MainApp extends StatelessWidget {
|
||||||
),
|
),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
color: Color(0x002EC1EB),
|
color: Color(0x002EC1EB),
|
||||||
home: Scaffold(
|
home: SftpConnectionList()
|
||||||
floatingActionButton: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
return FloatingActionButton(
|
|
||||||
child: Icon(Icons.add),
|
|
||||||
onPressed: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
isScrollControlled: true,
|
|
||||||
context: context,
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (context) => AddServerModal()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Text('nice World!'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
147
lib/sftp_connection_list.dart
Normal file
147
lib/sftp_connection_list.dart
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:fluxcloud/add_server_modal.dart';
|
||||||
|
import 'package:fluxcloud/connection.dart';
|
||||||
|
|
||||||
|
class SftpConnectionList extends StatefulWidget {
|
||||||
|
const SftpConnectionList({
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SftpConnectionList> createState() => _SftpConnectionListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SftpConnectionListState extends State<SftpConnectionList> {
|
||||||
|
|
||||||
|
late List<Connection> _connections;
|
||||||
|
bool _isConnectionsInit = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_getConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _getConnections() async {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
final secureMap = await storage.readAll();
|
||||||
|
setState(() {
|
||||||
|
_connections = secureMap.values.map((json) => Connection.fromJson(jsonDecode(json))).toList();
|
||||||
|
_isConnectionsInit = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getAuthString(int index) {
|
||||||
|
String result = 'Auth: ';
|
||||||
|
final hasKey = _connections[index].privateKey?.isNotEmpty ?? false;
|
||||||
|
final hasPass = _connections[index].password?.isNotEmpty ?? false;
|
||||||
|
|
||||||
|
if (hasKey) result += 'Private Key';
|
||||||
|
if (hasKey && hasPass) result += ', ';
|
||||||
|
if (hasPass) result += 'Password';
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showBottomSheet(BuildContext context, Connection? initialState) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => AddServerModal(updateConnectionList: _getConnections, initialState: initialState,)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
child: Icon(Icons.add),
|
||||||
|
onPressed: () => _showBottomSheet(context, null)
|
||||||
|
),
|
||||||
|
body: _isConnectionsInit ? RefreshIndicator(
|
||||||
|
onRefresh: () => _getConnections(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _connections.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
color: Theme.of(context).colorScheme.onSecondary,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: connect to connection here
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_connections[index].host!,
|
||||||
|
style: TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
Text('Port: ${_connections[index].port}'),
|
||||||
|
Text('Username: ${_connections[index].username}'),
|
||||||
|
Text(_getAuthString(index)),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text('Encryption: '),
|
||||||
|
Icon(
|
||||||
|
_connections[index].isEncryptionEnabled! ? Icons.check : Icons.close,
|
||||||
|
size: 18
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text('Default: '),
|
||||||
|
Icon(
|
||||||
|
_connections[index].isDefault! ? Icons.check : Icons.close,
|
||||||
|
size: 18
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(child: SizedBox()),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _showBottomSheet(context, _connections[index]),
|
||||||
|
icon: Icon(Icons.edit)
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
final storage = FlutterSecureStorage();
|
||||||
|
storage.delete(key: _connections[index].host!);
|
||||||
|
_getConnections();
|
||||||
|
},
|
||||||
|
icon: Icon(Icons.delete)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10,)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) : Center(child: CircularProgressIndicator())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
class Connection {
|
|
||||||
String? host;
|
|
||||||
int? port;
|
|
||||||
String? username;
|
|
||||||
String? privateKeyFilePath;
|
|
||||||
String? password;
|
|
||||||
|
|
||||||
Connection();
|
|
||||||
|
|
||||||
Connection.fromJson(Map<String, dynamic> json) {
|
|
||||||
host = json['host'];
|
|
||||||
port = json['port'];
|
|
||||||
username = json['username'];
|
|
||||||
privateKeyFilePath = json['privateKeyFilePath)'];
|
|
||||||
password = json['password'];
|
|
||||||
}
|
|
||||||
|
|
||||||
String get toJson => jsonEncode({
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'username': username,
|
|
||||||
'privateKeyFilePath': privateKeyFilePath,
|
|
||||||
'password': password
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
bool assertComplete() {
|
|
||||||
assert(host != null);
|
|
||||||
assert(port != null);
|
|
||||||
assert(username != null);
|
|
||||||
assert(privateKeyFilePath != null || password != null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue