2765 direct users to add to chat with multiselect rather than create (#2824)

* chore: abstract activity editting into builder widget

* feat: allow users to launch activities to existing chats instead of making new chat
pull/2245/head
ggurdin 6 months ago committed by GitHub
parent 454ddeb2c0
commit 8289a33c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4936,5 +4936,12 @@
"permissions": "Permissions",
"spaceChildPermission": "Who can add new chats and subspaces to this space",
"addEnvironmentOverride": "Add environment override",
"defaultOption": "Default"
"defaultOption": "Default",
"chatWithActivities": "Chat with activities",
"findYourPeople": "Find your people",
"launch": "Launch",
"launchActivityToChats": "Launch activity to chats",
"searchChats": "Search chats",
"selectChats": "Select chats",
"selectChatToStart": "Complete! Select a chat to start"
}

@ -203,12 +203,23 @@ abstract class AppRoutes {
...newRoomRoutes,
GoRoute(
path: '/planner',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const ActivityGenerator(),
const ActivityPlannerPage(),
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: '/generator',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const ActivityGenerator(),
),
),
],
),
],
),

@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/activity_generator/activity_generator.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_card.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/suggestion_form_field.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
@ -53,13 +54,15 @@ class ActivityGeneratorView extends StatelessWidget {
padding: const EdgeInsets.all(16),
itemCount: controller.activities!.length,
itemBuilder: (context, index) {
return ActivityPlanCard(
activity: controller.activities![index],
return ActivityPlannerBuilder(
initialActivity: controller.activities![index],
initialFilename: controller.filename,
room: controller.room,
onEdit: (updatedActivity) =>
controller.onEdit(index, updatedActivity),
onChange: controller.update,
initialImageURL: controller.filename,
builder: (c) {
return ActivityPlanCard(
controller: c,
);
},
);
},
);

@ -6,37 +6,23 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityPlanCard extends StatefulWidget {
final ActivityPlanModel activity;
final Room? room;
final VoidCallback onChange;
final ValueChanged<ActivityPlanModel> onEdit;
final double maxWidth;
final String? initialImageURL;
final ActivityPlannerBuilderState controller;
const ActivityPlanCard({
super.key,
required this.activity,
required this.room,
required this.onChange,
required this.onEdit,
this.maxWidth = 400,
this.initialImageURL,
required this.controller,
});
@override
@ -44,59 +30,8 @@ class ActivityPlanCard extends StatefulWidget {
}
class ActivityPlanCardState extends State<ActivityPlanCard> {
bool _isEditing = false;
late ActivityPlanModel _tempActivity;
late TextEditingController _titleController;
late TextEditingController _learningObjectiveController;
late TextEditingController _instructionsController;
final TextEditingController _newVocabController = TextEditingController();
final FocusNode _vocabFocusNode = FocusNode();
Uint8List? _avatar;
String? _filename;
String? _imageURL;
@override
void initState() {
super.initState();
_tempActivity = widget.activity;
_titleController = TextEditingController(text: _tempActivity.title);
_learningObjectiveController =
TextEditingController(text: _tempActivity.learningObjective);
_instructionsController =
TextEditingController(text: _tempActivity.instructions);
_filename = widget.initialImageURL?.split("/").last;
_imageURL = widget.activity.imageURL ?? widget.initialImageURL;
}
static const double itemPadding = 12;
@override
void dispose() {
_titleController.dispose();
_learningObjectiveController.dispose();
_instructionsController.dispose();
_newVocabController.dispose();
_vocabFocusNode.dispose();
super.dispose();
}
Future<void> _saveEdits() async {
final updatedActivity = ActivityPlanModel(
req: _tempActivity.req,
title: _titleController.text,
learningObjective: _learningObjectiveController.text,
instructions: _instructionsController.text,
vocab: _tempActivity.vocab,
imageURL: widget.activity.imageURL,
);
widget.onEdit(updatedActivity);
setState(() {
_isEditing = false;
});
}
Future<ActivityPlanModel> _addBookmark(ActivityPlanModel activity) async {
try {
return BookmarkedActivitiesRepo.save(activity);
@ -107,418 +42,350 @@ class ActivityPlanCardState extends State<ActivityPlanCard> {
} finally {
if (mounted) {
setState(() {});
widget.onChange();
}
}
}
Future<void> _removeBookmark() async {
try {
BookmarkedActivitiesRepo.remove(widget.activity.bookmarkId);
BookmarkedActivitiesRepo.remove(
widget.controller.updatedActivity.bookmarkId,
);
} catch (e, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: stack, data: widget.activity.toJson());
ErrorHandler.logError(
e: e,
s: stack,
data: widget.controller.updatedActivity.toJson(),
);
} finally {
if (mounted) {
setState(() {});
widget.onChange();
}
}
}
void _addVocab() {
setState(() {
_tempActivity.vocab.add(Vocab(lemma: _newVocabController.text, pos: ''));
_newVocabController.clear();
_vocabFocusNode.requestFocus();
});
}
void _removeVocab(int index) {
setState(() {
_tempActivity.vocab.removeAt(index);
});
}
void selectPhoto() async {
final resp = await selectFiles(
context,
type: FileSelectorType.images,
allowMultiple: false,
);
final photo = resp.singleOrNull;
if (photo == null) return;
final bytes = await photo.readAsBytes();
setState(() {
_avatar = bytes;
_filename = photo.name;
});
final url = await Matrix.of(context).client.uploadContent(
bytes,
filename: photo.name,
);
final updatedActivity = ActivityPlanModel(
req: _tempActivity.req,
title: _tempActivity.title,
learningObjective: _tempActivity.learningObjective,
instructions: _tempActivity.instructions,
vocab: _tempActivity.vocab,
imageURL: url.toString(),
);
widget.onEdit(updatedActivity);
}
Future<void> _setAvatarByImageURL() async {
if (_avatar != null || _imageURL == null) return;
final resp = await http
.get(Uri.parse(_imageURL!))
.timeout(const Duration(seconds: 5));
if (mounted) {
setState(() => _avatar = resp.bodyBytes);
Future<void> _onLaunch() async {
if (widget.controller.room != null) {
final resp = await showFutureLoadingDialog(
context: context,
future: widget.controller.launchToRoom,
);
if (!resp.isError) {
context.go("/rooms/${widget.controller.room!.id}");
}
return;
}
}
Future<void> _onLaunch() async {
await _setAvatarByImageURL();
await showFutureLoadingDialog(
return showDialog(
context: context,
future: () async {
String? avatarUrl;
if (_avatar != null) {
final client = Matrix.of(context).client;
final url = await client.uploadContent(
_avatar!,
filename: _filename,
);
avatarUrl = url.toString();
}
if (widget.room != null) {
await widget.room?.sendActivityPlan(
widget.activity,
avatar: _avatar,
filename: _filename,
);
context.go("/rooms/${widget.room?.id}");
return;
}
final client = Matrix.of(context).client;
final roomId = await client.createGroupChat(
preset: CreateRoomPreset.publicChat,
visibility: sdk.Visibility.private,
groupName:
widget.activity.title.isNotEmpty ? widget.activity.title : null,
initialState: [
if (_avatar != null) ...[
StateEvent(
type: EventTypes.RoomAvatar,
stateKey: '',
content: {
"url": avatarUrl,
},
builder: (context) {
return FullWidthDialog(
dialogContent: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
child: ActivityRoomSelection(
controller: widget.controller,
backButton: IconButton(
onPressed: Navigator.of(context).pop,
icon: const Icon(Icons.close),
),
],
StateEvent(
type: EventTypes.RoomPowerLevels,
stateKey: '',
content: defaultPowerLevels(client.userID!),
),
],
enableEncryption: false,
);
Room? room = client.getRoomById(roomId);
if (room == null) {
await client.waitForRoomInSync(roomId);
room = client.getRoomById(roomId);
}
if (room == null) return;
await room.sendActivityPlan(
widget.activity,
avatar: _avatar,
filename: _filename,
),
maxWidth: 400.0,
maxHeight: 650.0,
);
context.go("/rooms/$roomId/invite?filter=groups");
},
);
}
bool get isBookmarked =>
BookmarkedActivitiesRepo.isBookmarked(widget.activity);
bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked(
widget.controller.updatedActivity,
);
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxWidth),
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
margin: const EdgeInsets.symmetric(vertical: itemPadding),
child: Column(
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
child: Form(
key: widget.controller.formKey,
child: Column(
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
),
clipBehavior: Clip.hardEdge,
alignment: Alignment.center,
child: widget.controller.imageURL != null ||
widget.controller.avatar != null
? ClipRRect(
child: widget.controller.avatar == null
? CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: widget.controller.imageURL!,
placeholder: (context, url) {
return const Center(
child: CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return const Padding(
padding: EdgeInsets.all(28.0),
);
},
)
: Image.memory(
widget.controller.avatar!,
fit: BoxFit.cover,
),
)
: const Padding(
padding: EdgeInsets.all(28.0),
),
),
clipBehavior: Clip.hardEdge,
alignment: Alignment.center,
child: _imageURL != null || _avatar != null
? ClipRRect(
child: _avatar == null
? CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: _imageURL!,
placeholder: (context, url) {
return const Center(
child: CircularProgressIndicator(),
);
},
errorWidget: (context, url, error) {
return const Padding(
padding: EdgeInsets.all(28.0),
);
},
)
: Image.memory(
_avatar!,
fit: BoxFit.cover,
),
)
: const Padding(
padding: EdgeInsets.all(28.0),
if (widget.controller.isEditing)
Positioned(
top: 10.0,
right: 10.0,
child: IconButton(
icon: const Icon(Icons.upload_outlined),
onPressed: widget.controller.selectAvatar,
style: IconButton.styleFrom(
backgroundColor: Colors.black,
),
),
if (_isEditing)
Positioned(
top: 10.0,
right: 10.0,
child: IconButton(
icon: const Icon(Icons.upload_outlined),
onPressed: selectPhoto,
style: IconButton.styleFrom(
backgroundColor: Colors.black,
),
),
),
],
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.event_note_outlined),
const SizedBox(width: itemPadding),
Expanded(
child: _isEditing
? TextField(
controller: _titleController,
decoration: InputDecoration(
labelText: L10n.of(context).activityTitle,
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.event_note_outlined),
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller:
widget.controller.titleController,
decoration: InputDecoration(
labelText: L10n.of(context).activityTitle,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity.title,
style:
Theme.of(context).textTheme.bodyLarge,
),
maxLines: null,
)
: Text(
widget.activity.title,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (!_isEditing)
IconButton(
onPressed: isBookmarked
? () => _removeBookmark()
: () => _addBookmark(widget.activity),
icon: Icon(
isBookmarked
? Icons.bookmark
: Icons.bookmark_border,
),
if (!widget.controller.isEditing)
IconButton(
onPressed: _isBookmarked
? () => _removeBookmark()
: () => _addBookmark(
widget.controller.updatedActivity,
),
icon: Icon(
_isBookmarked
? Icons.bookmark
: Icons.bookmark_border,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.target,
color: Theme.of(context).colorScheme.secondary,
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.target,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: _isEditing
? TextField(
controller: _learningObjectiveController,
decoration: InputDecoration(
labelText: l10n.learningObjectiveLabel,
),
maxLines: null,
)
: Text(
widget.activity.learningObjective,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.steps_rounded,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: _isEditing
? TextField(
controller: _instructionsController,
decoration: InputDecoration(
labelText: l10n.instructions,
const SizedBox(width: itemPadding),
Expanded(
child: widget.controller.isEditing
? TextField(
controller: widget.controller
.learningObjectivesController,
decoration: InputDecoration(
labelText: l10n.learningObjectiveLabel,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.learningObjective,
style:
Theme.of(context).textTheme.bodyMedium,
),
maxLines: null,
)
: Text(
widget.activity.instructions,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: itemPadding),
if (widget.activity.vocab.isNotEmpty) ...[
),
],
),
const SizedBox(height: itemPadding),
Row(
children: [
Icon(
Symbols.dictionary,
Symbols.steps_rounded,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: List<Widget>.generate(
_tempActivity.vocab.length, (int index) {
return _isEditing
? Chip(
label: Text(
_tempActivity.vocab[index].lemma,
),
onDeleted: () => _removeVocab(index),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
)
: Chip(
label: Text(
_tempActivity.vocab[index].lemma,
),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
);
}).toList(),
),
child: widget.controller.isEditing
? TextField(
controller: widget
.controller.instructionsController,
decoration: InputDecoration(
labelText: l10n.instructions,
),
maxLines: null,
)
: Text(
widget.controller.updatedActivity
.instructions,
style:
Theme.of(context).textTheme.bodyMedium,
),
),
],
),
],
if (_isEditing) ...[
const SizedBox(height: itemPadding),
Padding(
padding: const EdgeInsets.only(top: itemPadding),
child: Row(
if (widget.controller.vocab.isNotEmpty) ...[
Row(
children: [
Icon(
Symbols.dictionary,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: itemPadding),
Expanded(
child: TextField(
controller: _newVocabController,
focusNode: _vocabFocusNode,
decoration: InputDecoration(
labelText: l10n.addVocabulary,
),
onSubmitted: (value) {
_addVocab();
},
child: Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: List<Widget>.generate(
widget.controller.vocab.length,
(int index) {
return widget.controller.isEditing
? Chip(
label: Text(
widget
.controller.vocab[index].lemma,
),
onDeleted: () => widget.controller
.removeVocab(index),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
)
: Chip(
label: Text(
widget
.controller.vocab[index].lemma,
),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
shape: const StadiumBorder(
side: BorderSide(
color: Colors.transparent,
),
),
);
}).toList(),
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _addVocab,
),
],
),
),
],
const SizedBox(height: itemPadding),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Tooltip(
message:
!_isEditing ? l10n.edit : l10n.saveChanges,
child: IconButton(
icon:
Icon(!_isEditing ? Icons.edit : Icons.save),
onPressed: () => !_isEditing
? setState(() {
_isEditing = true;
})
: _saveEdits(),
isSelected: _isEditing,
),
),
if (_isEditing)
Tooltip(
message: l10n.cancel,
child: IconButton(
icon: const Icon(Icons.cancel),
onPressed: () {
setState(() {
_isEditing = false;
});
],
if (widget.controller.isEditing) ...[
const SizedBox(height: itemPadding),
Padding(
padding: const EdgeInsets.only(top: itemPadding),
child: Row(
children: [
Expanded(
child: TextField(
controller: widget.controller.vocabController,
decoration: InputDecoration(
labelText: l10n.addVocabulary,
),
onSubmitted: (value) {
widget.controller.addVocab();
},
),
),
],
),
ElevatedButton.icon(
onPressed: !_isEditing ? _onLaunch : null,
icon: const Icon(Icons.send),
label: Text(l10n.launchActivityButton),
IconButton(
icon: const Icon(Icons.add),
onPressed: widget.controller.addVocab,
),
],
),
),
],
),
],
const SizedBox(height: itemPadding),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Tooltip(
message: !widget.controller.isEditing
? l10n.edit
: l10n.saveChanges,
child: IconButton(
icon: Icon(
!widget.controller.isEditing
? Icons.edit
: Icons.save,
),
onPressed: () => !widget.controller.isEditing
? setState(() {
widget.controller.isEditing = true;
})
: widget.controller.saveEdits(),
isSelected: widget.controller.isEditing,
),
),
if (widget.controller.isEditing)
Tooltip(
message: l10n.cancel,
child: IconButton(
icon: const Icon(Icons.cancel),
onPressed: widget.controller.clearEdits,
),
),
],
),
ElevatedButton.icon(
onPressed:
!widget.controller.isEditing ? _onLaunch : null,
icon: const Icon(Icons.send),
label: Text(l10n.launchActivityButton),
),
],
),
],
),
),
),
],
],
),
),
),
),

@ -0,0 +1,233 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/client_download_content_extension.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityPlannerBuilder extends StatefulWidget {
final ActivityPlanModel initialActivity;
final String? initialFilename;
final Room? room;
final Widget Function(ActivityPlannerBuilderState) builder;
final Future<void> Function(
String,
ActivityPlanModel,
Uint8List?,
String?,
)? onEdit;
const ActivityPlannerBuilder({
super.key,
required this.initialActivity,
this.initialFilename,
this.room,
required this.builder,
this.onEdit,
});
@override
State<ActivityPlannerBuilder> createState() => ActivityPlannerBuilderState();
}
class ActivityPlannerBuilderState extends State<ActivityPlannerBuilder> {
bool isEditing = false;
Uint8List? avatar;
String? imageURL;
String? filename;
final TextEditingController titleController = TextEditingController();
final TextEditingController instructionsController = TextEditingController();
final TextEditingController vocabController = TextEditingController();
final TextEditingController participantsController = TextEditingController();
final TextEditingController learningObjectivesController =
TextEditingController();
final List<Vocab> vocab = [];
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_resetActivity();
}
@override
void dispose() {
titleController.dispose();
learningObjectivesController.dispose();
instructionsController.dispose();
vocabController.dispose();
participantsController.dispose();
super.dispose();
}
Room? get room => widget.room;
ActivityPlanModel get updatedActivity {
final int participants = int.tryParse(participantsController.text.trim()) ??
widget.initialActivity.req.numberOfParticipants;
final updatedReq = widget.initialActivity.req;
updatedReq.numberOfParticipants = participants;
return ActivityPlanModel(
req: updatedReq,
title: titleController.text,
learningObjective: learningObjectivesController.text,
instructions: instructionsController.text,
vocab: vocab,
imageURL: imageURL,
);
}
Future<void> _resetActivity() async {
avatar = null;
filename = null;
imageURL = null;
titleController.text = widget.initialActivity.title;
learningObjectivesController.text =
widget.initialActivity.learningObjective;
instructionsController.text = widget.initialActivity.instructions;
participantsController.text =
widget.initialActivity.req.numberOfParticipants.toString();
vocab.clear();
vocab.addAll(widget.initialActivity.vocab);
imageURL = widget.initialActivity.imageURL;
filename = widget.initialFilename;
await _setAvatarByURL();
if (mounted) setState(() {});
}
void setEditing(bool editting) {
isEditing = editting;
if (mounted) setState(() {});
}
void addVocab() {
vocab.insert(
0,
Vocab(
lemma: vocabController.text.trim(),
pos: "",
),
);
vocabController.clear();
if (mounted) setState(() {});
}
void removeVocab(int index) {
vocab.removeAt(index);
if (mounted) setState(() {});
}
void selectAvatar() async {
final photo = await selectFiles(
context,
type: FileSelectorType.images,
allowMultiple: false,
);
final bytes = await photo.singleOrNull?.readAsBytes();
if (mounted) {
setState(() {
avatar = bytes;
filename = photo.singleOrNull?.name;
});
}
}
Future<void> _setAvatarByURL() async {
if (widget.initialActivity.imageURL == null) return;
try {
if (avatar == null) {
if (widget.initialActivity.imageURL!.startsWith("mxc")) {
final client = Matrix.of(context).client;
final mxcUri = Uri.parse(widget.initialActivity.imageURL!);
final data = await client.downloadMxcCached(mxcUri);
avatar = data;
filename = Uri.encodeComponent(
mxcUri.pathSegments.last,
);
} else {
final Response response =
await http.get(Uri.parse(widget.initialActivity.imageURL!));
avatar = response.bodyBytes;
filename = Uri.encodeComponent(
Uri.parse(widget.initialActivity.imageURL!).pathSegments.last,
);
}
}
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"imageURL": widget.initialActivity.imageURL,
},
);
}
}
Future<void> updateImageURL() async {
if (avatar == null) return;
final url = await Matrix.of(context).client.uploadContent(
avatar!,
filename: filename,
);
if (!mounted) return;
setState(() {
imageURL = url.toString();
});
}
Future<void> saveEdits() async {
if (!formKey.currentState!.validate()) return;
await updateImageURL();
setEditing(false);
if (widget.onEdit != null) {
await widget.onEdit!(
widget.initialActivity.bookmarkId,
updatedActivity,
avatar,
filename,
);
}
}
Future<void> clearEdits() async {
_resetActivity();
if (mounted) {
setState(() {
isEditing = false;
});
}
}
Future<void> launchToRoom() async {
return widget.room?.sendActivityPlan(
updatedActivity,
avatar: avatar,
filename: filename,
avatarURL: imageURL,
);
}
@override
Widget build(BuildContext context) => widget.builder(this);
}

