Link preview

This commit is contained in:
LittleSheep 2024-12-14 14:46:11 +08:00
parent b750cc3c67
commit 1f8d47f6c3
13 changed files with 752 additions and 16 deletions

View File

@ -444,5 +444,6 @@
"postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share",
"postShareImage": "Share via Image",
"appInitializing": "Initializing"
"appInitializing": "Initializing",
"poweredBy": "Powered by {}"
}

View File

@ -442,5 +442,6 @@
"postImageShareAds": "来 Solar Network 探索更多有趣帖子",
"postShare": "分享",
"postShareImage": "分享帖图",
"appInitializing": "正在初始化"
"appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持"
}

View File

@ -17,6 +17,7 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/link_preview.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/post.dart';
@ -92,6 +93,7 @@ class SolianApp extends StatelessWidget {
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
Provider(create: (ctx) => SnPostContentProvider(ctx)),
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
@ -111,7 +113,7 @@ class SolianApp extends StatelessWidget {
}
class _AppDelegate extends StatelessWidget {
const _AppDelegate({super.key});
const _AppDelegate();
@override
Widget build(BuildContext context) {
@ -134,7 +136,10 @@ class _AppDelegate extends StatelessWidget {
],
routerConfig: appRouter,
builder: (context, child) {
return _AppSplashScreen(child: child!);
return _AppSplashScreen(
key: const Key('global-splash-screen'),
child: child!,
);
},
);
}

View File

@ -0,0 +1,35 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/link.dart';
class SnLinkPreviewProvider {
late final SnNetworkProvider _sn;
final Map<String, SnLinkMeta> _cache = {};
SnLinkPreviewProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
}
Future<SnLinkMeta?> getLinkMeta(String url) async {
final b64 = utf8.fuse(base64Url);
final target = b64.encode(url);
if (_cache.containsKey(target)) return _cache[target];
log('[LinkPreview] Fetching $url ($target)');
try {
final resp = await _sn.client.get('/cgi/re/link/$target');
final meta = SnLinkMeta.fromJson(resp.data);
_cache[url] = meta;
return meta;
} catch (err) {
log('[LinkPreview] Failed to fetch $url ($target)');
return null;
}
}
}

View File

@ -26,6 +26,7 @@ class WebSocketProvider extends ChangeNotifier {
}
Future<void> tryConnect() async {
if (isConnected) return;
if (!_ua.isAuthorized) return;
log('[WebSocket] Connecting to the server...');
@ -76,6 +77,7 @@ class WebSocketProvider extends ChangeNotifier {
if (conn != null) {
conn!.sink.close();
}
conn = null;
isConnected = false;
notifyListeners();
}

26
lib/types/link.dart Normal file
View File

@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'link.g.dart';
part 'link.freezed.dart';
@freezed
class SnLinkMeta with _$SnLinkMeta {
const factory SnLinkMeta({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String entryId,
required String? icon,
required String url,
required String? title,
required String? image,
required String? video,
required String? audio,
required String description,
required String? siteName,
required String? type,
}) = _SnLinkMeta;
factory SnLinkMeta.fromJson(Map<String, dynamic> json) => _$SnLinkMetaFromJson(json);
}

448
lib/types/link.freezed.dart Normal file
View File

@ -0,0 +1,448 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'link.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnLinkMeta _$SnLinkMetaFromJson(Map<String, dynamic> json) {
return _SnLinkMeta.fromJson(json);
}
/// @nodoc
mixin _$SnLinkMeta {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get entryId => throw _privateConstructorUsedError;
String? get icon => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
String? get title => throw _privateConstructorUsedError;
String? get image => throw _privateConstructorUsedError;
String? get video => throw _privateConstructorUsedError;
String? get audio => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
String? get siteName => throw _privateConstructorUsedError;
String? get type => throw _privateConstructorUsedError;
/// Serializes this SnLinkMeta to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnLinkMetaCopyWith<SnLinkMeta> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnLinkMetaCopyWith<$Res> {
factory $SnLinkMetaCopyWith(
SnLinkMeta value, $Res Function(SnLinkMeta) then) =
_$SnLinkMetaCopyWithImpl<$Res, SnLinkMeta>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String description,
String? siteName,
String? type});
}
/// @nodoc
class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta>
implements $SnLinkMetaCopyWith<$Res> {
_$SnLinkMetaCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = null,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _value.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _value.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _value.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
siteName: freezed == siteName
? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnLinkMetaImplCopyWith<$Res>
implements $SnLinkMetaCopyWith<$Res> {
factory _$$SnLinkMetaImplCopyWith(
_$SnLinkMetaImpl value, $Res Function(_$SnLinkMetaImpl) then) =
__$$SnLinkMetaImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String description,
String? siteName,
String? type});
}
/// @nodoc
class __$$SnLinkMetaImplCopyWithImpl<$Res>
extends _$SnLinkMetaCopyWithImpl<$Res, _$SnLinkMetaImpl>
implements _$$SnLinkMetaImplCopyWith<$Res> {
__$$SnLinkMetaImplCopyWithImpl(
_$SnLinkMetaImpl _value, $Res Function(_$SnLinkMetaImpl) _then)
: super(_value, _then);
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = null,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_$SnLinkMetaImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _value.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _value.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _value.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
siteName: freezed == siteName
? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnLinkMetaImpl implements _SnLinkMeta {
const _$SnLinkMetaImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.entryId,
required this.icon,
required this.url,
required this.title,
required this.image,
required this.video,
required this.audio,
required this.description,
required this.siteName,
required this.type});
factory _$SnLinkMetaImpl.fromJson(Map<String, dynamic> json) =>
_$$SnLinkMetaImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String entryId;
@override
final String? icon;
@override
final String url;
@override
final String? title;
@override
final String? image;
@override
final String? video;
@override
final String? audio;
@override
final String description;
@override
final String? siteName;
@override
final String? type;
@override
String toString() {
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnLinkMetaImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.entryId, entryId) || other.entryId == entryId) &&
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.image, image) || other.image == image) &&
(identical(other.video, video) || other.video == video) &&
(identical(other.audio, audio) || other.audio == audio) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.siteName, siteName) ||
other.siteName == siteName) &&
(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
entryId,
icon,
url,
title,
image,
video,
audio,
description,
siteName,
type);
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
__$$SnLinkMetaImplCopyWithImpl<_$SnLinkMetaImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnLinkMetaImplToJson(
this,
);
}
}
abstract class _SnLinkMeta implements SnLinkMeta {
const factory _SnLinkMeta(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String entryId,
required final String? icon,
required final String url,
required final String? title,
required final String? image,
required final String? video,
required final String? audio,
required final String description,
required final String? siteName,
required final String? type}) = _$SnLinkMetaImpl;
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =
_$SnLinkMetaImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get entryId;
@override
String? get icon;
@override
String get url;
@override
String? get title;
@override
String? get image;
@override
String? get video;
@override
String? get audio;
@override
String get description;
@override
String? get siteName;
@override
String? get type;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
throw _privateConstructorUsedError;
}

45
lib/types/link.g.dart Normal file
View File

@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'link.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnLinkMetaImpl _$$SnLinkMetaImplFromJson(Map<String, dynamic> json) =>
_$SnLinkMetaImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
entryId: json['entry_id'] as String,
icon: json['icon'] as String?,
url: json['url'] as String,
title: json['title'] as String?,
image: json['image'] as String?,
video: json['video'] as String?,
audio: json['audio'] as String?,
description: json['description'] as String,
siteName: json['site_name'] as String?,
type: json['type'] as String?,
);
Map<String, dynamic> _$$SnLinkMetaImplToJson(_$SnLinkMetaImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'entry_id': instance.entryId,
'icon': instance.icon,
'url': instance.url,
'title': instance.title,
'image': instance.image,
'video': instance.video,
'audio': instance.audio,
'description': instance.description,
'site_name': instance.siteName,
'type': instance.type,
};

View File

@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:swipe_to/swipe_to.dart';
@ -22,6 +23,7 @@ class ChatMessage extends StatelessWidget {
final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit;
final Function(SnChatMessage)? onDelete;
const ChatMessage({
super.key,
required this.data,
@ -63,7 +65,7 @@ class ChatMessage extends StatelessWidget {
onReply!(data);
},
),
if (isOwner && onEdit != null)
if (isOwner && data.type == 'messages.new' && onEdit != null)
MenuItem(
label: 'edit'.tr(),
icon: Symbols.edit,
@ -71,7 +73,7 @@ class ChatMessage extends StatelessWidget {
onEdit!(data);
},
),
if (isOwner && onDelete != null)
if (isOwner && data.type == 'messages.new' && onDelete != null)
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
@ -109,9 +111,7 @@ class ChatMessage extends StatelessWidget {
radius: 12,
).padding(right: 6),
Text(
(data.sender.nick?.isNotEmpty ?? false)
? data.sender.nick!
: user?.nick ?? 'unknown',
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
).bold(),
const Gap(6),
Text(
@ -123,8 +123,7 @@ class ChatMessage extends StatelessWidget {
if (data.preload?.quoteEvent != null)
StyledWidget(Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
@ -153,6 +152,8 @@ class ChatMessage extends StatelessWidget {
)
],
).opacity(isPending ? 0.5 : 1),
if (data.body['text'] != null && (data.body['text']?.isNotEmpty ?? false))
LinkPreviewWidget(text: data.body['text']!),
if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList(
data: data.preload!.attachments!,
@ -161,10 +162,7 @@ class ChatMessage extends StatelessWidget {
maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8),
),
if (!hasMerged && !isCompact)
const Gap(12)
else if (!isCompact)
const Gap(6),
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
],
),
),
@ -174,6 +172,7 @@ class ChatMessage extends StatelessWidget {
class _ChatMessageText extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageText({super.key, required this.data});
@override
@ -184,6 +183,7 @@ class _ChatMessageText extends StatelessWidget {
children: [
MarkdownTextContent(
content: data.body['text'],
isSelectable: true,
isAutoWarp: true,
),
if (data.updatedAt != data.createdAt)
@ -212,6 +212,7 @@ class _ChatMessageText extends StatelessWidget {
class _ChatMessageSystemNotify extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageSystemNotify({super.key, required this.data});
String _formatDuration(Duration duration) {

View File

@ -0,0 +1,149 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:marquee/marquee.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/types/link.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../providers/link_preview.dart';
class LinkPreviewWidget extends StatefulWidget {
final String text;
const LinkPreviewWidget({super.key, required this.text});
@override
State<LinkPreviewWidget> createState() => _LinkPreviewWidgetState();
}
class _LinkPreviewWidgetState extends State<LinkPreviewWidget> {
final List<SnLinkMeta> _links = List.empty(growable: true);
Future<void> _getLinkMeta() async {
final linkRegex = RegExp(r'https?:\/\/[^\s/$.?#].[^\s]*');
final links = linkRegex.allMatches(widget.text).map((e) => e.group(0)).toSet();
final lp = context.read<SnLinkPreviewProvider>();
final List<Future<SnLinkMeta?>> futures = links.where((e) => e != null).map((e) => lp.getLinkMeta(e!)).toList();
final results = await Future.wait(futures);
_links.addAll(results.where((e) => e != null).map((e) => e!).toList());
if (_links.isNotEmpty && mounted) setState(() {});
}
@override
void initState() {
super.initState();
_getLinkMeta();
}
@override
Widget build(BuildContext context) {
if (_links.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 8,
children: _links
.map(
(e) => Container(
constraints: BoxConstraints(
maxWidth: ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) ? double.infinity : 480,
),
child: GestureDetector(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (e.image != null)
Container(
margin: const EdgeInsets.only(bottom: 4),
color: Theme.of(context).colorScheme.surfaceContainer,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
child: AutoResizeUniversalImage(
e.image!,
fit: BoxFit.contain,
),
),
),
),
SizedBox(
height: 48,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (e.icon != null)
UniversalImage(
e.icon!,
width: 36,
height: 36,
cacheHeight: 36,
cacheWidth: 36,
).padding(all: 4),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 24,
child: Marquee(
text: e.title ?? 'unknown'.tr(),
style: TextStyle(fontSize: 17, height: 1),
scrollAxis: Axis.horizontal,
showFadingOnlyWhenScrolling: true,
pauseAfterRound: const Duration(seconds: 3),
),
),
if (e.siteName != null)
Text(
e.siteName!,
style: TextStyle(fontSize: 13, height: 0.9),
).fontSize(11),
],
),
),
const Gap(6),
],
).padding(horizontal: 16),
),
Text(
e.description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 16),
const Gap(8),
Text(
e.url,
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).opacity(0.75).padding(horizontal: 16),
const Gap(4),
Text(
'poweredBy'.tr(args: ['HyperNet.Reader']),
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
).opacity(0.75).padding(horizontal: 16),
const Gap(16),
],
),
),
onTap: () {
launchUrlString(e.url, mode: LaunchMode.externalApplication);
},
),
),
)
.toList(),
);
}
}

View File

@ -132,6 +132,7 @@ class PostItem extends StatelessWidget {
_PostContentHeader(
data: data,
isAuthor: isAuthor,
isRelativeDate: !showFullPost,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onDeleted: () {
@ -204,6 +205,7 @@ class PostItem extends StatelessWidget {
children: [
_PostContentHeader(
isAuthor: isAuthor,
isRelativeDate: !showFullPost,
data: data,
showMenu: showMenu,
onShare: () => _doShare(context),
@ -219,6 +221,7 @@ class PostItem extends StatelessWidget {
).padding(horizontal: 16, bottom: 8),
_PostContentBody(
data: data,
isSelectable: showFullPost,
isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 6),
if (data.repostTo != null)
@ -850,16 +853,19 @@ class _PostContentHeader extends StatelessWidget {
class _PostContentBody extends StatelessWidget {
final SnPost data;
final bool isEnlarge;
final bool isSelectable;
const _PostContentBody({
required this.data,
this.isEnlarge = false,
this.isSelectable = false,
});
@override
Widget build(BuildContext context) {
if (data.body['content'] == null) return const SizedBox.shrink();
return MarkdownTextContent(
isSelectable: isSelectable,
textScaler: isEnlarge ? TextScaler.linear(1.1) : null,
content: data.body['content'],
attachments: data.preload?.attachments,

View File

@ -454,6 +454,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.7"
fading_edge_scrollview:
dependency: transitive
description:
name: fading_edge_scrollview
sha256: "1f84fe3ea8e251d00d5735e27502a6a250e4aa3d3b330d3fdcb475af741464ef"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
fake_async:
dependency: transitive
description:
@ -1050,6 +1058,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.2"
marquee:
dependency: "direct main"
description:
name: marquee
sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862
url: "https://pub.dev"
source: hosted
version: "2.3.0"
matcher:
dependency: transitive
description:

View File

@ -102,6 +102,7 @@ dependencies:
qr_flutter: ^4.1.0
file_saver: ^0.2.14
device_info_plus: ^11.2.0
marquee: ^2.3.0
dev_dependencies:
flutter_test: