Post reactions

This commit is contained in:
2025-05-05 13:13:42 +08:00
parent db7fef4a72
commit e4e562918c
21 changed files with 1054 additions and 919 deletions

View File

@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
@ -12,7 +11,7 @@ import 'package:island/route.gr.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/quill_content.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
@ -22,12 +21,14 @@ class PostItem extends HookConsumerWidget {
final EdgeInsets? padding;
final bool isOpenable;
final Function? onRefresh;
final Function(SnPost)? onUpdate;
const PostItem({
super.key,
required this.item,
this.padding,
this.isOpenable = true,
this.onRefresh,
this.onUpdate,
});
@override
@ -113,9 +114,7 @@ class PostItem extends HookConsumerWidget {
children: [
Text(item.publisher.nick).bold(),
if (item.content?.isNotEmpty ?? false)
QuillContent(
document: Document.fromJson(item.content!),
),
MarkdownTextContent(content: item.content!),
],
),
onTap: () {
@ -129,6 +128,19 @@ class PostItem extends HookConsumerWidget {
),
if (item.attachments.isNotEmpty)
CloudFileList(files: item.attachments),
PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.only(left: 48),
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(item.copyWith(reactionsCount: reactionsCount));
},
),
],
),
),
@ -136,3 +148,214 @@ class PostItem extends HookConsumerWidget {
);
}
}
class PostReactionList extends HookConsumerWidget {
final int parentId;
final Map<String, int> reactions;
final Function(String symbol, int attitude, int delta) onReact;
final EdgeInsets? padding;
const PostReactionList({
super.key,
required this.parentId,
required this.reactions,
this.padding,
required this.onReact,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final submitting = useState(false);
Future<void> reactPost(String symbol, int attitude) async {
final client = ref.watch(apiClientProvider);
submitting.value = true;
await client
.post(
'/posts/$parentId/reactions',
data: {'symbol': symbol, 'attitude': attitude},
)
.catchError((err) {
showErrorAlert(err);
return err;
})
.then((resp) {
var isRemoving = resp.statusCode == 204;
onReact(symbol, attitude, isRemoving ? -1 : 1);
HapticFeedback.heavyImpact();
});
submitting.value = false;
}
return SizedBox(
height: 28,
child: ListView(
scrollDirection: Axis.horizontal,
padding: padding ?? EdgeInsets.zero,
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: Icon(Symbols.add_reaction),
label: Text('react').tr(),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed:
submitting.value
? null
: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return _PostReactionSheet(
reactionsCount: reactions,
onReact: (symbol, attitude) {
reactPost(symbol, attitude);
},
);
},
);
},
),
),
for (final symbol in reactions.keys)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: Text(kReactionTemplates[symbol]?.icon ?? '?'),
label: Row(
spacing: 4,
children: [
Text(symbol),
Text('x${reactions[symbol]}').bold(),
],
),
onPressed:
submitting.value
? null
: () {
reactPost(
symbol,
kReactionTemplates[symbol]?.attitude ?? 0,
);
},
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
),
),
],
),
);
}
}
class _PostReactionSheet extends StatelessWidget {
final Map<String, int> reactionsCount;
final Function(String symbol, int attitude) onReact;
const _PostReactionSheet({
required this.reactionsCount,
required this.onReact,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'reactions'.plural(
reactionsCount.isNotEmpty
? reactionsCount.values.reduce((a, b) => a + b)
: 0,
),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView(
children: [
_buildReactionSection(context, 'Positive Reactions', 0),
_buildReactionSection(context, 'Neutral Reactions', 1),
_buildReactionSection(context, 'Negative Reactions', 2),
],
),
),
],
);
}
Widget _buildReactionSection(
BuildContext context,
String title,
int attitude,
) {
final allReactions =
kReactionTemplates.entries
.where((entry) => entry.value.attitude == attitude)
.map((entry) => entry.key)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title).fontSize(20).bold().padding(horizontal: 20, vertical: 12),
SizedBox(
height: 84,
child: GridView.builder(
scrollDirection: Axis.horizontal,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 100,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
childAspectRatio: 2.0,
),
itemCount: allReactions.length,
itemBuilder: (context, index) {
final symbol = allReactions[index];
final count = reactionsCount[symbol] ?? 0;
return InkWell(
onTap: () {
onReact(symbol, attitude);
Navigator.pop(context);
},
child: GridTile(
header: Text(
kReactionTemplates[symbol]?.icon ?? '',
textAlign: TextAlign.center,
).fontSize(24),
footer: Text(
count > 0 ? 'x$count' : '',
textAlign: TextAlign.center,
).bold().padding(bottom: 12),
child: Center(
child: Text(symbol, textAlign: TextAlign.center),
),
),
);
},
),
),
],
);
}
}

View File

@ -70,7 +70,8 @@ class PostQuickReply extends HookConsumerWidget {
radius: 16,
),
onTap: () {
showCupertinoModalBottomSheet(
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => PublisherModal(),
).then((value) {

View File

@ -22,66 +22,62 @@ class PublisherModal extends HookConsumerWidget {
child: Column(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: publishers.when(
data:
(value) =>
value.isEmpty
? ConstrainedBox(
constraints: BoxConstraints(maxWidth: 280),
child:
Column(
crossAxisAlignment:
CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'publishersEmpty',
textAlign: TextAlign.center,
).tr().fontSize(17).bold(),
Text(
'publishersEmptyDescription',
textAlign: TextAlign.center,
).tr(),
const Gap(12),
ElevatedButton(
onPressed: () {
context.router
.push(NewPublisherRoute())
.then((value) {
if (value != null) {
ref.invalidate(
publishersManagedProvider,
);
}
});
},
child: Text('createPublisher').tr(),
),
],
).center(),
)
: SingleChildScrollView(
child: Column(
children: [
for (final publisher in value)
ListTile(
leading: ProfilePictureWidget(
fileId: publisher.picture?.id,
),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () {
Navigator.pop(context, publisher);
child: publishers.when(
data:
(value) =>
value.isEmpty
? ConstrainedBox(
constraints: BoxConstraints(maxWidth: 280),
child:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'publishersEmpty',
textAlign: TextAlign.center,
).tr().fontSize(17).bold(),
Text(
'publishersEmptyDescription',
textAlign: TextAlign.center,
).tr(),
const Gap(12),
ElevatedButton(
onPressed: () {
context.router
.push(NewPublisherRoute())
.then((value) {
if (value != null) {
ref.invalidate(
publishersManagedProvider,
);
}
});
},
child: Text('createPublisher').tr(),
),
],
),
],
).center(),
)
: SingleChildScrollView(
child: Column(
children: [
for (final publisher in value)
ListTile(
leading: ProfilePictureWidget(
fileId: publisher.picture?.id,
),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () {
Navigator.pop(context, publisher);
},
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error: $e'),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error: $e'),
),
),
],