Share the post via image

This commit is contained in:
LittleSheep 2024-12-12 23:51:27 +08:00
parent bb5fe9c380
commit 240ad7dc7e
7 changed files with 285 additions and 51 deletions

View File

@ -403,6 +403,7 @@
"accountStatusOffline": "Offline",
"accountStatusLastSeen": "Last seen at {}",
"postArticle": "Article on the Solar Network",
"postStory": "Story on the Solar Network",
"articleWrittenAt": "Written at {}",
"articleEditedAt": "Edited at {}",
"attachmentSaved": "Saved to album",
@ -436,5 +437,7 @@
"publisherBlockHint": "Block {}",
"publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.",
"userUnblocked": "{} has been unblocked.",
"userBlocked": "{} has been blocked."
"userBlocked": "{} has been blocked.",
"postSharingViaPicture": "Capturing post as picture, please stand by...",
"postImageShareAds": "Explore posts on the Solar Network"
}

View File

@ -401,6 +401,7 @@
"accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"articleWrittenAt": "发表于 {}",
"articleEditedAt": "编辑于 {}",
"attachmentSaved": "已保存到相册",
@ -434,5 +435,7 @@
"publisherBlockHint": "屏蔽 {}",
"publisherBlockHintDescription": "你正要屏蔽此发布者的运营者,该操作也将屏蔽由同一用户运营的发布者。",
"userUnblocked": "已解除屏蔽用户 {}",
"userBlocked": "已屏蔽用户 {}"
"userBlocked": "已屏蔽用户 {}",
"postSharingViaPicture": "正在生成帖子截图,请稍等片刻……",
"postImageShareAds": "来 Solar Network 探索更多有趣帖子"
}

View File

@ -53,6 +53,11 @@ class SnPostContentProvider {
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
}
if (out.repostId != null) {
out = out.copyWith(
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
);
}
final attachments = await _attach.getMultiple(rids.toList());
out = out.copyWith(

View File

@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -14,19 +15,21 @@ class AttachmentList extends StatefulWidget {
final List<SnAttachment?> data;
final bool bordered;
final bool noGrow;
final bool isFlatted;
final double? maxHeight;
final EdgeInsets? listPadding;
const AttachmentList({
super.key,
required this.data,
this.bordered = false,
this.noGrow = false,
this.isFlatted = false,
this.maxHeight,
this.listPadding,
});
static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8));
static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8));
@override
State<AttachmentList> createState() => _AttachmentListState();
@ -44,9 +47,8 @@ class _AttachmentListState extends State<AttachmentList> {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, layoutConstraints) {
final borderSide = widget.bordered
? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none;
final borderSide =
widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints(
minWidth: 80,
@ -56,8 +58,7 @@ class _AttachmentListState extends State<AttachmentList> {
if (widget.data.isEmpty) return const SizedBox.shrink();
if (widget.data.length == 1) {
final singleAspectRatio =
widget.data[0]?.metadata['ratio']?.toDouble() ??
final singleAspectRatio = widget.data[0]?.metadata['ratio']?.toDouble() ??
switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
'audio' => 16 / 9,
'video' => 16 / 9,
@ -79,8 +80,7 @@ class _AttachmentListState extends State<AttachmentList> {
child: GestureDetector(
child: Builder(
builder: (context) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) ||
widget.noGrow) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || widget.noGrow) {
return Padding(
// Single child list-like displaying
padding: widget.listPadding ?? EdgeInsets.zero,
@ -129,6 +129,37 @@ class _AttachmentListState extends State<AttachmentList> {
);
}
if (widget.isFlatted) {
return Wrap(
spacing: 4,
runSpacing: 4,
children: widget.data
.mapIndexed(
(idx, ele) => AspectRatio(
aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(),
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
top: borderSide,
bottom: borderSide,
),
borderRadius: AttachmentList.kDefaultRadius,
),
child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(
data: ele,
heroTag: heroTags[idx],
),
),
),
),
)
.toList(),
);
}
return AspectRatio(
aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(),
child: Container(
@ -147,9 +178,7 @@ class _AttachmentListState extends State<AttachmentList> {
onTap: () {
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data
.where((ele) => ele != null)
.cast(),
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
),

View File

@ -5,10 +5,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:popover/popover.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
@ -56,6 +61,9 @@ class PostItem extends StatelessWidget {
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user!.id;
// Article headline preview
if (!showFullPost && data.type == 'article') {
return Container(
@ -65,6 +73,7 @@ class PostItem extends StatelessWidget {
children: [
_PostContentHeader(
data: data,
isAuthor: isAuthor,
onDeleted: () {
if (onDeleted != null) {}
},
@ -191,6 +200,118 @@ class PostItem extends StatelessWidget {
}
}
class PostShareImage extends StatelessWidget {
const PostShareImage({
super.key,
required this.data,
});
final SnPost data;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 480,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_PostContentHeader(
data: data,
onDeleted: () {},
showMenu: false,
isRelativeDate: false,
).padding(horizontal: 16, bottom: 8),
_PostHeadline(
data: data,
isEnlarge: data.type == 'article',
).padding(horizontal: 16, bottom: 8),
_PostContentBody(
data: data,
isEnlarge: data.type == 'article',
).padding(horizontal: 16, bottom: 8),
if (data.repostTo != null)
_PostQuoteContent(
child: data.repostTo!,
isRelativeDate: false,
isFlatted: true,
).padding(horizontal: 16, bottom: 8),
if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false))
AttachmentList(
data: data.preload!.attachments!,
isFlatted: true,
).padding(horizontal: 16, bottom: 8),
_PostBottomAction(
data: data,
showComments: true,
showReactions: true,
onChanged: (SnPost data) {},
).padding(left: 8, right: 14),
const Divider(height: 1),
const Gap(12),
SizedBox(
height: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${data.aliasPrefix} / ${data.alias ?? '#${data.id}'}',
style: GoogleFonts.robotoMono(fontSize: 17),
),
const Gap(2),
Text(
switch (data.type) {
'article' => 'postArticle'.tr(),
_ => 'postStory'.tr(),
},
style: GoogleFonts.robotoMono(fontSize: 12),
),
],
),
),
Text(
'postImageShareAds',
style: GoogleFonts.robotoMono(fontSize: 13),
).tr(),
],
),
),
QrImageView(
padding: EdgeInsets.zero,
data: 'https://solsynth.dev/posts/${data.id}',
version: QrVersions.auto,
size: 100,
gapless: true,
embeddedImage: AssetImage('assets/icon/icon-light-radius.png'),
embeddedImageStyle: QrEmbeddedImageStyle(
size: Size(32, 32),
),
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.circle,
color: Theme.of(context).colorScheme.onSurface,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
)
],
),
).padding(left: 16, right: 32, vertical: 8),
],
).padding(vertical: 16),
);
}
}
class _PostBottomAction extends StatelessWidget {
final SnPost data;
final bool showComments;
@ -204,17 +325,57 @@ class _PostBottomAction extends StatelessWidget {
required this.onChanged,
});
void _doShare() {
void _doShare(BuildContext context) {
final box = context.findRenderObject() as RenderBox?;
final url = 'https://solsynth.dev/posts/${data.id}';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Share.shareUri(Uri.parse(url));
Share.shareUri(Uri.parse(url), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
} else {
Share.share(url);
Share.share(url, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
}
}
void _doShareViaPicture() {
void _doShareViaPicture(BuildContext context) async {
final box = context.findRenderObject() as RenderBox?;
context.showSnackbar('postSharingViaPicture'.tr());
final controller = ScreenshotController();
final capturedImage = await controller.captureFromLongWidget(
InheritedTheme.captureAll(
context,
MediaQuery(
data: MediaQuery.of(context),
child: Material(
child: MultiProvider(
providers: [
Provider<SnNetworkProvider>(create: (_) => context.read()),
],
child: ResponsiveBreakpoints.builder(
breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
child: PostShareImage(data: data),
),
),
),
),
),
pixelRatio: 3,
context: context,
);
if (kIsWeb) return;
final directory = await getTemporaryDirectory();
final imagePath = await File(
'${directory.path}/sn-share-via-image-${DateTime.now().millisecondsSinceEpoch}.png',
).create();
await imagePath.writeAsBytes(capturedImage);
await Share.shareXFiles(
[XFile(imagePath.path)],
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
);
await imagePath.delete();
}
@override
@ -301,8 +462,8 @@ class _PostBottomAction extends StatelessWidget {
..removeLast(),
),
InkWell(
onTap: _doShare,
onLongPress: _doShareViaPicture,
onTap: () => _doShare(context),
onLongPress: () => _doShareViaPicture(context),
child: Icon(
Symbols.share,
size: 20,
@ -410,13 +571,17 @@ class _PostHeadline extends StatelessWidget {
class _PostContentHeader extends StatelessWidget {
final SnPost data;
final bool isAuthor;
final bool isCompact;
final bool isRelativeDate;
final bool showMenu;
final Function onDeleted;
const _PostContentHeader({
required this.data,
this.isAuthor = false,
this.isCompact = false,
this.isRelativeDate = true,
this.showMenu = true,
required this.onDeleted,
});
@ -446,9 +611,6 @@ class _PostContentHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user!.id;
return Row(
children: [
GestureDetector(
@ -484,9 +646,11 @@ class _PostContentHeader extends StatelessWidget {
children: [
Text('@${data.publisher.name}').fontSize(13),
const Gap(4),
Text(RelativeTime(context).format(
data.publishedAt ?? data.createdAt,
)).fontSize(13),
Text(
isRelativeDate
? RelativeTime(context).format(data.publishedAt ?? data.createdAt)
: DateFormat('y/M/d HH:mm').format(data.publishedAt ?? data.createdAt),
).fontSize(13),
],
).opacity(0.8),
],
@ -501,9 +665,11 @@ class _PostContentHeader extends StatelessWidget {
children: [
Text('@${data.publisher.name}').fontSize(13),
const Gap(4),
Text(RelativeTime(context).format(
data.publishedAt ?? data.createdAt,
)).fontSize(13),
Text(
isRelativeDate
? RelativeTime(context).format(data.publishedAt ?? data.createdAt)
: DateFormat('y/M/d HH:mm').format(data.publishedAt ?? data.createdAt),
).fontSize(13),
],
).opacity(0.8),
],
@ -628,8 +794,15 @@ class _PostContentBody extends StatelessWidget {
class _PostQuoteContent extends StatelessWidget {
final SnPost child;
final bool isRelativeDate;
final bool isFlatted;
const _PostQuoteContent({super.key, required this.child});
const _PostQuoteContent({
super.key,
this.isRelativeDate = true,
this.isFlatted = false,
required this.child,
});
@override
Widget build(BuildContext context) {
@ -650,6 +823,7 @@ class _PostQuoteContent extends StatelessWidget {
_PostContentHeader(
data: child,
isCompact: true,
isRelativeDate: isRelativeDate,
showMenu: false,
onDeleted: () {},
).padding(bottom: 4),
@ -665,12 +839,15 @@ class _PostQuoteContent extends StatelessWidget {
),
child: AttachmentList(
data: child.preload!.attachments!,
isFlatted: isFlatted,
listPadding: const EdgeInsets.symmetric(horizontal: 12),
),
).padding(
top: 8,
bottom: (child.preload?.attachments?.length ?? 0) > 1 ? 12 : 0,
),
)
else
const Gap(8),
],
),
),

View File

@ -266,10 +266,10 @@ packages:
dependency: transitive
description:
name: connectivity_plus
sha256: "876849631b0c7dc20f8b471a2a03142841b482438e3b707955464f5ffca3e4c3"
sha256: e0817759ec6d2d8e57eb234e6e57d2173931367a865850c7acea40d4b4f9c27d
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "6.1.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
@ -362,18 +362,18 @@ packages:
dependency: transitive
description:
name: device_info_plus
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431"
url: "https://pub.dev"
source: hosted
version: "11.1.1"
version: "11.2.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba"
sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "7.0.2"
dio:
dependency: "direct main"
description:
@ -1190,18 +1190,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
version: "8.1.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66
sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
pasteboard:
dependency: "direct main"
description:
@ -1402,6 +1402,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
relative_time:
dependency: "direct main"
description:
@ -1502,18 +1518,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400"
url: "https://pub.dev"
source: hosted
version: "10.1.2"
version: "10.1.3"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.1"
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:

View File

@ -99,6 +99,7 @@ dependencies:
package_info_plus: ^8.1.1
intl: ^0.19.0
screenshot: ^3.0.0
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test: