💄 Better screenshot of post

This commit is contained in:
2025-12-27 23:41:10 +08:00
parent 411c71dae0
commit a7c8a8d2ee
3 changed files with 325 additions and 250 deletions

View File

@@ -7,6 +7,7 @@ import 'package:island/models/post.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/post_item_screenshot.dart'; import 'package:island/widgets/post/post_item_screenshot.dart';
import 'package:island/widgets/post/post_shared.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory; import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -29,6 +30,9 @@ Future<void> sharePostAsScreenshot(
sharedPreferencesProvider.overrideWithValue( sharedPreferencesProvider.overrideWithValue(
ref.watch(sharedPreferencesProvider), ref.watch(sharedPreferencesProvider),
), ),
repliesProvider(
post.id,
).overrideWithValue(ref.watch(repliesProvider(post.id))),
], ],
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -15,10 +13,10 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/translate.dart'; import 'package:island/pods/translate.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/utils/share_utils.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/image.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_award_sheet.dart';
import 'package:island/widgets/post/post_pin_sheet.dart'; import 'package:island/widgets/post/post_pin_sheet.dart';
import 'package:island/widgets/post/post_shared.dart'; import 'package:island/widgets/post/post_shared.dart';
@@ -28,9 +26,6 @@ import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:island/widgets/share/share_sheet.dart'; import 'package:island/widgets/share/share_sheet.dart';
import 'package:island/widgets/post/compose_sheet.dart'; import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart'; import 'package:super_context_menu/super_context_menu.dart';
@@ -102,8 +97,7 @@ class PostActionableItem extends HookConsumerWidget {
final config = ref.watch(appSettingsProvider); final config = ref.watch(appSettingsProvider);
final widgetItem = InkWell( final widgetItem = InkWell(
borderRadius: borderRadius: borderRadius != null
borderRadius != null
? BorderRadius.all(Radius.circular(borderRadius!)) ? BorderRadius.all(Radius.circular(borderRadius!))
: null, : null,
child: PostItem( child: PostItem(
@@ -126,51 +120,6 @@ class PostActionableItem extends HookConsumerWidget {
}, },
); );
final screenshotController = useMemoized(() => ScreenshotController(), []);
void shareAsScreenshot() async {
if (kIsWeb) return;
showLoadingModal(context);
await screenshotController
.captureFromWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(
ref.watch(sharedPreferencesProvider),
),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 520,
child: PostItemScreenshot(item: item, isFullPost: isFullPost),
),
),
),
context: context,
pixelRatio: MediaQuery.of(context).devicePixelRatio,
delay: const Duration(seconds: 1),
)
.then((Uint8List? image) async {
if (image == null) return;
final directory = await getTemporaryDirectory();
final imagePath =
await File('${directory.path}/image.png').create();
await imagePath.writeAsBytes(image);
if (!context.mounted) return;
hideLoadingModal(context);
final box = context.findRenderObject() as RenderBox?;
await Share.shareXFiles([
XFile(imagePath.path),
], sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
})
.catchError((err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
});
}
return ContextMenuWidget( return ContextMenuWidget(
menuProvider: (_) { menuProvider: (_) {
return Menu( return Menu(
@@ -319,7 +268,7 @@ class PostActionableItem extends HookConsumerWidget {
title: 'sharePostPhoto'.tr(), title: 'sharePostPhoto'.tr(),
image: MenuImage.icon(Symbols.share_reviews), image: MenuImage.icon(Symbols.share_reviews),
callback: () { callback: () {
shareAsScreenshot(); sharePostAsScreenshot(context, ref, item);
}, },
), ),
MenuSeparator(), MenuSeparator(),
@@ -329,7 +278,7 @@ class PostActionableItem extends HookConsumerWidget {
callback: () { callback: () {
showAbuseReportSheet( showAbuseReportSheet(
context, context,
resourceIdentifier: 'post/${item.id}', resourceIdentifier: 'post:${item.id}',
); );
}, },
), ),
@@ -337,12 +286,10 @@ class PostActionableItem extends HookConsumerWidget {
); );
}, },
child: Material( child: Material(
color: color: config.cardTransparency < 1
config.cardTransparency < 1
? Colors.transparent ? Colors.transparent
: Theme.of(context).cardTheme.color, : Theme.of(context).cardTheme.color,
borderRadius: borderRadius: borderRadius != null
borderRadius != null
? BorderRadius.all(Radius.circular(borderRadius!)) ? BorderRadius.all(Radius.circular(borderRadius!))
: null, : null,
child: widgetItem, child: widgetItem,
@@ -417,22 +364,19 @@ class PostItem extends HookConsumerWidget {
reacting.value = false; reacting.value = false;
} }
final mostReaction = final mostReaction = item.reactionsCount.isEmpty
item.reactionsCount.isEmpty
? null ? null
: item.reactionsCount.entries : item.reactionsCount.entries
.sortedBy((e) => e.value) .sortedBy((e) => e.value)
.map((e) => e.key) .map((e) => e.key)
.last; .last;
final postLanguage = final postLanguage = item.content != null && isTranslatable
item.content != null && isTranslatable
? ref.watch(detectStringLanguageProvider(item.content!)) ? ref.watch(detectStringLanguageProvider(item.content!))
: null; : null;
final currentLanguage = isTranslatable ? context.locale.toString() : null; final currentLanguage = isTranslatable ? context.locale.toString() : null;
final translatableLanguage = final translatableLanguage = postLanguage != null && isTranslatable
postLanguage != null && isTranslatable
? postLanguage.substring(0, 2) != currentLanguage!.substring(0, 2) ? postLanguage.substring(0, 2) != currentLanguage!.substring(0, 2)
: false; : false;
@@ -466,8 +410,7 @@ class PostItem extends HookConsumerWidget {
} }
} }
final translatedWidget = final translatedWidget = (translatedText.value?.isNotEmpty ?? false)
(translatedText.value?.isNotEmpty ?? false)
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -487,8 +430,7 @@ class PostItem extends HookConsumerWidget {
) )
: null; : null;
final translatableWidget = final translatableWidget = (isTranslatable && translatableLanguage)
(isTranslatable && translatableLanguage)
? Align( ? Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: TextButton.icon(
@@ -497,17 +439,13 @@ class PostItem extends HookConsumerWidget {
padding: const WidgetStatePropertyAll( padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 2), EdgeInsets.symmetric(horizontal: 2),
), ),
visualDensity: const VisualDensity( visualDensity: const VisualDensity(horizontal: 0, vertical: -4),
horizontal: 0,
vertical: -4,
),
foregroundColor: WidgetStatePropertyAll( foregroundColor: WidgetStatePropertyAll(
translatedText.value == null ? null : Colors.grey, translatedText.value == null ? null : Colors.grey,
), ),
), ),
icon: const Icon(Symbols.translate), icon: const Icon(Symbols.translate),
label: label: translatedText.value != null
translatedText.value != null
? const Text('translated').tr() ? const Text('translated').tr()
: translating.value : translating.value
? const Text('translating').tr() ? const Text('translating').tr()
@@ -534,15 +472,13 @@ class PostItem extends HookConsumerWidget {
isFullPost: isFullPost, isFullPost: isFullPost,
isCompact: isCompact, isCompact: isCompact,
renderingPadding: renderingPadding, renderingPadding: renderingPadding,
trailing: trailing: isCompact
isCompact
? null ? null
: SizedBox( : SizedBox(
width: 36, width: 36,
height: 36, height: 36,
child: IconButton( child: IconButton(
icon: icon: mostReaction == null
mostReaction == null
? const Icon(Symbols.add_reaction) ? const Icon(Symbols.add_reaction)
: Badge( : Badge(
label: Center( label: Center(
@@ -556,10 +492,8 @@ class PostItem extends HookConsumerWidget {
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.primary.withOpacity(0.75), ).colorScheme.primary.withOpacity(0.75),
textColor: textColor: Theme.of(context).colorScheme.onPrimary,
Theme.of(context).colorScheme.onPrimary, child: mostReaction.contains('+')
child:
mostReaction.contains('+')
? HookConsumer( ? HookConsumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final baseUrl = ref.watch( final baseUrl = ref.watch(
@@ -570,8 +504,7 @@ class PostItem extends HookConsumerWidget {
return SizedBox( return SizedBox(
width: 32, width: 32,
height: 32, height: 32,
child: child: UniversalImage(
UniversalImage(
uri: stickerUri, uri: stickerUri,
width: 28, width: 28,
height: 28, height: 28,
@@ -580,14 +513,9 @@ class PostItem extends HookConsumerWidget {
); );
}, },
) )
: _buildReactionIcon( : _buildReactionIcon(mostReaction, 32).padding(
mostReaction,
32,
).padding(
bottom: bottom:
_getReactionImageAvailable( _getReactionImageAvailable(mostReaction)
mostReaction,
)
? 2 ? 2
: 0, : 0,
), ),
@@ -708,8 +636,7 @@ class PostReactionList extends HookConsumerWidget {
horizontal: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity, vertical: VisualDensity.minimumDensity,
), ),
onPressed: onPressed: submitting.value
submitting.value
? null ? null
: () { : () {
showModalBottomSheet( showModalBottomSheet(
@@ -741,8 +668,7 @@ class PostReactionList extends HookConsumerWidget {
Text('x${reactions[symbol]}').bold(), Text('x${reactions[symbol]}').bold(),
], ],
), ),
onPressed: onPressed: submitting.value
submitting.value
? null ? null
: () { : () {
reactPost( reactPost(

View File

@@ -4,10 +4,44 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/pods/config.dart';
import 'package:island/widgets/post/post_shared.dart'; import 'package:island/widgets/post/post_shared.dart';
import 'package:island/widgets/content/image.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
const kAvailableStickers = {
'angry',
'clap',
'confuse',
'pray',
'thumb_up',
'party',
};
bool _getReactionImageAvailable(String symbol) {
return kAvailableStickers.contains(symbol);
}
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
if (_getReactionImageAvailable(symbol)) {
return Image.asset(
'assets/images/stickers/$symbol.png',
width: size,
height: size,
fit: BoxFit.contain,
alignment: Alignment.bottomCenter,
);
} else {
return Text(
kReactionTemplates[symbol]?.icon ?? '',
style: TextStyle(fontSize: iconSize),
);
}
}
class PostItemScreenshot extends ConsumerWidget { class PostItemScreenshot extends ConsumerWidget {
final SnPost item; final SnPost item;
final EdgeInsets? padding; final EdgeInsets? padding;
@@ -51,18 +85,42 @@ class PostItemScreenshot extends ConsumerWidget {
renderingPadding: renderingPadding, renderingPadding: renderingPadding,
isRelativeTime: false, isRelativeTime: false,
trailing: mostReaction != null trailing: mostReaction != null
? Row( ? Badge(
children: [ label: Center(
Text( child: Text(
kReactionTemplates[mostReaction]?.icon ?? '',
style: const TextStyle(fontSize: 20),
),
const Gap(4),
Text(
'x${item.reactionsCount[mostReaction]}', 'x${item.reactionsCount[mostReaction]}',
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
textAlign: TextAlign.center,
),
),
offset: const Offset(4, 20),
backgroundColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.75),
textColor: Theme.of(context).colorScheme.onPrimary,
child: mostReaction.contains('+')
? Consumer(
builder: (context, ref, child) {
final baseUrl = ref.watch(serverUrlProvider);
final stickerUri =
'$baseUrl/sphere/stickers/lookup/$mostReaction/open';
return SizedBox(
width: 28,
height: 28,
child: UniversalImage(
uri: stickerUri,
width: 28,
height: 28,
fit: BoxFit.contain,
).center(),
);
},
)
: _buildReactionIcon(mostReaction, 32).padding(
bottom: _getReactionImageAvailable(mostReaction)
? 2
: 0,
), ),
],
) )
: null, : null,
), ),
@@ -81,6 +139,93 @@ class PostItemScreenshot extends ConsumerWidget {
isInteractive: false, isInteractive: false,
renderingPadding: renderingPadding, renderingPadding: renderingPadding,
), ),
if (item.repliesCount > 0)
Consumer(
builder: (context, ref, child) {
final repliesState = ref.watch(repliesProvider(item.id));
final posts = repliesState.posts;
return Container(
margin: EdgeInsets.only(
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
top: 8,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
'repliesCount',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
).plural(item.repliesCount).padding(horizontal: 5),
if (posts.isEmpty && repliesState.loading)
Row(
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Gap(8),
const Text('loading').tr(),
],
).padding(horizontal: 5),
if (posts.isNotEmpty)
...posts.map(
(post) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
ProfilePictureWidget(
file:
post.publisher.picture ??
post.publisher.account?.profile.picture,
radius: 12,
).padding(top: 4),
if (post.content?.isNotEmpty ?? false)
Expanded(
child: MarkdownTextContent(
content: post.content!,
attachments: post.attachments,
).padding(top: 2),
)
else
Expanded(
child:
Text(
'postHasAttachments',
style: const TextStyle(height: 2),
)
.plural(post.attachments.length)
.padding(top: 2),
),
],
),
),
),
],
),
);
},
),
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,
margin: const EdgeInsets.only(top: 8), margin: const EdgeInsets.only(top: 8),