import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:string_validator/string_validator.dart'; import '../controls/padding.dart'; import '../globals.dart'; import '../models/auth/basic_credentials.dart'; import '../models/auth/credentials_intf.dart'; import '../models/auth/oauth_credentials.dart'; import '../models/auth/profile.dart'; import '../routes.dart'; import '../services/auth_service.dart'; import '../utils/snackbar_builder.dart'; class SignInScreen extends StatefulWidget { const SignInScreen({super.key}); @override State createState() => _SignInScreenState(); } class _SignInScreenState extends State { static final _logger = Logger('$SignInScreen'); static const usernamePasswordType = 'Username/Password'; static const oauthType = 'Standard Login'; static final authTypes = [usernamePasswordType, oauthType]; final formKey = GlobalKey(); final usernameController = TextEditingController(); final serverNameController = TextEditingController(); final passwordController = TextEditingController(); var authType = oauthType; var hidePassword = true; var signInButtonEnabled = false; Profile? existingProfile; bool get showUsernameAndPasswordFields => authType == usernamePasswordType; bool get existingAccount => existingProfile != null; @override void initState() { super.initState(); final service = getIt(); if (service.loggedIn) { setCredentials(null, service.currentProfile); } else { newProfile(); } usernameController.addListener(() { _updateSignInButtonStatus(); }); passwordController.addListener(() { _updateSignInButtonStatus(); }); serverNameController.addListener(() { _updateSignInButtonStatus(); }); } void _updateSignInButtonStatus() { setState(() { signInButtonEnabled = switch (oauthType) { usernamePasswordType => serverNameController.text.isNotEmpty && usernameController.text.isNotEmpty && passwordController.text.isNotEmpty, oauthType => serverNameController.text.isNotEmpty, _ => false, }; }); } void newProfile() { usernameController.clear(); passwordController.clear(); serverNameController.clear(); authType = oauthType; signInButtonEnabled = false; existingProfile = null; } void setBasicCredentials(BasicCredentials credentials) { usernameController.text = credentials.username; passwordController.text = credentials.password; serverNameController.text = credentials.serverName; authType = usernamePasswordType; } void setOauthCredentials(OAuthCredentials credentials) { serverNameController.text = credentials.serverName; authType = oauthType; } void setCredentials(BuildContext? context, Profile profile) { final ICredentials credentials = profile.credentials; existingProfile = profile; signInButtonEnabled = !profile.loggedIn; if (credentials is BasicCredentials) { setBasicCredentials(credentials); return; } if (credentials is OAuthCredentials) { setOauthCredentials(credentials); return; } final msg = 'Unknown credentials type: ${credentials.runtimeType}'; _logger.severe(msg); if (context?.mounted ?? false) { buildSnackbar(context!, msg); } } @override Widget build(BuildContext context) { final service = getIt(); final loggedInProfiles = service.loggedInProfiles; final loggedOutProfiles = service.loggedOutProfiles; return Scaffold( appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Sign In'), IconButton( icon: const Icon(Icons.add), tooltip: 'Add new account', onPressed: () { newProfile(); setState(() {}); }, ) ], ), ), body: Padding( padding: const EdgeInsets.all(20.0), child: Form( key: formKey, child: Center( child: ListView( children: [ Center( child: DropdownButton( value: authType, items: authTypes .map( (a) => DropdownMenuItem(value: a, child: Text(a))) .toList(), onChanged: existingAccount ? null : (value) { if (existingAccount) { buildSnackbar(context, "Can't change the type on an existing account"); return; } authType = value!; setState(() {}); }), ), const VerticalPadding(), TextFormField( autocorrect: false, readOnly: existingAccount, autovalidateMode: AutovalidateMode.onUserInteraction, controller: serverNameController, validator: (value) => isFQDN(value ?? '') ? null : 'Not a valid server name', decoration: InputDecoration( hintText: 'Server Name (friendica.example.com)', border: OutlineInputBorder( borderSide: BorderSide( color: Theme .of(context) .colorScheme .background, ), borderRadius: BorderRadius.circular(5.0), ), labelText: 'Server Name', ), ), const VerticalPadding(), if (!showUsernameAndPasswordFields) ...[ Text( existingAccount ? 'Configured to sign in as user ${existingProfile ?.handle}' : 'Relatica will open the requested Friendica site in a web browser where you will be asked to authorize this client.', softWrap: true, ), const VerticalPadding(), ], if (showUsernameAndPasswordFields) ...[ TextFormField( readOnly: existingAccount, autovalidateMode: AutovalidateMode.onUserInteraction, controller: usernameController, keyboardType: TextInputType.emailAddress, validator: (value) { if (value == null) { return null; } if (value.contains('@')) { return isEmail(value) ? null : 'Not a valid Friendica Account Address'; } return isAlphanumeric(value.replaceAll('-', '')) ? null : 'Username should be alpha-numeric'; }, decoration: InputDecoration( prefixIcon: const Icon(Icons.alternate_email), hintText: 'Username (user@example.com)', border: OutlineInputBorder( borderSide: BorderSide( color: Theme .of(context) .colorScheme .background, ), borderRadius: BorderRadius.circular(5.0), ), labelText: 'Username', ), ), const VerticalPadding(), TextFormField( readOnly: existingAccount, obscureText: hidePassword, controller: passwordController, validator: (value) { if (value == null || value.isEmpty) { return 'Password field cannot be empty'; } return null; }, decoration: InputDecoration( prefixIcon: const Icon(Icons.password), suffixIcon: IconButton( onPressed: () { setState(() { hidePassword = !hidePassword; }); }, icon: hidePassword ? const Icon(Icons.remove_red_eye_outlined) : const Icon(Icons.remove_red_eye), ), hintText: 'Password', border: OutlineInputBorder( borderSide: BorderSide( color: Theme .of(context) .colorScheme .background, ), borderRadius: BorderRadius.circular(5.0), ), labelText: 'Password', ), ), const VerticalPadding(), ], signInButtonEnabled ? ElevatedButton( onPressed: () async => await _signIn(context), child: const Text('Signin'), ) : SizedBox(), const VerticalPadding(), Text( 'Logged out:', style: Theme .of(context) .textTheme .headlineSmall, ), loggedOutProfiles.isEmpty ? const Text( 'No logged out profiles', textAlign: TextAlign.center, ) : Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all( width: 0.5, )), child: Column( children: loggedOutProfiles.map((p) { return ListTile( onTap: () { setCredentials(context, p); setState(() {}); }, title: Text(p.handle), subtitle: Text(p.credentials is BasicCredentials ? usernamePasswordType : oauthType), trailing: ElevatedButton( onPressed: () async { final confirm = await showYesNoDialog(context, 'Remove login information from app?'); if (confirm ?? false) { await service.removeProfile(p); } setState(() {}); }, child: const Text('Remove'), ), ); }).toList(), ), ), const VerticalPadding(), Text( 'Logged in:', style: Theme .of(context) .textTheme .headlineSmall, ), loggedInProfiles.isEmpty ? const Text( 'No logged in profiles', textAlign: TextAlign.center, ) : Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all( width: 0.5, )), child: Column( children: loggedInProfiles.map((p) { final active = service.loggedIn ? p.id == service.currentProfile.id : false; return ListTile( onTap: () async { setCredentials(context, p); setState(() {}); }, title: Text( p.handle, style: active ? const TextStyle( fontWeight: FontWeight.bold, fontStyle: FontStyle.italic) : null, ), subtitle: Text( p.credentials is BasicCredentials ? usernamePasswordType : oauthType, style: active ? const TextStyle( fontWeight: FontWeight.bold, fontStyle: FontStyle.italic) : null, ), trailing: ElevatedButton( onPressed: () async { final confirm = await showYesNoDialog( context, 'Log out account?'); if (confirm == true) { await getIt().signOut(p); setState(() {}); } }, child: const Text('Sign out'), ), ); }).toList(), ), ), ], ), ), ), ), ); } Future _signIn(BuildContext context) async { final valid = formKey.currentState?.validate() ?? false; if (!valid) { buildSnackbar( context, 'Cannot login. Fix highlighted errors and try again.'); return; } ICredentials? creds; if (existingProfile != null) { creds = existingProfile?.credentials; } else { switch (authType) { case usernamePasswordType: creds = BasicCredentials( username: usernameController.text, password: passwordController.text, serverName: serverNameController.text); break; case oauthType: creds = OAuthCredentials.bootstrap(serverNameController.text); break; default: buildSnackbar(context, 'Unknown authorization type: $authType'); break; } } if (creds == null) { return; } buildSnackbar(context, 'Attempting to sign in account...'); final result = await getIt().signIn( creds, withNotification: false, ); if (mounted && result.isFailure) { buildSnackbar(context, 'Error signing in: ${result.error}'); return; } if (mounted) { buildSnackbar(context, 'Account signed in...'); } await getIt().setActiveProfile(result.value); if (mounted) { context.goNamed(ScreenPaths.timelines); } } }