From 8289a33c2d35d9b00cc5b94e71a997db9ef8b63f Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 16 May 2025 13:48:18 -0400 Subject: [PATCH] 2765 direct users to add to chat with multiselect rather than create (#2824) * chore: abstract activity editting into builder widget * feat: allow users to launch activities to existing chats instead of making new chat --- assets/l10n/intl_en.arb | 9 +- lib/config/routes.dart | 15 +- .../activity_generator_view.dart | 15 +- .../activity_planner/activity_plan_card.dart | 701 +++++------ .../activity_planner_builder.dart | 233 ++++ .../activity_planner_page.dart | 8 +- .../activity_planner_page_appbar.dart | 8 +- .../bookmarked_activity_list.dart | 12 +- .../activity_room_selection.dart | 619 ++++++++++ .../activity_suggestion_carousel.dart | 309 ++--- .../activity_suggestion_dialog.dart | 1075 +++++++---------- .../activity_suggestions_area.dart | 104 +- .../suggestions_page.dart | 1 - .../common/widgets/full_width_dialog.dart | 42 +- .../extensions/room_events_extension.dart | 18 +- .../public_spaces/public_spaces_area.dart | 2 +- 16 files changed, 1842 insertions(+), 1329 deletions(-) create mode 100644 lib/pangea/activity_planner/activity_planner_builder.dart create mode 100644 lib/pangea/activity_suggestions/activity_room_selection.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9eaee21fc..d610de1dd 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4936,5 +4936,12 @@ "permissions": "Permissions", "spaceChildPermission": "Who can add new chats and subspaces to this space", "addEnvironmentOverride": "Add environment override", - "defaultOption": "Default" + "defaultOption": "Default", + "chatWithActivities": "Chat with activities", + "findYourPeople": "Find your people", + "launch": "Launch", + "launchActivityToChats": "Launch activity to chats", + "searchChats": "Search chats", + "selectChats": "Select chats", + "selectChatToStart": "Complete! Select a chat to start" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index bc4b0eaa6..d36038ad0 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -203,12 +203,23 @@ abstract class AppRoutes { ...newRoomRoutes, GoRoute( path: '/planner', - redirect: loggedOutRedirect, pageBuilder: (context, state) => defaultPageBuilder( context, state, - const ActivityGenerator(), + const ActivityPlannerPage(), ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: '/generator', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const ActivityGenerator(), + ), + ), + ], ), ], ), diff --git a/lib/pangea/activity_generator/activity_generator_view.dart b/lib/pangea/activity_generator/activity_generator_view.dart index c197e6f88..b86c4fe91 100644 --- a/lib/pangea/activity_generator/activity_generator_view.dart +++ b/lib/pangea/activity_generator/activity_generator_view.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/activity_generator/activity_generator.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_card.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/suggestion_form_field.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; @@ -53,13 +54,15 @@ class ActivityGeneratorView extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: controller.activities!.length, itemBuilder: (context, index) { - return ActivityPlanCard( - activity: controller.activities![index], + return ActivityPlannerBuilder( + initialActivity: controller.activities![index], + initialFilename: controller.filename, room: controller.room, - onEdit: (updatedActivity) => - controller.onEdit(index, updatedActivity), - onChange: controller.update, - initialImageURL: controller.filename, + builder: (c) { + return ActivityPlanCard( + controller: c, + ); + }, ); }, ); diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 0d721e11e..cc1e3b1a4 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -6,37 +6,23 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; import 'package:material_symbols_icons/symbols.dart'; -import 'package:matrix/matrix.dart' as sdk; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; -import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class ActivityPlanCard extends StatefulWidget { - final ActivityPlanModel activity; - final Room? room; - final VoidCallback onChange; - final ValueChanged onEdit; - final double maxWidth; - final String? initialImageURL; + final ActivityPlannerBuilderState controller; const ActivityPlanCard({ super.key, - required this.activity, - required this.room, - required this.onChange, - required this.onEdit, - this.maxWidth = 400, - this.initialImageURL, + required this.controller, }); @override @@ -44,59 +30,8 @@ class ActivityPlanCard extends StatefulWidget { } class ActivityPlanCardState extends State { - bool _isEditing = false; - late ActivityPlanModel _tempActivity; - late TextEditingController _titleController; - late TextEditingController _learningObjectiveController; - late TextEditingController _instructionsController; - final TextEditingController _newVocabController = TextEditingController(); - final FocusNode _vocabFocusNode = FocusNode(); - - Uint8List? _avatar; - String? _filename; - String? _imageURL; - - @override - void initState() { - super.initState(); - _tempActivity = widget.activity; - _titleController = TextEditingController(text: _tempActivity.title); - _learningObjectiveController = - TextEditingController(text: _tempActivity.learningObjective); - _instructionsController = - TextEditingController(text: _tempActivity.instructions); - _filename = widget.initialImageURL?.split("/").last; - _imageURL = widget.activity.imageURL ?? widget.initialImageURL; - } - static const double itemPadding = 12; - @override - void dispose() { - _titleController.dispose(); - _learningObjectiveController.dispose(); - _instructionsController.dispose(); - _newVocabController.dispose(); - _vocabFocusNode.dispose(); - super.dispose(); - } - - Future _saveEdits() async { - final updatedActivity = ActivityPlanModel( - req: _tempActivity.req, - title: _titleController.text, - learningObjective: _learningObjectiveController.text, - instructions: _instructionsController.text, - vocab: _tempActivity.vocab, - imageURL: widget.activity.imageURL, - ); - - widget.onEdit(updatedActivity); - setState(() { - _isEditing = false; - }); - } - Future _addBookmark(ActivityPlanModel activity) async { try { return BookmarkedActivitiesRepo.save(activity); @@ -107,418 +42,350 @@ class ActivityPlanCardState extends State { } finally { if (mounted) { setState(() {}); - widget.onChange(); } } } Future _removeBookmark() async { try { - BookmarkedActivitiesRepo.remove(widget.activity.bookmarkId); + BookmarkedActivitiesRepo.remove( + widget.controller.updatedActivity.bookmarkId, + ); } catch (e, stack) { debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: stack, data: widget.activity.toJson()); + ErrorHandler.logError( + e: e, + s: stack, + data: widget.controller.updatedActivity.toJson(), + ); } finally { if (mounted) { setState(() {}); - widget.onChange(); } } } - void _addVocab() { - setState(() { - _tempActivity.vocab.add(Vocab(lemma: _newVocabController.text, pos: '')); - _newVocabController.clear(); - _vocabFocusNode.requestFocus(); - }); - } - - void _removeVocab(int index) { - setState(() { - _tempActivity.vocab.removeAt(index); - }); - } - - void selectPhoto() async { - final resp = await selectFiles( - context, - type: FileSelectorType.images, - allowMultiple: false, - ); - - final photo = resp.singleOrNull; - if (photo == null) return; - final bytes = await photo.readAsBytes(); - - setState(() { - _avatar = bytes; - _filename = photo.name; - }); - - final url = await Matrix.of(context).client.uploadContent( - bytes, - filename: photo.name, - ); - - final updatedActivity = ActivityPlanModel( - req: _tempActivity.req, - title: _tempActivity.title, - learningObjective: _tempActivity.learningObjective, - instructions: _tempActivity.instructions, - vocab: _tempActivity.vocab, - imageURL: url.toString(), - ); - - widget.onEdit(updatedActivity); - } - - Future _setAvatarByImageURL() async { - if (_avatar != null || _imageURL == null) return; - final resp = await http - .get(Uri.parse(_imageURL!)) - .timeout(const Duration(seconds: 5)); - if (mounted) { - setState(() => _avatar = resp.bodyBytes); + Future _onLaunch() async { + if (widget.controller.room != null) { + final resp = await showFutureLoadingDialog( + context: context, + future: widget.controller.launchToRoom, + ); + if (!resp.isError) { + context.go("/rooms/${widget.controller.room!.id}"); + } + return; } - } - Future _onLaunch() async { - await _setAvatarByImageURL(); - await showFutureLoadingDialog( + return showDialog( context: context, - future: () async { - String? avatarUrl; - if (_avatar != null) { - final client = Matrix.of(context).client; - final url = await client.uploadContent( - _avatar!, - filename: _filename, - ); - avatarUrl = url.toString(); - } - - if (widget.room != null) { - await widget.room?.sendActivityPlan( - widget.activity, - avatar: _avatar, - filename: _filename, - ); - - context.go("/rooms/${widget.room?.id}"); - return; - } - - final client = Matrix.of(context).client; - final roomId = await client.createGroupChat( - preset: CreateRoomPreset.publicChat, - visibility: sdk.Visibility.private, - groupName: - widget.activity.title.isNotEmpty ? widget.activity.title : null, - initialState: [ - if (_avatar != null) ...[ - StateEvent( - type: EventTypes.RoomAvatar, - stateKey: '', - content: { - "url": avatarUrl, - }, + builder: (context) { + return FullWidthDialog( + dialogContent: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + child: ActivityRoomSelection( + controller: widget.controller, + backButton: IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.close), ), - ], - StateEvent( - type: EventTypes.RoomPowerLevels, - stateKey: '', - content: defaultPowerLevels(client.userID!), ), - ], - enableEncryption: false, - ); - - Room? room = client.getRoomById(roomId); - if (room == null) { - await client.waitForRoomInSync(roomId); - room = client.getRoomById(roomId); - } - if (room == null) return; - - await room.sendActivityPlan( - widget.activity, - avatar: _avatar, - filename: _filename, + ), + maxWidth: 400.0, + maxHeight: 650.0, ); - - context.go("/rooms/$roomId/invite?filter=groups"); }, ); } - bool get isBookmarked => - BookmarkedActivitiesRepo.isBookmarked(widget.activity); + bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked( + widget.controller.updatedActivity, + ); @override Widget build(BuildContext context) { final l10n = L10n.of(context); return Center( child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: widget.maxWidth), + constraints: const BoxConstraints(maxWidth: 400), child: Card( margin: const EdgeInsets.symmetric(vertical: itemPadding), - child: Column( - children: [ - AnimatedSize( - duration: FluffyThemes.animationDuration, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), + child: Form( + key: widget.controller.formKey, + child: Column( + children: [ + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + ), + clipBehavior: Clip.hardEdge, + alignment: Alignment.center, + child: widget.controller.imageURL != null || + widget.controller.avatar != null + ? ClipRRect( + child: widget.controller.avatar == null + ? CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: widget.controller.imageURL!, + placeholder: (context, url) { + return const Center( + child: CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return const Padding( + padding: EdgeInsets.all(28.0), + ); + }, + ) + : Image.memory( + widget.controller.avatar!, + fit: BoxFit.cover, + ), + ) + : const Padding( + padding: EdgeInsets.all(28.0), + ), ), - clipBehavior: Clip.hardEdge, - alignment: Alignment.center, - child: _imageURL != null || _avatar != null - ? ClipRRect( - child: _avatar == null - ? CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: _imageURL!, - placeholder: (context, url) { - return const Center( - child: CircularProgressIndicator(), - ); - }, - errorWidget: (context, url, error) { - return const Padding( - padding: EdgeInsets.all(28.0), - ); - }, - ) - : Image.memory( - _avatar!, - fit: BoxFit.cover, - ), - ) - : const Padding( - padding: EdgeInsets.all(28.0), + if (widget.controller.isEditing) + Positioned( + top: 10.0, + right: 10.0, + child: IconButton( + icon: const Icon(Icons.upload_outlined), + onPressed: widget.controller.selectAvatar, + style: IconButton.styleFrom( + backgroundColor: Colors.black, ), - ), - if (_isEditing) - Positioned( - top: 10.0, - right: 10.0, - child: IconButton( - icon: const Icon(Icons.upload_outlined), - onPressed: selectPhoto, - style: IconButton.styleFrom( - backgroundColor: Colors.black, ), ), - ), - ], + ], + ), ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Icons.event_note_outlined), - const SizedBox(width: itemPadding), - Expanded( - child: _isEditing - ? TextField( - controller: _titleController, - decoration: InputDecoration( - labelText: L10n.of(context).activityTitle, + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.event_note_outlined), + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? TextField( + controller: + widget.controller.titleController, + decoration: InputDecoration( + labelText: L10n.of(context).activityTitle, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity.title, + style: + Theme.of(context).textTheme.bodyLarge, ), - maxLines: null, - ) - : Text( - widget.activity.title, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - if (!_isEditing) - IconButton( - onPressed: isBookmarked - ? () => _removeBookmark() - : () => _addBookmark(widget.activity), - icon: Icon( - isBookmarked - ? Icons.bookmark - : Icons.bookmark_border, + ), + if (!widget.controller.isEditing) + IconButton( + onPressed: _isBookmarked + ? () => _removeBookmark() + : () => _addBookmark( + widget.controller.updatedActivity, + ), + icon: Icon( + _isBookmarked + ? Icons.bookmark + : Icons.bookmark_border, + ), ), + ], + ), + const SizedBox(height: itemPadding), + Row( + children: [ + Icon( + Symbols.target, + color: Theme.of(context).colorScheme.secondary, ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Symbols.target, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: _isEditing - ? TextField( - controller: _learningObjectiveController, - decoration: InputDecoration( - labelText: l10n.learningObjectiveLabel, - ), - maxLines: null, - ) - : Text( - widget.activity.learningObjective, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Symbols.steps_rounded, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: _isEditing - ? TextField( - controller: _instructionsController, - decoration: InputDecoration( - labelText: l10n.instructions, + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? TextField( + controller: widget.controller + .learningObjectivesController, + decoration: InputDecoration( + labelText: l10n.learningObjectiveLabel, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity + .learningObjective, + style: + Theme.of(context).textTheme.bodyMedium, ), - maxLines: null, - ) - : Text( - widget.activity.instructions, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - if (widget.activity.vocab.isNotEmpty) ...[ + ), + ], + ), + const SizedBox(height: itemPadding), Row( children: [ Icon( - Symbols.dictionary, + Symbols.steps_rounded, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(width: itemPadding), Expanded( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: List.generate( - _tempActivity.vocab.length, (int index) { - return _isEditing - ? Chip( - label: Text( - _tempActivity.vocab[index].lemma, - ), - onDeleted: () => _removeVocab(index), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ) - : Chip( - label: Text( - _tempActivity.vocab[index].lemma, - ), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ); - }).toList(), - ), + child: widget.controller.isEditing + ? TextField( + controller: widget + .controller.instructionsController, + decoration: InputDecoration( + labelText: l10n.instructions, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity + .instructions, + style: + Theme.of(context).textTheme.bodyMedium, + ), ), ], ), - ], - if (_isEditing) ...[ const SizedBox(height: itemPadding), - Padding( - padding: const EdgeInsets.only(top: itemPadding), - child: Row( + if (widget.controller.vocab.isNotEmpty) ...[ + Row( children: [ + Icon( + Symbols.dictionary, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: itemPadding), Expanded( - child: TextField( - controller: _newVocabController, - focusNode: _vocabFocusNode, - decoration: InputDecoration( - labelText: l10n.addVocabulary, - ), - onSubmitted: (value) { - _addVocab(); - }, + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: List.generate( + widget.controller.vocab.length, + (int index) { + return widget.controller.isEditing + ? Chip( + label: Text( + widget + .controller.vocab[index].lemma, + ), + onDeleted: () => widget.controller + .removeVocab(index), + backgroundColor: Colors.transparent, + visualDensity: VisualDensity.compact, + shape: const StadiumBorder( + side: BorderSide( + color: Colors.transparent, + ), + ), + ) + : Chip( + label: Text( + widget + .controller.vocab[index].lemma, + ), + backgroundColor: Colors.transparent, + visualDensity: VisualDensity.compact, + shape: const StadiumBorder( + side: BorderSide( + color: Colors.transparent, + ), + ), + ); + }).toList(), ), ), - IconButton( - icon: const Icon(Icons.add), - onPressed: _addVocab, - ), ], ), - ), - ], - const SizedBox(height: itemPadding), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Tooltip( - message: - !_isEditing ? l10n.edit : l10n.saveChanges, - child: IconButton( - icon: - Icon(!_isEditing ? Icons.edit : Icons.save), - onPressed: () => !_isEditing - ? setState(() { - _isEditing = true; - }) - : _saveEdits(), - isSelected: _isEditing, - ), - ), - if (_isEditing) - Tooltip( - message: l10n.cancel, - child: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - setState(() { - _isEditing = false; - }); + ], + if (widget.controller.isEditing) ...[ + const SizedBox(height: itemPadding), + Padding( + padding: const EdgeInsets.only(top: itemPadding), + child: Row( + children: [ + Expanded( + child: TextField( + controller: widget.controller.vocabController, + decoration: InputDecoration( + labelText: l10n.addVocabulary, + ), + onSubmitted: (value) { + widget.controller.addVocab(); }, ), ), - ], - ), - ElevatedButton.icon( - onPressed: !_isEditing ? _onLaunch : null, - icon: const Icon(Icons.send), - label: Text(l10n.launchActivityButton), + IconButton( + icon: const Icon(Icons.add), + onPressed: widget.controller.addVocab, + ), + ], + ), ), ], - ), - ], + const SizedBox(height: itemPadding), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Tooltip( + message: !widget.controller.isEditing + ? l10n.edit + : l10n.saveChanges, + child: IconButton( + icon: Icon( + !widget.controller.isEditing + ? Icons.edit + : Icons.save, + ), + onPressed: () => !widget.controller.isEditing + ? setState(() { + widget.controller.isEditing = true; + }) + : widget.controller.saveEdits(), + isSelected: widget.controller.isEditing, + ), + ), + if (widget.controller.isEditing) + Tooltip( + message: l10n.cancel, + child: IconButton( + icon: const Icon(Icons.cancel), + onPressed: widget.controller.clearEdits, + ), + ), + ], + ), + ElevatedButton.icon( + onPressed: + !widget.controller.isEditing ? _onLaunch : null, + icon: const Icon(Icons.send), + label: Text(l10n.launchActivityButton), + ), + ], + ), + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart new file mode 100644 index 000000000..a45521044 --- /dev/null +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -0,0 +1,233 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/utils/client_download_content_extension.dart'; +import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ActivityPlannerBuilder extends StatefulWidget { + final ActivityPlanModel initialActivity; + final String? initialFilename; + final Room? room; + + final Widget Function(ActivityPlannerBuilderState) builder; + + final Future Function( + String, + ActivityPlanModel, + Uint8List?, + String?, + )? onEdit; + + const ActivityPlannerBuilder({ + super.key, + required this.initialActivity, + this.initialFilename, + this.room, + required this.builder, + this.onEdit, + }); + + @override + State createState() => ActivityPlannerBuilderState(); +} + +class ActivityPlannerBuilderState extends State { + bool isEditing = false; + Uint8List? avatar; + String? imageURL; + String? filename; + + final TextEditingController titleController = TextEditingController(); + final TextEditingController instructionsController = TextEditingController(); + final TextEditingController vocabController = TextEditingController(); + final TextEditingController participantsController = TextEditingController(); + final TextEditingController learningObjectivesController = + TextEditingController(); + + final List vocab = []; + + final GlobalKey formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _resetActivity(); + } + + @override + void dispose() { + titleController.dispose(); + learningObjectivesController.dispose(); + instructionsController.dispose(); + vocabController.dispose(); + participantsController.dispose(); + super.dispose(); + } + + Room? get room => widget.room; + + ActivityPlanModel get updatedActivity { + final int participants = int.tryParse(participantsController.text.trim()) ?? + widget.initialActivity.req.numberOfParticipants; + + final updatedReq = widget.initialActivity.req; + updatedReq.numberOfParticipants = participants; + + return ActivityPlanModel( + req: updatedReq, + title: titleController.text, + learningObjective: learningObjectivesController.text, + instructions: instructionsController.text, + vocab: vocab, + imageURL: imageURL, + ); + } + + Future _resetActivity() async { + avatar = null; + filename = null; + imageURL = null; + + titleController.text = widget.initialActivity.title; + learningObjectivesController.text = + widget.initialActivity.learningObjective; + instructionsController.text = widget.initialActivity.instructions; + participantsController.text = + widget.initialActivity.req.numberOfParticipants.toString(); + + vocab.clear(); + vocab.addAll(widget.initialActivity.vocab); + + imageURL = widget.initialActivity.imageURL; + filename = widget.initialFilename; + await _setAvatarByURL(); + if (mounted) setState(() {}); + } + + void setEditing(bool editting) { + isEditing = editting; + if (mounted) setState(() {}); + } + + void addVocab() { + vocab.insert( + 0, + Vocab( + lemma: vocabController.text.trim(), + pos: "", + ), + ); + vocabController.clear(); + if (mounted) setState(() {}); + } + + void removeVocab(int index) { + vocab.removeAt(index); + if (mounted) setState(() {}); + } + + void selectAvatar() async { + final photo = await selectFiles( + context, + type: FileSelectorType.images, + allowMultiple: false, + ); + final bytes = await photo.singleOrNull?.readAsBytes(); + if (mounted) { + setState(() { + avatar = bytes; + filename = photo.singleOrNull?.name; + }); + } + } + + Future _setAvatarByURL() async { + if (widget.initialActivity.imageURL == null) return; + try { + if (avatar == null) { + if (widget.initialActivity.imageURL!.startsWith("mxc")) { + final client = Matrix.of(context).client; + final mxcUri = Uri.parse(widget.initialActivity.imageURL!); + final data = await client.downloadMxcCached(mxcUri); + avatar = data; + filename = Uri.encodeComponent( + mxcUri.pathSegments.last, + ); + } else { + final Response response = + await http.get(Uri.parse(widget.initialActivity.imageURL!)); + avatar = response.bodyBytes; + filename = Uri.encodeComponent( + Uri.parse(widget.initialActivity.imageURL!).pathSegments.last, + ); + } + } + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: { + "imageURL": widget.initialActivity.imageURL, + }, + ); + } + } + + Future updateImageURL() async { + if (avatar == null) return; + final url = await Matrix.of(context).client.uploadContent( + avatar!, + filename: filename, + ); + if (!mounted) return; + setState(() { + imageURL = url.toString(); + }); + } + + Future saveEdits() async { + if (!formKey.currentState!.validate()) return; + await updateImageURL(); + setEditing(false); + if (widget.onEdit != null) { + await widget.onEdit!( + widget.initialActivity.bookmarkId, + updatedActivity, + avatar, + filename, + ); + } + } + + Future clearEdits() async { + _resetActivity(); + if (mounted) { + setState(() { + isEditing = false; + }); + } + } + + Future launchToRoom() async { + return widget.room?.sendActivityPlan( + updatedActivity, + avatar: avatar, + filename: filename, + avatarURL: imageURL, + ); + } + + @override + Widget build(BuildContext context) => widget.builder(this); +} diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index e9caefe00..f5316e56a 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -14,8 +14,8 @@ enum PageMode { } class ActivityPlannerPage extends StatefulWidget { - final String roomID; - const ActivityPlannerPage({super.key, required this.roomID}); + final String? roomID; + const ActivityPlannerPage({super.key, this.roomID}); @override ActivityPlannerPageState createState() => ActivityPlannerPageState(); @@ -23,7 +23,9 @@ class ActivityPlannerPage extends StatefulWidget { class ActivityPlannerPageState extends State { PageMode pageMode = PageMode.featuredActivities; - Room? get room => Matrix.of(context).client.getRoomById(widget.roomID); + Room? get room => widget.roomID != null + ? Matrix.of(context).client.getRoomById(widget.roomID!) + : null; void _setPageMode(PageMode? mode) { if (mode == null) return; diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart index 9c86b34b9..c80eb5726 100644 --- a/lib/pangea/activity_planner/activity_planner_page_appbar.dart +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -11,11 +11,11 @@ import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; class ActivityPlannerPageAppBar extends StatelessWidget implements PreferredSizeWidget { final PageMode pageMode; - final String roomID; + final String? roomID; const ActivityPlannerPageAppBar({ required this.pageMode, - required this.roomID, + this.roomID, super.key, }); @@ -68,7 +68,9 @@ class ActivityPlannerPageAppBar extends StatelessWidget alignment: Alignment.center, child: InkWell( customBorder: const CircleBorder(), - onTap: () => context.go('/rooms/$roomID/planner/generator'), + onTap: () => roomID != null + ? context.go('/rooms/$roomID/planner/generator') + : context.go("/homepage/planner/generator"), child: Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart index a94d5f279..168f5d149 100644 --- a/lib/pangea/activity_planner/bookmarked_activity_list.dart +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; @@ -97,11 +98,16 @@ class BookmarkedActivitiesListState extends State { showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: activity, - buttonText: L10n.of(context).inviteAndLaunch, - room: widget.room, onEdit: _onEdit, + room: widget.room, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: l10n.launch, + ); + }, ); }, ); diff --git a/lib/pangea/activity_suggestions/activity_room_selection.dart b/lib/pangea/activity_suggestions/activity_room_selection.dart new file mode 100644 index 000000000..155363c65 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_room_selection.dart @@ -0,0 +1,619 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart'; +import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; + +class ActivityRoomSelection extends StatefulWidget { + final ActivityPlannerBuilderState controller; + final Widget backButton; + + const ActivityRoomSelection({ + super.key, + required this.controller, + required this.backButton, + }); + + @override + State createState() => ActivityRoomSelectionState(); +} + +class ActivityRoomSelectionState extends State { + final TextEditingController searchController = TextEditingController(); + final FocusNode searchFocusNode = FocusNode(); + + bool _loading = false; + bool _complete = false; + + bool _hasBotDM = true; + List _launchableRooms = []; + final List _selectedRooms = []; + + @override + void initState() { + super.initState(); + _launchableRooms = Matrix.of(context) + .client + .rooms + .where((room) { + return room.canSendDefaultStates && + !room.isSpace && + !room.isAnalyticsRoom; + }) + .toList() + .sorted((a, b) { + final aIsBotDM = a.directChatMatrixID == BotName.byEnvironment; + final bIsBotDM = b.directChatMatrixID == BotName.byEnvironment; + if (aIsBotDM && !bIsBotDM) return -1; + if (!aIsBotDM && bIsBotDM) return 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + _hasBotDM = Matrix.of(context).client.rooms.any((room) { + if (room.isDirectChat && + room.directChatMatrixID == BotName.byEnvironment) { + return true; + } + if (room.botOptions?.mode == BotMode.directChat) { + return true; + } + return false; + }); + } + + @override + void dispose() { + searchController.dispose(); + searchFocusNode.dispose(); + super.dispose(); + } + + List get _filteredRooms { + final searchText = searchController.text.toLowerCase(); + return _launchableRooms.where((room) { + return room.name.toLowerCase().contains(searchText); + }).toList(); + } + + void _toggleRoomSelection(String roomId) { + _selectedRooms.contains(roomId) + ? _selectedRooms.remove(roomId) + : _selectedRooms.add(roomId); + if (_selectedRooms.contains(roomId)) { + _complete = false; + } + + setState(() {}); + } + + Map get _spaceDelegateCandidates { + final spaces = Matrix.of(context).client.rooms.where((r) => r.isSpace); + final candidates = {}; + for (final space in spaces) { + for (final spaceChild in space.spaceChildren) { + final roomId = spaceChild.roomId; + if (roomId == null) continue; + candidates[roomId] = space; + } + } + return candidates; + } + + final Map _launchStatus = {}; + + Future _sendActivityPlan(Room room) async { + try { + setState(() => _launchStatus[room.id] = 0); + await room.sendActivityPlan( + widget.controller.updatedActivity, + avatar: widget.controller.avatar, + filename: widget.controller.filename, + avatarURL: widget.controller.imageURL, + ); + _launchStatus[room.id] = 1; + } catch (e, s) { + _launchStatus[room.id] = -1; + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": room.id, + "activity": widget.controller.updatedActivity.toJson(), + "filename": widget.controller.filename, + "avatarURL": widget.controller.imageURL, + }, + ); + } finally { + if (mounted) { + setState(() {}); + } + } + } + + Future _launchBotDM() async { + try { + setState(() => _launchStatus["placeholder"] = 0); + + Uri? avatarUrl; + final imageUrl = widget.controller.imageURL ?? + widget.controller.updatedActivity.imageURL; + + Uint8List? avatar = widget.controller.avatar; + if (avatar != null) { + avatarUrl = await Matrix.of(context).client.uploadContent( + widget.controller.avatar!, + ); + } else if (imageUrl != null) { + final Response response = await http.get(Uri.parse(imageUrl)); + avatar = response.bodyBytes; + avatarUrl = await Matrix.of(context).client.uploadContent( + avatar, + ); + } + + // avatar == null ? null : await client.uploadContent(avatar); + final roomId = await Matrix.of(context).client.createRoom( + name: widget.controller.updatedActivity.title, + invite: [BotName.byEnvironment], + isDirect: true, + preset: CreateRoomPreset.trustedPrivateChat, + initialState: [ + BotOptionsModel(mode: BotMode.directChat).toStateEvent, + if (avatar != null && avatarUrl != null) + StateEvent( + type: EventTypes.RoomAvatar, + content: {'url': avatarUrl.toString()}, + ), + ], + ); + Room? room = Matrix.of(context).client.getRoomById(roomId); + if (room == null) { + await Matrix.of(context).client.waitForRoomInSync( + roomId, + join: true, + ); + + room = Matrix.of(context).client.getRoomById(roomId); + if (room == null) { + throw Exception("Room not found"); + } + + await room.sendActivityPlan( + widget.controller.updatedActivity, + avatar: widget.controller.avatar, + filename: widget.controller.filename, + avatarURL: widget.controller.imageURL, + ); + } + _launchStatus["placeholder"] = 1; + return roomId; + } catch (e, s) { + _launchStatus["placeholder"] = -1; + ErrorHandler.logError( + e: e, + s: s, + data: { + "activity": widget.controller.updatedActivity.toJson(), + "filename": widget.controller.filename, + "avatarURL": widget.controller.imageURL, + }, + ); + } finally { + if (mounted) { + setState(() {}); + } + } + return null; + } + + Future _launch() async { + setState(() => _loading = true); + try { + final List futures = []; + for (final roomId in _selectedRooms) { + if (_launchStatus[roomId] == 1) { + continue; + } + + final Room? room = _launchableRooms.firstWhereOrNull( + (r) => r.id == roomId, + ); + if (room == null) { + if (roomId == 'placeholder') futures.add(_launchBotDM()); + } else { + futures.add(_sendActivityPlan(room)); + } + } + + final resp = await Future.wait(futures); + _complete = true; + if (!mounted) return; + if (_selectedRooms.length == 1 && + _launchStatus[_selectedRooms.first] == 1) { + if (_selectedRooms.first == 'placeholder' && resp.first != null) { + context.go("/rooms/${resp.first}"); + Navigator.of(context).pop(); + } else if (_selectedRooms.first != 'placeholder') { + context.go('/rooms/${_selectedRooms.first}'); + Navigator.of(context).pop(); + } + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "activity": widget.controller.updatedActivity.toJson(), + "filename": widget.controller.filename, + "avatarURL": widget.controller.imageURL, + }, + ); + } finally { + if (mounted) { + setState(() => _loading = false); + } + } + } + + String _tooltip(String roomId) { + final status = _launchStatus[roomId]; + if (status == 0) { + return "Sending..."; + } else if (status == 1) { + return "Go to chat"; + } else if (status == -1) { + return "Failed to send"; + } + return ""; + } + + void _onTap(Room room) { + final status = _launchStatus[room.id]; + if (status == 0) { + return; + } else if (status == 1) { + context.go('/rooms/${room.id}'); + Navigator.of(context).pop(); + } else if (status == -1) { + return; + } + + debugPrint("Toggling room selection for ${room.id}"); + _toggleRoomSelection(room.id); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).selectChats), + leading: widget.backButton, + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + spacing: 16.0, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + controller: searchController, + focusNode: searchFocusNode, + textInputAction: TextInputAction.search, + onChanged: (text) => setState(() {}), + decoration: InputDecoration( + filled: true, + fillColor: theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + hintText: L10n.of(context).searchChats, + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + tooltip: L10n.of(context).cancel, + icon: const Icon(Icons.close_outlined), + onPressed: () { + setState(() { + searchController.clear(); + searchFocusNode.unfocus(); + }); + }, + color: theme.colorScheme.onPrimaryContainer, + ) + : IconButton( + onPressed: () => searchFocusNode.requestFocus(), + icon: Icon( + Icons.search_outlined, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _filteredRooms.length + (_hasBotDM ? 0 : 1), + itemBuilder: (context, index) { + if (!_hasBotDM && index == 0) { + return ChatActivityPlaceholder( + activity: widget.controller.updatedActivity, + selected: _selectedRooms.contains("placeholder"), + onTap: () { + _toggleRoomSelection("placeholder"); + }, + tooltip: _tooltip("placeholder"), + status: _launchStatus["placeholder"], + avatar: widget.controller.avatar, + ); + } + if (!_hasBotDM) index--; + + final room = _filteredRooms[index]; + final displayname = room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ); + final space = _spaceDelegateCandidates[room.id]; + return Tooltip( + message: _tooltip(room.id), + child: ListTile( + title: Text(displayname), + leading: SizedBox( + width: Avatar.defaultSize, + height: Avatar.defaultSize, + child: Stack( + children: [ + if (space != null) + Positioned( + top: 0, + left: 0, + child: Avatar( + border: BorderSide( + width: 2, + color: theme.colorScheme.surface, + ), + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 4, + ), + mxContent: space.avatar, + size: Avatar.defaultSize * 0.75, + name: space.getLocalizedDisplayname(), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Avatar( + border: space == null + ? room.isSpace + ? BorderSide( + width: 1, + color: theme.dividerColor, + ) + : null + : BorderSide( + width: 2, + color: theme.colorScheme.surface, + ), + mxContent: room.avatar, + size: Avatar.defaultSize * 0.75, + name: displayname, + presenceUserId: room.directChatMatrixID, + ), + ), + ], + ), + ), + trailing: Container( + width: 30.0, + height: 30.0, + alignment: Alignment.center, + child: Builder( + builder: (context) { + final status = _launchStatus[room.id]; + + if (status == 0) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(), + ); + } else if (status == 1) { + return const Icon( + Icons.check_circle_outline, + color: AppConfig.success, + ); + } else if (status == -1) { + return Icon( + Icons.error_outline, + color: theme.colorScheme.error, + ); + } + + return Checkbox( + value: _selectedRooms.contains(room.id), + onChanged: (_) => _onTap(room), + ); + }, + ), + ), + onTap: () => _onTap(room), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _complete + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context).selectChatToStart), + ) + : ElevatedButton( + onPressed: _selectedRooms.isNotEmpty ? _launch : null, + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.all(6.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + disabledBackgroundColor: theme.colorScheme.primary, + disabledForegroundColor: theme.colorScheme.onPrimary, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _loading + ? const Expanded( + child: SizedBox( + height: 10, + child: LinearProgressIndicator(), + ), + ) + : Text( + L10n.of(context).launchActivityToChats, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class ChatActivityPlaceholder extends StatelessWidget { + final ActivityPlanModel activity; + final bool selected; + final VoidCallback onTap; + final String tooltip; + final Uint8List? avatar; + final int? status; + + const ChatActivityPlaceholder({ + required this.activity, + required this.selected, + required this.onTap, + required this.tooltip, + required this.status, + this.avatar, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const size = Avatar.defaultSize * 0.75; + return Tooltip( + message: tooltip, + child: ListTile( + title: Text(activity.title), + leading: SizedBox( + width: Avatar.defaultSize, + height: Avatar.defaultSize, + child: SizedBox( + width: size, + height: size, + child: Material( + color: theme.brightness == Brightness.light + ? Colors.white + : Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(size / 2), + side: BorderSide.none, + ), + clipBehavior: Clip.hardEdge, + child: avatar != null + ? Image.memory(avatar!) + : activity.imageURL != null + ? activity.imageURL!.startsWith('mxc') + ? MxcImage( + uri: Uri.parse(activity.imageURL!), + width: size, + height: size, + cacheKey: activity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: activity.imageURL!, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => + const SizedBox(), + fit: BoxFit.cover, + ) + : const SizedBox(), + ), + ), + ), + trailing: Container( + width: 30.0, + height: 30.0, + alignment: Alignment.center, + child: Builder( + builder: (context) { + if (status == 0) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(), + ); + } else if (status == 1) { + return const Icon( + Icons.check_circle_outline, + color: AppConfig.success, + ); + } else if (status == -1) { + return Icon( + Icons.error_outline, + color: theme.colorScheme.error, + ); + } + + return Checkbox( + value: selected, + onChanged: (_) => onTap(), + ); + }, + ), + ), + onTap: onTap, + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart index 3a31c89ac..d6b4d11f1 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart @@ -11,6 +11,7 @@ import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; @@ -44,7 +45,6 @@ class ActivitySuggestionCarousel extends StatefulWidget { class ActivitySuggestionCarouselState extends State { - bool _isOpen = true; bool _loading = true; String? _error; @@ -138,7 +138,6 @@ class ActivitySuggestionCarouselState void _close() { widget.onActivitySelected(null, null, null); - setState(() => _isOpen = false); } void _onClickCard() { @@ -150,13 +149,23 @@ class ActivitySuggestionCarouselState ); return; } + showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: _currentActivity!, - buttonText: L10n.of(context).selectActivity, - onLaunch: widget.onActivitySelected, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: L10n.of(context).selectActivity, + onLaunch: () => widget.onActivitySelected( + controller.updatedActivity, + controller.avatar, + controller.filename, + ), + ); + }, ); }, ); @@ -167,164 +176,156 @@ class ActivitySuggestionCarouselState final theme = Theme.of(context); return AnimatedSize( duration: FluffyThemes.animationDuration, - child: !_isOpen - ? const SizedBox.shrink() - : AnimatedOpacity( - duration: FluffyThemes.animationDuration, - opacity: widget.enabled ? 1.0 : 0.5, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(24.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 16.0, - horizontal: 4.0, - ), - child: Column( - spacing: 16.0, + child: AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: widget.enabled ? 1.0 : 0.5, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(24.0), + ), + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 4.0, + ), + child: Column( + spacing: 16.0, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - L10n.of(context).newChatActivityTitle, - style: theme.textTheme.titleLarge, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: widget.enabled ? _close : null, - ), - ], - ), + Text( + L10n.of(context).newChatActivityTitle, + style: theme.textTheme.titleLarge, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text(L10n.of(context).newChatActivityDesc), + IconButton( + icon: const Icon(Icons.close), + onPressed: widget.enabled ? _close : null, ), - Row( - spacing: _isColumnMode ? 16.0 : 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MouseRegion( - cursor: _canMoveLeft - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - child: GestureDetector( - onTap: _canMoveLeft ? _moveLeft : null, - child: Icon( - Icons.chevron_left_outlined, - size: 32.0, - color: _canMoveLeft ? null : theme.disabledColor, - ), - ), - ), - Container( - constraints: - BoxConstraints(maxHeight: _cardHeight + 12.0), - child: _error != null || - (_currentActivity == null && !_loading) - ? const SizedBox.shrink() - : _loading - ? Shimmer.fromColors( - baseColor: theme.colorScheme.primary - .withAlpha(50), - highlightColor: theme.colorScheme.primary - .withAlpha(150), - child: Container( - height: _cardHeight, - width: _cardWidth, - decoration: BoxDecoration( - color: theme - .colorScheme.surfaceContainer, - borderRadius: - BorderRadius.circular(24.0), - ), - ), - ) - : ActivitySuggestionCard( - selected: widget.selectedActivity == - _currentActivity, - activity: _currentActivity!, - onPressed: - widget.enabled ? _onClickCard : null, - width: _cardWidth, - height: _cardHeight, - image: _currentActivity == - widget.selectedActivity - ? widget.selectedActivityImage - : null, - onChange: () { - if (mounted) setState(() {}); - }, - ), - ), - MouseRegion( - cursor: _canMoveRight - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - child: GestureDetector( - onTap: _canMoveRight ? _moveRight : null, - child: Icon( - Icons.chevron_right_outlined, - size: 32.0, - color: _canMoveRight ? null : theme.disabledColor, - ), - ), - ), - ], + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text(L10n.of(context).newChatActivityDesc), + ), + Row( + spacing: _isColumnMode ? 16.0 : 4.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MouseRegion( + cursor: _canMoveLeft + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: _canMoveLeft ? _moveLeft : null, + child: Icon( + Icons.chevron_left_outlined, + size: 32.0, + color: _canMoveLeft ? null : theme.disabledColor, + ), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16.0, - children: _activityItems.mapIndexed((i, activity) { - final selected = activity == _currentActivity; - return InkWell( - enableFeedback: widget.enabled, - borderRadius: BorderRadius.circular(12.0), - onTap: widget.enabled - ? () => _setActivityByIndex(i) - : null, - child: ImageFiltered( - imageFilter: ImageFilter.blur( - sigmaX: selected ? 0.0 : 0.5, - sigmaY: selected ? 0.0 : 0.5, - ), - child: Opacity( - opacity: selected ? 1.0 : 0.5, - child: ClipOval( - child: SizedBox.fromSize( - size: const Size.fromRadius(12.0), - child: activity.imageURL != null - ? CachedNetworkImage( - imageUrl: activity.imageURL!, - errorWidget: (context, url, error) => - const SizedBox(), - progressIndicatorBuilder: - (context, url, progress) { - return CircularProgressIndicator( - value: progress.progress, - ); - }, - ) - : CircleAvatar( - backgroundColor: - theme.colorScheme.secondary, - radius: 12.0, - ), + ), + Container( + constraints: BoxConstraints(maxHeight: _cardHeight + 12.0), + child: _error != null || + (_currentActivity == null && !_loading) + ? const SizedBox.shrink() + : _loading + ? Shimmer.fromColors( + baseColor: + theme.colorScheme.primary.withAlpha(50), + highlightColor: + theme.colorScheme.primary.withAlpha(150), + child: Container( + height: _cardHeight, + width: _cardWidth, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(24.0), + ), ), + ) + : ActivitySuggestionCard( + selected: + widget.selectedActivity == _currentActivity, + activity: _currentActivity!, + onPressed: widget.enabled ? _onClickCard : null, + width: _cardWidth, + height: _cardHeight, + image: + _currentActivity == widget.selectedActivity + ? widget.selectedActivityImage + : null, + onChange: () { + if (mounted) setState(() {}); + }, ), - ), + ), + MouseRegion( + cursor: _canMoveRight + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: _canMoveRight ? _moveRight : null, + child: Icon( + Icons.chevron_right_outlined, + size: 32.0, + color: _canMoveRight ? null : theme.disabledColor, + ), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16.0, + children: _activityItems.mapIndexed((i, activity) { + final selected = activity == _currentActivity; + return InkWell( + enableFeedback: widget.enabled, + borderRadius: BorderRadius.circular(12.0), + onTap: widget.enabled ? () => _setActivityByIndex(i) : null, + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: selected ? 0.0 : 0.5, + sigmaY: selected ? 0.0 : 0.5, + ), + child: Opacity( + opacity: selected ? 1.0 : 0.5, + child: ClipOval( + child: SizedBox.fromSize( + size: const Size.fromRadius(12.0), + child: activity.imageURL != null + ? CachedNetworkImage( + imageUrl: activity.imageURL!, + errorWidget: (context, url, error) => + const SizedBox(), + progressIndicatorBuilder: + (context, url, progress) { + return CircularProgressIndicator( + value: progress.progress, + ); + }, + ) + : CircleAvatar( + backgroundColor: + theme.colorScheme.secondary, + radius: 12.0, + ), ), - ); - }).toList(), + ), + ), ), - ], - ), + ); + }).toList(), ), - ), + ], + ), + ), + ), ); } } diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index effc1dfb7..c2e16f87d 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -1,54 +1,34 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:matrix/matrix.dart' as sdk; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; -import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/utils/client_download_content_extension.dart'; -import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; +enum _PageMode { + activity, + roomSelection, +} + class ActivitySuggestionDialog extends StatefulWidget { - final ActivityPlanModel initialActivity; + final ActivityPlannerBuilderState controller; final String buttonText; - final Room? room; - final Function( - ActivityPlanModel, - Uint8List?, - String?, - )? onLaunch; - - final Future Function( - String, - ActivityPlanModel, - Uint8List?, - String?, - )? onEdit; + final VoidCallback? onLaunch; const ActivitySuggestionDialog({ - required this.initialActivity, + required this.controller, required this.buttonText, this.onLaunch, - this.onEdit, - this.room, super.key, }); @@ -58,641 +38,482 @@ class ActivitySuggestionDialog extends StatefulWidget { } class ActivitySuggestionDialogState extends State { - bool _isEditing = false; - Uint8List? _avatar; - String? _imageURL; - String? _filename; - - final TextEditingController _titleController = TextEditingController(); - final TextEditingController _instructionsController = TextEditingController(); - final TextEditingController _vocabController = TextEditingController(); - final TextEditingController _participantsController = TextEditingController(); - final TextEditingController _learningObjectivesController = - TextEditingController(); - - // storing this separately so that we can dismiss edits, - // rather than directly modifying the activity with each change - final List _vocab = []; + _PageMode _pageMode = _PageMode.activity; - final GlobalKey _formKey = GlobalKey(); + double get _width => FluffyThemes.isColumnMode(context) + ? 400.0 + : MediaQuery.of(context).size.width; - @override - void initState() { - super.initState(); - _titleController.text = widget.initialActivity.title; - _learningObjectivesController.text = - widget.initialActivity.learningObjective; - _instructionsController.text = widget.initialActivity.instructions; - _participantsController.text = - widget.initialActivity.req.numberOfParticipants.toString(); - _vocab.addAll(widget.initialActivity.vocab); - _imageURL = widget.initialActivity.imageURL; - _setAvatarByURL(); - } - - @override - void dispose() { - _titleController.dispose(); - _learningObjectivesController.dispose(); - _instructionsController.dispose(); - _vocabController.dispose(); - _participantsController.dispose(); - super.dispose(); - } - - void _setEditing(bool editting) { - _isEditing = editting; - if (mounted) setState(() {}); - } - - void _setAvatar() async { - final photo = await selectFiles( - context, - type: FileSelectorType.images, - allowMultiple: false, - ); - final bytes = await photo.singleOrNull?.readAsBytes(); - if (mounted) { - setState(() { - _avatar = bytes; - _filename = photo.singleOrNull?.name; - }); - } - } - - Future _setAvatarByURL() async { - if (widget.initialActivity.imageURL == null) return; - try { - if (_avatar == null) { - if (widget.initialActivity.imageURL!.startsWith("mxc")) { - final client = Matrix.of(context).client; - final mxcUri = Uri.parse(widget.initialActivity.imageURL!); - final data = await client.downloadMxcCached(mxcUri); - _avatar = data; - _filename = Uri.encodeComponent( - mxcUri.pathSegments.last, - ); - } else { - final Response response = - await http.get(Uri.parse(widget.initialActivity.imageURL!)); - _avatar = response.bodyBytes; - _filename = Uri.encodeComponent( - Uri.parse(widget.initialActivity.imageURL!).pathSegments.last, - ); - } - } - } catch (err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "imageURL": widget.initialActivity.imageURL, - }, + Future _launchActivity() async { + if (widget.onLaunch != null) { + await widget.controller.updateImageURL(); + widget.onLaunch!.call(); + Navigator.of(context).pop(); + } else if (widget.controller.room != null) { + final resp = await showFutureLoadingDialog( + context: context, + future: widget.controller.launchToRoom, ); + if (!resp.isError) { + context.go("/rooms/${widget.controller.room!.id}"); + Navigator.of(context).pop(); + } + } else { + _setPageMode(_PageMode.roomSelection); } } - void _clearEdits() { - _avatar = null; - _filename = null; - _setAvatarByURL(); - _vocab.clear(); - _vocab.addAll(widget.initialActivity.vocab); - if (mounted) setState(() {}); - } - - ActivityPlanModel get _updatedActivity => ActivityPlanModel( - req: widget.initialActivity.req, - title: _titleController.text, - learningObjective: _learningObjectivesController.text, - instructions: _instructionsController.text, - vocab: _vocab, - imageURL: _imageURL, - ); - - Future _updateImageURL() async { - if (_avatar == null) return; - final url = await Matrix.of(context).client.uploadContent( - _avatar!, - filename: _filename, - ); - if (!mounted) return; + void _setPageMode(_PageMode mode) { setState(() { - _imageURL = url.toString(); + _pageMode = mode; }); } - void _addVocab() { - _vocab.insert( - 0, - Vocab( - lemma: _vocabController.text.trim(), - pos: "", - ), - ); - _vocabController.clear(); - if (mounted) setState(() {}); - } - - void _removeVocab(int index) { - _vocab.removeAt(index); - if (mounted) setState(() {}); - } - - Future _launchActivity() async { - await _updateImageURL(); - - if (widget.room != null) { - await widget.room!.sendActivityPlan( - _updatedActivity, - avatar: _avatar, - filename: _filename, - ); - context.go("/rooms/${widget.room!.id}/invite"); - return; - } - - final client = Matrix.of(context).client; - final roomId = await client.createGroupChat( - preset: CreateRoomPreset.publicChat, - visibility: sdk.Visibility.private, - groupName: _updatedActivity.title, - initialState: [ - if (_updatedActivity.imageURL != null) - StateEvent( - type: EventTypes.RoomAvatar, - stateKey: '', - content: { - "url": _updatedActivity.imageURL, - }, - ), - StateEvent( - type: EventTypes.RoomPowerLevels, - stateKey: '', - content: defaultPowerLevels(client.userID!), - ), - ], - enableEncryption: false, - ); - - Room? room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) { - await client.waitForRoomInSync(roomId); - room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) return; - } - - await room.sendActivityPlan( - _updatedActivity, - avatar: _avatar, - filename: _filename, - ); - - context.go("/rooms/$roomId/invite?filter=groups"); - } - - Future _saveEdits() async { - if (!_formKey.currentState!.validate()) return; - await _updateImageURL(); - _setEditing(false); - if (widget.onEdit != null) { - await widget.onEdit!( - widget.initialActivity.bookmarkId, - _updatedActivity, - _avatar, - _filename, - ); - } - } - - double get width { - if (FluffyThemes.isColumnMode(context)) { - return 400.0; - } - return MediaQuery.of(context).size.width; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); final body = Stack( alignment: Alignment.topCenter, children: [ - Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( - constraints: const BoxConstraints( - maxHeight: 400.0, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - width: width, - child: ClipRRect( - borderRadius: BorderRadius.circular(24.0), - child: _avatar != null - ? Image.memory(_avatar!, fit: BoxFit.cover) - : _updatedActivity.imageURL != null - ? _updatedActivity.imageURL!.startsWith("mxc") - ? MxcImage( - uri: Uri.parse( - _updatedActivity.imageURL!, - ), - width: width, - height: 200, - cacheKey: _updatedActivity.bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: _updatedActivity.imageURL!, - fit: BoxFit.cover, - placeholder: (context, url) => - const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => - const SizedBox(), - ) - : null, - ), - ), - if (_isEditing) - Positioned( - bottom: 8.0, - child: InkWell( - borderRadius: BorderRadius.circular(90), - onTap: _setAvatar, - child: const CircleAvatar( - radius: 24.0, - child: Icon( - Icons.add_a_photo_outlined, - size: 24.0, - ), - ), - ), - ), - ], - ), - Flexible( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - if (_isEditing) - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: TextFormField( - controller: _titleController, - decoration: InputDecoration( - labelText: L10n.of(context).activityTitle, - ), - maxLines: 2, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: Text( - _updatedActivity.title, - style: theme.textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - maxLines: 6, - overflow: TextOverflow.ellipsis, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.target, - child: TextFormField( - controller: _learningObjectivesController, - decoration: InputDecoration( - labelText: - L10n.of(context).learningObjectiveLabel, - ), - maxLines: 4, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.target, - child: Text( - _updatedActivity.learningObjective, - maxLines: 6, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: TextFormField( - controller: _instructionsController, - decoration: InputDecoration( - labelText: L10n.of(context).instructions, - ), - maxLines: 8, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: Text( - _updatedActivity.instructions, - maxLines: 8, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge, - ), + DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + ), + child: _pageMode == _PageMode.activity + ? Form( + key: widget.controller.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: _width, + child: widget.controller.avatar != null + ? Image.memory( + widget.controller.avatar!, + fit: BoxFit.cover, + ) + : widget.controller.updatedActivity.imageURL != + null + ? widget.controller.updatedActivity + .imageURL! + .startsWith("mxc") + ? MxcImage( + uri: Uri.parse( + widget.controller.updatedActivity + .imageURL!, + ), + width: _width, + height: 200, + cacheKey: widget.controller + .updatedActivity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: widget.controller + .updatedActivity.imageURL!, + fit: BoxFit.cover, + placeholder: (context, url) => + const Center( + child: + CircularProgressIndicator(), + ), + errorWidget: + (context, url, error) => + const SizedBox(), + ) + : null, ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: TextFormField( - controller: _participantsController, - decoration: InputDecoration( - labelText: L10n.of(context).classRoster, - ), - maxLines: 1, - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || value.isEmpty) { - return null; - } - - try { - final val = int.parse(value); - if (val <= 0) { - return L10n.of(context).pleaseEnterInt; - } - } catch (e) { - return L10n.of(context).pleaseEnterANumber; - } - return null; - }, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: Text( - L10n.of(context).countParticipants( - _updatedActivity.req.numberOfParticipants, + if (widget.controller.isEditing) + Positioned( + bottom: 8.0, + child: InkWell( + borderRadius: BorderRadius.circular(90), + onTap: widget.controller.selectAvatar, + child: const CircleAvatar( + radius: 24.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 24.0, + ), + ), ), - style: theme.textTheme.bodyLarge, ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: - const BoxConstraints(maxHeight: 60.0), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: _vocab - .mapIndexed( - (i, vocab) => Container( - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.0), - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => _removeVocab(i), - child: Row( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: [ - Text(vocab.lemma), - const Icon( - Icons.close, - size: 12.0, + ], + ), + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: TextFormField( + controller: + widget.controller.titleController, + decoration: InputDecoration( + labelText: + L10n.of(context).activityTitle, + ), + maxLines: 2, + minLines: 1, + ), + ) + else + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: Text( + widget.controller.updatedActivity.title, + style: + theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.target, + child: TextFormField( + controller: widget.controller + .learningObjectivesController, + decoration: InputDecoration( + labelText: L10n.of(context) + .learningObjectiveLabel, + ), + maxLines: 4, + minLines: 1, + ), + ) + else + ActivitySuggestionCardRow( + icon: Symbols.target, + child: Text( + widget.controller.updatedActivity + .learningObjective, + maxLines: 6, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.steps, + child: TextFormField( + controller: widget + .controller.instructionsController, + decoration: InputDecoration( + labelText: + L10n.of(context).instructions, + ), + maxLines: 8, + minLines: 1, + ), + ) + else + ActivitySuggestionCardRow( + icon: Symbols.steps, + child: Text( + widget.controller.updatedActivity + .instructions, + maxLines: 8, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: TextFormField( + controller: widget + .controller.participantsController, + decoration: InputDecoration( + labelText: L10n.of(context).classRoster, + ), + maxLines: 1, + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return null; + } + + try { + final val = int.parse(value); + if (val <= 0) { + return L10n.of(context) + .pleaseEnterInt; + } + } catch (e) { + return L10n.of(context) + .pleaseEnterANumber; + } + return null; + }, + ), + ) + else + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: Text( + L10n.of(context).countParticipants( + widget.controller.updatedActivity.req + .numberOfParticipants, + ), + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .mapIndexed( + (i, vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius.circular( + 24.0, + ), + ), + child: MouseRegion( + cursor: SystemMouseCursors + .click, + child: GestureDetector( + onTap: () => widget + .controller + .removeVocab(i), + child: Row( + spacing: 4.0, + mainAxisSize: + MainAxisSize.min, + children: [ + Text(vocab.lemma), + const Icon( + Icons.close, + size: 12.0, + ), + ], + ), + ), ), - ], - ), + ), + ) + .toList(), + ), + ), + ), + ) + else + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius.circular( + 24.0, + ), + ), + child: Text( + vocab.lemma, + style: theme + .textTheme.bodyMedium, + ), + ), + ) + .toList(), + ), + ), + ), + ), + if (widget.controller.isEditing) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + spacing: 4.0, + children: [ + Expanded( + child: TextFormField( + controller: widget + .controller.vocabController, + decoration: InputDecoration( + hintText: L10n.of(context) + .addVocabulary, ), + maxLines: 1, + onFieldSubmitted: (_) => + widget.controller.addVocab(), ), ), - ) - .toList(), - ), - ), - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: - const BoxConstraints(maxHeight: 60.0), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: _vocab - .map( - (vocab) => Container( - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.0), - ), - child: Text( - vocab.lemma, - style: theme.textTheme.bodyMedium, - ), + IconButton( + padding: const EdgeInsets.all(0.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 16.0, + icon: const Icon(Icons.add_outlined), + onPressed: widget.controller.addVocab, ), - ) - .toList(), - ), - ), - ), - ), - if (_isEditing) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 4.0, - children: [ - Expanded( - child: TextFormField( - controller: _vocabController, - decoration: InputDecoration( - hintText: L10n.of(context).addVocabulary, + ], ), - maxLines: 1, - onFieldSubmitted: (_) => _addVocab(), ), - ), - IconButton( - padding: const EdgeInsets.all(0.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 16.0, - icon: const Icon(Icons.add_outlined), - onPressed: _addVocab, - ), ], ), ), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - spacing: 6.0, - children: [ - if (_isEditing && widget.onEdit != null) - Expanded( - child: ElevatedButton( - onPressed: _saveEdits, - style: ElevatedButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.all(6.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - child: Text( - L10n.of(context).save, - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.onPrimary), - ), - ), - ) - else - Expanded( - child: ElevatedButton( - onPressed: () async { - if (!_formKey.currentState!.validate()) { - return; - } - final resp = await showFutureLoadingDialog( - context: context, - future: () async { - if (widget.onLaunch != null) { - await _updateImageURL(); - - widget.onLaunch!.call( - _updatedActivity, - _avatar, - _filename, - ); - } else { - await _launchActivity(); - } - }, - ); - - if (resp.isError) return; - Navigator.of(context).pop(); - }, - style: ElevatedButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.all(6.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - child: Text( - widget.buttonText, - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.onPrimary), - ), ), ), - if (_isEditing) - GestureDetector( - child: const Icon(Icons.close_outlined, size: 16.0), - onTap: () { - _clearEdits(); - _setEditing(false); - }, - ) - else - IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 6.0, + children: [ + if (widget.controller.isEditing) + Expanded( + child: ElevatedButton( + onPressed: widget.controller.saveEdits, + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.all(6.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: + theme.colorScheme.onPrimary, + ), + child: Text( + L10n.of(context).save, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ) + else + Expanded( + child: ElevatedButton( + onPressed: () async { + if (!widget.controller.formKey.currentState! + .validate()) { + return; + } + _launchActivity(); + }, + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.all(6.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: + theme.colorScheme.onPrimary, + ), + child: Text( + widget.buttonText, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + if (widget.controller.isEditing) + GestureDetector( + child: const Icon( + Icons.close_outlined, + size: 16.0, + ), + onTap: () { + widget.controller.clearEdits(); + widget.controller.setEditing(false); + }, + ) + else + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + padding: const EdgeInsets.all(6.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 24.0, + icon: const Icon(Icons.edit_outlined), + onPressed: () => + widget.controller.setEditing(true), + ), + ], ), - padding: const EdgeInsets.all(6.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 24.0, - icon: const Icon(Icons.edit_outlined), - onPressed: () => _setEditing(true), ), - ], + ], + ), + ) + : ActivityRoomSelection( + controller: widget.controller, + backButton: BackButton( + onPressed: () => _setPageMode( + _PageMode.activity, + ), + ), ), - ), - ], - ), ), - Positioned( - top: 4.0, - left: 4.0, - child: IconButton( - icon: const Icon(Icons.close_outlined), - onPressed: Navigator.of(context).pop, - tooltip: L10n.of(context).close, + if (_pageMode == _PageMode.activity) + Positioned( + top: 4.0, + left: 4.0, + child: IconButton( + icon: const Icon(Icons.close_outlined), + onPressed: Navigator.of(context).pop, + tooltip: L10n.of(context).close, + ), ), - ), ], ); - final content = AnimatedSize( - duration: FluffyThemes.animationDuration, - child: ConstrainedBox( - constraints: FluffyThemes.isColumnMode(context) - ? BoxConstraints(maxWidth: width) - : BoxConstraints( - maxWidth: width, - maxHeight: MediaQuery.of(context).size.height, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: body, - ), - ), - ); - - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), - child: FluffyThemes.isColumnMode(context) - ? Dialog(child: content) - : Dialog.fullscreen(child: content), + return FullWidthDialog( + dialogContent: body, + maxWidth: _width, + maxHeight: 650.0, ); } } diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 3e8e54799..27360c8d7 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -10,16 +10,14 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:shimmer/shimmer.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; -import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -135,10 +133,15 @@ class ActivitySuggestionsAreaState extends State { showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: activity, - buttonText: L10n.of(context).inviteAndLaunch, room: widget.room, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: L10n.of(context).launch, + ); + }, ); }, ); @@ -165,7 +168,7 @@ class ActivitySuggestionsAreaState extends State { children: [ Flexible( child: Text( - L10n.of(context).startChat, + L10n.of(context).chatWithActivities, style: isColumnMode ? theme.textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold) @@ -175,91 +178,10 @@ class ActivitySuggestionsAreaState extends State { overflow: TextOverflow.ellipsis, ), ), - Material( - type: MaterialType.transparency, - child: Row( - spacing: 8.0, - children: [ - InkWell( - customBorder: const CircleBorder(), - onTap: () => context.go('/homepage/newgroup'), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(36.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 10.0, - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - CustomizedSvg( - svgUrl: - "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.plusIconPath}", - colorReplacements: { - "#CDBEF9": colorToHex( - Theme.of(context).colorScheme.secondary, - ), - }, - height: 16.0, - width: 16.0, - ), - Text( - isColumnMode - ? L10n.of(context).createOwnChat - : L10n.of(context).chat, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - InkWell( - customBorder: const CircleBorder(), - onTap: () => context.go('/homepage/planner'), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(36.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 10.0, - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - CustomizedSvg( - svgUrl: - "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.crayonIconPath}", - colorReplacements: { - "#CDBEF9": colorToHex( - Theme.of(context).colorScheme.secondary, - ), - }, - height: 16.0, - width: 16.0, - ), - Text( - isColumnMode - ? L10n.of(context).makeYourOwnActivity - : L10n.of(context).createActivity, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), + IconButton( + icon: const Icon(Icons.menu_outlined), + onPressed: () => context.go('/homepage/planner'), + tooltip: L10n.of(context).activityPlannerTitle, ), ], ), diff --git a/lib/pangea/activity_suggestions/suggestions_page.dart b/lib/pangea/activity_suggestions/suggestions_page.dart index 73115cade..45beaaf97 100644 --- a/lib/pangea/activity_suggestions/suggestions_page.dart +++ b/lib/pangea/activity_suggestions/suggestions_page.dart @@ -19,7 +19,6 @@ class SuggestionsPage extends StatelessWidget { vertical: 16.0, ), child: Column( - mainAxisSize: MainAxisSize.min, spacing: 24.0, children: [ if (!isColumnMode) const LearningProgressIndicators(), diff --git a/lib/pangea/common/widgets/full_width_dialog.dart b/lib/pangea/common/widgets/full_width_dialog.dart index e3f93ffc6..c95060407 100644 --- a/lib/pangea/common/widgets/full_width_dialog.dart +++ b/lib/pangea/common/widgets/full_width_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; @@ -16,24 +18,32 @@ class FullWidthDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ConstrainedBox( - constraints: FluffyThemes.isColumnMode(context) - ? BoxConstraints( - maxWidth: maxWidth, - maxHeight: maxHeight, - ) - : BoxConstraints( - maxWidth: MediaQuery.of(context).size.width, - maxHeight: MediaQuery.of(context).size.height, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: dialogContent, + final isColumnMode = FluffyThemes.isColumnMode(context); + final content = AnimatedSize( + duration: FluffyThemes.animationDuration, + child: ConstrainedBox( + constraints: isColumnMode + ? BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ) + : BoxConstraints( + maxWidth: MediaQuery.of(context).size.width, + maxHeight: MediaQuery.of(context).size.height, + ), + child: ClipRRect( + borderRadius: + isColumnMode ? BorderRadius.circular(20.0) : BorderRadius.zero, + child: dialogContent, + ), ), ); - return FluffyThemes.isColumnMode(context) - ? Dialog(child: content) - : Dialog.fullscreen(child: content); + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), + child: isColumnMode + ? Dialog(child: content) + : Dialog.fullscreen(child: content), + ); } } diff --git a/lib/pangea/extensions/room_events_extension.dart b/lib/pangea/extensions/room_events_extension.dart index 626b768f3..c72044a54 100644 --- a/lib/pangea/extensions/room_events_extension.dart +++ b/lib/pangea/extensions/room_events_extension.dart @@ -274,10 +274,20 @@ extension EventsRoomExtension on Room { }) async { Uint8List? bytes = avatar; if (avatarURL != null && bytes == null) { - final resp = await http - .get(Uri.parse(avatarURL)) - .timeout(const Duration(seconds: 5)); - bytes = resp.bodyBytes; + try { + final resp = await http + .get(Uri.parse(avatarURL)) + .timeout(const Duration(seconds: 5)); + bytes = resp.bodyBytes; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "avatarURL": avatarURL, + }, + ); + } } MatrixFile? file; diff --git a/lib/pangea/public_spaces/public_spaces_area.dart b/lib/pangea/public_spaces/public_spaces_area.dart index 36b0e5a60..645f321a4 100644 --- a/lib/pangea/public_spaces/public_spaces_area.dart +++ b/lib/pangea/public_spaces/public_spaces_area.dart @@ -176,7 +176,7 @@ class PublicSpacesAreaState extends State { key: const ValueKey('title'), children: [ Text( - L10n.of(context).publicSpacesTitle, + L10n.of(context).findYourPeople, style: isColumnMode ? theme.textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold)