Basic post item

This commit is contained in:
2024-11-09 11:16:14 +08:00
parent bac90aad23
commit 07b8ec6e96
16 changed files with 726 additions and 13 deletions

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountImage extends StatelessWidget {
final String content;
final Color? backgroundColor;
final Color? foregroundColor;
final double? radius;
final Widget? fallbackWidget;
const AccountImage({
super.key,
required this.content,
this.backgroundColor,
this.foregroundColor,
this.radius,
this.fallbackWidget,
});
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final url = sn.getAttachmentUrl(content);
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return CircleAvatar(
key: Key('attachment-${content.hashCode}'),
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: content.isNotEmpty
? ResizeImage(
UniversalImage.provider(url),
width: ((radius ?? 20) * devicePixelRatio * 2).round(),
height: ((radius ?? 20) * devicePixelRatio * 2).round(),
)
: null,
child: content.isEmpty
? (fallbackWidget ??
Icon(
Icons.account_circle,
size: radius != null ? radius! * 1.2 : 24,
color: foregroundColor,
))
: null,
);
}
}

View File

@ -0,0 +1,183 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:path/path.dart';
class MarkdownTextContent extends StatefulWidget {
final String content;
final bool isSelectable;
final bool isLargeText;
final bool isAutoWarp;
const MarkdownTextContent({
super.key,
required this.content,
this.isSelectable = false,
this.isLargeText = false,
this.isAutoWarp = false,
});
@override
State<MarkdownTextContent> createState() => _MarkdownTextContentState();
}
class _MarkdownTextContentState extends State<MarkdownTextContent> {
Widget _buildContent(BuildContext context) {
return Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: widget.content,
padding: EdgeInsets.zero,
styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context),
).copyWith(
textScaler: TextScaler.linear(widget.isLargeText ? 1.1 : 1),
blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).dividerColor,
),
),
),
codeblockDecoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.3,
),
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
)),
builders: {
'code': _MarkdownTextCodeElement(),
},
softLineBreak: true,
extensionSet: markdown.ExtensionSet(
<markdown.BlockSyntax>[
markdown.CodeBlockSyntax(),
...markdown.ExtensionSet.commonMark.blockSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
],
<markdown.InlineSyntax>[
if (widget.isAutoWarp) markdown.LineBreakSyntax(),
_UserNameCardInlineSyntax(),
markdown.AutolinkSyntax(),
markdown.AutolinkExtensionSyntax(),
markdown.CodeSyntax(),
...markdown.ExtensionSet.commonMark.inlineSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
),
onTapLink: (text, href, title) async {
if (href == null) return;
if (href.startsWith('solink://')) {
// final segments = href.replaceFirst('solink://', '').split('/');
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
imageBuilder: (uri, title, alt) {
var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
// final segments = url.replaceFirst('solink://', '').split('/');
return const SizedBox.shrink();
}
return UniversalImage(
url,
width: width,
height: height,
fit: fit,
);
},
);
}
@override
Widget build(BuildContext context) {
if (widget.isSelectable) {
return SelectionArea(child: _buildContent(context));
}
return _buildContent(context);
}
}
class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final alias = match[0]!;
final anchor = markdown.Element.text('a', alias)
..attributes['href'] = Uri.encodeFull(
'solink://users/${alias.substring(1)}',
);
parser.addNode(anchor);
return true;
}
}
class _MarkdownTextCodeElement extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(
markdown.Element element,
TextStyle? preferredStyle,
) {
var language = '';
if (element.attributes['class'] != null) {
String lg = element.attributes['class'] as String;
language = lg.substring(9).trim();
}
return SizedBox(
child: FutureBuilder(
future: (() async {
final docPath = '../../../';
final highlightingPath =
join(docPath, 'assets/highlighting', language);
await Highlighter.initialize([highlightingPath]);
return Highlighter(
language: highlightingPath,
theme: PlatformDispatcher.instance.platformBrightness ==
Brightness.light
? await HighlighterTheme.loadLightTheme()
: await HighlighterTheme.loadDarkTheme(),
);
})(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final highlighter = snapshot.data!;
return Text.rich(
highlighter.highlight(element.textContent.trim()),
style: GoogleFonts.robotoMono(),
);
}
return Text(
element.textContent.trim(),
style: GoogleFonts.robotoMono(),
);
},
),
).padding(all: 8);
}
}

View File

@ -1,5 +1,11 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:gap/gap.dart';
class PostItem extends StatelessWidget {
final SnPost data;
@ -8,16 +14,61 @@ class PostItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PostContentHeader(data: data),
_PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6),
],
);
}
}
class _PostContentPublisher extends StatelessWidget {
const _PostContentPublisher({super.key});
class _PostContentHeader extends StatelessWidget {
final SnPost data;
const _PostContentHeader({required this.data});
@override
Widget build(BuildContext context) {
return const Placeholder();
return Row(
children: [
AccountImage(content: data.publisher.avatar),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.publisher.nick).bold(),
Row(
children: [
Text('@${data.publisher.name}').fontSize(13),
const Gap(4),
Text(RelativeTime(context).format(data.publishedAt))
.fontSize(13),
],
).opacity(0.8),
],
),
),
IconButton(
onPressed: () {},
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
icon: const Icon(
Symbols.more_horiz,
size: 16,
),
),
],
).padding(horizontal: 12, vertical: 8);
}
}
class _PostContentBody extends StatelessWidget {
final dynamic data;
const _PostContentBody({this.data});
@override
Widget build(BuildContext context) {
if (data['content'] == null) return const SizedBox.shrink();
return MarkdownTextContent(content: data['content']);
}
}

View File

@ -0,0 +1,147 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:flutter_animate/flutter_animate.dart';
class UniversalImage extends StatelessWidget {
final String url;
final double? width, height;
final BoxFit? fit;
final bool noProgressIndicator;
final bool noErrorWidget;
final double? cacheWidth, cacheHeight;
const UniversalImage(
this.url, {
super.key,
this.width,
this.height,
this.fit,
this.noProgressIndicator = false,
this.noErrorWidget = false,
this.cacheWidth,
this.cacheHeight,
});
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
return CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
memCacheHeight: cacheHeight != null
? (cacheHeight! * devicePixelRatio).round()
: null,
memCacheWidth: cacheWidth != null
? (cacheWidth! * devicePixelRatio).round()
: null,
progressIndicatorBuilder: noProgressIndicator
? null
: (context, url, downloadProgress) => Center(
child: TweenAnimationBuilder(
tween: Tween(
begin: 0,
end: downloadProgress.progress ?? 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: downloadProgress.progress != null
? value.toDouble()
: null,
),
),
),
errorWidget: noErrorWidget
? null
: (context, url, error) {
return Container(
color: Theme.of(context).colorScheme.surface,
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimateWidgetExtensions(Icon(Icons.close, size: 24))
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(
error.toString(),
textAlign: TextAlign.center,
),
],
).center(),
);
},
);
}
return Image.network(
url,
width: width,
height: height,
fit: fit,
cacheHeight: cacheHeight != null
? (cacheHeight! * devicePixelRatio).round()
: null,
cacheWidth:
cacheWidth != null ? (cacheWidth! * devicePixelRatio).round() : null,
loadingBuilder: noProgressIndicator
? null
: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: TweenAnimationBuilder(
tween: Tween(
begin: 0,
end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null,
),
),
);
},
errorBuilder: noErrorWidget
? null
: (context, error, stackTrace) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimateWidgetExtensions(Icon(Icons.close, size: 24))
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(
error.toString(),
textAlign: TextAlign.center,
),
],
).center(),
),
);
},
);
}
static ImageProvider provider(String url) {
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
return CachedNetworkImageProvider(url);
}
return NetworkImage(url);
}
}