feat: New simplified login process with more prominent SSO and nicer layout
This commit is contained in:
parent
865cbe9618
commit
bcf0d5e238
13 changed files with 391 additions and 997 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'homeserver_bottom_sheet.dart';
|
||||
import 'homeserver_picker.dart';
|
||||
|
||||
class HomeserverAppBar extends StatelessWidget {
|
||||
|
|
@ -13,25 +16,57 @@ class HomeserverAppBar extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
focusNode: controller.homeserverFocusNode,
|
||||
controller: controller.homeserverController,
|
||||
onChanged: controller.onChanged,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Navigator.of(context).canPop()
|
||||
? IconButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
)
|
||||
: null,
|
||||
prefixText: '${L10n.of(context)!.homeserver}: ',
|
||||
hintText: L10n.of(context)!.enterYourHomeserver,
|
||||
suffixIcon: const Icon(Icons.search),
|
||||
errorText: controller.error,
|
||||
return TypeAheadField<HomeserverBenchmarkResult>(
|
||||
suggestionsBoxDecoration: SuggestionsBoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
elevation: Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4,
|
||||
shadowColor: Theme.of(context).appBarTheme.shadowColor ?? Colors.black,
|
||||
constraints: const BoxConstraints(maxHeight: 256),
|
||||
),
|
||||
itemBuilder: (context, homeserver) => ListTile(
|
||||
title: Text(homeserver.homeserver.baseUrl.toString()),
|
||||
subtitle: Text(homeserver.homeserver.description ?? ''),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.info_outlined),
|
||||
onPressed: () => showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => HomeserverBottomSheet(
|
||||
homeserver: homeserver,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
suggestionsCallback: (pattern) async {
|
||||
final homeserverList =
|
||||
await const JoinmatrixOrgParser().fetchHomeservers();
|
||||
final benchmark = await HomeserverListProvider.benchmarkHomeserver(
|
||||
homeserverList,
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
return benchmark;
|
||||
},
|
||||
onSuggestionSelected: (suggestion) {
|
||||
controller.homeserverController.text =
|
||||
suggestion.homeserver.baseUrl.host;
|
||||
controller.checkHomeserverAction();
|
||||
},
|
||||
textFieldConfiguration: TextFieldConfiguration(
|
||||
controller: controller.homeserverController,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Navigator.of(context).canPop()
|
||||
? IconButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
)
|
||||
: null,
|
||||
prefixText: '${L10n.of(context)!.homeserver}: ',
|
||||
hintText: L10n.of(context)!.enterYourHomeserver,
|
||||
suffixIcon: const Icon(Icons.search),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: controller.checkHomeserverAction,
|
||||
autocorrect: false,
|
||||
),
|
||||
readOnly: !AppConfig.allowOtherHomeservers,
|
||||
onSubmitted: (_) => controller.checkHomeserverAction(),
|
||||
autocorrect: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import 'package:adaptive_dialog/adaptive_dialog.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pages/homeserver_picker/homeserver_bottom_sheet.dart';
|
||||
import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart';
|
||||
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../utils/localized_exception_extension.dart';
|
||||
|
||||
|
|
@ -35,14 +35,8 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
|||
final TextEditingController homeserverController = TextEditingController(
|
||||
text: AppConfig.defaultHomeserver,
|
||||
);
|
||||
final FocusNode homeserverFocusNode = FocusNode();
|
||||
String? error;
|
||||
List<HomeserverBenchmarkResult>? benchmarkResults;
|
||||
bool displayServerList = false;
|
||||
|
||||
bool get loadingHomeservers =>
|
||||
AppConfig.allowOtherHomeservers && benchmarkResults == null;
|
||||
String searchTerm = '';
|
||||
String? error;
|
||||
|
||||
bool isTorBrowser = false;
|
||||
|
||||
|
|
@ -65,98 +59,34 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
|||
isTorBrowser = isTor;
|
||||
}
|
||||
|
||||
void _updateFocus() {
|
||||
if (benchmarkResults == null) _loadHomeserverList();
|
||||
if (homeserverFocusNode.hasFocus) {
|
||||
setState(() {
|
||||
displayServerList = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void showServerInfo(HomeserverBenchmarkResult server) =>
|
||||
showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => HomeserverBottomSheet(
|
||||
homeserver: server,
|
||||
),
|
||||
);
|
||||
|
||||
void onChanged(String text) => setState(() {
|
||||
searchTerm = text;
|
||||
});
|
||||
|
||||
List<HomeserverBenchmarkResult> get filteredHomeservers => benchmarkResults!
|
||||
.where(
|
||||
(element) =>
|
||||
element.homeserver.baseUrl.host.contains(searchTerm) ||
|
||||
(element.homeserver.description?.contains(searchTerm) ?? false),
|
||||
)
|
||||
.toList();
|
||||
|
||||
void _loadHomeserverList() async {
|
||||
try {
|
||||
final homeserverList =
|
||||
await const JoinmatrixOrgParser().fetchHomeservers();
|
||||
final benchmark = await HomeserverListProvider.benchmarkHomeserver(
|
||||
homeserverList,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
benchmarkResults = benchmark;
|
||||
});
|
||||
} catch (e, s) {
|
||||
Logs().e('Homeserver benchmark failed', e, s);
|
||||
benchmarkResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
void setServer(String server) => setState(() {
|
||||
homeserverController.text = server;
|
||||
searchTerm = '';
|
||||
homeserverFocusNode.unfocus();
|
||||
displayServerList = false;
|
||||
});
|
||||
String? _lastCheckedUrl;
|
||||
|
||||
/// Starts an analysis of the given homeserver. It uses the current domain and
|
||||
/// makes sure that it is prefixed with https. Then it searches for the
|
||||
/// well-known information and forwards to the login page depending on the
|
||||
/// login type.
|
||||
Future<void> checkHomeserverAction() async {
|
||||
Future<void> checkHomeserverAction([_]) async {
|
||||
homeserverController.text =
|
||||
homeserverController.text.trim().toLowerCase().replaceAll(' ', '-');
|
||||
if (homeserverController.text == _lastCheckedUrl) return;
|
||||
_lastCheckedUrl = homeserverController.text;
|
||||
setState(() {
|
||||
homeserverFocusNode.unfocus();
|
||||
error = null;
|
||||
error = _rawLoginTypes = loginHomeserverSummary = null;
|
||||
isLoading = true;
|
||||
searchTerm = '';
|
||||
displayServerList = false;
|
||||
});
|
||||
|
||||
try {
|
||||
homeserverController.text =
|
||||
homeserverController.text.trim().toLowerCase().replaceAll(' ', '-');
|
||||
var homeserver = Uri.parse(homeserverController.text);
|
||||
if (homeserver.scheme.isEmpty) {
|
||||
homeserver = Uri.https(homeserverController.text, '');
|
||||
}
|
||||
final matrix = Matrix.of(context);
|
||||
matrix.loginHomeserverSummary =
|
||||
await matrix.getLoginClient().checkHomeserver(homeserver);
|
||||
final ssoSupported = matrix.loginHomeserverSummary!.loginFlows
|
||||
.any((flow) => flow.type == 'm.login.sso');
|
||||
|
||||
try {
|
||||
await Matrix.of(context).getLoginClient().register();
|
||||
matrix.loginRegistrationSupported = true;
|
||||
} on MatrixException catch (e) {
|
||||
matrix.loginRegistrationSupported = e.requireAdditionalAuthentication;
|
||||
}
|
||||
|
||||
if (!ssoSupported && matrix.loginRegistrationSupported == false) {
|
||||
// Server does not support SSO or registration. We can skip to login page:
|
||||
VRouter.of(context).to('login');
|
||||
} else {
|
||||
VRouter.of(context).to('connect');
|
||||
final client = Matrix.of(context).getLoginClient();
|
||||
loginHomeserverSummary = await client.checkHomeserver(homeserver);
|
||||
if (supportsSso) {
|
||||
_rawLoginTypes = await client.request(
|
||||
RequestType.GET,
|
||||
'/client/r0/login',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => error = (e).toLocalizedString(context));
|
||||
|
|
@ -167,17 +97,71 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
homeserverFocusNode.removeListener(_updateFocus);
|
||||
super.dispose();
|
||||
HomeserverSummary? loginHomeserverSummary;
|
||||
|
||||
bool _supportsFlow(String flowType) =>
|
||||
loginHomeserverSummary?.loginFlows.any((flow) => flow.type == flowType) ??
|
||||
false;
|
||||
|
||||
bool get supportsSso => _supportsFlow('m.login.sso');
|
||||
|
||||
bool isDefaultPlatform =
|
||||
(PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS);
|
||||
|
||||
bool get supportsPasswordLogin => _supportsFlow('m.login.password');
|
||||
|
||||
Map<String, dynamic>? _rawLoginTypes;
|
||||
|
||||
void ssoLoginAction(String id) async {
|
||||
final redirectUrl = kIsWeb
|
||||
? '${html.window.origin!}/web/auth.html'
|
||||
: isDefaultPlatform
|
||||
? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login'
|
||||
: 'http://localhost:3001//login';
|
||||
final url =
|
||||
'${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}';
|
||||
final urlScheme = isDefaultPlatform
|
||||
? Uri.parse(redirectUrl).scheme
|
||||
: "http://localhost:3001";
|
||||
final result = await FlutterWebAuth2.authenticate(
|
||||
url: url,
|
||||
callbackUrlScheme: urlScheme,
|
||||
);
|
||||
final token = Uri.parse(result).queryParameters['loginToken'];
|
||||
if (token?.isEmpty ?? false) return;
|
||||
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => Matrix.of(context).getLoginClient().login(
|
||||
LoginType.mLoginToken,
|
||||
token: token,
|
||||
initialDeviceDisplayName: PlatformInfos.clientName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<IdentityProvider>? get identityProviders {
|
||||
final loginTypes = _rawLoginTypes;
|
||||
if (loginTypes == null) return null;
|
||||
final rawProviders = loginTypes.tryGetList('flows')!.singleWhere(
|
||||
(flow) => flow['type'] == AuthenticationTypes.sso,
|
||||
)['identity_providers'];
|
||||
final list = (rawProviders as List)
|
||||
.map((json) => IdentityProvider.fromJson(json))
|
||||
.toList();
|
||||
if (PlatformInfos.isCupertinoStyle) {
|
||||
list.sort((a, b) => a.brand == 'apple' ? -1 : 1);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void login() => VRouter.of(context).to('login');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
homeserverFocusNode.addListener(_updateFocus);
|
||||
_checkTorBrowser();
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(checkHomeserverAction);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -204,3 +188,20 @@ class HomeserverPickerController extends State<HomeserverPicker> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IdentityProvider {
|
||||
final String? id;
|
||||
final String? name;
|
||||
final String? icon;
|
||||
final String? brand;
|
||||
|
||||
IdentityProvider({this.id, this.name, this.icon, this.brand});
|
||||
|
||||
factory IdentityProvider.fromJson(Map<String, dynamic> json) =>
|
||||
IdentityProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
icon: json['icon'],
|
||||
brand: json['brand'],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/widgets/layouts/login_scaffold.dart';
|
||||
import '../../config/themes.dart';
|
||||
import '../../widgets/mxc_image.dart';
|
||||
import 'homeserver_app_bar.dart';
|
||||
import 'homeserver_picker.dart';
|
||||
|
||||
|
|
@ -16,15 +16,19 @@ class HomeserverPickerView extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final benchmarkResults = controller.benchmarkResults;
|
||||
final identityProviders = controller.identityProviders;
|
||||
final errorText = controller.error;
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 12,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
child: HomeserverAppBar(controller: controller),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: HomeserverAppBar(controller: controller),
|
||||
),
|
||||
// display a prominent banner to import session for TOR browser
|
||||
// users. This feature is just some UX sugar as TOR users are
|
||||
// usually forced to logout as TOR browser is non-persistent
|
||||
|
|
@ -49,108 +53,119 @@ class HomeserverPickerView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Expanded(
|
||||
child: controller.displayServerList
|
||||
? ListView(
|
||||
child: controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator.adaptive())
|
||||
: ListView(
|
||||
children: [
|
||||
if (controller.displayServerList)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onInverseSurface,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: benchmarkResults == null
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: CircularProgressIndicator
|
||||
.adaptive(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: controller.filteredHomeservers
|
||||
.map(
|
||||
(server) => ListTile(
|
||||
trailing: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.info_outlined,
|
||||
color: Colors.black,
|
||||
),
|
||||
onPressed: () => controller
|
||||
.showServerInfo(server),
|
||||
),
|
||||
onTap: () => controller.setServer(
|
||||
server.homeserver.baseUrl.host,
|
||||
),
|
||||
title: Text(
|
||||
server.homeserver.baseUrl.host,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
server.homeserver.description ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
Image.asset(
|
||||
'assets/info-logo.png',
|
||||
height: 96,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
thickness: 1,
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
L10n.of(context)!.continueWith,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
thickness: 1,
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Image.asset(
|
||||
'assets/banner_transparent.png',
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => launchUrlString(AppConfig.privacyUrl),
|
||||
child: Text(L10n.of(context)!.privacy),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.restoreBackup,
|
||||
child: Text(L10n.of(context)!.hydrate),
|
||||
),
|
||||
Hero(
|
||||
tag: 'loginButton',
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
errorText,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
L10n.of(context)!
|
||||
.pleaseTryAgainLaterOrChooseDifferentServer,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (identityProviders != null) ...[
|
||||
...identityProviders.map(
|
||||
(provider) => _LoginButton(
|
||||
icon: provider.icon == null
|
||||
? const Icon(Icons.open_in_new_outlined)
|
||||
: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: MxcImage(
|
||||
placeholder: (_) =>
|
||||
const Icon(Icons.web_outlined),
|
||||
uri: Uri.parse(provider.icon!),
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
label: provider.name ??
|
||||
provider.brand ??
|
||||
L10n.of(context)!.singlesignon,
|
||||
onPressed: () =>
|
||||
controller.ssoLoginAction(provider.id!),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (controller.supportsPasswordLogin)
|
||||
_LoginButton(
|
||||
onPressed: controller.login,
|
||||
icon: const Icon(Icons.login_outlined),
|
||||
label: L10n.of(context)!.signInWithPassword,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: controller.restoreBackup,
|
||||
child: Text(L10n.of(context)!.hydrate),
|
||||
),
|
||||
),
|
||||
onPressed: controller.isLoading
|
||||
? null
|
||||
: controller.checkHomeserverAction,
|
||||
icon: const Icon(Icons.start_outlined),
|
||||
label: controller.isLoading
|
||||
? const LinearProgressIndicator()
|
||||
: Text(L10n.of(context)!.letsStart),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -158,3 +173,33 @@ class HomeserverPickerView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final void Function() onPressed;
|
||||
|
||||
const _LoginButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
label: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue