diff --git a/lib/add_server_modal.dart b/lib/add_server_modal.dart index d2810bd..0fa7f5d 100644 --- a/lib/add_server_modal.dart +++ b/lib/add_server_modal.dart @@ -1,13 +1,16 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:fluxcloud/user.dart'; +import 'package:fluxcloud/connection.dart'; class AddServerModal extends StatefulWidget { const AddServerModal({ - super.key, + super.key, required this.updateConnectionList, this.initialState, }); + final Function updateConnectionList; + final Connection? initialState; + @override State createState() => _AddServerModalState(); } @@ -21,9 +24,23 @@ class _AddServerModalState extends State { bool _fileSelected = false; bool _showPassword = false; + bool _showPrivateKey = false; + bool _isPrivateKeyValid = false; 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 Widget build(BuildContext context) { return SingleChildScrollView( @@ -41,11 +58,12 @@ class _AddServerModalState extends State { spacing: 16, children: [ Text( - 'Add Connection', + widget.initialState == null ? 'Add Connection' : 'Edit Connection', textAlign: TextAlign.left, style: TextStyle(fontSize: 18), ), TextFormField( + initialValue: widget.initialState?.host, decoration: InputDecoration( labelText: 'Host', ), @@ -61,6 +79,7 @@ class _AddServerModalState extends State { onSaved: (value) => _connection.host = value, ), TextFormField( + initialValue: widget.initialState?.port.toString(), keyboardType: TextInputType.number, decoration: InputDecoration( labelText: 'Port', @@ -77,6 +96,7 @@ class _AddServerModalState extends State { onSaved: (value) => _connection.port = int.parse(value!), ), TextFormField( + initialValue: widget.initialState?.username, decoration: InputDecoration( labelText: 'Username', ), @@ -85,30 +105,69 @@ class _AddServerModalState extends State { ), TextFormField( controller: _privateKeyFileController, + obscureText: !_showPrivateKey, readOnly: true, onTap: () async { - final XFile? result = await openFile(); - if (result != null) { - setState(() => _fileSelected = true); - _privateKeyFileController.text = result.name; - _connection.privateKeyFilePath = result.path; + final XFile? file = await openFile(); + if (file != null) { + const knownHeaders = [ + '-----BEGIN OPENSSH PRIVATE KEY-----', + '-----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( labelText: 'Private Key File', - suffixIcon: _fileSelected ? IconButton( - onPressed: () => setState(() { - _fileSelected = false; - _privateKeyFileController.clear(); - }), - icon: Icon(Icons.remove) + suffixIcon: _fileSelected ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => setState(() => _showPrivateKey = !_showPrivateKey), + icon: _showPrivateKey ? Icon(Icons.visibility) : Icon(Icons.visibility_off) + ), + IconButton( + onPressed: () => setState(() { + _fileSelected = false; + _privateKeyFileController.clear(); + }), + icon: Icon(Icons.remove) + ), + ], ) : null ), validator: (value) { - // TODO: validate the file to see if it is valid private key if (_privateKeyFileController.text.isEmpty && _passwordController.text.isEmpty) { return 'At least provide one of these'; } + if (!_isPrivateKeyValid) { + return 'Invalid private key file'; + } return null; } ), @@ -130,7 +189,18 @@ class _AddServerModalState extends State { }, 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( onPressed: () async { if (_formKey.currentState?.validate() == true) { @@ -138,12 +208,13 @@ class _AddServerModalState extends State { assert(_connection.assertComplete()); final storage = FlutterSecureStorage(); await storage.write(key: _connection.host!, value: _connection.toJson); + widget.updateConnectionList(); if (context.mounted) { Navigator.pop(context); } } }, - child: Text('Add Connection') + child: Text(widget.initialState == null ? 'Add Connection' : 'Save') ) ], ) diff --git a/lib/connection.dart b/lib/connection.dart new file mode 100644 index 0000000..bab5ee6 --- /dev/null +++ b/lib/connection.dart @@ -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 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; + } +} diff --git a/lib/main.dart b/lib/main.dart index 4656a3e..b396836 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:fluxcloud/add_server_modal.dart'; +import 'package:fluxcloud/sftp_connection_list.dart'; void main() { runApp(const MainApp()); @@ -23,26 +23,7 @@ class MainApp extends StatelessWidget { ), themeMode: ThemeMode.system, color: Color(0x002EC1EB), - home: Scaffold( - 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!'), - ), - ), + home: SftpConnectionList() ); } } diff --git a/lib/sftp_connection_list.dart b/lib/sftp_connection_list.dart new file mode 100644 index 0000000..ab78e5b --- /dev/null +++ b/lib/sftp_connection_list.dart @@ -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 createState() => _SftpConnectionListState(); +} + +class _SftpConnectionListState extends State { + + late List _connections; + bool _isConnectionsInit = false; + + @override + void initState() { + super.initState(); + _getConnections(); + } + + Future _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()) + ); + } + +} + diff --git a/lib/user.dart b/lib/user.dart deleted file mode 100644 index f3e9a05..0000000 --- a/lib/user.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:convert'; - -class Connection { - String? host; - int? port; - String? username; - String? privateKeyFilePath; - String? password; - - Connection(); - - Connection.fromJson(Map 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; - } -}