feat: start a chat using an activity template (#2107)

Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com>
This commit is contained in:
ggurdin 2025-03-11 14:36:50 -04:00 committed by GitHub
parent e150a3b0a9
commit 07cbf2426a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1209 additions and 58 deletions

View file

@ -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"
}

View file

@ -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',

View file

@ -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(

View file

@ -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> vocab;
String title;
String learningObjective;
String instructions;
List<Vocab> 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;
}

View file

@ -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;
}

View file

@ -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<Object>,
)
: 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,
),
),
),
),
],
),
),
),
);
}
}

View file

@ -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),
),
],
),
],
);
}
}

View file

@ -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),
],
),
);
}
}

View file

@ -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<ActivitySuggestionEditCard> {
final TextEditingController _titleController = TextEditingController();
final TextEditingController _instructionsController = TextEditingController();
final TextEditingController _vocabController = TextEditingController();
final TextEditingController _participantsController = TextEditingController();
final TextEditingController _learningObjectivesController =
TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@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,
),
),
),
),
],
),
],
),
);
}
}

View file

@ -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<ActivitySuggestionsArea> {
// @override
// void initState() {
// super.initState();
// }
class ActivitySuggestionsArea extends StatefulWidget {
const ActivitySuggestionsArea({super.key});
// Future<void> 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<ActivitySuggestionsArea> {
@override
void initState() {
super.initState();
_setActivityItems();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
ActivityPlanModel? selectedActivity;
bool isEditing = false;
Uint8List? avatar;
final List<ActivityPlanModel> _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<void> _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<String?> _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<void> 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<Widget> 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<Widget>()
.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,
),
),
);
}
}

View file

@ -0,0 +1,3 @@
class ActivitySuggestionsConstants {
static const String plusIconPath = "add_icon.svg";
}

View file

@ -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),
),
],
),
),
),
);
}
}

View file

@ -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(),
),
],
),
),
);
}
}

View file

@ -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<String?>(

View file

@ -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<BottomNavBar> {
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,
),
),
],
),
),
),
);
}
}