Custom reaction via sticker

This commit is contained in:
2025-10-12 23:18:58 +08:00
parent 3604373a1e
commit ce12f28e56
6 changed files with 366 additions and 68 deletions

View File

@@ -135,6 +135,11 @@
"reactionPositive": "Postive",
"reactionNegative": "Negative",
"reactionNeutral": "Neutral",
"customReaction": "Custom Reaction",
"customReactions": "Custom Reactions",
"stickerPlaceholder": "Sticker Placeholder",
"reactionAttitude": "Reaction Attitude",
"addReaction": "Add Reaction",
"connectionConnected": "Connected",
"connectionDisconnected": "Disconnected",
"connectionReconnecting": "Reconnecting",
@@ -1221,5 +1226,6 @@
"noStickerPacks": "No Sticker Packs",
"refresh": "Refresh",
"spoiler": "Spoiler",
"activityHeatmap": "Activity Heatmap"
"activityHeatmap": "Activity Heatmap",
"custom": "Custom"
}

View File

@@ -940,7 +940,7 @@
"editBot": "编辑机器人",
"botAutomatedBy": "由 {} 自动化",
"botDetails": "机器人详情",
"overview": "总",
"overview": "总",
"keys": "密钥",
"botNotFound": "机器人未找到。",
"newBotKey": "新建密钥",

View File

@@ -116,9 +116,6 @@ class WindowScaffold extends HookConsumerWidget {
children: [
if (isWideScreen(context))
Row(
key: Key(
'app-page-action-${router.state.pageKey.value}',
),
children: [
const Spacer(),
...pageActionsButton,

View File

@@ -17,6 +17,7 @@ import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/image.dart';
import 'package:island/widgets/post/post_item_screenshot.dart';
import 'package:island/widgets/post/post_award_sheet.dart';
import 'package:island/widgets/post/post_pin_sheet.dart';
@@ -549,15 +550,39 @@ class PostItem extends HookConsumerWidget {
).colorScheme.primary.withOpacity(0.75),
textColor:
Theme.of(context).colorScheme.onPrimary,
child: _buildReactionIcon(
mostReaction,
32,
).padding(
bottom:
_getReactionImageAvailable(mostReaction)
? 2
: 0,
),
child:
mostReaction.contains('+')
? HookConsumer(
builder: (context, ref, child) {
final baseUrl = ref.watch(
serverUrlProvider,
);
final stickerUri =
'$baseUrl/sphere/stickers/lookup/$mostReaction/open';
return SizedBox(
width: 32,
height: 32,
child:
UniversalImage(
uri: stickerUri,
width: 28,
height: 28,
fit: BoxFit.contain,
).center(),
);
},
)
: _buildReactionIcon(
mostReaction,
32,
).padding(
bottom:
_getReactionImageAvailable(
mostReaction,
)
? 2
: 0,
),
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(

View File

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
@@ -14,6 +15,8 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/stickers/sticker_picker.dart';
import 'package:island/pods/config.dart';
part 'post_reaction_sheet.g.dart';
@@ -106,63 +109,225 @@ class PostReactionSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const 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,
return DefaultTabController(
length: 2,
child: Column(
children: [
Padding(
padding: const 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,
),
),
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 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,
Symbols.mood,
'reactionPositive'.tr(),
0,
),
_buildReactionSection(
context,
Symbols.sentiment_neutral,
'reactionNeutral'.tr(),
1,
),
_buildReactionSection(
context,
Symbols.mood_bad,
'reactionNegative'.tr(),
2,
),
],
const Divider(height: 1),
TabBar(tabs: [Tab(text: 'overview'.tr()), Tab(text: 'custom'.tr())]),
const Divider(height: 1),
Expanded(
child: TabBarView(
children: [
ListView(
children: [
_buildCustomReactionSection(context),
_buildReactionSection(
context,
Symbols.mood,
'reactionPositive'.tr(),
0,
),
_buildReactionSection(
context,
Symbols.sentiment_neutral,
'reactionNeutral'.tr(),
1,
),
_buildReactionSection(
context,
Symbols.mood_bad,
'reactionNegative'.tr(),
2,
),
const Gap(8),
],
),
CustomReactionForm(
postId: postId,
onReact: (s, a) => onReact(s.replaceAll(':', ''), a),
),
],
),
),
),
],
],
),
);
}
Widget _buildCustomReactionSection(BuildContext context) {
final customReactions =
reactionsCount.entries
.where((entry) => entry.key.contains('+'))
.map((entry) => entry.key)
.toList();
if (customReactions.isEmpty) return const SizedBox.shrink();
return HookConsumer(
builder: (context, ref, child) {
final baseUrl = ref.watch(serverUrlProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8,
children: [
const Icon(Symbols.emoji_symbols),
Text('customReactions'.tr()).fontSize(17).bold(),
],
).padding(horizontal: 24, top: 16, bottom: 6),
SizedBox(
height: 120,
child: GridView.builder(
scrollDirection: Axis.horizontal,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 120,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
childAspectRatio: 1.0,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: customReactions.length,
itemBuilder: (context, index) {
final symbol = customReactions[index];
final count = reactionsCount[symbol] ?? 0;
final stickerUri =
'$baseUrl/sphere/stickers/lookup/$symbol/open';
return GestureDetector(
onLongPressStart: (details) {
if (count > 0) {
showReactionDetailsPopup(
context,
symbol,
details.localPosition,
postId,
reactionsCount[symbol] ?? 0,
);
}
},
onSecondaryTapUp: (details) {
if (count > 0) {
showReactionDetailsPopup(
context,
symbol,
details.localPosition,
postId,
reactionsCount[symbol] ?? 0,
);
}
},
child: Badge(
label: Text('x$count'),
isLabelVisible: count > 0,
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
offset: Offset(0, 0),
child: Card(
margin: const EdgeInsets.symmetric(vertical: 4),
color:
Theme.of(
context,
).colorScheme.surfaceContainerLowest,
child: InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () {
onReact(
symbol,
1,
); // Custom reactions use neutral attitude
Navigator.pop(context);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(width: double.infinity),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: NetworkImage(stickerUri),
fit: BoxFit.contain,
colorFilter:
(reactionsMade[symbol] ?? false)
? ColorFilter.mode(
Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.7),
BlendMode.srcATop,
)
: null,
),
),
),
const Gap(8),
Text(
symbol,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
shadows: [
Shadow(
blurRadius: 4,
offset: Offset(0.5, 0.5),
color: Colors.black,
),
],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
);
},
),
),
],
);
},
);
}
@@ -407,6 +572,110 @@ class ReactionDetailsPopup extends HookConsumerWidget {
}
}
class CustomReactionForm extends HookConsumerWidget {
final String postId;
final Function(String symbol, int attitude) onReact;
const CustomReactionForm({
super.key,
required this.postId,
required this.onReact,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final attitude = useState<int>(1);
final symbol = useState<String>('');
Future<void> submitCustomReaction() async {
if (symbol.value.isEmpty) return;
onReact(symbol.value, attitude.value);
Navigator.pop(context);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'customReaction'.tr(),
style: Theme.of(context).textTheme.titleLarge,
),
const Gap(24),
TextField(
decoration: InputDecoration(
labelText: 'stickerPlaceholder'.tr(),
hintText: 'prefix+slug',
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
suffixIcon: InkWell(
onTapDown: (details) async {
await showStickerPickerPopover(
context,
details.globalPosition.translate(-300, -280),
alignment: Alignment.topLeft,
onPick: (placeholder) {
// Remove the surrounding : from the placeholder
symbol.value = placeholder.substring(
1,
placeholder.length - 1,
);
},
);
},
child: const Icon(Symbols.sticky_note_2),
),
),
controller: TextEditingController(text: symbol.value),
onChanged: (value) => symbol.value = value,
),
const Gap(24),
Text(
'reactionAttitude'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
SegmentedButton(
segments: [
ButtonSegment(
value: 0,
icon: const Icon(Symbols.sentiment_satisfied),
label: Text('attitudePositive'.tr()),
),
ButtonSegment(
value: 1,
icon: const Icon(Symbols.sentiment_stressed),
label: Text('attitudeNeutral'.tr()),
),
ButtonSegment(
value: 2,
icon: const Icon(Symbols.sentiment_sad),
label: Text('attitudeNegative'.tr()),
),
],
selected: {attitude.value},
onSelectionChanged: (Set<int> newSelection) {
attitude.value = newSelection.first;
},
),
const Gap(32),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: symbol.value.isEmpty ? null : submitCustomReaction,
icon: const Icon(Symbols.send),
label: Text('addReaction'.tr()),
),
),
Gap(MediaQuery.of(context).padding.bottom + 24),
],
),
);
}
}
Future<void> showReactionDetailsPopup(
BuildContext context,
String symbol,

View File

@@ -282,13 +282,14 @@ class _StickersGrid extends StatelessWidget {
Future<void> showStickerPickerPopover(
BuildContext context,
Offset offset, {
Alignment? alignment,
required void Function(String placeholder) onPick,
}) async {
// Use flutter_popup_card to present the anchored popup near trigger.
await showPopupCard<void>(
context: context,
offset: offset,
alignment: Alignment.topLeft,
alignment: alignment ?? Alignment.topLeft,
dimBackground: true,
builder:
(ctx) => SizedBox(