@ -14,8 +14,8 @@ enum PageMode {
}
class ActivityPlannerPage extends StatefulWidget {
final String roomID;
const ActivityPlannerPage({super.key, required this.roomID});
final String? roomID;
const ActivityPlannerPage({super.key, this.roomID});
@override
ActivityPlannerPageState createState() => ActivityPlannerPageState();
@ -23,7 +23,9 @@ class ActivityPlannerPage extends StatefulWidget {
class ActivityPlannerPageState extends State<ActivityPlannerPage> {
PageMode pageMode = PageMode.featuredActivities;
Room? get room => Matrix.of(context).client.getRoomById(widget.roomID);
Room? get room => widget.roomID != null
? Matrix.of(context).client.getRoomById(widget.roomID!)
: null;
void _setPageMode(PageMode? mode) {
if (mode == null) return;

@ -11,11 +11,11 @@ import 'package:fluffychat/pangea/common/widgets/customized_svg.dart';
class ActivityPlannerPageAppBar extends StatelessWidget
implements PreferredSizeWidget {
final PageMode pageMode;
final String roomID;
final String? roomID;
const ActivityPlannerPageAppBar({
required this.pageMode,
required this.roomID,
this.roomID,
super.key,
});
@ -68,7 +68,9 @@ class ActivityPlannerPageAppBar extends StatelessWidget
alignment: Alignment.center,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => context.go('/rooms/$roomID/planner/generator'),
onTap: () => roomID != null
? context.go('/rooms/$roomID/planner/generator')
: context.go("/homepage/planner/generator"),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,

@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart';
@ -97,11 +98,16 @@ class BookmarkedActivitiesListState extends State<BookmarkedActivitiesList> {
showDialog(
context: context,
builder: (context) {
return ActivitySuggestionDialog(
return ActivityPlannerBuilder(
initialActivity: activity,
buttonText: L10n.of(context).inviteAndLaunch,
room: widget.room,
onEdit: _onEdit,
room: widget.room,
builder: (controller) {
return ActivitySuggestionDialog(
controller: controller,
buttonText: l10n.launch,
);
},
);
},
);

@ -0,0 +1,619 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class ActivityRoomSelection extends StatefulWidget {
final ActivityPlannerBuilderState controller;
final Widget backButton;
const ActivityRoomSelection({
super.key,
required this.controller,
required this.backButton,
});
@override
State<ActivityRoomSelection> createState() => ActivityRoomSelectionState();
}
class ActivityRoomSelectionState extends State<ActivityRoomSelection> {
final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
bool _loading = false;
bool _complete = false;
bool _hasBotDM = true;
List<Room> _launchableRooms = [];
final List<String> _selectedRooms = [];
@override
void initState() {
super.initState();
_launchableRooms = Matrix.of(context)
.client
.rooms
.where((room) {
return room.canSendDefaultStates &&
!room.isSpace &&
!room.isAnalyticsRoom;
})
.toList()
.sorted((a, b) {
final aIsBotDM = a.directChatMatrixID == BotName.byEnvironment;
final bIsBotDM = b.directChatMatrixID == BotName.byEnvironment;
if (aIsBotDM && !bIsBotDM) return -1;
if (!aIsBotDM && bIsBotDM) return 1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
_hasBotDM = Matrix.of(context).client.rooms.any((room) {
if (room.isDirectChat &&
room.directChatMatrixID == BotName.byEnvironment) {
return true;
}
if (room.botOptions?.mode == BotMode.directChat) {
return true;
}
return false;
});
}
@override
void dispose() {
searchController.dispose();
searchFocusNode.dispose();
super.dispose();
}
List<Room> get _filteredRooms {
final searchText = searchController.text.toLowerCase();
return _launchableRooms.where((room) {
return room.name.toLowerCase().contains(searchText);
}).toList();
}
void _toggleRoomSelection(String roomId) {
_selectedRooms.contains(roomId)
? _selectedRooms.remove(roomId)
: _selectedRooms.add(roomId);
if (_selectedRooms.contains(roomId)) {
_complete = false;
}
setState(() {});
}
Map<String, Room> get _spaceDelegateCandidates {
final spaces = Matrix.of(context).client.rooms.where((r) => r.isSpace);
final candidates = <String, Room>{};
for (final space in spaces) {
for (final spaceChild in space.spaceChildren) {
final roomId = spaceChild.roomId;
if (roomId == null) continue;
candidates[roomId] = space;
}
}
return candidates;
}
final Map<String, int> _launchStatus = {};
Future<void> _sendActivityPlan(Room room) async {
try {
setState(() => _launchStatus[room.id] = 0);
await room.sendActivityPlan(
widget.controller.updatedActivity,
avatar: widget.controller.avatar,
filename: widget.controller.filename,
avatarURL: widget.controller.imageURL,
);
_launchStatus[room.id] = 1;
} catch (e, s) {
_launchStatus[room.id] = -1;
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": room.id,
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() {});
}
}
}
Future<String?> _launchBotDM() async {
try {
setState(() => _launchStatus["placeholder"] = 0);
Uri? avatarUrl;
final imageUrl = widget.controller.imageURL ??
widget.controller.updatedActivity.imageURL;
Uint8List? avatar = widget.controller.avatar;
if (avatar != null) {
avatarUrl = await Matrix.of(context).client.uploadContent(
widget.controller.avatar!,
);
} else if (imageUrl != null) {
final Response response = await http.get(Uri.parse(imageUrl));
avatar = response.bodyBytes;
avatarUrl = await Matrix.of(context).client.uploadContent(
avatar,
);
}
// avatar == null ? null : await client.uploadContent(avatar);
final roomId = await Matrix.of(context).client.createRoom(
name: widget.controller.updatedActivity.title,
invite: [BotName.byEnvironment],
isDirect: true,
preset: CreateRoomPreset.trustedPrivateChat,
initialState: [
BotOptionsModel(mode: BotMode.directChat).toStateEvent,
if (avatar != null && avatarUrl != null)
StateEvent(
type: EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
],
);
Room? room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) {
await Matrix.of(context).client.waitForRoomInSync(
roomId,
join: true,
);
room = Matrix.of(context).client.getRoomById(roomId);
if (room == null) {
throw Exception("Room not found");
}
await room.sendActivityPlan(
widget.controller.updatedActivity,
avatar: widget.controller.avatar,
filename: widget.controller.filename,
avatarURL: widget.controller.imageURL,
);
}
_launchStatus["placeholder"] = 1;
return roomId;
} catch (e, s) {
_launchStatus["placeholder"] = -1;
ErrorHandler.logError(
e: e,
s: s,
data: {
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() {});
}
}
return null;
}
Future<void> _launch() async {
setState(() => _loading = true);
try {
final List<Future> futures = [];
for (final roomId in _selectedRooms) {
if (_launchStatus[roomId] == 1) {
continue;
}
final Room? room = _launchableRooms.firstWhereOrNull(
(r) => r.id == roomId,
);
if (room == null) {
if (roomId == 'placeholder') futures.add(_launchBotDM());
} else {
futures.add(_sendActivityPlan(room));
}
}
final resp = await Future.wait(futures);
_complete = true;
if (!mounted) return;
if (_selectedRooms.length == 1 &&
_launchStatus[_selectedRooms.first] == 1) {
if (_selectedRooms.first == 'placeholder' && resp.first != null) {
context.go("/rooms/${resp.first}");
Navigator.of(context).pop();
} else if (_selectedRooms.first != 'placeholder') {
context.go('/rooms/${_selectedRooms.first}');
Navigator.of(context).pop();
}
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"activity": widget.controller.updatedActivity.toJson(),
"filename": widget.controller.filename,
"avatarURL": widget.controller.imageURL,
},
);
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
String _tooltip(String roomId) {
final status = _launchStatus[roomId];
if (status == 0) {
return "Sending...";
} else if (status == 1) {
return "Go to chat";
} else if (status == -1) {
return "Failed to send";
}
return "";
}
void _onTap(Room room) {
final status = _launchStatus[room.id];
if (status == 0) {
return;
} else if (status == 1) {
context.go('/rooms/${room.id}');
Navigator.of(context).pop();
} else if (status == -1) {
return;
}
debugPrint("Toggling room selection for ${room.id}");
_toggleRoomSelection(room.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).selectChats),
leading: widget.backButton,
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Column(
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
controller: searchController,
focusNode: searchFocusNode,
textInputAction: TextInputAction.search,
onChanged: (text) => setState(() {}),
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintText: L10n.of(context).searchChats,
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
suffixIcon: searchController.text.isNotEmpty
? IconButton(
tooltip: L10n.of(context).cancel,
icon: const Icon(Icons.close_outlined),
onPressed: () {
setState(() {
searchController.clear();
searchFocusNode.unfocus();
});
},
color: theme.colorScheme.onPrimaryContainer,
)
: IconButton(
onPressed: () => searchFocusNode.requestFocus(),
icon: Icon(
Icons.search_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
),
),
Expanded(
child: ListView.builder(
itemCount: _filteredRooms.length + (_hasBotDM ? 0 : 1),
itemBuilder: (context, index) {
if (!_hasBotDM && index == 0) {
return ChatActivityPlaceholder(
activity: widget.controller.updatedActivity,
selected: _selectedRooms.contains("placeholder"),
onTap: () {
_toggleRoomSelection("placeholder");
},
tooltip: _tooltip("placeholder"),
status: _launchStatus["placeholder"],
avatar: widget.controller.avatar,
);
}
if (!_hasBotDM) index--;
final room = _filteredRooms[index];
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
final space = _spaceDelegateCandidates[room.id];
return Tooltip(
message: _tooltip(room.id),
child: ListTile(
title: Text(displayname),
leading: SizedBox(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
child: Stack(
children: [
if (space != null)
Positioned(
top: 0,
left: 0,
child: Avatar(
border: BorderSide(
width: 2,
color: theme.colorScheme.surface,
),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 4,
),
mxContent: space.avatar,
size: Avatar.defaultSize * 0.75,
name: space.getLocalizedDisplayname(),
),
),
Positioned(
bottom: 0,
right: 0,
child: Avatar(
border: space == null
? room.isSpace
? BorderSide(
width: 1,
color: theme.dividerColor,
)
: null
: BorderSide(
width: 2,
color: theme.colorScheme.surface,
),
mxContent: room.avatar,
size: Avatar.defaultSize * 0.75,
name: displayname,
presenceUserId: room.directChatMatrixID,
),
),
],
),
),
trailing: Container(
width: 30.0,
height: 30.0,
alignment: Alignment.center,
child: Builder(
builder: (context) {
final status = _launchStatus[room.id];
if (status == 0) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
);
} else if (status == 1) {
return const Icon(
Icons.check_circle_outline,
color: AppConfig.success,
);
} else if (status == -1) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
);
}
return Checkbox(
value: _selectedRooms.contains(room.id),
onChanged: (_) => _onTap(room),
);
},
),
),
onTap: () => _onTap(room),
),
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _complete
? Padding(
padding: const EdgeInsets.all(8.0),
child: Text(L10n.of(context).selectChatToStart),
)
: ElevatedButton(
onPressed: _selectedRooms.isNotEmpty ? _launch : null,
style: ElevatedButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.all(6.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
disabledBackgroundColor: theme.colorScheme.primary,
disabledForegroundColor: theme.colorScheme.onPrimary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_loading
? const Expanded(
child: SizedBox(
height: 10,
child: LinearProgressIndicator(),
),
)
: Text(
L10n.of(context).launchActivityToChats,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onPrimary,
),
),
],
),
),
),
],
),
),
);
}
}
class ChatActivityPlaceholder extends StatelessWidget {
final ActivityPlanModel activity;
final bool selected;
final VoidCallback onTap;
final String tooltip;
final Uint8List? avatar;
final int? status;
const ChatActivityPlaceholder({
required this.activity,
required this.selected,
required this.onTap,
required this.tooltip,
required this.status,
this.avatar,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const size = Avatar.defaultSize * 0.75;
return Tooltip(
message: tooltip,
child: ListTile(
title: Text(activity.title),
leading: SizedBox(
width: Avatar.defaultSize,
height: Avatar.defaultSize,
child: SizedBox(
width: size,
height: size,
child: Material(
color: theme.brightness == Brightness.light
? Colors.white
: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(size / 2),
side: BorderSide.none,
),
clipBehavior: Clip.hardEdge,
child: avatar != null
? Image.memory(avatar!)
: activity.imageURL != null
? activity.imageURL!.startsWith('mxc')
? MxcImage(
uri: Uri.parse(activity.imageURL!),
width: size,
height: size,
cacheKey: activity.bookmarkId,
fit: BoxFit.cover,
)
: CachedNetworkImage(
imageUrl: activity.imageURL!,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) =>
const SizedBox(),
fit: BoxFit.cover,
)
: const SizedBox(),
),
),
),
trailing: Container(
width: 30.0,
height: 30.0,
alignment: Alignment.center,
child: Builder(
builder: (context) {
if (status == 0) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
);
} else if (status == 1) {
return const Icon(
Icons.check_circle_outline,
color: AppConfig.success,
);
} else if (status == -1) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
);
}
return Checkbox(
value: selected,
onChanged: (_) => onTap(),
);
},
),
),
onTap: onTap,
),
);
}
}

@ -11,6 +11,7 @@ import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/media_enum.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart';
@ -44,7 +45,6 @@ class ActivitySuggestionCarousel extends StatefulWidget {
class ActivitySuggestionCarouselState
extends State<ActivitySuggestionCarousel> {
bool _isOpen = true;
bool _loading = true;
String? _error;
@ -138,7 +138,6 @@ class ActivitySuggestionCarouselState
void _close() {
widget.onActivitySelected(null, null, null);
setState(() => _isOpen = false);
}
void _onClickCard() {
@ -150,13 +149,23 @@ class ActivitySuggestionCarouselState
);
return;
}
showDialog(
context: context,
builder: (context) {
return ActivitySuggestionDialog(
return ActivityPlannerBuilder(
initialActivity: _currentActivity!,
buttonText: L10n.of(context).selectActivity,
onLaunch: widget.onActivitySelected,
builder: (controller) {
return ActivitySuggestionDialog(
controller: controller,
buttonText: L10n.of(context).selectActivity,
onLaunch: () => widget.onActivitySelected(
controller.updatedActivity,
controller.avatar,
controller.filename,
),
);
},
);
},
);
@ -167,164 +176,156 @@ class ActivitySuggestionCarouselState
final theme = Theme.of(context);
return AnimatedSize(
duration: FluffyThemes.animationDuration,
child: !_isOpen
? const SizedBox.shrink()
: AnimatedOpacity(
duration: FluffyThemes.animationDuration,
opacity: widget.enabled ? 1.0 : 0.5,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 4.0,
),
child: Column(
spacing: 16.0,
child: AnimatedOpacity(
duration: FluffyThemes.animationDuration,
opacity: widget.enabled ? 1.0 : 0.5,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 4.0,
),
child: Column(
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
L10n.of(context).newChatActivityTitle,
style: theme.textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.enabled ? _close : null,
),
],
),
Text(
L10n.of(context).newChatActivityTitle,
style: theme.textTheme.titleLarge,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(L10n.of(context).newChatActivityDesc),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.enabled ? _close : null,
),
Row(
spacing: _isColumnMode ? 16.0 : 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
MouseRegion(
cursor: _canMoveLeft
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveLeft ? _moveLeft : null,
child: Icon(
Icons.chevron_left_outlined,
size: 32.0,
color: _canMoveLeft ? null : theme.disabledColor,
),
),
),
Container(
constraints:
BoxConstraints(maxHeight: _cardHeight + 12.0),
child: _error != null ||
(_currentActivity == null && !_loading)
? const SizedBox.shrink()
: _loading
? Shimmer.fromColors(
baseColor: theme.colorScheme.primary
.withAlpha(50),
highlightColor: theme.colorScheme.primary
.withAlpha(150),
child: Container(
height: _cardHeight,
width: _cardWidth,
decoration: BoxDecoration(
color: theme
.colorScheme.surfaceContainer,
borderRadius:
BorderRadius.circular(24.0),
),
),
)
: ActivitySuggestionCard(
selected: widget.selectedActivity ==
_currentActivity,
activity: _currentActivity!,
onPressed:
widget.enabled ? _onClickCard : null,
width: _cardWidth,
height: _cardHeight,
image: _currentActivity ==
widget.selectedActivity
? widget.selectedActivityImage
: null,
onChange: () {
if (mounted) setState(() {});
},
),
),
MouseRegion(
cursor: _canMoveRight
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveRight ? _moveRight : null,
child: Icon(
Icons.chevron_right_outlined,
size: 32.0,
color: _canMoveRight ? null : theme.disabledColor,
),
),
),
],
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(L10n.of(context).newChatActivityDesc),
),
Row(
spacing: _isColumnMode ? 16.0 : 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
MouseRegion(
cursor: _canMoveLeft
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveLeft ? _moveLeft : null,
child: Icon(
Icons.chevron_left_outlined,
size: 32.0,
color: _canMoveLeft ? null : theme.disabledColor,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16.0,
children: _activityItems.mapIndexed((i, activity) {
final selected = activity == _currentActivity;
return InkWell(
enableFeedback: widget.enabled,
borderRadius: BorderRadius.circular(12.0),
onTap: widget.enabled
? () => _setActivityByIndex(i)
: null,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: selected ? 0.0 : 0.5,
sigmaY: selected ? 0.0 : 0.5,
),
child: Opacity(
opacity: selected ? 1.0 : 0.5,
child: ClipOval(
child: SizedBox.fromSize(
size: const Size.fromRadius(12.0),
child: activity.imageURL != null
? CachedNetworkImage(
imageUrl: activity.imageURL!,
errorWidget: (context, url, error) =>
const SizedBox(),
progressIndicatorBuilder:
(context, url, progress) {
return CircularProgressIndicator(
value: progress.progress,
);
},
)
: CircleAvatar(
backgroundColor:
theme.colorScheme.secondary,
radius: 12.0,
),
),
Container(
constraints: BoxConstraints(maxHeight: _cardHeight + 12.0),
child: _error != null ||
(_currentActivity == null && !_loading)
? const SizedBox.shrink()
: _loading
? Shimmer.fromColors(
baseColor:
theme.colorScheme.primary.withAlpha(50),
highlightColor:
theme.colorScheme.primary.withAlpha(150),
child: Container(
height: _cardHeight,
width: _cardWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
),
)
: ActivitySuggestionCard(
selected:
widget.selectedActivity == _currentActivity,
activity: _currentActivity!,
onPressed: widget.enabled ? _onClickCard : null,
width: _cardWidth,
height: _cardHeight,
image:
_currentActivity == widget.selectedActivity
? widget.selectedActivityImage
: null,
onChange: () {
if (mounted) setState(() {});
},
),
),
),
MouseRegion(
cursor: _canMoveRight
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: _canMoveRight ? _moveRight : null,
child: Icon(
Icons.chevron_right_outlined,
size: 32.0,
color: _canMoveRight ? null : theme.disabledColor,
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16.0,
children: _activityItems.mapIndexed((i, activity) {
final selected = activity == _currentActivity;
return InkWell(
enableFeedback: widget.enabled,
borderRadius: BorderRadius.circular(12.0),
onTap: widget.enabled ? () => _setActivityByIndex(i) : null,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: selected ? 0.0 : 0.5,
sigmaY: selected ? 0.0 : 0.5,
),
child: Opacity(
opacity: selected ? 1.0 : 0.5,
child: ClipOval(
child: SizedBox.fromSize(
size: const Size.fromRadius(12.0),
child: activity.imageURL != null
? CachedNetworkImage(
imageUrl: activity.imageURL!,
errorWidget: (context, url, error) =>
const SizedBox(),
progressIndicatorBuilder:
(context, url, progress) {
return CircularProgressIndicator(
value: progress.progress,
);
},
)
: CircleAvatar(
backgroundColor:
theme.colorScheme.secondary,
radius: 12.0,
),
),
);
}).toList(),
),
),
),
],
),
);
}).toList(),
),
),
],
),
),
),
);
}
}

