You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			511 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
			
		
		
	
	
			511 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
import 'package:flutter/material.dart';
 | 
						|
 | 
						|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | 
						|
import 'package:matrix/encryption.dart';
 | 
						|
import 'package:matrix/matrix.dart';
 | 
						|
 | 
						|
import 'package:fluffychat/config/themes.dart';
 | 
						|
import 'package:fluffychat/l10n/l10n.dart';
 | 
						|
import 'package:fluffychat/utils/error_reporter.dart';
 | 
						|
import 'package:fluffychat/utils/fluffy_share.dart';
 | 
						|
import 'package:fluffychat/utils/localized_exception_extension.dart';
 | 
						|
import 'package:fluffychat/utils/platform_infos.dart';
 | 
						|
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
 | 
						|
import 'package:fluffychat/widgets/future_loading_dialog.dart';
 | 
						|
import '../../utils/adaptive_bottom_sheet.dart';
 | 
						|
import '../key_verification/key_verification_dialog.dart';
 | 
						|
 | 
						|
class BootstrapDialog extends StatefulWidget {
 | 
						|
  final bool wipe;
 | 
						|
  final Client client;
 | 
						|
 | 
						|
  const BootstrapDialog({
 | 
						|
    super.key,
 | 
						|
    this.wipe = false,
 | 
						|
    required this.client,
 | 
						|
  });
 | 
						|
 | 
						|
  Future<bool?> show(BuildContext context) => showAdaptiveBottomSheet(
 | 
						|
        context: context,
 | 
						|
        builder: (context) => this,
 | 
						|
      );
 | 
						|
 | 
						|
  @override
 | 
						|
  BootstrapDialogState createState() => BootstrapDialogState();
 | 
						|
}
 | 
						|
 | 
						|
class BootstrapDialogState extends State<BootstrapDialog> {
 | 
						|
  final TextEditingController _recoveryKeyTextEditingController =
 | 
						|
      TextEditingController();
 | 
						|
 | 
						|
  late Bootstrap bootstrap;
 | 
						|
 | 
						|
  String? _recoveryKeyInputError;
 | 
						|
 | 
						|
  bool _recoveryKeyInputLoading = false;
 | 
						|
 | 
						|
  String? titleText;
 | 
						|
 | 
						|
  bool _recoveryKeyStored = false;
 | 
						|
  bool _recoveryKeyCopied = false;
 | 
						|
 | 
						|
  bool? _storeInSecureStorage = false;
 | 
						|
 | 
						|
  bool? _wipe;
 | 
						|
 | 
						|
  String get _secureStorageKey =>
 | 
						|
      'ssss_recovery_key_${bootstrap.client.userID}';
 | 
						|
 | 
						|
  bool get _supportsSecureStorage =>
 | 
						|
      PlatformInfos.isMobile || PlatformInfos.isDesktop;
 | 
						|
 | 
						|
  String _getSecureStorageLocalizedName() {
 | 
						|
    if (PlatformInfos.isAndroid) {
 | 
						|
      return L10n.of(context).storeInAndroidKeystore;
 | 
						|
    }
 | 
						|
    if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
 | 
						|
      return L10n.of(context).storeInAppleKeyChain;
 | 
						|
    }
 | 
						|
    return L10n.of(context).storeSecurlyOnThisDevice;
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  void initState() {
 | 
						|
    _createBootstrap(widget.wipe);
 | 
						|
    super.initState();
 | 
						|
  }
 | 
						|
 | 
						|
  void _createBootstrap(bool wipe) async {
 | 
						|
    _wipe = wipe;
 | 
						|
    titleText = null;
 | 
						|
    _recoveryKeyStored = false;
 | 
						|
    bootstrap =
 | 
						|
        widget.client.encryption!.bootstrap(onUpdate: (_) => setState(() {}));
 | 
						|
    final key = await const FlutterSecureStorage().read(key: _secureStorageKey);
 | 
						|
    if (key == null) return;
 | 
						|
    _recoveryKeyTextEditingController.text = key;
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    final theme = Theme.of(context);
 | 
						|
    _wipe ??= widget.wipe;
 | 
						|
    final buttons = <Widget>[];
 | 
						|
    Widget body = const CircularProgressIndicator.adaptive();
 | 
						|
    titleText = L10n.of(context).loadingPleaseWait;
 | 
						|
 | 
						|
    if (bootstrap.newSsssKey?.recoveryKey != null &&
 | 
						|
        _recoveryKeyStored == false) {
 | 
						|
      final key = bootstrap.newSsssKey!.recoveryKey;
 | 
						|
      titleText = L10n.of(context).recoveryKey;
 | 
						|
      return Scaffold(
 | 
						|
        appBar: AppBar(
 | 
						|
          centerTitle: true,
 | 
						|
          leading: IconButton(
 | 
						|
            icon: const Icon(Icons.close),
 | 
						|
            onPressed: Navigator.of(context).pop,
 | 
						|
          ),
 | 
						|
          title: Text(L10n.of(context).recoveryKey),
 | 
						|
        ),
 | 
						|
        body: Center(
 | 
						|
          child: ConstrainedBox(
 | 
						|
            constraints:
 | 
						|
                const BoxConstraints(maxWidth: FluffyThemes.columnWidth * 1.5),
 | 
						|
            child: ListView(
 | 
						|
              padding: const EdgeInsets.all(16.0),
 | 
						|
              children: [
 | 
						|
                ListTile(
 | 
						|
                  contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | 
						|
                  trailing: CircleAvatar(
 | 
						|
                    backgroundColor: Colors.transparent,
 | 
						|
                    child: Icon(
 | 
						|
                      Icons.info_outlined,
 | 
						|
                      color: theme.colorScheme.primary,
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                  subtitle: Text(L10n.of(context).chatBackupDescription),
 | 
						|
                ),
 | 
						|
                const Divider(
 | 
						|
                  height: 32,
 | 
						|
                  thickness: 1,
 | 
						|
                ),
 | 
						|
                TextField(
 | 
						|
                  minLines: 2,
 | 
						|
                  maxLines: 4,
 | 
						|
                  readOnly: true,
 | 
						|
                  style: const TextStyle(fontFamily: 'RobotoMono'),
 | 
						|
                  controller: TextEditingController(text: key),
 | 
						|
                  decoration: const InputDecoration(
 | 
						|
                    contentPadding: EdgeInsets.all(16),
 | 
						|
                    suffixIcon: Icon(Icons.key_outlined),
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
                const SizedBox(height: 16),
 | 
						|
                if (_supportsSecureStorage)
 | 
						|
                  CheckboxListTile.adaptive(
 | 
						|
                    contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | 
						|
                    value: _storeInSecureStorage,
 | 
						|
                    activeColor: theme.colorScheme.primary,
 | 
						|
                    onChanged: (b) {
 | 
						|
                      setState(() {
 | 
						|
                        _storeInSecureStorage = b;
 | 
						|
                      });
 | 
						|
                    },
 | 
						|
                    title: Text(_getSecureStorageLocalizedName()),
 | 
						|
                    subtitle:
 | 
						|
                        Text(L10n.of(context).storeInSecureStorageDescription),
 | 
						|
                  ),
 | 
						|
                const SizedBox(height: 16),
 | 
						|
                CheckboxListTile.adaptive(
 | 
						|
                  contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
 | 
						|
                  value: _recoveryKeyCopied,
 | 
						|
                  activeColor: theme.colorScheme.primary,
 | 
						|
                  onChanged: (b) {
 | 
						|
                    FluffyShare.share(key!, context);
 | 
						|
                    setState(() => _recoveryKeyCopied = true);
 | 
						|
                  },
 | 
						|
                  title: Text(L10n.of(context).copyToClipboard),
 | 
						|
                  subtitle: Text(L10n.of(context).saveKeyManuallyDescription),
 | 
						|
                ),
 | 
						|
                const SizedBox(height: 16),
 | 
						|
                ElevatedButton.icon(
 | 
						|
                  icon: const Icon(Icons.check_outlined),
 | 
						|
                  label: Text(L10n.of(context).next),
 | 
						|
                  onPressed:
 | 
						|
                      (_recoveryKeyCopied || _storeInSecureStorage == true)
 | 
						|
                          ? () {
 | 
						|
                              if (_storeInSecureStorage == true) {
 | 
						|
                                const FlutterSecureStorage().write(
 | 
						|
                                  key: _secureStorageKey,
 | 
						|
                                  value: key,
 | 
						|
                                );
 | 
						|
                              }
 | 
						|
                              setState(() => _recoveryKeyStored = true);
 | 
						|
                            }
 | 
						|
                          : null,
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      switch (bootstrap.state) {
 | 
						|
        case BootstrapState.loading:
 | 
						|
          break;
 | 
						|
        case BootstrapState.askWipeSsss:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.wipeSsss(_wipe!),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askBadSsss:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.ignoreBadSecrets(true),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askUseExistingSsss:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.useExistingSsss(!_wipe!),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askUnlockSsss:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.unlockedSsss(),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askNewSsss:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.newSsss(),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.openExistingSsss:
 | 
						|
          _recoveryKeyStored = true;
 | 
						|
          return Scaffold(
 | 
						|
            appBar: AppBar(
 | 
						|
              centerTitle: true,
 | 
						|
              leading: IconButton(
 | 
						|
                icon: const Icon(Icons.close),
 | 
						|
                onPressed: Navigator.of(context).pop,
 | 
						|
              ),
 | 
						|
              title: Text(L10n.of(context).chatBackup),
 | 
						|
            ),
 | 
						|
            body: Center(
 | 
						|
              child: ConstrainedBox(
 | 
						|
                constraints: const BoxConstraints(
 | 
						|
                  maxWidth: FluffyThemes.columnWidth * 1.5,
 | 
						|
                ),
 | 
						|
                child: ListView(
 | 
						|
                  padding: const EdgeInsets.all(16.0),
 | 
						|
                  children: [
 | 
						|
                    ListTile(
 | 
						|
                      contentPadding:
 | 
						|
                          const EdgeInsets.symmetric(horizontal: 8.0),
 | 
						|
                      trailing: Icon(
 | 
						|
                        Icons.info_outlined,
 | 
						|
                        color: theme.colorScheme.primary,
 | 
						|
                      ),
 | 
						|
                      subtitle: Text(
 | 
						|
                        L10n.of(context).pleaseEnterRecoveryKeyDescription,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                    const Divider(height: 32),
 | 
						|
                    TextField(
 | 
						|
                      minLines: 1,
 | 
						|
                      maxLines: 2,
 | 
						|
                      autocorrect: false,
 | 
						|
                      readOnly: _recoveryKeyInputLoading,
 | 
						|
                      autofillHints: _recoveryKeyInputLoading
 | 
						|
                          ? null
 | 
						|
                          : [AutofillHints.password],
 | 
						|
                      controller: _recoveryKeyTextEditingController,
 | 
						|
                      style: const TextStyle(fontFamily: 'RobotoMono'),
 | 
						|
                      decoration: InputDecoration(
 | 
						|
                        contentPadding: const EdgeInsets.all(16),
 | 
						|
                        hintStyle: TextStyle(
 | 
						|
                          fontFamily: theme.textTheme.bodyLarge?.fontFamily,
 | 
						|
                        ),
 | 
						|
                        prefixIcon: const Icon(Icons.key_outlined),
 | 
						|
                        labelText: L10n.of(context).recoveryKey,
 | 
						|
                        hintText: 'Es** **** **** ****',
 | 
						|
                        errorText: _recoveryKeyInputError,
 | 
						|
                        errorMaxLines: 2,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                    const SizedBox(height: 16),
 | 
						|
                    ElevatedButton.icon(
 | 
						|
                      style: ElevatedButton.styleFrom(
 | 
						|
                        foregroundColor: theme.colorScheme.onPrimary,
 | 
						|
                        iconColor: theme.colorScheme.onPrimary,
 | 
						|
                        backgroundColor: theme.colorScheme.primary,
 | 
						|
                      ),
 | 
						|
                      icon: _recoveryKeyInputLoading
 | 
						|
                          ? const CircularProgressIndicator.adaptive()
 | 
						|
                          : const Icon(Icons.lock_open_outlined),
 | 
						|
                      label: Text(L10n.of(context).unlockOldMessages),
 | 
						|
                      onPressed: _recoveryKeyInputLoading
 | 
						|
                          ? null
 | 
						|
                          : () async {
 | 
						|
                              setState(() {
 | 
						|
                                _recoveryKeyInputError = null;
 | 
						|
                                _recoveryKeyInputLoading = true;
 | 
						|
                              });
 | 
						|
                              try {
 | 
						|
                                final key = _recoveryKeyTextEditingController
 | 
						|
                                    .text
 | 
						|
                                    .trim();
 | 
						|
                                if (key.isEmpty) return;
 | 
						|
                                await bootstrap.newSsssKey!.unlock(
 | 
						|
                                  keyOrPassphrase: key,
 | 
						|
                                );
 | 
						|
                                await bootstrap.openExistingSsss();
 | 
						|
                                Logs().d('SSSS unlocked');
 | 
						|
                                if (bootstrap.encryption.crossSigning.enabled) {
 | 
						|
                                  Logs().v(
 | 
						|
                                    'Cross signing is already enabled. Try to self-sign',
 | 
						|
                                  );
 | 
						|
                                  try {
 | 
						|
                                    await bootstrap
 | 
						|
                                        .client.encryption!.crossSigning
 | 
						|
                                        .selfSign(recoveryKey: key);
 | 
						|
                                    Logs().d('Successful selfsigned');
 | 
						|
                                  } catch (e, s) {
 | 
						|
                                    Logs().e(
 | 
						|
                                      'Unable to self sign with recovery key after successfully open existing SSSS',
 | 
						|
                                      e,
 | 
						|
                                      s,
 | 
						|
                                    );
 | 
						|
                                  }
 | 
						|
                                }
 | 
						|
                              } on InvalidPassphraseException catch (e) {
 | 
						|
                                setState(
 | 
						|
                                  () => _recoveryKeyInputError =
 | 
						|
                                      e.toLocalizedString(context),
 | 
						|
                                );
 | 
						|
                              } on FormatException catch (_) {
 | 
						|
                                setState(
 | 
						|
                                  () => _recoveryKeyInputError =
 | 
						|
                                      L10n.of(context).wrongRecoveryKey,
 | 
						|
                                );
 | 
						|
                              } catch (e, s) {
 | 
						|
                                ErrorReporter(
 | 
						|
                                  context,
 | 
						|
                                  'Unable to open SSSS with recovery key',
 | 
						|
                                ).onErrorCallback(e, s);
 | 
						|
                                setState(
 | 
						|
                                  () => _recoveryKeyInputError =
 | 
						|
                                      e.toLocalizedString(context),
 | 
						|
                                );
 | 
						|
                              } finally {
 | 
						|
                                setState(
 | 
						|
                                  () => _recoveryKeyInputLoading = false,
 | 
						|
                                );
 | 
						|
                              }
 | 
						|
                            },
 | 
						|
                    ),
 | 
						|
                    const SizedBox(height: 16),
 | 
						|
                    Row(
 | 
						|
                      children: [
 | 
						|
                        const Expanded(child: Divider()),
 | 
						|
                        Padding(
 | 
						|
                          padding: const EdgeInsets.all(12.0),
 | 
						|
                          child: Text(L10n.of(context).or),
 | 
						|
                        ),
 | 
						|
                        const Expanded(child: Divider()),
 | 
						|
                      ],
 | 
						|
                    ),
 | 
						|
                    const SizedBox(height: 16),
 | 
						|
                    ElevatedButton.icon(
 | 
						|
                      icon: const Icon(Icons.cast_connected_outlined),
 | 
						|
                      label: Text(L10n.of(context).transferFromAnotherDevice),
 | 
						|
                      onPressed: _recoveryKeyInputLoading
 | 
						|
                          ? null
 | 
						|
                          : () async {
 | 
						|
                              final consent = await showOkCancelAlertDialog(
 | 
						|
                                context: context,
 | 
						|
                                title: L10n.of(context).verifyOtherDevice,
 | 
						|
                                message: L10n.of(context)
 | 
						|
                                    .verifyOtherDeviceDescription,
 | 
						|
                                okLabel: L10n.of(context).ok,
 | 
						|
                                cancelLabel: L10n.of(context).cancel,
 | 
						|
                              );
 | 
						|
                              if (consent != OkCancelResult.ok) return;
 | 
						|
                              final req = await showFutureLoadingDialog(
 | 
						|
                                context: context,
 | 
						|
                                delay: false,
 | 
						|
                                future: () async {
 | 
						|
                                  await widget.client.updateUserDeviceKeys();
 | 
						|
                                  return widget.client
 | 
						|
                                      .userDeviceKeys[widget.client.userID!]!
 | 
						|
                                      .startVerification();
 | 
						|
                                },
 | 
						|
                              );
 | 
						|
                              if (req.error != null) return;
 | 
						|
                              await KeyVerificationDialog(request: req.result!)
 | 
						|
                                  .show(context);
 | 
						|
                              Navigator.of(context, rootNavigator: false).pop();
 | 
						|
                            },
 | 
						|
                    ),
 | 
						|
                    const SizedBox(height: 16),
 | 
						|
                    ElevatedButton.icon(
 | 
						|
                      style: ElevatedButton.styleFrom(
 | 
						|
                        backgroundColor: theme.colorScheme.errorContainer,
 | 
						|
                        foregroundColor: theme.colorScheme.onErrorContainer,
 | 
						|
                        iconColor: theme.colorScheme.onErrorContainer,
 | 
						|
                      ),
 | 
						|
                      icon: const Icon(Icons.delete_outlined),
 | 
						|
                      label: Text(L10n.of(context).recoveryKeyLost),
 | 
						|
                      onPressed: _recoveryKeyInputLoading
 | 
						|
                          ? null
 | 
						|
                          : () async {
 | 
						|
                              if (OkCancelResult.ok ==
 | 
						|
                                  await showOkCancelAlertDialog(
 | 
						|
                                    useRootNavigator: false,
 | 
						|
                                    context: context,
 | 
						|
                                    title: L10n.of(context).recoveryKeyLost,
 | 
						|
                                    message: L10n.of(context).wipeChatBackup,
 | 
						|
                                    okLabel: L10n.of(context).ok,
 | 
						|
                                    cancelLabel: L10n.of(context).cancel,
 | 
						|
                                    isDestructive: true,
 | 
						|
                                  )) {
 | 
						|
                                setState(() => _createBootstrap(true));
 | 
						|
                              }
 | 
						|
                            },
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          );
 | 
						|
        case BootstrapState.askWipeCrossSigning:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.wipeCrossSigning(_wipe!),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askSetupCrossSigning:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.askSetupCrossSigning(
 | 
						|
              setupMasterKey: true,
 | 
						|
              setupSelfSigningKey: true,
 | 
						|
              setupUserSigningKey: true,
 | 
						|
            ),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.askWipeOnlineKeyBackup:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.wipeOnlineKeyBackup(_wipe!),
 | 
						|
          );
 | 
						|
 | 
						|
          break;
 | 
						|
        case BootstrapState.askSetupOnlineKeyBackup:
 | 
						|
          WidgetsBinding.instance.addPostFrameCallback(
 | 
						|
            (_) => bootstrap.askSetupOnlineKeyBackup(true),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.error:
 | 
						|
          titleText = L10n.of(context).oopsSomethingWentWrong;
 | 
						|
          body = const Icon(Icons.error_outline, color: Colors.red, size: 80);
 | 
						|
          buttons.add(
 | 
						|
            ElevatedButton(
 | 
						|
              onPressed: () =>
 | 
						|
                  Navigator.of(context, rootNavigator: false).pop<bool>(false),
 | 
						|
              child: Text(L10n.of(context).close),
 | 
						|
            ),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        case BootstrapState.done:
 | 
						|
          titleText = L10n.of(context).everythingReady;
 | 
						|
          body = Column(
 | 
						|
            mainAxisSize: MainAxisSize.min,
 | 
						|
            children: [
 | 
						|
              const Icon(
 | 
						|
                Icons.check_circle_rounded,
 | 
						|
                size: 120,
 | 
						|
                color: Colors.green,
 | 
						|
              ),
 | 
						|
              const SizedBox(height: 16),
 | 
						|
              Text(
 | 
						|
                L10n.of(context).yourChatBackupHasBeenSetUp,
 | 
						|
                style: const TextStyle(fontSize: 20),
 | 
						|
              ),
 | 
						|
              const SizedBox(height: 16),
 | 
						|
            ],
 | 
						|
          );
 | 
						|
          buttons.add(
 | 
						|
            ElevatedButton(
 | 
						|
              onPressed: () =>
 | 
						|
                  Navigator.of(context, rootNavigator: false).pop<bool>(false),
 | 
						|
              child: Text(L10n.of(context).close),
 | 
						|
            ),
 | 
						|
          );
 | 
						|
          break;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return Scaffold(
 | 
						|
      appBar: AppBar(
 | 
						|
        leading: Center(
 | 
						|
          child: CloseButton(
 | 
						|
            onPressed: () =>
 | 
						|
                Navigator.of(context, rootNavigator: false).pop<bool>(true),
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
        title: Text(titleText ?? L10n.of(context).loadingPleaseWait),
 | 
						|
      ),
 | 
						|
      body: Center(
 | 
						|
        child: Padding(
 | 
						|
          padding: const EdgeInsets.all(20.0),
 | 
						|
          child: Column(
 | 
						|
            mainAxisSize: MainAxisSize.min,
 | 
						|
            crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
            children: [
 | 
						|
              body,
 | 
						|
              const SizedBox(height: 8),
 | 
						|
              ...buttons,
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |