From 8792adf3c536b000fb5dcef5d6d22bc4a016a9a9 Mon Sep 17 00:00:00 2001 From: Giorgio Azzinnaro Date: Thu, 19 Dec 2024 13:06:35 +0100 Subject: [PATCH 01/20] feat: allow uploading images for groups --- android/app/src/main/AndroidManifest.xml | 6 + lib/actions/groups.dart | 20 ++ lib/epics/groups.dart | 62 ++++-- lib/main.dart | 2 +- lib/presentation/containers/group_form.dart | 6 +- .../containers/group_form.freezed.dart | 16 +- lib/presentation/containers/home.dart | 5 +- lib/presentation/screens/group_create.dart | 8 +- lib/presentation/screens/profile.dart | 114 ++--------- lib/presentation/widgets/group_form.dart | 45 +++-- .../widgets/image_form_field.dart | 190 ++++++++++++++++++ lib/presentation/widgets/profile_picture.dart | 47 ++--- lib/presentation/widgets/widgets.dart | 1 + macos/Runner/AppDelegate.swift | 4 + pubspec.lock | 24 +++ pubspec.yaml | 1 + web/index.html | 4 + 17 files changed, 387 insertions(+), 168 deletions(-) create mode 100644 lib/presentation/widgets/image_form_field.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d365e371..5f04d32f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ android:label="GRUP" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + combineEpics([ +createGroupsEpics(GroupsRepository groups, StorageRepository storage) => + combineEpics([ _createRetrieveAllGroupsEpic(groups), _createRetrieveOneGroupEpic(groups), - _createCreateOneGroupEpic(groups), - _createUpdateOneGroupEpic(groups), + _createCreateOneGroupEpic(groups, storage), + _createUpdateOneGroupEpic(groups, storage), _createDeleteOneGroupEpic(groups), _reloadGroupOnScheduleDateChange, _loadGroupsOnSignInEpic, @@ -103,28 +105,46 @@ Epic _createRetrieveOneGroupEpic(GroupsRepository groups) { } /// Create a group -Epic _createCreateOneGroupEpic(GroupsRepository groups) { - return (Stream actions, EpicStore store) => actions - .whereType>() - .asyncMap( - (action) => groups - .createGroup(action.entity) - .then((group) => SuccessCreateOne(group)) - .catchError( - (error) => FailCreateOne(entity: action.entity, error: error)), +Epic _createCreateOneGroupEpic( + GroupsRepository groups, StorageRepository storage) { + return (Stream actions, EpicStore store) => + actions.whereType().asyncMap( + (action) async { + final picture = action.image != null + ? await storage.uploadPublicXFile( + const Uuid().v7(), action.image!) + : null; + + final group = action.group.copyWith(picture: picture); + + return groups + .createGroup(group) + .then((group) => SuccessCreateOne(group)) + .catchError( + (error) => FailCreateOne(entity: group, error: error)); + }, ); } /// Update a group -Epic _createUpdateOneGroupEpic(GroupsRepository groups) { - return (Stream actions, EpicStore store) => actions - .whereType>() - .asyncMap( - (action) => groups - .updateGroup(action.entity) - .then((group) => SuccessUpdateOne(group)) - .catchError( - (error) => FailUpdateOne(entity: action.entity, error: error)), +Epic _createUpdateOneGroupEpic( + GroupsRepository groups, StorageRepository storage) { + return (Stream actions, EpicStore store) => + actions.whereType().asyncMap( + (action) async { + final picture = action.image != null + ? await storage.uploadPublicXFile( + const Uuid().v7(), action.image!) + : null; + + final group = action.group.copyWith(picture: picture); + + return groups + .updateGroup(group) + .then((group) => SuccessUpdateOne(group)) + .catchError( + (error) => FailUpdateOne(entity: group, error: error)); + }, ); } diff --git a/lib/main.dart b/lib/main.dart index d4ee5862..fcbde17e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -83,7 +83,7 @@ Future> _initStore(SupabaseClient supabase) async { createRouterEpics(router), createAuthEpics(), createDefaultRulesEpics(defaultRulesRepository), - createGroupsEpics(groupsRepository), + createGroupsEpics(groupsRepository, storageRepository), createMembersEpic(membersRepository), createInvitesEpics(invitesRepository), createProfileEpics(profilesRepository, storageRepository), diff --git a/lib/presentation/containers/group_form.dart b/lib/presentation/containers/group_form.dart index c7eb9ae7..d6f60625 100644 --- a/lib/presentation/containers/group_form.dart +++ b/lib/presentation/containers/group_form.dart @@ -2,11 +2,11 @@ import 'package:flutter/foundation.dart'; // ignore: unused_import import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:parousia/actions/actions.dart'; import 'package:parousia/models/models.dart'; import 'package:parousia/presentation/presentation.dart'; import 'package:parousia/state/state.dart'; import 'package:redux/redux.dart'; -import 'package:redux_entity/redux_entity.dart'; part 'group_form.freezed.dart'; @@ -35,7 +35,7 @@ class GroupFormContainer extends StatelessWidget { sealed class _ViewModel with _$ViewModel { const factory _ViewModel({ required bool loading, - required ValueSetter onSave, + required OnGroupSaveCallback onSave, Group? group, }) = __ViewModel; @@ -49,7 +49,7 @@ sealed class _ViewModel with _$ViewModel { (store.state.groups.loadingIds[groupId] ?? false), // TODO unique action per source onSave: (group) => store.dispatch( - RequestUpdateOne(group), + UpdateGroupAction(group: group.$1, image: group.$2), ), ); } diff --git a/lib/presentation/containers/group_form.freezed.dart b/lib/presentation/containers/group_form.freezed.dart index b29de91b..1e3dbce6 100644 --- a/lib/presentation/containers/group_form.freezed.dart +++ b/lib/presentation/containers/group_form.freezed.dart @@ -17,7 +17,7 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$ViewModel { bool get loading => throw _privateConstructorUsedError; - ValueSetter get onSave => throw _privateConstructorUsedError; + OnGroupSaveCallback get onSave => throw _privateConstructorUsedError; Group? get group => throw _privateConstructorUsedError; /// Create a copy of _ViewModel @@ -33,7 +33,7 @@ abstract class _$ViewModelCopyWith<$Res> { _ViewModel value, $Res Function(_ViewModel) then) = __$ViewModelCopyWithImpl<$Res, _ViewModel>; @useResult - $Res call({bool loading, ValueSetter onSave, Group? group}); + $Res call({bool loading, OnGroupSaveCallback onSave, Group? group}); $GroupCopyWith<$Res>? get group; } @@ -65,7 +65,7 @@ class __$ViewModelCopyWithImpl<$Res, $Val extends _ViewModel> onSave: null == onSave ? _value.onSave : onSave // ignore: cast_nullable_to_non_nullable - as ValueSetter, + as OnGroupSaveCallback, group: freezed == group ? _value.group : group // ignore: cast_nullable_to_non_nullable @@ -96,7 +96,7 @@ abstract class _$$_ViewModelImplCopyWith<$Res> __$$_ViewModelImplCopyWithImpl<$Res>; @override @useResult - $Res call({bool loading, ValueSetter onSave, Group? group}); + $Res call({bool loading, OnGroupSaveCallback onSave, Group? group}); @override $GroupCopyWith<$Res>? get group; @@ -127,7 +127,7 @@ class __$$_ViewModelImplCopyWithImpl<$Res> onSave: null == onSave ? _value.onSave : onSave // ignore: cast_nullable_to_non_nullable - as ValueSetter, + as OnGroupSaveCallback, group: freezed == group ? _value.group : group // ignore: cast_nullable_to_non_nullable @@ -145,7 +145,7 @@ class _$_ViewModelImpl with DiagnosticableTreeMixin implements __ViewModel { @override final bool loading; @override - final ValueSetter onSave; + final OnGroupSaveCallback onSave; @override final Group? group; @@ -189,13 +189,13 @@ class _$_ViewModelImpl with DiagnosticableTreeMixin implements __ViewModel { abstract class __ViewModel implements _ViewModel { const factory __ViewModel( {required final bool loading, - required final ValueSetter onSave, + required final OnGroupSaveCallback onSave, final Group? group}) = _$_ViewModelImpl; @override bool get loading; @override - ValueSetter get onSave; + OnGroupSaveCallback get onSave; @override Group? get group; diff --git a/lib/presentation/containers/home.dart b/lib/presentation/containers/home.dart index ea168dac..8aa7c62e 100644 --- a/lib/presentation/containers/home.dart +++ b/lib/presentation/containers/home.dart @@ -7,7 +7,6 @@ import 'package:parousia/models/models.dart'; import 'package:parousia/presentation/presentation.dart'; import 'package:parousia/state/state.dart'; import 'package:redux/redux.dart'; -import 'package:redux_entity/redux_entity.dart'; part 'home.freezed.dart'; @@ -47,8 +46,8 @@ sealed class _ViewModel with _$ViewModel { groups: store.state.groups.entities.values, onGroupCreate: (value) => store.dispatch( switch (value) { - GroupCreateResultNew(group: final group) => - RequestCreateOne(group), + GroupCreateResultNew(group: final group, image: final image) => + CreateGroupAction(group: group, image: image), GroupCreateResultJoin(code: final code) => JoinWithInviteCodeAction(code), }, diff --git a/lib/presentation/screens/group_create.dart b/lib/presentation/screens/group_create.dart index af657f8f..432a890d 100644 --- a/lib/presentation/screens/group_create.dart +++ b/lib/presentation/screens/group_create.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:parousia/models/models.dart'; import 'package:parousia/presentation/presentation.dart'; @@ -8,8 +9,9 @@ sealed class GroupCreateResult {} class GroupCreateResultNew extends GroupCreateResult { final Group group; + final XFile? image; - GroupCreateResultNew(this.group); + GroupCreateResultNew(this.group, this.image); } class GroupCreateResultJoin extends GroupCreateResult { @@ -47,8 +49,8 @@ class GroupCreateScreen extends StatelessWidget { onJoin: (code) => context .pop(GroupCreateResultJoin(code))), GroupForm( - onSave: (group) => context - .pop(GroupCreateResultNew(group))), + onSave: (group) => context.pop( + GroupCreateResultNew(group.$1, group.$2))), ], ), ), diff --git a/lib/presentation/screens/profile.dart b/lib/presentation/screens/profile.dart index ca6f6e55..33f35ed7 100644 --- a/lib/presentation/screens/profile.dart +++ b/lib/presentation/screens/profile.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -27,100 +24,29 @@ class _ProfileScreenState extends State { final _formKey = GlobalKey(); final _nameFocusNode = FocusNode(); + late ImageController _imageController; late TextEditingController _nameController; bool _enableSaveButton = false; - XFile? _tempImageFile; @override void initState() { super.initState(); + _imageController = ImageController(); _nameController = TextEditingController(text: widget.profile?.displayName); } @override void dispose() { + _imageController.dispose(); _nameFocusNode.dispose(); _nameController.dispose(); super.dispose(); } - ImageProvider? _profilePicture() { - if (_tempImageFile != null) { - if (kIsWeb) { - return NetworkImage(_tempImageFile!.path); - } else { - return FileImage(File(_tempImageFile!.path)); - } - } else if (widget.profile?.picture != null) { - return NetworkImage(widget.profile!.picture!); - } - return null; - } - - // TODO: move to a widget and make it reusable - _changeImage() async { - final source = await showAdaptiveDialog( - context: context, - builder: (context) { - final l10n = AppLocalizations.of(context)!; - - return SimpleDialog( - title: Text(l10n.chooseNewProfilePicture), - children: [ - SimpleDialogOption( - onPressed: () => Navigator.pop(context, ImageSource.camera), - child: Row( - children: [ - const Icon(Icons.camera_alt), - const SizedBox(width: 8), - Text(l10n.camera), - ], - ), - ), - SimpleDialogOption( - onPressed: () => Navigator.pop(context, ImageSource.gallery), - child: Row( - children: [ - const Icon(Icons.photo_library), - const SizedBox(width: 8), - Text(l10n.gallery), - ], - ), - ), - ], - ); - }, - ); - - if (source != null) { - _pickImageTemp(source); - } - } - - Future _pickImageTemp(ImageSource source) async { - final picker = ImagePicker(); - final imageFile = await picker.pickImage( - source: source, - preferredCameraDevice: CameraDevice.front, - maxWidth: 512, - maxHeight: 512, - requestFullMetadata: false, - ); - if (imageFile == null) { - return; - } - - setState(() { - _tempImageFile = imageFile; - _enableSaveButton = true; - }); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final profilePicture = _profilePicture(); return Scaffold( appBar: AppBar( @@ -135,6 +61,16 @@ class _ProfileScreenState extends State { ), body: Form( key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: () { + setState(() { + if (_formKey.currentState!.validate()) { + _enableSaveButton = true; + } else { + _enableSaveButton = false; + } + }); + }, child: Column( children: [ Expanded( @@ -142,10 +78,11 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - ProfilePicture( - onPressed: () => _changeImage(), - image: profilePicture, - name: _nameController.value.text, + ImageFormField( + controller: _imageController, + initialImage: widget.profile?.picture != null + ? NetworkImage(widget.profile!.picture!) + : null, radius: 64, ), TextFormField( @@ -160,15 +97,6 @@ class _ProfileScreenState extends State { } return null; }, - onChanged: (value) { - setState(() { - if (_formKey.currentState!.validate()) { - _enableSaveButton = true; - } else { - _enableSaveButton = false; - } - }); - }, ), // TODO(borgoat): options to link additional auth providers ], @@ -186,8 +114,10 @@ class _ProfileScreenState extends State { }); _formKey.currentState!.save(); _nameFocusNode.unfocus(); - widget.onSave( - (_nameController.text.trim(), _tempImageFile)); + widget.onSave(( + _nameController.text.trim(), + _imageController.value, + )); if (!(widget.userNavigated ?? false)) { Navigator.of(context).pop(context); diff --git a/lib/presentation/widgets/group_form.dart b/lib/presentation/widgets/group_form.dart index ac94838e..dbb91736 100644 --- a/lib/presentation/widgets/group_form.dart +++ b/lib/presentation/widgets/group_form.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:parousia/models/group.dart'; +import 'package:parousia/presentation/presentation.dart'; + +typedef OnGroupSaveCallback = ValueSetter<(Group group, XFile? image)>; class GroupForm extends StatefulWidget { - final ValueSetter onSave; + final OnGroupSaveCallback onSave; final Group? group; const GroupForm({ @@ -19,14 +22,16 @@ class GroupForm extends StatefulWidget { } class _GroupFormState extends State { - final _formKey = GlobalKey(); + final _formKey = GlobalKey(); + late ImageController _imageController; late TextEditingController _nameController; late TextEditingController _descriptionController; @override void initState() { super.initState(); + _imageController = ImageController(); _nameController = TextEditingController(text: widget.group?.displayName); _descriptionController = TextEditingController(text: widget.group?.description); @@ -34,6 +39,7 @@ class _GroupFormState extends State { @override void dispose() { + _imageController.dispose(); _descriptionController.dispose(); _nameController.dispose(); super.dispose(); @@ -43,7 +49,7 @@ class _GroupFormState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return FormBuilder( + return Form( key: _formKey, child: Column( children: [ @@ -52,8 +58,18 @@ class _GroupFormState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - FormBuilderTextField( - name: 'name', + ListenableBuilder( + listenable: _nameController, + builder: (context, child) => ImageFormField( + radius: 64, + icon: Icons.group, + controller: _imageController, + initialImage: widget.group?.picture != null + ? NetworkImage(widget.group!.picture!) + : null, + ), + ), + TextFormField( controller: _nameController, decoration: InputDecoration( labelText: l10n.enterGroupName, @@ -68,8 +84,7 @@ class _GroupFormState extends State { return null; }, ), - FormBuilderTextField( - name: 'description', + TextFormField( controller: _descriptionController, minLines: 2, maxLines: 5, @@ -93,14 +108,16 @@ class _GroupFormState extends State { if (_formKey.currentState!.validate()) { final displayName = _nameController.text.trim(); final description = _descriptionController.text.trim(); - const picture = null; // TODO + final picture = _imageController.value; _formKey.currentState!.save(); - widget.onSave(Group( - id: widget.group?.id ?? '', - displayName: displayName, - description: description.isNotEmpty ? description : null, - picture: picture, + widget.onSave(( + Group( + id: widget.group?.id ?? '', + displayName: displayName, + description: description.isNotEmpty ? description : null, + ), + picture )); } }, diff --git a/lib/presentation/widgets/image_form_field.dart b/lib/presentation/widgets/image_form_field.dart new file mode 100644 index 00000000..4c0ae75e --- /dev/null +++ b/lib/presentation/widgets/image_form_field.dart @@ -0,0 +1,190 @@ +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:parousia/presentation/presentation.dart'; + +class ImageController extends ValueNotifier { + ImageController() : super(null); +} + +class ImageFormField extends FormField { + ImageFormField({ + super.key, + super.validator, + super.onSaved, + this.controller, + this.initialImage, + this.icon, + this.radius, + }) : super(builder: (state) => (state as _ImageFormFieldState).builder()); + + /// Controls the state of the image. + /// + /// If null, this image will create its own [ImageController] + /// and initialize it with [initialValue]. + final ImageController? controller; + + /// The initial image to show. + final ImageProvider? initialImage; + + /// The icon to show when there is no image. + final IconData? icon; + + /// The radius of the image. + final double? radius; + + @override + FormFieldState createState() => _ImageFormFieldState(); +} + +class _ImageFormFieldState extends FormFieldState { + late final ImageController controller; + + final _imagePicker = ImagePicker(); + final _imageCropper = ImageCropper(); + + @override + ImageFormField get widget => super.widget as ImageFormField; + + @override + void initState() { + super.initState(); + + controller = widget.controller ?? ImageController(); + controller.addListener(_handleControllerChanged); + } + + @override + void dispose() { + controller.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void didChange(XFile? value) { + super.didChange(value); + if (controller.value != value) { + controller.value = value!; + } + } + + @override + void reset() { + controller.value = widget.initialValue; + super.reset(); + } + + void _handleControllerChanged() { + if (controller.value != value) { + didChange(controller.value); + } + } + + Widget builder() { + return Stack( + children: [ + ProfilePicture( + onPressed: _changeImage, + image: value != null + ? FileImage(File(value!.path)) + : widget.initialImage, + radius: widget.radius, + icon: widget.icon, + // loadingValue: // TODO This should show a loading indicator when the image is being uploaded. + ), + Positioned.directional( + textDirection: Directionality.of(context), + end: 0, + bottom: 0, + child: IconButton.filledTonal( + icon: const Icon(Icons.camera_alt_outlined), + tooltip: AppLocalizations.of(context)!.chooseNewProfilePicture, + onPressed: _changeImage, + ), + ), + ], + ); + } + + Future _changeImage() async { + final source = await _getImageSource(); + if (source == null) return; + + final image = await _getImageAndCrop(source); + if (image == null) return; + + didChange(image); + } + + /// Get an image from the camera or gallery and crop it, if possible. + Future _getImageAndCrop(ImageSource source) async { + final imageFile = await _imagePicker.pickImage( + source: source, + preferredCameraDevice: CameraDevice.front, + maxWidth: 512, + maxHeight: 512, + requestFullMetadata: false, + ); + if (imageFile == null) return null; + + try { + final cropped = await _imageCropper.cropImage( + sourcePath: imageFile.path, + aspectRatio: CropAspectRatio(ratioX: 1, ratioY: 1), + compressFormat: ImageCompressFormat.jpg, + maxHeight: 512, + maxWidth: 512, + ); + if (cropped == null) return null; + return XFile(cropped.path); + } catch (e) { + developer.log('Failed to crop image', error: e); + return XFile(imageFile.path); + } + } + + /// Show a dialog to choose the image source. + /// If the camera is not supported, the gallery will be the default source. + Future _getImageSource() async { + if (!_imagePicker.supportsImageSource(ImageSource.camera)) { + return ImageSource.gallery; + } + + return showAdaptiveDialog( + context: context, + builder: (context) { + final l10n = AppLocalizations.of(context)!; + + return SimpleDialog( + title: Text(l10n.chooseNewProfilePicture), + children: [ + SimpleDialogOption( + onPressed: () => Navigator.pop(context, ImageSource.camera), + child: Row( + children: [ + const Icon(Icons.camera_alt), + const SizedBox(width: 8), + Text(l10n.camera), + ], + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(context, ImageSource.gallery), + child: Row( + children: [ + const Icon(Icons.photo_library), + const SizedBox(width: 8), + Text(l10n.gallery), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/presentation/widgets/profile_picture.dart b/lib/presentation/widgets/profile_picture.dart index eb01eef0..bcf1c7ee 100644 --- a/lib/presentation/widgets/profile_picture.dart +++ b/lib/presentation/widgets/profile_picture.dart @@ -8,12 +8,16 @@ class ProfilePicture extends StatelessWidget { this.image, this.name, this.radius, + this.loadingValue = 1, + this.icon = Icons.person, }); final VoidCallback? onPressed; final ImageProvider? image; final String? name; final double? radius; + final double? loadingValue; + final IconData? icon; @override Widget build(BuildContext context) { @@ -21,30 +25,27 @@ class ProfilePicture extends StatelessWidget { final nameInitials = getNameInitials(name); final padding = radius != null ? radius! / 8.0 : 1.0; - return OutlinedButton( + return ElevatedButton( onPressed: onPressed, - style: OutlinedButton.styleFrom( - shape: const CircleBorder(), - padding: EdgeInsets.all(padding), - side: BorderSide( - color: theme.colorScheme.primary, - width: padding, - ), - ), - child: CircleAvatar( - radius: radius, - backgroundColor: theme.colorScheme.primary.withOpacity(0.3), - foregroundImage: image, - child: nameInitials != null && nameInitials.isNotEmpty - ? Text( - nameInitials, - // style: theme.textTheme.headlineLarge, - ) - : Icon( - Icons.person, - size: radius, - color: theme.colorScheme.primary, - ), + style: OutlinedButton.styleFrom(shape: CircleBorder()), + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: radius, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.3), + foregroundImage: image, + child: nameInitials != null && nameInitials.isNotEmpty + ? Text(nameInitials) + : Icon(icon, size: radius, color: theme.colorScheme.primary), + ), + Positioned.fill( + child: CircularProgressIndicator( + strokeWidth: padding, + value: loadingValue, + ), + ), + ], ), ); } diff --git a/lib/presentation/widgets/widgets.dart b/lib/presentation/widgets/widgets.dart index 30cfae5b..0bb2b689 100644 --- a/lib/presentation/widgets/widgets.dart +++ b/lib/presentation/widgets/widgets.dart @@ -9,6 +9,7 @@ export 'group_form.dart'; export 'group_join.dart'; export 'group_members.dart'; export 'groups_list.dart'; +export 'image_form_field.dart'; export 'invite_modal.dart'; export 'profile_picture.dart'; export 'reply_button.dart'; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df28..b3c17614 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/pubspec.lock b/pubspec.lock index c212d50d..4136b164 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -716,6 +716,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_cropper: + dependency: "direct main" + description: + name: image_cropper + sha256: "266760ed426d7121f0ada02c672bfe5c1b5c714e908328716aee756f045709dc" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + image_cropper_for_web: + dependency: transitive + description: + name: image_cropper_for_web + sha256: "34256c8fb7fcb233251787c876bb37271744459b593a948a2db73caa323034d0" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + image_cropper_platform_interface: + dependency: transitive + description: + name: image_cropper_platform_interface + sha256: e8e9d2ca36360387aee39295ce49029362ae4df3071f23e8e71f2b81e40b7531 + url: "https://pub.dev" + source: hosted + version: "7.0.0" image_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 98db266c..840b5939 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: # Image picker to let users upload their profile picture image_picker: ^1.0.4 + image_cropper: ^8.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/web/index.html b/web/index.html index 6666201c..0a2a0f14 100644 --- a/web/index.html +++ b/web/index.html @@ -35,6 +35,10 @@ + + + +