From 07cbf2426a6f259733aef0dd667da95b534084ae Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:36:50 -0400 Subject: [PATCH] feat: start a chat using an activity template (#2107) Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> --- assets/l10n/intl_en.arb | 8 +- lib/config/routes.dart | 25 +- lib/pages/settings/settings_view.dart | 22 +- .../activity_planner/activity_plan_model.dart | 46 ++- .../activity_plan_request.dart | 30 +- .../activity_suggestion_card.dart | 117 +++++++ .../activity_suggestion_card_content.dart | 172 +++++++++++ .../activity_suggestion_card_row.dart | 29 ++ .../activity_suggestion_edit_card.dart | 278 +++++++++++++++++ .../activity_suggestions_area.dart | 288 +++++++++++++++--- .../activity_suggestions_constants.dart | 3 + .../create_chat_card.dart | 61 ++++ .../suggestions_page.dart | 31 ++ lib/pangea/common/widgets/customized_svg.dart | 6 +- lib/pangea/layouts/bottom_nav_layout.dart | 151 +++++++++ 15 files changed, 1209 insertions(+), 58 deletions(-) create mode 100644 lib/pangea/activity_suggestions/activity_suggestion_card.dart create mode 100644 lib/pangea/activity_suggestions/activity_suggestion_card_content.dart create mode 100644 lib/pangea/activity_suggestions/activity_suggestion_card_row.dart create mode 100644 lib/pangea/activity_suggestions/activity_suggestion_edit_card.dart create mode 100644 lib/pangea/activity_suggestions/activity_suggestions_constants.dart create mode 100644 lib/pangea/activity_suggestions/create_chat_card.dart create mode 100644 lib/pangea/activity_suggestions/suggestions_page.dart create mode 100644 lib/pangea/layouts/bottom_nav_layout.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index bf0c096f6..f43632c0a 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4715,7 +4715,7 @@ "myBookmarkedActivities": "My Bookmarked Activities", "noBookmarkedActivities": "No bookmarked activities", "activityTitle": "Activity Title", - "addVocabulary": "Add Vocabulary", + "addVocabulary": "Add vocabulary", "instructions": "Instructions", "bookmark": "Bookmark this activity", "numberOfLearners": "Number of learners", @@ -4807,5 +4807,9 @@ "morphAnalyticsListBody": "These are all the grammar concepts in the language you're learning! You'll unlock them as you encounter them while chatting. Click for details.", "knockSpaceSuccess": "You have requested to join this space! An admin will respond to your request when they receive it 😀", "joinByCode": "Join by code", - "createASpace": "Create a space" + "createASpace": "Create a space", + "inviteAndLaunch": "Invite and launch", + "createOwnChat": "Create your own chat", + "pleaseEnterInt": "Please enter a number", + "home": "Home" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index a6b25959e..465649d14 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -30,7 +30,10 @@ import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; +import 'package:fluffychat/pangea/activity_suggestions/suggestions_page.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; +import 'package:fluffychat/pangea/layouts/bottom_nav_layout.dart'; import 'package:fluffychat/pangea/login/pages/login_or_signup_view.dart'; import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/pangea/login/pages/user_settings.dart'; @@ -176,7 +179,13 @@ abstract class AppRoutes { ), sideView: child, ) - : child, + // #Pangea + // : child, + : state.fullPath?.split("/").reversed.elementAt(1) == 'rooms' && + state.pathParameters['roomid'] != null + ? child + : BottomNavLayout(mainView: child), + // Pangea# ), routes: [ GoRoute( @@ -186,7 +195,10 @@ abstract class AppRoutes { context, state, FluffyThemes.isColumnMode(context) - ? const EmptyPage() + // #Pangea + // ? const EmptyPage() + ? const ActivitySuggestionsArea() + // Pangea# : ChatList( activeChat: state.pathParameters['roomid'], ), @@ -233,6 +245,15 @@ abstract class AppRoutes { const JoinWithAlias(), ), ), + GoRoute( + path: '/homepage', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SuggestionsPage(), + ), + ), // Pangea# GoRoute( path: 'archive', diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index e5c678ff2..a5e061218 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -53,16 +53,18 @@ class SettingsView extends StatelessWidget { ], Expanded( child: Scaffold( - appBar: FluffyThemes.isColumnMode(context) - ? null - : AppBar( - title: Text(L10n.of(context).settings), - leading: Center( - child: BackButton( - onPressed: () => context.go('/rooms'), - ), - ), - ), + // #Pangea + // appBar: FluffyThemes.isColumnMode(context) + // ? null + // : AppBar( + // title: Text(L10n.of(context).settings), + // leading: Center( + // child: BackButton( + // onPressed: () => context.go('/rooms'), + // ), + // ), + // ), + // Pangea# body: ListTileTheme( iconColor: theme.colorScheme.onSurface, child: ListView( diff --git a/lib/pangea/activity_planner/activity_plan_model.dart b/lib/pangea/activity_planner/activity_plan_model.dart index da2e44287..5b35ad758 100644 --- a/lib/pangea/activity_planner/activity_plan_model.dart +++ b/lib/pangea/activity_planner/activity_plan_model.dart @@ -1,11 +1,14 @@ +import 'package:flutter/foundation.dart'; + import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; class ActivityPlanModel { final ActivityPlanRequest req; - final String title; - final String learningObjective; - final String instructions; - final List vocab; + String title; + String learningObjective; + String instructions; + List vocab; + String? imageURL; String? bookmarkId; ActivityPlanModel({ @@ -14,6 +17,7 @@ class ActivityPlanModel { required this.learningObjective, required this.instructions, required this.vocab, + this.imageURL, this.bookmarkId, }); @@ -27,6 +31,7 @@ class ActivityPlanModel { json['vocab'].map((vocab) => Vocab.fromJson(vocab)), ), bookmarkId: json['bookmark_id'], + imageURL: json['image_url'], ); } @@ -38,6 +43,7 @@ class ActivityPlanModel { 'instructions': instructions, 'vocab': vocab.map((vocab) => vocab.toJson()).toList(), 'bookmark_id': bookmarkId, + 'image_url': imageURL, }; } @@ -63,6 +69,28 @@ class ActivityPlanModel { bool get isBookmarked { return bookmarkId != null; } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ActivityPlanModel && + other.req == req && + other.title == title && + other.learningObjective == learningObjective && + other.instructions == instructions && + listEquals(other.vocab, vocab) && + other.bookmarkId == bookmarkId; + } + + @override + int get hashCode => + req.hashCode ^ + title.hashCode ^ + learningObjective.hashCode ^ + instructions.hashCode ^ + Object.hashAll(vocab) ^ + bookmarkId.hashCode; } class Vocab { @@ -87,4 +115,14 @@ class Vocab { 'pos': pos, }; } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Vocab && other.lemma == lemma && other.pos == pos; + } + + @override + int get hashCode => lemma.hashCode ^ pos.hashCode; } diff --git a/lib/pangea/activity_planner/activity_plan_request.dart b/lib/pangea/activity_planner/activity_plan_request.dart index 2ab21d1e9..d080a2e5f 100644 --- a/lib/pangea/activity_planner/activity_plan_request.dart +++ b/lib/pangea/activity_planner/activity_plan_request.dart @@ -10,7 +10,7 @@ class ActivityPlanRequest { final String languageOfInstructions; final String targetLanguage; final int count; - final int numberOfParticipants; + int numberOfParticipants; ActivityPlanRequest({ required this.topic, @@ -57,4 +57,32 @@ class ActivityPlanRequest { String get storageKey => '$topic-$mode-$objective-${media.string}-$cefrLevel-$languageOfInstructions-$targetLanguage-$numberOfParticipants'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ActivityPlanRequest && + other.topic == topic && + other.mode == mode && + other.objective == objective && + other.media == media && + other.cefrLevel == cefrLevel && + other.languageOfInstructions == languageOfInstructions && + other.targetLanguage == targetLanguage && + other.count == count && + other.numberOfParticipants == numberOfParticipants; + } + + @override + int get hashCode => + topic.hashCode ^ + mode.hashCode ^ + objective.hashCode ^ + media.hashCode ^ + cefrLevel.hashCode ^ + languageOfInstructions.hashCode ^ + targetLanguage.hashCode ^ + count.hashCode ^ + numberOfParticipants.hashCode; } diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_card.dart new file mode 100644 index 000000000..15054811b --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestion_card.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_content.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_edit_card.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; +import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; + +class ActivitySuggestionCard extends StatelessWidget { + final ActivityPlanModel activity; + final ActivitySuggestionsAreaState controller; + final VoidCallback onPressed; + + final double width; + final double height; + + const ActivitySuggestionCard({ + super.key, + required this.activity, + required this.controller, + required this.onPressed, + required this.width, + required this.height, + }); + + bool get _isSelected => controller.selectedActivity == activity; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(8.0), + child: PressableButton( + onPressed: onPressed, + borderRadius: BorderRadius.circular(24.0), + color: theme.colorScheme.primary, + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: controller.isEditing && _isSelected + ? 675 + : _isSelected + ? 400 + : height, + width: width, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(24.0), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 100, + width: width, + decoration: BoxDecoration( + image: activity.imageURL != null + ? DecorationImage( + image: controller.avatar == null || !_isSelected + ? NetworkImage(activity.imageURL!) + : MemoryImage(controller.avatar!) + as ImageProvider, + ) + : null, + borderRadius: BorderRadius.circular(24.0), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: 16.0, + left: 12.0, + right: 12.0, + bottom: 12.0, + ), + child: controller.isEditing && _isSelected + ? ActivitySuggestionEditCard( + activity: activity, + controller: controller, + ) + : ActivitySuggestionCardContent( + activity: activity, + isSelected: _isSelected, + controller: controller, + ), + ), + ), + ], + ), + if (controller.isEditing && _isSelected) + Positioned( + top: 75.0, + child: InkWell( + borderRadius: BorderRadius.circular(90), + onTap: controller.selectPhoto, + child: const CircleAvatar( + radius: 16.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 16.0, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card_content.dart b/lib/pangea/activity_suggestions/activity_suggestion_card_content.dart new file mode 100644 index 000000000..3e7957665 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestion_card_content.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; + +class ActivitySuggestionCardContent extends StatelessWidget { + final ActivityPlanModel activity; + final ActivitySuggestionsAreaState controller; + final bool isSelected; + + const ActivitySuggestionCardContent({ + super.key, + required this.activity, + required this.controller, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: Text( + activity.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + ActivitySuggestionCardRow( + icon: Symbols.target, + child: Text( + activity.learningObjective, + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + ActivitySuggestionCardRow( + icon: Symbols.target, + child: Text( + activity.instructions, + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 54.0), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: activity.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha(50), + borderRadius: BorderRadius.circular(24.0), + ), + child: Text( + vocab.lemma, + style: theme.textTheme.bodySmall, + ), + ), + ) + .toList(), + ), + ), + ), + ), + if (!isSelected) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 54.0), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Align( + alignment: Alignment.topLeft, + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: activity.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha(50), + borderRadius: BorderRadius.circular(24.0), + ), + child: Text( + vocab.lemma, + style: theme.textTheme.bodySmall, + ), + ), + ) + .toList(), + ), + ), + ), + ), + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: Text( + L10n.of(context).countParticipants( + activity.req.numberOfParticipants, + ), + style: theme.textTheme.bodySmall, + ), + ), + if (isSelected) + Row( + spacing: 6.0, + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => controller.onLaunch(activity), + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.all(4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: Text( + L10n.of(context).inviteAndLaunch, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + 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: 16.0, + icon: const Icon(Icons.edit_outlined), + onPressed: () => controller.setEditting(true), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart b/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart new file mode 100644 index 000000000..e03e24fdf --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class ActivitySuggestionCardRow extends StatelessWidget { + final IconData icon; + final Widget child; + + const ActivitySuggestionCardRow({ + required this.icon, + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + spacing: 4.0, + children: [ + Icon( + icon, + size: 12.0, + ), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_edit_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_edit_card.dart new file mode 100644 index 000000000..a4e578c0c --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestion_edit_card.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; + +class ActivitySuggestionEditCard extends StatefulWidget { + final ActivityPlanModel activity; + final ActivitySuggestionsAreaState controller; + + const ActivitySuggestionEditCard({ + super.key, + required this.activity, + required this.controller, + }); + + @override + ActivitySuggestionEditCardState createState() => + ActivitySuggestionEditCardState(); +} + +class ActivitySuggestionEditCardState + extends State { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _instructionsController = TextEditingController(); + final TextEditingController _vocabController = TextEditingController(); + final TextEditingController _participantsController = TextEditingController(); + final TextEditingController _learningObjectivesController = + TextEditingController(); + + final GlobalKey _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _titleController.text = widget.activity.title; + _learningObjectivesController.text = widget.activity.learningObjective; + _instructionsController.text = widget.activity.instructions; + _participantsController.text = + widget.activity.req.numberOfParticipants.toString(); + } + + @override + void dispose() { + _titleController.dispose(); + _learningObjectivesController.dispose(); + _instructionsController.dispose(); + _vocabController.dispose(); + _participantsController.dispose(); + super.dispose(); + } + + void _updateActivity() { + widget.controller.updateActivity((activity) { + activity.title = _titleController.text; + activity.learningObjective = _learningObjectivesController.text; + activity.instructions = _instructionsController.text; + activity.req.numberOfParticipants = + int.tryParse(_participantsController.text) ?? 3; + return activity; + }); + } + + void _addVocab() { + widget.controller.updateActivity((activity) { + activity.vocab.insert( + 0, + Vocab( + lemma: _vocabController.text.trim(), + pos: "", + ), + ); + return activity; + }); + _vocabController.clear(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: TextFormField( + controller: _titleController, + decoration: InputDecoration( + labelText: L10n.of(context).activityTitle, + ), + style: theme.textTheme.bodySmall, + maxLines: 2, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.target, + child: TextFormField( + style: theme.textTheme.bodySmall, + controller: _learningObjectivesController, + decoration: InputDecoration( + labelText: L10n.of(context).learningObjectiveLabel, + ), + maxLines: 4, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.target, + child: TextFormField( + style: theme.textTheme.bodySmall, + controller: _instructionsController, + decoration: InputDecoration( + labelText: L10n.of(context).instructions, + ), + maxLines: 8, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: TextFormField( + controller: _participantsController, + style: theme.textTheme.bodySmall, + 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; + }, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 54.0), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.activity.vocab + .mapIndexed( + (i, vocab) => Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha(50), + borderRadius: BorderRadius.circular(24.0), + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + widget.controller.updateActivity((activity) { + activity.vocab.removeAt(i); + return activity; + }); + }, + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + vocab.lemma, + style: theme.textTheme.bodySmall, + ), + const Icon(Icons.close, size: 12.0), + ], + ), + ), + ), + ), + ) + .toList(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + spacing: 4.0, + children: [ + Expanded( + child: TextFormField( + controller: _vocabController, + style: theme.textTheme.bodySmall, + 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, + ), + ], + ), + ), + Row( + spacing: 6.0, + children: [ + GestureDetector( + child: const Icon(Icons.save_outlined, size: 16.0), + onTap: () { + if (!_formKey.currentState!.validate()) { + return; + } + _updateActivity(); + widget.controller.setEditting(false); + }, + ), + GestureDetector( + child: const Icon(Icons.close_outlined, size: 16.0), + onTap: () => widget.controller.setEditting(false), + ), + Expanded( + child: ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + _updateActivity(); + widget.controller.onLaunch(widget.activity); + }, + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.all(4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: Text( + L10n.of(context).inviteAndLaunch, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 01a3f462e..0b0c72917 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -1,45 +1,257 @@ -// // shows n rows of activity suggestions vertically, where n is the number of rows -// // as the user tries to scroll horizontally to the right, the client will fetch more activity suggestions +// shows n rows of activity suggestions vertically, where n is the number of rows +// as the user tries to scroll horizontally to the right, the client will fetch more activity suggestions -// import 'package:flutter/material.dart'; +import 'dart:developer'; -// import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; -// import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; -// class ActivitySuggestionsArea extends StatefulWidget { -// const ActivitySuggestionsArea({super.key}); +import 'package:collection/collection.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' as sdk; +import 'package:matrix/matrix.dart'; -// @override -// ActivitySuggestionsAreaState createState() => ActivitySuggestionsAreaState(); -// } +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/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/create_chat_card.dart'; +import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.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/utils/file_selector.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; -// class ActivitySuggestionsAreaState extends State { -// @override -// void initState() { -// super.initState(); -// } +class ActivitySuggestionsArea extends StatefulWidget { + const ActivitySuggestionsArea({super.key}); -// Future fetchMoreSuggestions() async { -// ActivitySearchRepo.get( -// ActivityPlanRequest(), -// ); -// } + @override + ActivitySuggestionsAreaState createState() => ActivitySuggestionsAreaState(); +} -// @override -// Widget build(BuildContext context) { -// return Container( -// child: ListView.builder( -// scrollDirection: Axis.vertical, -// itemCount: 5, -// itemBuilder: (context, index) { -// return Container( -// height: 100, -// width: 100, -// color: Colors.blue, -// margin: const EdgeInsets.all(10), -// ); -// }, -// ), -// ); -// } -// } +class ActivitySuggestionsAreaState extends State { + @override + void initState() { + super.initState(); + _setActivityItems(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + ActivityPlanModel? selectedActivity; + bool isEditing = false; + Uint8List? avatar; + final List _activityItems = []; + final ScrollController _scrollController = ScrollController(); + + final double cardHeight = 275.0; + final double cardWidth = 250.0; + + void _scrollToItem(int index) { + final viewportDimension = _scrollController.position.viewportDimension; + final double scrollOffset = FluffyThemes.isColumnMode(context) + ? index * cardWidth - (viewportDimension / 2) + (cardWidth / 2) + : (index + 1) * (cardHeight + 8.0); + + final maxScrollExtent = _scrollController.position.maxScrollExtent; + final safeOffset = scrollOffset.clamp(0.0, maxScrollExtent); + + if (safeOffset == _scrollController.offset) { + return; + } + + _scrollController.animateTo( + safeOffset, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + ); + } + + Future _setActivityItems() async { + final ActivityPlanRequest request = ActivityPlanRequest( + topic: "", + mode: "", + objective: "", + media: MediaEnum.nan, + cefrLevel: LanguageLevelTypeEnum.a1, + languageOfInstructions: LanguageKeys.defaultLanguage, + targetLanguage: + MatrixState.pangeaController.languageController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + numberOfParticipants: 3, + count: 5, + ); + final resp = await ActivitySearchRepo.get(request); + _activityItems.addAll(resp.activityPlans); + setState(() {}); + } + + void setSelectedActivity(ActivityPlanModel? activity) { + selectedActivity = activity; + isEditing = false; + if (mounted) setState(() {}); + } + + void setEditting(bool editting) { + if (selectedActivity == null) return; + isEditing = editting; + if (mounted) setState(() {}); + } + + void selectPhoto() async { + final photo = await selectFiles( + context, + type: FileSelectorType.images, + allowMultiple: false, + ); + final bytes = await photo.singleOrNull?.readAsBytes(); + + setState(() { + avatar = bytes; + }); + } + + void updateActivity( + ActivityPlanModel Function(ActivityPlanModel) update, + ) { + if (selectedActivity == null) return; + update(selectedActivity!); + if (mounted) setState(() {}); + } + + Future _getAvatarURL(ActivityPlanModel activity) async { + if (activity.imageURL == null && avatar == null) return null; + try { + if (avatar == null) { + final Response response = await http.get(Uri.parse(activity.imageURL!)); + avatar = response.bodyBytes; + } + return (await Matrix.of(context).client.uploadContent(avatar!)) + .toString(); + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: { + "imageURL": activity.imageURL, + }, + ); + } + return null; + } + + Future onLaunch(ActivityPlanModel activity) async { + final client = Matrix.of(context).client; + + await showFutureLoadingDialog( + context: context, + future: () async { + final avatarURL = await _getAvatarURL(activity); + final roomId = await client.createGroupChat( + preset: CreateRoomPreset.publicChat, + visibility: sdk.Visibility.private, + groupName: activity.title, + initialState: [ + if (avatarURL != null) + StateEvent( + type: EventTypes.RoomAvatar, + content: {'url': avatarURL.toString()}, + ), + 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; + } + + final eventId = await room.pangeaSendTextEvent( + activity.markdown, + messageTag: ModelKey.messageTagActivityPlan, + ); + + if (eventId == null) { + debugger(when: kDebugMode); + return; + } + + await room.setPinnedEvents([eventId]); + context.go("/rooms/$roomId/invite"); + }, + ); + } + + @override + Widget build(BuildContext context) { + final List cards = _activityItems + .mapIndexed((i, activity) { + return ActivitySuggestionCard( + activity: activity, + controller: this, + onPressed: () { + if (isEditing && selectedActivity == activity) { + setEditting(false); + } else if (selectedActivity == activity) { + setSelectedActivity(null); + } else { + setSelectedActivity(activity); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToItem(i); + }); + }, + width: cardWidth, + height: cardHeight, + ); + }) + .cast() + .toList(); + + cards.insert( + 0, + CreateChatCard( + width: cardWidth, + height: cardHeight, + ), + ); + + return Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.all(16.0), + child: FluffyThemes.isColumnMode(context) + ? ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: cards.length, + itemBuilder: (context, index) => cards[index], + controller: _scrollController, + ) + : SingleChildScrollView( + controller: _scrollController, + child: Wrap( + children: cards, + ), + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestions_constants.dart b/lib/pangea/activity_suggestions/activity_suggestions_constants.dart new file mode 100644 index 000000000..bb3d9f18a --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestions_constants.dart @@ -0,0 +1,3 @@ +class ActivitySuggestionsConstants { + static const String plusIconPath = "add_icon.svg"; +} diff --git a/lib/pangea/activity_suggestions/create_chat_card.dart b/lib/pangea/activity_suggestions/create_chat_card.dart new file mode 100644 index 000000000..d19580bd5 --- /dev/null +++ b/lib/pangea/activity_suggestions/create_chat_card.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; +import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; +import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; + +class CreateChatCard extends StatelessWidget { + final double width; + final double height; + + const CreateChatCard({ + required this.width, + required this.height, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(8.0), + child: PressableButton( + onPressed: () => context.go('/rooms/newgroup'), + borderRadius: BorderRadius.circular(24.0), + color: theme.colorScheme.primary, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(24.0), + ), + height: height, + width: width, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: CustomizedSvg( + svgUrl: + "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.plusIconPath}", + colorReplacements: const {}, + height: 80, + width: 80, + ), + ), + const SizedBox(height: 24.0), + Text( + L10n.of(context).createOwnChat, + style: theme.textTheme.bodyLarge + ?.copyWith(color: theme.colorScheme.secondary), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/suggestions_page.dart b/lib/pangea/activity_suggestions/suggestions_page.dart new file mode 100644 index 000000000..2de5f0906 --- /dev/null +++ b/lib/pangea/activity_suggestions/suggestions_page.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; +import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; + +class SuggestionsPage extends StatelessWidget { + const SuggestionsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: EdgeInsets.only( + top: 16, + left: 16, + right: 16, + ), + child: LearningProgressIndicators(), + ), + Expanded( + child: ActivitySuggestionsArea(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/common/widgets/customized_svg.dart b/lib/pangea/common/widgets/customized_svg.dart index b326b9d6d..4239cf60f 100644 --- a/lib/pangea/common/widgets/customized_svg.dart +++ b/lib/pangea/common/widgets/customized_svg.dart @@ -111,7 +111,11 @@ class CustomizedSvg extends StatelessWidget { Widget build(BuildContext context) { final cached = _getSvgFromCache(); if (cached != null) { - return SvgPicture.string(cached); + return SvgPicture.string( + cached, + width: width, + height: height, + ); } return FutureBuilder( diff --git a/lib/pangea/layouts/bottom_nav_layout.dart b/lib/pangea/layouts/bottom_nav_layout.dart new file mode 100644 index 000000000..cd314abdf --- /dev/null +++ b/lib/pangea/layouts/bottom_nav_layout.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +class BottomNavLayout extends StatelessWidget { + final Widget mainView; + + const BottomNavLayout({ + super.key, + required this.mainView, + }); + @override + Widget build(BuildContext context) { + return Scaffold( + body: mainView, + bottomNavigationBar: const BottomNavBar(), + ); + } +} + +class BottomNavBar extends StatefulWidget { + const BottomNavBar({ + super.key, + }); + + @override + BottomNavBarState createState() => BottomNavBarState(); +} + +class BottomNavBarState extends State { + int get selectedIndex { + final route = GoRouterState.of(context).fullPath.toString(); + if (route.contains("settings")) { + return 2; + } + if (route.contains('homepage')) { + return 0; + } + return 1; + } + + @override + void didUpdateWidget(covariant BottomNavBar oldWidget) { + super.didUpdateWidget(oldWidget); + debugPrint("didUpdateWidget"); + } + + void onItemTapped(int index) { + switch (index) { + case 0: + context.go('/rooms/homepage'); + break; + case 1: + context.go('/rooms'); + break; + case 2: + context.go('/rooms/settings'); + break; + } + + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + BottomNavItem( + controller: this, + icon: Icons.home, + label: L10n.of(context).home, + index: 0, + ), + BottomNavItem( + controller: this, + icon: Icons.chat_bubble_outline, + label: L10n.of(context).chats, + index: 1, + ), + BottomNavItem( + controller: this, + icon: Icons.settings, + label: L10n.of(context).settings, + index: 2, + ), + ], + ), + ); + } +} + +class BottomNavItem extends StatelessWidget { + final BottomNavBarState controller; + final int index; + final IconData icon; + final String label; + + const BottomNavItem({ + required this.controller, + required this.index, + required this.icon, + required this.label, + super.key, + }); + + @override + Widget build(BuildContext context) { + final isSelected = controller.selectedIndex == index; + final theme = Theme.of(context); + return Expanded( + child: GestureDetector( + onTap: () => controller.onItemTapped(index), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 6.0, + ), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.secondaryContainer, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSecondaryContainer, + ), + Text( + label, + style: TextStyle( + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSecondaryContainer, + ), + ), + ], + ), + ), + ), + ); + } +}