diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4172e44..1501d43 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14,5 +14,17 @@ "general": "General", "connection": "Connection", "authentication": "Authentication", - "other": "Other" + "other": "Other", + "invalidPort": "Invalid port", + "invalidPath": "Invalid path", + "invalidIpDomain": "Invalid IP or domain", + "ipDomainNotEmpty": "IP or domain cannot be empty", + "nameNotEmpty": "Name cannot be empty", + "invalidUsernamePassword": "Invalid username or password", + "tooManyAttempts": "Too many attempts. Try again later.", + "cantReachServer": "Can't reach server. Check connection data.", + "sslError": "SSL error", + "unknownError": "Unknown error", + "connectionNotCreated": "Connection couldn't be created", + "connecting": "Connecting..." } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ac3d3b9..6814495 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -14,5 +14,17 @@ "general": "General", "connection": "Conexión", "authentication": "Autenticación", - "other": "Otros" + "other": "Otros", + "invalidPort": "Puerto no válido", + "invalidPath": "Ruta no válida", + "invalidIpDomain": "IP o dominio no válido", + "ipDomainNotEmpty": "IP o dominio no puede estar vacío", + "nameNotEmpty": "Name cannot be empty", + "invalidUsernamePassword": "Usuario o contraseña no válidos.", + "tooManyAttempts": "Demasiados intentos. Prueba de nuevo más tarde.", + "cantReachServer": "No se puede alcanzar el servidor. Comprueba los datos de conexión.", + "sslError": "Error de SSL", + "unknownError": "Error desconocido", + "connectionNotCreated": "No se pudo crear la conexión", + "connecting": "Conectando..." } \ No newline at end of file diff --git a/lib/models/server.dart b/lib/models/server.dart index 13e3d1b..b17bf04 100644 --- a/lib/models/server.dart +++ b/lib/models/server.dart @@ -1,14 +1,16 @@ class Server { - final String name; - final String connectionMethod; - final String domain; - final String? path; - final int? port; - final String user; - final String password; - final bool defaultServer; + final String id; + String name; + String connectionMethod; + String domain; + String? path; + int? port; + String user; + String password; + bool defaultServer; - const Server({ + Server({ + required this.id, required this.name, required this.connectionMethod, required this.domain, diff --git a/lib/providers/servers_provider.dart b/lib/providers/servers_provider.dart index b1cca2f..d1bec04 100644 --- a/lib/providers/servers_provider.dart +++ b/lib/providers/servers_provider.dart @@ -27,10 +27,87 @@ class ServersProvider with ChangeNotifier { notifyListeners(); } + Future createServer(Server server) async { + final saved = await saveServerIntoDb(server); + if (saved == true) { + if (server.defaultServer == true) { + final defaultServer = await setDefaultServer(server); + if (defaultServer == true) { + _serversList.add(server); + notifyListeners(); + return true; + } + else { + return false; + } + } + else { + _serversList.add(server); + notifyListeners(); + return true; + } + } + else { + return false; + } + } + + Future setDefaultServer(Server server) async { + final updated = await setDefaultServerDb(server.id); + if (updated == true) { + List newServers = _serversList.map((s) { + if (s.id == server.id) { + s.defaultServer = true; + return s; + } + else { + s.defaultServer = false; + return s; + } + }).toList(); + _serversList = newServers; + notifyListeners(); + return true; + } + else { + return false; + } + } + + Future saveServerIntoDb(Server server) async { + try { + return await _dbInstance!.transaction((txn) async { + await txn.rawInsert( + 'INSERT INTO servers (id, name, connectionMethod, domain, path, port, user, password, defaultServer) VALUES ("${server.id}", "${server.name}", "${server.connectionMethod}", "${server.domain}", "${server.path}", ${server.port}, "${server.user}", "${server.password}", 0)', + ); + return true; + }); + } catch (e) { + return false; + } + } + + Future setDefaultServerDb(String id) async { + try { + return await _dbInstance!.transaction((txn) async { + await txn.rawUpdate( + 'UPDATE servers SET defaultServer = 0 WHERE defaultServer = 1', + ); + await txn.rawUpdate( + 'UPDATE servers SET defaultServer = 1 WHERE id = "$id"', + ); + return true; + }); + } catch (e) { + return false; + } + } + void saveFromDb(List>? data) async { if (data != null) { for (var server in data) { final Server serverObj = Server( + id: server['id'], name: server['name'], connectionMethod: server['connectionMethod'], domain: server['domain'], @@ -38,10 +115,10 @@ class ServersProvider with ChangeNotifier { port: server['port'], user: server['user'], password: server['password'], - defaultServer: convertFromIntToBool(server['isDefaultServer'])!, + defaultServer: convertFromIntToBool(server['defaultServer'])!, ); _serversList.add(serverObj); - if (convertFromIntToBool(server['isDefaultServer']) == true) { + if (convertFromIntToBool(server['defaultServer']) == true) { _selectedServer = serverObj; } } diff --git a/lib/screens/connect/connect.dart b/lib/screens/connect/connect.dart index 888c8b2..3a9a7c6 100644 --- a/lib/screens/connect/connect.dart +++ b/lib/screens/connect/connect.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:adguard_home_manager/providers/servers_provider.dart'; class Connect extends StatelessWidget { const Connect({Key? key}) : super(key: key); @override Widget build(BuildContext context) { + final serversProvider = Provider.of(context); + return Container(); } } \ No newline at end of file diff --git a/lib/services/database.dart b/lib/services/database.dart index d698499..d243e26 100644 --- a/lib/services/database.dart +++ b/lib/services/database.dart @@ -8,7 +8,7 @@ Future> loadDb() async { 'adguard_home_manager.db', version: 1, onCreate: (Database db, int version) async { - await db.execute("CREATE TABLE servers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, connectionMethod TEXT, domain TEXT, path TEXT, port INTEGER, user TEXT, password TEXT, defaultServer INTEGER)"); + await db.execute("CREATE TABLE servers (id TEXT PRIMARY KEY, name TEXT, connectionMethod TEXT, domain TEXT, path TEXT, port INTEGER, user TEXT, password TEXT, defaultServer INTEGER)"); }, onUpgrade: (Database db, int oldVersion, int newVersion) async { diff --git a/lib/services/http_requests.dart b/lib/services/http_requests.dart new file mode 100644 index 0000000..35dc709 --- /dev/null +++ b/lib/services/http_requests.dart @@ -0,0 +1,47 @@ +// ignore_for_file: depend_on_referenced_packages + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'package:adguard_home_manager/models/server.dart'; + +Future login(Server server) async { + try { + final result = await http.post( + Uri.parse("${server.connectionMethod}://${server.domain}${server.path ?? ""}${server.port != null ? ':${server.port}' : ""}/control/login"), + body: jsonEncode({ + "name": server.user, + "password": server.password + }) + ); + if (result.statusCode == 200) { + return {'result': 'success'}; + } + else if (result.statusCode == 400) { + return { + 'result': 'error', + 'message': 'invalid_username_password' + }; + } + else if (result.statusCode == 429) { + return { + 'result': 'error', + 'message': 'many_attempts' + }; + } + else { + return {'result': 'error'}; + } + } on SocketException { + return {'result': 'no_connection'}; + } on TimeoutException { + return {'result': 'no_connection'}; + } on HandshakeException { + return {'result': 'ssl_error'}; + } catch (e) { + return {'result': 'error'}; + } +} \ No newline at end of file diff --git a/lib/widgets/add_server_modal.dart b/lib/widgets/add_server_modal.dart index 09b412c..8dfa88a 100644 --- a/lib/widgets/add_server_modal.dart +++ b/lib/widgets/add_server_modal.dart @@ -1,8 +1,15 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:provider/provider.dart'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:adguard_home_manager/widgets/custom_radio_toggle.dart'; +import 'package:adguard_home_manager/services/http_requests.dart'; +import 'package:adguard_home_manager/providers/servers_provider.dart'; +import 'package:adguard_home_manager/models/server.dart'; import 'package:adguard_home_manager/config/system_overlay_style.dart'; class AddServerModal extends StatefulWidget { @@ -13,7 +20,10 @@ class AddServerModal extends StatefulWidget { } class _AddServerModalState extends State { + final uuid = const Uuid(); + final TextEditingController nameController = TextEditingController(); + String? nameError; String connectionType = "http"; @@ -32,6 +42,10 @@ class _AddServerModalState extends State { bool defaultServer = false; + bool allDataValid = false; + + bool isConnecting = false; + Widget sectionLabel(String label) { return Padding( padding: const EdgeInsets.symmetric( @@ -53,12 +67,14 @@ class _AddServerModalState extends State { required TextEditingController controller, String? error, required IconData icon, - TextInputType? keyboardType + TextInputType? keyboardType, + Function(String)? onChanged, }) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: TextFormField( controller: controller, + onChanged: onChanged, decoration: InputDecoration( prefixIcon: Icon(icon), errorText: error, @@ -74,8 +90,168 @@ class _AddServerModalState extends State { ); } + void checkDataValid() { + if ( + nameController.text != '' && + ipDomainController.text != '' && + ipDomainError == null && + pathError == null && + portError == null && + userController.text != '' && + passwordController.text != '' + ) { + setState(() { + allDataValid = true; + }); + } + else { + setState(() { + allDataValid = false; + }); + } + } + + + void validatePort(String? value) { + if (value != null && value != '') { + if (int.tryParse(value) != null && int.parse(value) <= 65535) { + setState(() { + portError = null; + }); + } + else { + setState(() { + portError = AppLocalizations.of(context)!.invalidPort; + }); + } + } + else { + setState(() { + portError = null; + }); + } + checkDataValid(); + } + + void validateSubroute(String? value) { + if (value != null && value != '') { + RegExp subrouteRegexp = RegExp(r'^\/\b([A-Za-z0-9_\-~/]*)[^\/|\.|\:]$'); + if (subrouteRegexp.hasMatch(value) == true) { + setState(() { + pathError = null; + }); + } + else { + setState(() { + pathError = AppLocalizations.of(context)!.invalidPath; + }); + } + } + else { + setState(() { + pathError = null; + }); + } + checkDataValid(); + } + + void validateAddress(String? value) { + if (value != null && value != '') { + RegExp ipAddress = RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$'); + RegExp domain = RegExp(r'^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$'); + if (ipAddress.hasMatch(value) == true || domain.hasMatch(value) == true) { + setState(() { + ipDomainError = null; + }); + } + else { + setState(() { + ipDomainError = AppLocalizations.of(context)!.invalidIpDomain; + }); + } + } + else { + setState(() { + ipDomainError = AppLocalizations.of(context)!.ipDomainNotEmpty; + }); + } + checkDataValid(); + } + @override Widget build(BuildContext context) { + final serversProvider = Provider.of(context, listen: false); + + final mediaQuery = MediaQuery.of(context); + + void connect() async { + final Server serverObj = Server( + id: uuid.v4(), + name: nameController.text, + connectionMethod: connectionType, + domain: ipDomainController.text, + port: int.parse(portController.text), + user: userController.text, + password: passwordController.text, + defaultServer: defaultServer + ); + final result = await login(serverObj); + if (result['result'] == 'success') { + final serverCreated = await serversProvider.createServer(serverObj); + if (serverCreated == true) { + Navigator.pop(context); + } + else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.connectionNotCreated), + backgroundColor: Colors.red, + ) + ); + } + } + else if (result['result'] == 'error' && result['message'] == 'invalid_username_password') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.invalidUsernamePassword), + backgroundColor: Colors.red, + ) + ); + } + else if (result['result'] == 'error' && result['message'] == 'many_attempts') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.tooManyAttempts), + backgroundColor: Colors.red, + ) + ); + } + else if (result['result'] == 'error' && result['message'] == 'no_connection') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.cantReachServer), + backgroundColor: Colors.red, + ) + ); + } + else if (result['result'] == 'error' && result['message'] == 'ssl_error') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.sslError), + backgroundColor: Colors.red, + ) + ); + } + else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.unknownError), + backgroundColor: Colors.red, + ) + ); + } + } + return Stack( children: [ Scaffold( @@ -89,7 +265,9 @@ class _AddServerModalState extends State { padding: const EdgeInsets.only(right: 10), child: IconButton( tooltip: AppLocalizations.of(context)!.connect, - onPressed: () => {}, + onPressed: allDataValid == true + ? () => connect() + : null, icon: const Icon(Icons.login_rounded) ), ), @@ -102,7 +280,11 @@ class _AddServerModalState extends State { textField( label: AppLocalizations.of(context)!.name, controller: nameController, - icon: Icons.badge_rounded + icon: Icons.badge_rounded, + error: nameError, + onChanged: (value) => value == '' + ? setState(() => nameError = AppLocalizations.of(context)!.nameNotEmpty) + : setState(() => nameError = null) ), sectionLabel(AppLocalizations.of(context)!.connection), Row( @@ -128,14 +310,16 @@ class _AddServerModalState extends State { controller: ipDomainController, icon: Icons.link_rounded, error: ipDomainError, - keyboardType: TextInputType.url + keyboardType: TextInputType.url, + onChanged: validateAddress ), const SizedBox(height: 20), textField( label: AppLocalizations.of(context)!.path, controller: pathController, icon: Icons.route_rounded, - error: pathError + error: pathError, + onChanged: validateSubroute ), const SizedBox(height: 20), textField( @@ -143,20 +327,23 @@ class _AddServerModalState extends State { controller: portController, icon: Icons.numbers_rounded, error: portError, - keyboardType: TextInputType.number + keyboardType: TextInputType.number, + onChanged: validatePort ), sectionLabel(AppLocalizations.of(context)!.authentication), textField( label: AppLocalizations.of(context)!.username, controller: userController, icon: Icons.person_rounded, + onChanged: (_) => checkDataValid() ), const SizedBox(height: 20), textField( label: AppLocalizations.of(context)!.password, controller: passwordController, icon: Icons.lock_rounded, - keyboardType: TextInputType.visiblePassword + keyboardType: TextInputType.visiblePassword, + onChanged: (_) => checkDataValid() ), sectionLabel(AppLocalizations.of(context)!.other), Material( @@ -187,6 +374,39 @@ class _AddServerModalState extends State { const SizedBox(height: 20), ], ), + ), + AnimatedOpacity( + opacity: isConnecting == true ? 1 : 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: isConnecting == true ? false : true, + child: Scaffold( + backgroundColor: Colors.transparent, + body: Container( + width: mediaQuery.size.width, + height: mediaQuery.size.height, + color: const Color.fromRGBO(0, 0, 0, 0.7), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator( + color: Colors.white, + ), + const SizedBox(height: 30), + Text( + AppLocalizations.of(context)!.connecting, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 26 + ), + ) + ], + ), + ), + ), + ), ) ], ); diff --git a/pubspec.lock b/pubspec.lock index 6568f22..f0026b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" cupertino_icons: dependency: "direct main" description: @@ -355,6 +362,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 70f7590..01d71f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: dynamic_color: ^1.5.4 animations: ^2.0.5 device_info_plus: ^4.1.2 + uuid: ^3.0.6 dev_dependencies: flutter_test: