feat: public spaces on homepage (#2318)

This commit is contained in:
ggurdin 2025-04-02 12:30:44 -04:00 committed by GitHub
parent f1106e0aa8
commit 10c41c7112
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 668 additions and 318 deletions

View file

@ -4855,5 +4855,6 @@
"goodJobTranslation": "Good work on this translation.",
"makingProgress": "You're making progress!",
"keepPracticing": "Keep practicing!",
"niceJob": "Nice job!"
"niceJob": "Nice job!",
"publicSpacesTitle": "Learning communities"
}

View file

@ -45,7 +45,6 @@ class ActivityPlannerPageState extends State<ActivityPlannerPage> {
child: SingleChildScrollView(
child: ActivitySuggestionsArea(
scrollDirection: Axis.vertical,
showCreateChatCard: false,
room: room,
),
),

View file

@ -36,7 +36,6 @@ class BookmarkedActivitiesListState extends State<BookmarkedActivitiesList> {
bool get _isColumnMode => FluffyThemes.isColumnMode(context);
double get cardHeight => _isColumnMode ? 315.0 : 240.0;
double get cardPadding => _isColumnMode ? 8.0 : 0.0;
double get cardWidth => _isColumnMode ? 225.0 : 150.0;
Future<void> _onEdit(
@ -98,7 +97,6 @@ class BookmarkedActivitiesListState extends State<BookmarkedActivitiesList> {
},
width: cardWidth,
height: cardHeight,
padding: cardPadding,
onChange: () => setState(() {}),
);
}).toList(),

View file

@ -16,7 +16,6 @@ class ActivitySuggestionCard extends StatelessWidget {
final double width;
final double height;
final double padding;
final bool selected;
final VoidCallback onChange;
@ -27,7 +26,6 @@ class ActivitySuggestionCard extends StatelessWidget {
required this.onPressed,
required this.width,
required this.height,
required this.padding,
required this.onChange,
this.selected = false,
this.image,
@ -38,175 +36,171 @@ class ActivitySuggestionCard extends StatelessWidget {
final theme = Theme.of(context);
final isBookmarked = BookmarkedActivitiesRepo.isBookmarked(activity);
return Padding(
padding: EdgeInsets.all(padding),
child: PressableButton(
depressed: selected || onPressed == null,
onPressed: onPressed,
borderRadius: BorderRadius.circular(24.0),
color: theme.brightness == Brightness.dark
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2,
child: Container(
decoration: BoxDecoration(
border: selected
? Border.all(
color: theme.colorScheme.primary,
)
: null,
borderRadius: BorderRadius.circular(24.0),
),
height: height,
width: width,
child: Stack(
alignment: Alignment.topCenter,
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
return PressableButton(
depressed: selected || onPressed == null,
onPressed: onPressed,
borderRadius: BorderRadius.circular(24.0),
color: theme.brightness == Brightness.dark
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2,
child: Container(
decoration: BoxDecoration(
border: selected
? Border.all(
color: theme.colorScheme.primary,
)
: null,
borderRadius: BorderRadius.circular(24.0),
),
height: 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: width - 16.0,
width: width - 16.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
margin: const EdgeInsets.only(top: 8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: image != null
? Image.memory(image!)
: activity.imageURL != null
? activity.imageURL!.startsWith("mxc")
? MxcImage(
uri: Uri.parse(activity.imageURL!),
width: width - 16.0,
height: width - 16.0,
cacheKey: activity.bookmarkId,
)
: CachedNetworkImage(
imageUrl: activity.imageURL!,
placeholder: (context, url) =>
const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) =>
const SizedBox(),
)
: null,
),
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: width - 16.0,
width: width - 16.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Row(
children: [
margin: const EdgeInsets.only(top: 8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: image != null
? Image.memory(image!)
: activity.imageURL != null
? activity.imageURL!.startsWith("mxc")
? MxcImage(
uri: Uri.parse(activity.imageURL!),
width: width - 16.0,
height: width - 16.0,
cacheKey: activity.bookmarkId,
)
: CachedNetworkImage(
imageUrl: activity.imageURL!,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) =>
const SizedBox(),
)
: null,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Row(
children: [
Flexible(
child: Text(
activity.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
const Icon(
Icons.group_outlined,
size: 12.0,
),
Text(
"${activity.req.numberOfParticipants}",
style: theme.textTheme.labelSmall,
),
],
),
),
if (activity.req.mode.isNotEmpty)
Flexible(
child: Text(
activity.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
const Icon(
Icons.group_outlined,
size: 16.0,
),
Text(
"${activity.req.numberOfParticipants}",
style: theme.textTheme.bodySmall,
),
],
),
),
if (activity.req.mode.isNotEmpty)
Flexible(
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 8.0,
),
child: Text(
activity.req.mode,
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
child: Text(
activity.req.mode,
style: theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
],
),
),
],
),
Positioned(
top: 4.0,
right: 4.0,
child: IconButton(
icon: Icon(
isBookmarked ? Icons.bookmark : Icons.bookmark_border,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: onPressed != null
? () async {
await (isBookmarked
? BookmarkedActivitiesRepo.remove(
activity.bookmarkId,
)
: BookmarkedActivitiesRepo.save(activity));
onChange();
}
: null,
style: IconButton.styleFrom(
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer
.withAlpha(180),
),
),
],
),
Positioned(
top: 4.0,
right: 4.0,
child: IconButton(
icon: Icon(
isBookmarked ? Icons.bookmark : Icons.bookmark_border,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: onPressed != null
? () async {
await (isBookmarked
? BookmarkedActivitiesRepo.remove(
activity.bookmarkId,
)
: BookmarkedActivitiesRepo.save(activity));
onChange();
}
: null,
style: IconButton.styleFrom(
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer
.withAlpha(180),
),
),
],
),
),
],
),
),
);

View file

@ -236,7 +236,6 @@ class ActivitySuggestionCarouselState
widget.enabled ? _onClickCard : null,
width: _cardWidth,
height: _cardHeight,
padding: 0.0,
image: _currentActivity ==
widget.selectedActivity
? widget.selectedActivityImage

View file

@ -4,9 +4,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
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';
@ -14,22 +16,22 @@ 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';
class ActivitySuggestionsArea extends StatefulWidget {
final Axis? scrollDirection;
final bool showCreateChatCard;
final bool showMakeActivityCard;
final bool showTitle;
final Room? room;
const ActivitySuggestionsArea({
super.key,
this.scrollDirection,
this.showCreateChatCard = true,
this.showMakeActivityCard = true,
this.showTitle = false,
this.room,
});
@override
@ -55,7 +57,6 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
final List<ActivityPlanModel> _activityItems = [];
final ScrollController _scrollController = ScrollController();
double get cardHeight => _isColumnMode ? 315.0 : 240.0;
double get cardPadding => _isColumnMode ? 8.0 : 0.0;
double get cardWidth => _isColumnMode ? 225.0 : 150.0;
Future<void> _setActivityItems() async {
@ -83,6 +84,8 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final List<Widget> cards = _loading
? List.generate(5, (i) {
return Shimmer.fromColors(
@ -91,7 +94,6 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
child: Container(
height: cardHeight,
width: cardWidth,
margin: EdgeInsets.all(cardPadding),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
@ -117,7 +119,6 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
},
width: cardWidth,
height: cardHeight,
padding: cardPadding,
onChange: () {
if (mounted) setState(() {});
},
@ -129,28 +130,141 @@ class ActivitySuggestionsAreaState extends State<ActivitySuggestionsArea> {
final scrollDirection = widget.scrollDirection ??
(_isColumnMode ? Axis.horizontal : Axis.vertical);
return scrollDirection == Axis.horizontal
? ConstrainedBox(
constraints: BoxConstraints(maxHeight: cardHeight + 36.0),
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
child: ListView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 4.0),
children: cards,
return Column(
spacing: 8.0,
children: [
if (widget.showTitle)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
L10n.of(context).startChat,
style: isColumnMode
? theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)
: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
),
)
: SizedBox(
width: MediaQuery.of(context).size.width,
child: Wrap(
alignment: WrapAlignment.spaceEvenly,
runSpacing: 16.0,
spacing: 4.0,
children: cards,
),
);
Row(
spacing: 8.0,
children: [
InkWell(
customBorder: const CircleBorder(),
onTap: () => context.go('/rooms/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('/rooms/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,
),
),
],
),
),
),
],
),
],
),
Container(
decoration: const BoxDecoration(),
child: scrollDirection == Axis.horizontal
? Expanded(
child: Scrollbar(
thumbVisibility: true,
controller: _scrollController,
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8.0,
children: cards,
),
),
),
),
)
: SizedBox(
width: MediaQuery.of(context).size.width,
child: Wrap(
alignment: WrapAlignment.spaceEvenly,
runSpacing: 16.0,
spacing: 4.0,
children: cards,
),
),
),
],
);
}
}

View file

@ -1,147 +1,33 @@
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/config/themes.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart';
import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart';
import 'package:fluffychat/pangea/common/widgets/customized_svg.dart';
import 'package:fluffychat/pangea/public_spaces/public_spaces_area.dart';
class SuggestionsPage extends StatelessWidget {
const SuggestionsPage({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
return SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isColumnMode ? 36.0 : 4.0,
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 24.0,
children: [
if (!isColumnMode)
Padding(
padding:
EdgeInsets.symmetric(horizontal: isColumnMode ? 0 : 12.0),
child: const LearningProgressIndicators(),
),
Padding(
padding: EdgeInsets.only(
left: isColumnMode ? 0.0 : 4.0,
right: isColumnMode ? 0.0 : 4.0,
top: 16.0,
bottom: 16.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
L10n.of(context).startChat,
style: isColumnMode
? theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)
: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Row(
spacing: 8.0,
children: [
InkWell(
customBorder: const CircleBorder(),
onTap: () => context.go('/rooms/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('/rooms/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,
),
),
],
),
),
),
],
),
],
),
if (!isColumnMode) const LearningProgressIndicators(),
const ActivitySuggestionsArea(
showTitle: true,
scrollDirection: Axis.horizontal,
),
const ActivitySuggestionsArea(),
const PublicSpacesArea(),
],
),
),

View file

@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class PublicSpaceCard extends StatelessWidget {
final PublicRoomsChunk space;
final double width;
final double height;
const PublicSpaceCard({
super.key,
required this.space,
required this.width,
required this.height,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PressableButton(
onPressed: () {},
borderRadius: BorderRadius.circular(24.0),
color: theme.brightness == Brightness.dark
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
height: height,
width: width,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: height - 16.0,
width: height - 16.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.0),
),
margin: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: space.avatarUrl != null
? MxcImage(
uri: space.avatarUrl!,
width: width - 16.0,
height: width - 16.0,
)
: const SizedBox(),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
spacing: 4.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
space.name ?? '',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24.0),
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 8.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
const Icon(
Icons.group_outlined,
size: 12.0,
),
Text(
L10n.of(context).countParticipants(
space.numJoinedMembers,
),
style: theme.textTheme.labelSmall,
),
],
),
),
],
),
if (space.topic != null)
Flexible(
child: Text(
space.topic!,
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
maxLines: 4,
),
),
],
),
),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,217 @@
// 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:async';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/public_spaces/public_space_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PublicSpacesArea extends StatefulWidget {
const PublicSpacesArea({super.key});
@override
PublicSpacesAreaState createState() => PublicSpacesAreaState();
}
class PublicSpacesAreaState extends State<PublicSpacesArea> {
@override
void initState() {
super.initState();
_setSpaceItems();
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_coolDown?.cancel();
super.dispose();
}
bool _loading = true;
bool _isSearching = false;
final List<PublicRoomsChunk> _spaceItems = [];
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
Timer? _coolDown;
final double cardHeight = 150.0;
final double cardWidth = 450.0;
Future<void> _setSpaceItems() async {
_spaceItems.clear();
setState(() => _loading = true);
try {
final resp = await Matrix.of(context).client.queryPublicRooms(
filter: PublicRoomQueryFilter(
roomTypes: ['m.space'],
genericSearchTerm: _searchController.text,
),
limit: 100,
);
_spaceItems.addAll(resp.chunk);
_spaceItems.sort((a, b) {
int getPriority(item) {
final bool hasTopic = item.topic != null && item.topic!.isNotEmpty;
final bool hasAvatar = item.avatarUrl != null;
if (hasTopic && hasAvatar) return 0; // Highest priority
if (hasAvatar) return 1; // Second priority
if (hasTopic) return 2; // Third priority
return 3; // Lowest priority
}
return getPriority(a).compareTo(getPriority(b));
});
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _onSearchEnter(String text, {bool globalSearch = true}) {
if (text.isEmpty) {
_setSpaceItems();
return;
}
_coolDown?.cancel();
_coolDown = Timer(const Duration(milliseconds: 500), _setSpaceItems);
}
void _toggleSearching() {
setState(() {
_isSearching = !_isSearching;
_searchController.clear();
_setSpaceItems();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final List<Widget> cards = _loading && _spaceItems.isEmpty
? List.generate(5, (i) {
return Shimmer.fromColors(
baseColor: theme.colorScheme.primary.withAlpha(20),
highlightColor: theme.colorScheme.primary.withAlpha(50),
child: Container(
height: cardHeight,
width: cardWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(24.0),
),
),
);
})
: _spaceItems
.map((space) {
return PublicSpaceCard(
space: space,
width: cardWidth,
height: cardHeight,
);
})
.cast<Widget>()
.toList();
if (_loading && _spaceItems.isNotEmpty) {
cards.add(
const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator.adaptive(),
),
);
}
return Column(
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: _isSearching
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
key: const ValueKey('search'),
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _searchController,
onChanged: _onSearchEnter,
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 12.0,
),
isDense: true,
border: OutlineInputBorder(),
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: _toggleSearching,
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
key: const ValueKey('title'),
children: [
Text(
L10n.of(context).publicSpacesTitle,
style: isColumnMode
? theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)
: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.search),
onPressed: _toggleSearching,
),
],
),
),
Container(
decoration: const BoxDecoration(),
child: Expanded(
child: Scrollbar(
thumbVisibility: true,
controller: _scrollController,
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8.0,
children: cards,
),
),
),
),
),
),
],
);
}
}