refactor: make activity card into a dialog when launching / editing, adjust sizing to fit two-per-row on small screens (#2123)

This commit is contained in:
ggurdin 2025-03-12 10:23:38 -04:00 committed by GitHub
parent 6502c3d26c
commit c204f484c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 692 additions and 652 deletions

View file

@ -1,47 +1,39 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:flutter_gen/gen_l10n/l10n.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/activity_suggestions/activity_suggestion_card_row.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;
final double padding;
const ActivitySuggestionCard({
super.key,
required this.activity,
required this.controller,
required this.onPressed,
required this.width,
required this.height,
required this.padding,
});
bool get _isSelected => controller.selectedActivity == activity;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(8.0),
padding: EdgeInsets.all(padding),
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,
child: SizedBox(
height: height,
width: width,
child: Stack(
alignment: Alignment.topCenter,
@ -62,10 +54,7 @@ class ActivitySuggestionCard extends StatelessWidget {
decoration: BoxDecoration(
image: activity.imageURL != null
? DecorationImage(
image: controller.avatar == null || !_isSelected
? NetworkImage(activity.imageURL!)
: MemoryImage(controller.avatar!)
as ImageProvider<Object>,
image: NetworkImage(activity.imageURL!),
)
: null,
borderRadius: BorderRadius.circular(24.0),
@ -74,40 +63,74 @@ class ActivitySuggestionCard extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: 16.0,
top: 12.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,
child: 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: 1,
overflow: TextOverflow.ellipsis,
),
),
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 (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

@ -1,172 +0,0 @@
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,581 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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';
import 'package:matrix/matrix_api_lite/generated/model.dart' as sdk;
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_row.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/utils/file_selector.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivitySuggestionDialog extends StatefulWidget {
final ActivityPlanModel activity;
const ActivitySuggestionDialog({
required this.activity,
super.key,
});
@override
ActivitySuggestionDialogState createState() =>
ActivitySuggestionDialogState();
}
class ActivitySuggestionDialogState extends State<ActivitySuggestionDialog> {
bool _isEditing = false;
Uint8List? _avatar;
String? _avatarURL;
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> _vocab = [];
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();
_vocab.addAll(widget.activity.vocab);
}
@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);
}
Future<void> _setAvatarURL() async {
if (widget.activity.imageURL == null && _avatar == null) return;
try {
if (_avatar == null) {
final Response response =
await http.get(Uri.parse(widget.activity.imageURL!));
_avatar = response.bodyBytes;
}
final resp = await Matrix.of(context).client.uploadContent(_avatar!);
if (mounted) setState(() => _avatarURL = resp.toString());
widget.activity.imageURL = _avatarURL;
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"imageURL": widget.activity.imageURL,
},
);
}
}
void _clearEdits() {
_avatar = null;
_avatarURL = null;
_vocab.clear();
_vocab.addAll(widget.activity.vocab);
if (mounted) setState(() {});
}
Future<void> _updateTextFields() async {
widget.activity.title = _titleController.text;
widget.activity.learningObjective = _learningObjectivesController.text;
widget.activity.instructions = _instructionsController.text;
widget.activity.req.numberOfParticipants =
int.tryParse(_participantsController.text) ?? 3;
widget.activity.vocab = _vocab;
}
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<void> _launch() async {
final client = Matrix.of(context).client;
final resp = await showFutureLoadingDialog(
context: context,
future: () async {
await _setAvatarURL();
final roomId = await client.createGroupChat(
preset: CreateRoomPreset.publicChat,
visibility: sdk.Visibility.private,
groupName: widget.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(
widget.activity.markdown,
messageTag: ModelKey.messageTagActivityPlan,
);
if (eventId == null) {
debugger(when: kDebugMode);
return;
}
await room.setPinnedEvents([eventId]);
context.go("/rooms/$roomId/invite");
},
);
if (!resp.isError) {
Navigator.of(context).pop();
}
}
@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: [
Container(
height: 200,
decoration: BoxDecoration(
image: widget.activity.imageURL != null || _avatar != null
? DecorationImage(
image: _avatar != null
? MemoryImage(_avatar!)
: NetworkImage(widget.activity.imageURL!)
as ImageProvider<Object>,
)
: null,
borderRadius: BorderRadius.circular(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,
),
style: theme.textTheme.bodySmall,
maxLines: 2,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Icons.event_note_outlined,
child: Text(
widget.activity.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (_isEditing)
ActivitySuggestionCardRow(
icon: Symbols.target,
child: TextFormField(
style: theme.textTheme.bodySmall,
controller: _learningObjectivesController,
decoration: InputDecoration(
labelText:
L10n.of(context).learningObjectiveLabel,
),
maxLines: 4,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.target,
child: Text(
widget.activity.learningObjective,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (_isEditing)
ActivitySuggestionCardRow(
icon: Symbols.target,
child: TextFormField(
style: theme.textTheme.bodySmall,
controller: _instructionsController,
decoration: InputDecoration(
labelText: L10n.of(context).instructions,
),
maxLines: 8,
minLines: 1,
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.target,
child: Text(
widget.activity.instructions,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (_isEditing)
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;
},
),
)
else
ActivitySuggestionCardRow(
icon: Icons.group_outlined,
child: Text(
L10n.of(context).countParticipants(
widget.activity.req.numberOfParticipants,
),
style: theme.textTheme.bodySmall,
),
),
if (_isEditing)
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 54.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(50),
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,
style: theme
.textTheme.bodySmall,
),
const Icon(
Icons.close,
size: 12.0,
),
],
),
),
),
),
)
.toList(),
),
),
),
)
else
ActivitySuggestionCardRow(
icon: Symbols.dictionary,
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 54.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(50),
borderRadius:
BorderRadius.circular(24.0),
),
child: Text(
vocab.lemma,
style: theme.textTheme.bodySmall,
),
),
)
.toList(),
),
),
),
),
if (_isEditing)
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,
),
],
),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 6.0,
children: [
if (_isEditing)
GestureDetector(
child: const Icon(Icons.save_outlined, size: 16.0),
onTap: () {
if (!_formKey.currentState!.validate()) {
return;
}
_updateTextFields();
_setEditing(false);
},
),
if (_isEditing)
GestureDetector(
child: const Icon(Icons.close_outlined, size: 16.0),
onTap: () {
_clearEdits();
_setEditing(false);
},
),
Expanded(
child: ElevatedButton(
onPressed: () {
if (!_formKey.currentState!.validate()) {
return;
}
_updateTextFields();
_launch();
},
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,
),
),
),
),
if (!_isEditing)
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: () => _setEditing(true),
),
],
),
),
],
),
),
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 (_isEditing)
Positioned(
top: 160.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,
),
),
),
),
],
);
final content = AnimatedSize(
duration: FluffyThemes.animationDuration,
child: ConstrainedBox(
constraints: FluffyThemes.isColumnMode(context)
? const BoxConstraints(
maxWidth: 400.0,
)
: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
maxHeight: MediaQuery.of(context).size.height,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: body,
),
),
);
return FluffyThemes.isColumnMode(context)
? Dialog(child: content)
: Dialog.fullscreen(child: content);
}
}

View file

@ -1,278 +0,0 @@
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,17 +1,9 @@
// 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 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
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';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
@ -19,15 +11,10 @@ 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/activity_suggestion_dialog.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 ActivitySuggestionsArea extends StatefulWidget {
@ -50,14 +37,12 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
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;
final double cardHeight = 235.0;
final double cardPadding = 8.0;
double get cardWidth => FluffyThemes.isColumnMode(context) ? 225.0 : 160.0;
void _scrollToItem(int index) {
final viewportDimension = _scrollController.position.viewportDimension;
@ -98,131 +83,26 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
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);
});
_scrollToItem(i);
showDialog(
context: context,
builder: (context) {
return ActivitySuggestionDialog(
activity: activity,
);
},
);
},
width: cardWidth,
height: cardHeight,
padding: cardPadding,
);
})
.cast<Widget>()
@ -233,25 +113,22 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
CreateChatCard(
width: cardWidth,
height: cardHeight,
padding: cardPadding,
),
);
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,
),
),
padding: EdgeInsets.symmetric(
vertical: 16.0,
horizontal: FluffyThemes.isColumnMode(context) ? 16.0 : 0.0,
),
child: SingleChildScrollView(
controller: _scrollController,
child: Wrap(
children: cards,
),
),
);
}
}

View file

@ -11,10 +11,12 @@ import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
class CreateChatCard extends StatelessWidget {
final double width;
final double height;
final double padding;
const CreateChatCard({
required this.width,
required this.height,
required this.padding,
super.key,
});
@ -22,7 +24,7 @@ class CreateChatCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(8.0),
padding: EdgeInsets.all(padding),
child: PressableButton(
onPressed: () => context.go('/rooms/newgroup'),
borderRadius: BorderRadius.circular(24.0),
@ -46,11 +48,15 @@ class CreateChatCard extends StatelessWidget {
width: 80,
),
),
const SizedBox(height: 24.0),
Text(
L10n.of(context).createOwnChat,
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.secondary),
const SizedBox(height: 16.0),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
L10n.of(context).createOwnChat,
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.secondary),
textAlign: TextAlign.center,
),
),
],
),

View file

@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
class FullWidthDialog extends StatelessWidget {
final Widget dialogContent;
final double maxWidth;
@ -16,7 +17,7 @@ class FullWidthDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final content = ConstrainedBox(
constraints: kIsWeb
constraints: FluffyThemes.isColumnMode(context)
? BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
@ -31,6 +32,8 @@ class FullWidthDialog extends StatelessWidget {
),
);
return kIsWeb ? Dialog(child: content) : Dialog.fullscreen(child: content);
return FluffyThemes.isColumnMode(context)
? Dialog(child: content)
: Dialog.fullscreen(child: content);
}
}