@ -10,16 +10,14 @@ import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart';
import 'package:fluffychat/pangea/activity_planner/media_enum.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart';
import 'package:fluffychat/pangea/common/widgets/customized_svg.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -135,10 +133,15 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
showDialog(
context: context,
builder: (context) {
return ActivitySuggestionDialog(
return ActivityPlannerBuilder(
initialActivity: activity,
buttonText: L10n.of(context).inviteAndLaunch,
room: widget.room,
builder: (controller) {
return ActivitySuggestionDialog(
controller: controller,
buttonText: L10n.of(context).launch,
);
},
);
},
);
@ -165,7 +168,7 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
children: [
Flexible(
child: Text(
L10n.of(context).startChat,
L10n.of(context).chatWithActivities,
style: isColumnMode
? theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)
@ -175,91 +178,10 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
overflow: TextOverflow.ellipsis,
),
),
Material(
type: MaterialType.transparency,
child: Row(
spacing: 8.0,
children: [
InkWell(
customBorder: const CircleBorder(),
onTap: () => context.go('/homepage/newgroup'),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(36.0),
),
padding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 10.0,
),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
CustomizedSvg(
svgUrl:
"${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.plusIconPath}",
colorReplacements: {
"#CDBEF9": colorToHex(
Theme.of(context).colorScheme.secondary,
),
},
height: 16.0,
width: 16.0,
),
Text(
isColumnMode
? L10n.of(context).createOwnChat
: L10n.of(context).chat,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
InkWell(
customBorder: const CircleBorder(),
onTap: () => context.go('/homepage/planner'),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(36.0),
),
padding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 10.0,
),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
CustomizedSvg(
svgUrl:
"${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.crayonIconPath}",
colorReplacements: {
"#CDBEF9": colorToHex(
Theme.of(context).colorScheme.secondary,
),
},
height: 16.0,
width: 16.0,
),
Text(
isColumnMode
? L10n.of(context).makeYourOwnActivity
: L10n.of(context).createActivity,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
IconButton(
icon: const Icon(Icons.menu_outlined),
onPressed: () => context.go('/homepage/planner'),
tooltip: L10n.of(context).activityPlannerTitle,
),
],
),

@ -19,7 +19,6 @@ class SuggestionsPage extends StatelessWidget {
vertical: 16.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 24.0,
children: [
if (!isColumnMode) const LearningProgressIndicators(),

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
@ -16,24 +18,32 @@ class FullWidthDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final content = ConstrainedBox(
constraints: FluffyThemes.isColumnMode(context)
? BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
)
: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
maxHeight: MediaQuery.of(context).size.height,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: dialogContent,
final isColumnMode = FluffyThemes.isColumnMode(context);
final content = AnimatedSize(
duration: FluffyThemes.animationDuration,
child: ConstrainedBox(
constraints: isColumnMode
? BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
)
: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
maxHeight: MediaQuery.of(context).size.height,
),
child: ClipRRect(
borderRadius:
isColumnMode ? BorderRadius.circular(20.0) : BorderRadius.zero,
child: dialogContent,
),
),
);
return FluffyThemes.isColumnMode(context)
? Dialog(child: content)
: Dialog.fullscreen(child: content);
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5),
child: isColumnMode
? Dialog(child: content)
: Dialog.fullscreen(child: content),
);
}
}

@ -274,10 +274,20 @@ extension EventsRoomExtension on Room {
}) async {
Uint8List? bytes = avatar;
if (avatarURL != null && bytes == null) {
final resp = await http
.get(Uri.parse(avatarURL))
.timeout(const Duration(seconds: 5));
bytes = resp.bodyBytes;
try {
final resp = await http
.get(Uri.parse(avatarURL))
.timeout(const Duration(seconds: 5));
bytes = resp.bodyBytes;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"avatarURL": avatarURL,
},
);
}
}
MatrixFile? file;

@ -176,7 +176,7 @@ class PublicSpacesAreaState extends State<PublicSpacesArea> {
key: const ValueKey('title'),
children: [
Text(
L10n.of(context).publicSpacesTitle,
L10n.of(context).findYourPeople,
style: isColumnMode
? theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)

Loading…
Cancel
Save