From ce12f28e5664f8b7c416249dae67d20aa5ce2dce Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 12 Oct 2025 23:18:58 +0800 Subject: [PATCH] :sparkles: Custom reaction via sticker --- assets/i18n/en-US.json | 8 +- assets/i18n/zh-CN.json | 2 +- lib/widgets/app_scaffold.dart | 3 - lib/widgets/post/post_item.dart | 43 ++- lib/widgets/post/post_reaction_sheet.dart | 375 +++++++++++++++++++--- lib/widgets/stickers/sticker_picker.dart | 3 +- 6 files changed, 366 insertions(+), 68 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 65731860..95a11a48 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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" } diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 5d52d283..4b35e5b7 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -940,7 +940,7 @@ "editBot": "编辑机器人", "botAutomatedBy": "由 {} 自动化", "botDetails": "机器人详情", - "overview": "总揽", + "overview": "总览", "keys": "密钥", "botNotFound": "机器人未找到。", "newBotKey": "新建密钥", diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index bb738a1f..977cf26c 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -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, diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 5359829e..9db0c9e1 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -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( diff --git a/lib/widgets/post/post_reaction_sheet.dart b/lib/widgets/post/post_reaction_sheet.dart index d859f16e..4382d737 100644 --- a/lib/widgets/post/post_reaction_sheet.dart +++ b/lib/widgets/post/post_reaction_sheet.dart @@ -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(1); + final symbol = useState(''); + + Future 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 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 showReactionDetailsPopup( BuildContext context, String symbol, diff --git a/lib/widgets/stickers/sticker_picker.dart b/lib/widgets/stickers/sticker_picker.dart index b8900071..1a4d1ea2 100644 --- a/lib/widgets/stickers/sticker_picker.dart +++ b/lib/widgets/stickers/sticker_picker.dart @@ -282,13 +282,14 @@ class _StickersGrid extends StatelessWidget { Future 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( context: context, offset: offset, - alignment: Alignment.topLeft, + alignment: alignment ?? Alignment.topLeft, dimBackground: true, builder: (ctx) => SizedBox(