import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:OpenContacts/client_holder.dart'; import 'package:OpenContacts/clients/api_client.dart'; import 'package:OpenContacts/models/authentication_data.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({this.onLoginSuccessful, this.cachedUsername, super.key}); final String? cachedUsername; final Function(AuthenticationData)? onLoginSuccessful; @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _totpController = TextEditingController(); final ScrollController _scrollController = ScrollController(); late final FocusNode _passwordFocusNode; late final FocusNode _totpFocusNode; bool _isLoading = false; bool _isEmailResetSend = false; String _error = ""; bool _needsTotp = false; double get _errorOpacity => _error.isEmpty ? 0.0 : 1.0; @override void initState() { super.initState(); _usernameController.text = widget.cachedUsername ?? ""; _passwordFocusNode = FocusNode(); _totpFocusNode = FocusNode(); } @override void dispose() { _passwordFocusNode.dispose(); _totpFocusNode.dispose(); super.dispose(); } RegExp emailReg = RegExp( r"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$", caseSensitive: false, multiLine: false, ); Future submit() async { if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { setState(() { _error = "Please enter a valid username/password combination."; }); return; } setState(() { _error = ""; _isLoading = true; }); try { final authData = await ApiClient.tryLogin( username: _usernameController.text, password: _passwordController.text, oneTimePad: _totpController.text.isEmpty ? null : _totpController.text, ); if (!authData.isAuthenticated) { setState(() { _error = "Login unsuccessful: Server sent invalid response."; _isLoading = false; }); return; } setState(() { _error = ""; _isLoading = false; }); await loginSuccessful(authData); } catch (e, s) { setState(() { if (e == ApiClient.totpKey) { if (_needsTotp == false) { _error = "Please enter your 2FA-Code"; _totpFocusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCirc); }); } else { _error = "The given 2FA code is not valid."; } _needsTotp = true; } else { _error = "Login unsuccessful: $e."; } if (kDebugMode) { FlutterError.reportError(FlutterErrorDetails( exception: e, stack: s, )); } _isLoading = false; }); } } Future passwordResetSubmit() async { if (_usernameController.text.isEmpty) { setState(() { _error = "Please provide an email on the 'Username' textbox"; }); return; } if (_usernameController.text.contains(emailReg)) { setState(() { _error = "An email to reset your password has been requested to resonite."; _isEmailResetSend = true; }); return; } setState(() { _error = ""; _isEmailResetSend = true; }); /*try { final authData = await ApiClient.tryLogin( username: _usernameController.text, password: _passwordController.text, oneTimePad: _totpController.text.isEmpty ? null : _totpController.text, ); if (!authData.isAuthenticated) { setState(() { _error = "Login unsuccessful: Server sent invalid response."; _isLoading = false; }); return; } setState(() { _error = ""; _isLoading = false; }); await loginSuccessful(authData); } catch (e, s) { setState(() { if (e == ApiClient.totpKey) { if (_needsTotp == false) { _error = "Please enter your 2FA-Code"; _totpFocusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCirc); }); } else { _error = "The given 2FA code is not valid."; } _needsTotp = true; } else { _error = "Login unsuccessful: $e."; } if (kDebugMode) { FlutterError.reportError(FlutterErrorDetails( exception: e, stack: s, )); } _isLoading = false; }); }*/ } Future loginSuccessful(AuthenticationData authData) async { final settingsClient = ClientHolder.of(context).settingsClient; final notificationManager = FlutterLocalNotificationsPlugin(); if (settingsClient.currentSettings.notificationsDenied.value == null) { if (context.mounted) { await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("This app needs to ask your permission to send background notifications."), content: const Text("Are you okay with that?"), actions: [ TextButton( onPressed: () async { Navigator.of(context).pop(); await settingsClient .changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: true)); }, child: const Text("No"), ), TextButton( onPressed: () async { Navigator.of(context).pop(); final requestResult = switch (Platform.operatingSystem) { "android" => await notificationManager .resolvePlatformSpecificImplementation() ?.requestNotificationsPermission(), "fuschia" => null, // "fuschia" is not supported by flutter_local_notifications "ios" => await notificationManager .resolvePlatformSpecificImplementation() ?.requestPermissions(alert: true, badge: true, sound: true), "linux" => null, // don't want to deal with this right now "macos" => await notificationManager .resolvePlatformSpecificImplementation() ?.requestPermissions(alert: true, badge: true, sound: true), "windows" => null, // also don't want to deal with this right now _ => null, }; await settingsClient.changeSettings(settingsClient.currentSettings .copyWith(notificationsDenied: requestResult == null ? false : !requestResult)); }, child: const Text("Yes"), ) ], ); }, ); } } await widget.onLoginSuccessful?.call(authData); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("OpenContacts"), ), body: Builder(builder: (context) { return ListView( controller: _scrollController, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 64), child: Center( child: Text("Sign In", style: Theme.of(context).textTheme.headlineMedium), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( controller: _usernameController, onEditingComplete: () => _passwordFocusNode.requestFocus(), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), border: OutlineInputBorder( borderRadius: BorderRadius.circular(32), ), labelText: 'Username', ), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( controller: _passwordController, focusNode: _passwordFocusNode, onEditingComplete: submit, obscureText: true, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), border: OutlineInputBorder(borderRadius: BorderRadius.circular(32)), labelText: 'Password', ), ), ), if (_needsTotp) Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( controller: _totpController, focusNode: _totpFocusNode, onEditingComplete: submit, obscureText: false, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), border: OutlineInputBorder( borderRadius: BorderRadius.circular(32), ), labelText: '2FA Code', ), ), ), Padding( padding: const EdgeInsets.only(top: 16), child: _isLoading ? const Center(child: CircularProgressIndicator()) : TextButton.icon( onPressed: submit, icon: const Icon(Icons.login), label: const Text("Login"), ), ), Padding( padding: const EdgeInsets.only(top: 16), child: _isEmailResetSend ? const Center(child: CircularProgressIndicator()) : TextButton.icon( onPressed: passwordResetSubmit, icon: const Icon(Icons.refresh), label: const Text("Forgot Password?"), ), ), Center( child: AnimatedOpacity( opacity: _errorOpacity, duration: const Duration(milliseconds: 200), child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: Text(_error, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.red)), ), ), ) ], ); }), ); } }