✨ Post reactions
This commit is contained in:
@ -47,60 +47,56 @@ class AccountPickerSheet extends HookConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: onSearchChanged,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search accounts...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: onSearchChanged,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search accounts...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 16,
|
||||
),
|
||||
autofocus: true,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
autofocus: true,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final searchResult = ref.watch(
|
||||
searchAccountsProvider(query: searchController.text),
|
||||
);
|
||||
),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final searchResult = ref.watch(
|
||||
searchAccountsProvider(query: searchController.text),
|
||||
);
|
||||
|
||||
return searchResult.when(
|
||||
data:
|
||||
(accounts) => ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final account = accounts[index];
|
||||
return ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
fileId: account.profile.pictureId,
|
||||
),
|
||||
title: Text(account.nick),
|
||||
subtitle: Text('@${account.name}'),
|
||||
onTap: () => Navigator.of(context).pop(account),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => Center(child: Text('Error: $error')),
|
||||
);
|
||||
},
|
||||
),
|
||||
return searchResult.when(
|
||||
data:
|
||||
(accounts) => ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final account = accounts[index];
|
||||
return ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
fileId: account.profile.pictureId,
|
||||
),
|
||||
title: Text(account.nick),
|
||||
subtitle: Text('@${account.name}'),
|
||||
onTap: () => Navigator.of(context).pop(account),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -70,7 +70,8 @@ class PostQuickReply extends HookConsumerWidget {
|
||||
radius: 16,
|
||||
),
|
||||
onTap: () {
|
||||
showCupertinoModalBottomSheet(
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => PublisherModal(),
|
||||
).then((value) {
|
||||
|
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -1,25 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class QuillContent extends HookConsumerWidget {
|
||||
final Document document;
|
||||
const QuillContent({super.key, required this.document});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useMemoized(() => QuillController.basic());
|
||||
|
||||
useEffect(() {
|
||||
controller.document = document;
|
||||
controller.readOnly = true;
|
||||
return null;
|
||||
}, [document]);
|
||||
|
||||
return QuillEditor.basic(
|
||||
controller: controller,
|
||||
config: const QuillEditorConfig(),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user