Compare commits

...

10 Commits

Author SHA1 Message Date
552b4b2572 🚀 Launch 3.0.0+111 2025-07-03 01:20:54 +08:00
594ac39e3d Article attachments 2025-07-03 01:18:07 +08:00
23321171f3 💄 Optimized attachment insert in article compose 2025-07-03 01:11:56 +08:00
ee72d79c93 Hide attachments list on article 2025-07-03 01:06:01 +08:00
a20c2598fc 🐛 Fixes render errors of unauthorized 2025-07-03 00:49:11 +08:00
2eba871a6d 💄 Web article has max width 2025-07-03 00:39:45 +08:00
46919dec31 📝 Updated about screen 2025-07-03 00:34:11 +08:00
9dd6cffe0c 🐛 Trying to fix push notification 2025-07-03 00:27:30 +08:00
2ea9f5e907 💄 Optimized rendering for article post 2025-07-03 00:16:10 +08:00
050750a808 🐛 Fixes articles bugs 2025-07-02 23:57:07 +08:00
22 changed files with 969 additions and 414 deletions

View File

@ -375,7 +375,9 @@
"postContent": "Content", "postContent": "Content",
"postSettings": "Settings", "postSettings": "Settings",
"postPublisherUnselected": "Publisher Unspecified", "postPublisherUnselected": "Publisher Unspecified",
"postVisibility": "Visibility", "postType": "Post Type",
"articleAttachmentHint": "Attachments must be uploaded and inserted into the article body to be visible.",
"postVisibility": "Post Visibility",
"postVisibilityPublic": "Public", "postVisibilityPublic": "Public",
"postVisibilityFriends": "Friends Only", "postVisibilityFriends": "Friends Only",
"postVisibilityUnlisted": "Unlisted", "postVisibilityUnlisted": "Unlisted",
@ -686,12 +688,14 @@
"aboutScreenDeveloperSectionTitle": "Developer", "aboutScreenDeveloperSectionTitle": "Developer",
"aboutScreenContactUsTitle": "Contact Us", "aboutScreenContactUsTitle": "Contact Us",
"aboutScreenLicenseTitle": "License", "aboutScreenLicenseTitle": "License",
"aboutScreenLicenseContent": "All copyright reserved © {} Solsynth\nOpen-sourced under license GNU AGPL v3.0", "aboutScreenLicenseContent": "GNU Affero General Public License v3.0",
"aboutScreenCopyright": "© {} {}. All rights reserved.", "aboutScreenCopyright": "All rights reserved © Solsynth {}",
"aboutScreenMadeWith": "Made with ❤︎️ by Solar Network Team",
"aboutScreenFailedToLoadPackageInfo": "Failed to load package info: {error}", "aboutScreenFailedToLoadPackageInfo": "Failed to load package info: {error}",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"copyToClipboardTooltip": "Copy to clipboard", "copyToClipboardTooltip": "Copy to clipboard",
"postForwardingTo": "Forwarding to", "postForwardingTo": "Forwarding to",
"postReplyingTo": "Replying to", "postReplyingTo": "Replying to",
"postEditing": "You are editing an existing post" "postEditing": "You are editing an existing post",
"postArticle": "Article"
} }

View File

@ -512,5 +512,33 @@
"orderId": "订单 ID", "orderId": "订单 ID",
"enterOrderId": "输入您的订单 ID", "enterOrderId": "输入您的订单 ID",
"restore": "恢复", "restore": "恢复",
"keyboardShortcuts": "键盘快捷键" "keyboardShortcuts": "键盘快捷键",
"about": "关于",
"membershipCancel": "取消会员订阅",
"membershipCancelConfirm": "您确定要取消您的会员订阅?",
"membershipCancelHint": "您确定要取消您的会员订阅吗?您将不会再被收费。您的会员资格将在当前计费周期结束前保持有效。并且您在当前订阅结束之前无法重新订阅。",
"membershipCancelSuccess": "您的会员订阅已成功取消。",
"aboutScreenTitle": "关于",
"aboutScreenVersionInfo": "版本 {} ({})",
"aboutScreenAppInfoSectionTitle": "应用信息",
"aboutScreenPackageNameLabel": "包名",
"aboutScreenVersionLabel": "版本",
"aboutScreenBuildNumberLabel": "构建编号",
"aboutScreenLinksSectionTitle": "链接",
"aboutScreenPrivacyPolicyTitle": "隐私政策",
"aboutScreenTermsOfServiceTitle": "服务条款",
"aboutScreenOpenSourceLicensesTitle": "开源许可证",
"aboutScreenDeveloperSectionTitle": "开发者",
"aboutScreenContactUsTitle": "联系我们",
"aboutScreenLicenseTitle": "许可证",
"aboutScreenLicenseContent": "GNU Affero General Public License v3.0",
"aboutScreenCopyright": "版权所有 © 索尔辛茨 {}",
"aboutScreenMadeWith": "由 Solar Network Team 用 ❤︎️ 制作",
"aboutScreenFailedToLoadPackageInfo": "加载包信息失败:{error}",
"copiedToClipboard": "已复制到剪贴板",
"copyToClipboardTooltip": "复制到剪贴板",
"postForwardingTo": "转发给",
"postReplyingTo": "回复给",
"postEditing": "您正在编辑现有帖子",
"postArticle": "文章"
} }

View File

@ -221,7 +221,7 @@ class IslandApp extends HookConsumerWidget {
Future(() { Future(() {
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
if (user.hasValue) { if (user.value != null) {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient); subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier); final wsNotifier = ref.read(websocketStateProvider.notifier);

View File

@ -18,8 +18,13 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final user = SnAccount.fromJson(response.data); final user = SnAccount.fromJson(response.data);
state = AsyncValue.data(user); state = AsyncValue.data(user);
} catch (error, stackTrace) { } catch (error, stackTrace) {
log("[UserInfo] Failed to fetch user info: $error"); log(
state = AsyncValue.error(error, stackTrace); "[UserInfo] Failed to fetch user info...",
name: 'UserInfoNotifier',
error: error,
stackTrace: stackTrace,
);
state = AsyncValue.data(null);
} }
} }

View File

@ -1,24 +1,34 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.native.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
class AboutScreen extends StatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key}); const AboutScreen({super.key});
@override @override
State<AboutScreen> createState() => _AboutScreenState(); ConsumerState<AboutScreen> createState() => _AboutScreenState();
} }
class _AboutScreenState extends State<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
PackageInfo _packageInfo = PackageInfo( PackageInfo _packageInfo = PackageInfo(
appName: 'Solian', appName: 'Solian',
packageName: 'dev.solsynth.solian', packageName: 'dev.solsynth.solian',
version: '1.0.0', version: '1.0.0',
buildNumber: '1', buildNumber: '1',
); );
BaseDeviceInfo? _deviceInfo;
String? _deviceUdid;
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
@ -26,6 +36,7 @@ class _AboutScreenState extends State<AboutScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_initPackageInfo(); _initPackageInfo();
_initDeviceInfo();
} }
Future<void> _initPackageInfo() async { Future<void> _initPackageInfo() async {
@ -49,6 +60,25 @@ class _AboutScreenState extends State<AboutScreen> {
} }
} }
Future<void> _initDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
_deviceInfo = await deviceInfoPlugin.deviceInfo;
_deviceUdid = await getUdid();
if (mounted) {
setState(() {});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'aboutScreenFailedToLoadDeviceInfo'.tr(
args: [e.toString()],
);
});
}
}
}
Future<void> _launchURL(String url) async { Future<void> _launchURL(String url) async {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@ -108,25 +138,66 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.info_outline, icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(), label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName, value: _packageInfo.packageName,
), ),
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.update, icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(), label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version, value: _packageInfo.version,
), ),
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.build, icon: Symbols.build,
label: 'aboutScreenBuildNumberLabel'.tr(), label: 'aboutScreenBuildNumberLabel'.tr(),
value: _packageInfo.buildNumber, value: _packageInfo.buildNumber,
), ),
], ],
), ),
if (_deviceInfo != null) const SizedBox(height: 16),
if (_deviceInfo != null)
_buildSection(
context,
title: 'Device Information',
children: [
_buildInfoItem(
context,
icon: Symbols.label,
label: 'Device Name',
value: _deviceInfo?.data['name'],
),
_buildInfoItem(
context,
icon: Symbols.fingerprint,
label: 'Device Identifier',
value: _deviceUdid ?? 'N/A',
copyable: true,
),
const Divider(height: 1),
_buildListTile(
context,
icon: Symbols.notifications_active,
title: 'Reactivate Push Notifications',
onTap: () async {
showLoadingModal(context);
try {
await subscribePushNotification(
ref.watch(apiClientProvider),
);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Links Card // Links Card
@ -136,7 +207,7 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildListTile( _buildListTile(
context, context,
icon: Icons.privacy_tip_outlined, icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(), title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap: onTap:
() => _launchURL( () => _launchURL(
@ -145,7 +216,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.description_outlined, icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(), title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap: onTap:
() => _launchURL( () => _launchURL(
@ -154,7 +225,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.code, icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(), title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () { onTap: () {
showLicensePage( showLicensePage(
@ -177,14 +248,14 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildListTile( _buildListTile(
context, context,
icon: Icons.email_outlined, icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(), title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev', subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'), onTap: () => _launchURL('mailto:lily@solsynth.dev'),
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.copyright, icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(), title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr( subtitle: 'aboutScreenLicenseContent'.tr(
args: [DateTime.now().year.toString()], args: [DateTime.now().year.toString()],
@ -202,14 +273,25 @@ class _AboutScreenState extends State<AboutScreen> {
// Copyright // Copyright
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Column(
'aboutScreenCopyright'.tr( children: [
args: [DateTime.now().year.toString(), "Solsynth"], Text(
), 'aboutScreenCopyright'.tr(
style: theme.textTheme.bodySmall, args: [DateTime.now().year.toString()],
textAlign: TextAlign.center, ),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const Gap(1),
Text(
'aboutScreenMadeWith'.tr(),
textAlign: TextAlign.center,
).fontSize(10).opacity(0.8),
],
), ),
), ),
Gap(MediaQuery.of(context).padding.bottom + 16),
], ],
), ),
), ),
@ -247,6 +329,7 @@ class _AboutScreenState extends State<AboutScreen> {
required IconData icon, required IconData icon,
required String label, required String label,
required String value, required String value,
bool copyable = false,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@ -263,13 +346,14 @@ class _AboutScreenState extends State<AboutScreen> {
SelectableText( SelectableText(
value, value,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
maxLines: copyable ? 1 : null,
), ),
], ],
), ),
), ),
if (value.startsWith('http') || value.contains('@')) if (value.startsWith('http') || value.contains('@') || copyable)
IconButton( IconButton(
icon: const Icon(Icons.copy, size: 16), icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -301,7 +385,7 @@ class _AboutScreenState extends State<AboutScreen> {
subtitle: subtitle != null ? Text(subtitle) : null, subtitle: subtitle != null ? Text(subtitle) : null,
isThreeLine: multipleLines, isThreeLine: multipleLines,
trailing: const Icon( trailing: const Icon(
Icons.chevron_right, Symbols.chevron_right,
).padding(top: multipleLines ? 8 : 0), ).padding(top: multipleLines ? 8 : 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onTap: onTap, onTap: onTap,

View File

@ -59,7 +59,7 @@ class AccountScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
if (!user.hasValue || user.value == null) { if (user.value == null || user.value == null) {
return _UnauthorizedAccountScreen(); return _UnauthorizedAccountScreen();
} }
@ -367,12 +367,23 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
), ),
), ),
const Gap(8), const Gap(8),
TextButton( Row(
onPressed: () { mainAxisAlignment: MainAxisAlignment.center,
context.push('/settings'); children: [
}, TextButton(
child: Text('appSettings').tr(), onPressed: () {
).center(), context.push('/about');
},
child: Text('about').tr(),
),
TextButton(
onPressed: () {
context.push('/settings');
},
child: Text('appSettings').tr(),
),
],
),
], ],
), ),
).center(), ).center(),

View File

@ -82,7 +82,7 @@ class EventCalanderScreen extends HookConsumerWidget {
), ),
// Show user profile if viewing someone else's calendar // Show user profile if viewing someone else's calendar
if (name != 'me' && user.hasValue) if (name != 'me' && user.value != null)
AccountNameplate(name: name), AccountNameplate(name: name),
], ],
), ),
@ -106,7 +106,7 @@ class EventCalanderScreen extends HookConsumerWidget {
).padding(horizontal: 8, vertical: 4), ).padding(horizontal: 8, vertical: 4),
// Show user profile if viewing someone else's calendar // Show user profile if viewing someone else's calendar
if (name != 'me' && user.hasValue) if (name != 'me' && user.value != null)
AccountNameplate(name: name), AccountNameplate(name: name),
Gap(MediaQuery.of(context).padding.bottom + 16), Gap(MediaQuery.of(context).padding.bottom + 16),
], ],

View File

@ -72,6 +72,8 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async {
@riverpod @riverpod
Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async { Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value == null) return null;
final account = await ref.watch(accountProvider(uname).future); final account = await ref.watch(accountProvider(uname).future);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
try { try {
@ -87,6 +89,8 @@ Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
@riverpod @riverpod
Future<SnRelationship?> accountRelationship(Ref ref, String uname) async { Future<SnRelationship?> accountRelationship(Ref ref, String uname) async {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value == null) return null;
final account = await ref.watch(accountProvider(uname).future); final account = await ref.watch(accountProvider(uname).future);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
try { try {
@ -219,6 +223,8 @@ class AccountProfileScreen extends HookConsumerWidget {
]; ];
} }
final user = ref.watch(userInfoProvider);
return account.when( return account.when(
data: data:
(data) => AppScaffold( (data) => AppScaffold(
@ -379,56 +385,60 @@ class AccountProfileScreen extends HookConsumerWidget {
).padding(horizontal: 24), ).padding(horizontal: 24),
), ),
SliverToBoxAdapter( if (user.value != null)
child: const Divider(height: 1).padding(top: 24, bottom: 12), SliverToBoxAdapter(
), child: const Divider(
SliverToBoxAdapter( height: 1,
child: Row( ).padding(top: 24, bottom: 12),
spacing: 8, ),
children: [ if (user.value != null)
Expanded( SliverToBoxAdapter(
child: FilledButton.icon( child: Row(
style: ButtonStyle( spacing: 8,
backgroundColor: WidgetStatePropertyAll( children: [
accountRelationship.value == null Expanded(
? null child: FilledButton.icon(
: Theme.of(context).colorScheme.secondary, style: ButtonStyle(
), backgroundColor: WidgetStatePropertyAll(
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: relationshipAction,
label:
Text(
accountRelationship.value == null accountRelationship.value == null
? 'addFriendShort' ? null
: 'added', : Theme.of(context).colorScheme.secondary,
).tr(), ),
icon: foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null accountRelationship.value == null
? const Icon(Symbols.person_add) ? null
: const Icon(Symbols.person_check), : Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: relationshipAction,
label:
Text(
accountRelationship.value == null
? 'addFriendShort'
: 'added',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.person_add)
: const Icon(Symbols.person_check),
),
), ),
), Expanded(
Expanded( child: FilledButton.icon(
child: FilledButton.icon( onPressed: directMessageAction,
onPressed: directMessageAction, icon: const Icon(Symbols.message),
icon: const Icon(Symbols.message), label:
label: Text(
Text( accountChat.value == null
accountChat.value == null ? 'createDirectMessage'
? 'createDirectMessage' : 'gotoDirectMessage',
: 'gotoDirectMessage', maxLines: 1,
maxLines: 1, ).tr(),
).tr(), ),
), ),
), ],
], ).padding(horizontal: 16),
).padding(horizontal: 16), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: const Divider(height: 1).padding(top: 12), child: const Divider(height: 1).padding(top: 12),
), ),

View File

@ -51,54 +51,59 @@ class _ArticleDetailContent extends HookConsumerWidget {
); );
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.stretch, child: ConstrainedBox(
children: [ constraints: const BoxConstraints(maxWidth: 560),
if (article.preview?.imageUrl != null) child: Column(
Image.network( crossAxisAlignment: CrossAxisAlignment.stretch,
article.preview!.imageUrl!, children: [
width: double.infinity, if (article.preview?.imageUrl != null)
height: 200, Image.network(
fit: BoxFit.cover, article.preview!.imageUrl!,
), width: double.infinity,
Padding( height: 200,
padding: const EdgeInsets.all(16.0), fit: BoxFit.cover,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
style: Theme.of(context).textTheme.headlineSmall,
), ),
const SizedBox(height: 8), Padding(
if (article.feed?.title != null) padding: const EdgeInsets.all(16.0),
Text( child: Column(
article.feed!.title, crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( children: [
color: Theme.of(context).colorScheme.onSurfaceVariant, Text(
article.title,
style: Theme.of(context).textTheme.headlineSmall,
), ),
), const SizedBox(height: 8),
const Divider(height: 32), if (article.feed?.title != null)
if (article.content != null) Text(
...MarkdownTextContent.buildGenerator( article.feed!.title,
isDark: Theme.of(context).brightness == Brightness.dark, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
).buildWidgets(markdownContent) color: Theme.of(context).colorScheme.onSurfaceVariant,
else if (article.preview?.description != null) ),
Text(article.preview!.description!),
const Gap(24),
FilledButton(
onPressed:
() => launchUrlString(
article.url,
mode: LaunchMode.externalApplication,
), ),
child: const Text('Read Full Article'), const Divider(height: 32),
if (article.content != null)
...MarkdownTextContent.buildGenerator(
isDark: Theme.of(context).brightness == Brightness.dark,
).buildWidgets(markdownContent)
else if (article.preview?.description != null)
Text(article.preview!.description!),
const Gap(24),
FilledButton(
onPressed:
() => launchUrlString(
article.url,
mode: LaunchMode.externalApplication,
),
child: const Text('Read Full Article'),
),
Gap(MediaQuery.of(context).padding.bottom),
],
), ),
Gap(MediaQuery.of(context).padding.bottom), ),
], ],
),
), ),
], ),
), ),
); );
} }

View File

@ -324,9 +324,7 @@ class CreatorHubScreen extends HookConsumerWidget {
subtitle: Text('createPublisherHint').tr(), subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.push('/creators/publishers/new').then(( context.push('/creators/new').then((value) {
value,
) {
if (value != null) { if (value != null) {
ref.invalidate(publishersManagedProvider); ref.invalidate(publishersManagedProvider);
} }

View File

@ -126,16 +126,21 @@ class ArticlesScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(title ?? 'Articles')), appBar: AppBar(title: Text(title ?? 'Articles')),
body: CustomScrollView( body: Center(
slivers: [ child: ConstrainedBox(
SliverPadding( constraints: const BoxConstraints(maxWidth: 560),
padding: const EdgeInsets.only(top: 8, left: 8, right: 8), child: CustomScrollView(
sliver: SliverArticlesList( slivers: [
feedId: feedId, SliverPadding(
publisherId: publisherId, padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
), sliver: SliverArticlesList(
feedId: feedId,
publisherId: publisherId,
),
),
],
), ),
], ),
), ),
); );
} }

View File

@ -307,7 +307,7 @@ class _ActivityListView extends HookConsumerWidget {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
if (user.hasValue && !contentOnly) if (user.value != null && !contentOnly)
SliverToBoxAdapter(child: CheckInWidget()), SliverToBoxAdapter(child: CheckInWidget()),
SliverList.builder( SliverList.builder(
itemCount: widgetCount, itemCount: widgetCount,

View File

@ -238,7 +238,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [ children: [
// Publisher row // Publisher row
Card( Card(
margin: EdgeInsets.only(bottom: 8), margin: EdgeInsets.only(top: 8),
elevation: 1, elevation: 1,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -265,12 +265,22 @@ class ArticleComposeScreen extends HookConsumerWidget {
}); });
}, },
), ),
const Gap(12), const Gap(16),
Text( if (state.currentPublisher.value == null)
state.currentPublisher.value?.name ?? Text(
'postPublisherUnselected'.tr(), 'postPublisherUnselected'.tr(),
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), )
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.currentPublisher.value!.nick).bold(),
Text(
'@${state.currentPublisher.value!.name}',
).fontSize(12),
],
),
], ],
), ),
), ),
@ -311,8 +321,15 @@ class ArticleComposeScreen extends HookConsumerWidget {
builder: (context, attachments, _) { builder: (context, attachments, _) {
if (attachments.isEmpty) return const SizedBox.shrink(); if (attachments.isEmpty) return const SizedBox.shrink();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Gap(16), const Gap(16),
Text(
'articleAttachmentHint'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
ValueListenableBuilder<Map<int, double>>( ValueListenableBuilder<Map<int, double>>(
valueListenable: state.attachmentProgress, valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) { builder: (context, progressMap, _) {
@ -322,8 +339,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [ children: [
for (var idx = 0; idx < attachments.length; idx++) for (var idx = 0; idx < attachments.length; idx++)
SizedBox( SizedBox(
width: 120, width: 280,
height: 120, height: 280,
child: AttachmentPreview( child: AttachmentPreview(
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
@ -348,6 +365,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
delta, delta,
); );
}, },
onInsert:
() => ComposeLogic.insertAttachment(
ref,
state,
idx,
),
), ),
), ),
], ],

View File

@ -63,7 +63,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
}); });
} }
Future<void> subscribePushNotification(Dio apiClient) async { Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
await FirebaseMessaging.instance.requestPermission( await FirebaseMessaging.instance.requestPermission(
provisional: true, provisional: true,
alert: true, alert: true,
@ -97,6 +100,8 @@ Future<void> subscribePushNotification(Dio apiClient) async {
deviceToken, deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
); );
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
} }
} }

View File

@ -59,7 +59,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
}, },
options: Options(method: initialStatus == null ? 'POST' : 'PATCH'), options: Options(method: initialStatus == null ? 'POST' : 'PATCH'),
); );
if (user.hasValue) { if (user.value != null) {
ref.invalidate(accountStatusProvider(user.value!.name)); ref.invalidate(accountStatusProvider(user.value!.name));
} }
if (!context.mounted) return; if (!context.mounted) return;

View File

@ -350,7 +350,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
return AnimatedPositioned( return AnimatedPositioned(
duration: Duration(milliseconds: 1850), duration: Duration(milliseconds: 1850),
top: top:
!user.hasValue || user.value == null ||
user.value == null || user.value == null ||
websocketState == WebSocketState.connected() websocketState == WebSocketState.connected()
? -indicatorHeight ? -indicatorHeight
@ -362,7 +362,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
child: IgnorePointer( child: IgnorePointer(
child: Material( child: Material(
elevation: elevation:
!user.hasValue || websocketState == WebSocketState.connected() user.value == null || websocketState == WebSocketState.connected()
? 0 ? 0
: 4, : 4,
child: AnimatedContainer( child: AnimatedContainer(

View File

@ -15,6 +15,7 @@ class AttachmentPreview extends StatelessWidget {
final double? progress; final double? progress;
final Function(int)? onMove; final Function(int)? onMove;
final Function? onDelete; final Function? onDelete;
final Function? onInsert;
final Function? onRequestUpload; final Function? onRequestUpload;
const AttachmentPreview({ const AttachmentPreview({
super.key, super.key,
@ -23,6 +24,7 @@ class AttachmentPreview extends StatelessWidget {
this.onRequestUpload, this.onRequestUpload,
this.onMove, this.onMove,
this.onDelete, this.onDelete,
this.onInsert,
}); });
@override @override
@ -104,7 +106,11 @@ class AttachmentPreview extends StatelessWidget {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
), ),
Gap(6), Gap(6),
Center(child: LinearProgressIndicator(value: progress)), Center(
child: LinearProgressIndicator(
value: progress != null ? progress! / 100.0 : null,
),
),
], ],
), ),
), ),
@ -166,6 +172,18 @@ class AttachmentPreview extends StatelessWidget {
onMove?.call(1); onMove?.call(1);
}, },
), ),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
], ],
), ),
), ),

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -6,11 +7,14 @@ import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart'; import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.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/content/cloud_files.dart';
import 'package:island/widgets/content/markdown_latex.dart'; import 'package:island/widgets/content/markdown_latex.dart';
import 'package:markdown/markdown.dart' as markdown; import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart'; import 'package:markdown_widget/markdown_widget.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'image.dart'; import 'image.dart';
@ -23,6 +27,7 @@ class MarkdownTextContent extends HookConsumerWidget {
final TextStyle? linkStyle; final TextStyle? linkStyle;
final EdgeInsets? linesMargin; final EdgeInsets? linesMargin;
final bool isSelectable; final bool isSelectable;
final List<SnCloudFile>? attachments;
const MarkdownTextContent({ const MarkdownTextContent({
super.key, super.key,
@ -33,6 +38,7 @@ class MarkdownTextContent extends HookConsumerWidget {
this.linkStyle, this.linkStyle,
this.isSelectable = false, this.isSelectable = false,
this.linesMargin, this.linesMargin,
this.attachments,
}); });
@override @override
@ -109,6 +115,29 @@ class MarkdownTextContent extends HookConsumerWidget {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (uri.scheme == 'solian') { if (uri.scheme == 'solian') {
switch (uri.host) { switch (uri.host) {
case 'files':
final file = attachments?.firstWhereOrNull(
(file) => file.id == uri.pathSegments[0],
);
if (file == null) {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudFileWidget(
item: file,
fit: BoxFit.cover,
).clipRRect(all: 8),
),
);
case 'stickers': case 'stickers':
final size = doesEnlargeSticker ? 96.0 : 24.0; final size = doesEnlargeSticker ? 96.0 : 24.0;
return ClipRRect( return ClipRRect(
@ -132,9 +161,9 @@ class MarkdownTextContent extends HookConsumerWidget {
); );
} }
} }
final content = UniversalImage( final content = ConstrainedBox(
uri: uri.toString(), constraints: BoxConstraints(maxHeight: 360),
fit: BoxFit.cover, child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain),
); );
return content; return content;
}, },

View File

@ -474,6 +474,23 @@ class ComposeLogic {
state.attachments.value = clone; state.attachments.value = clone;
} }
static void insertAttachment(WidgetRef ref, ComposeState state, int index) {
final attachment = state.attachments.value[index];
if (!attachment.isOnCloud) {
return;
}
final cloudFile = attachment.data as SnCloudFile;
final markdown = '![${cloudFile.name}](solian://files/${cloudFile.id})';
final controller = state.contentController;
final text = controller.text;
final selection = controller.selection;
final newText = text.replaceRange(selection.start, selection.end, markdown);
controller.text = newText;
controller.selection = TextSelection.fromPosition(
TextPosition(offset: selection.start + markdown.length),
);
}
static Future<void> performAction( static Future<void> performAction(
WidgetRef ref, WidgetRef ref,
ComposeState state, ComposeState state,

View File

@ -56,13 +56,432 @@ class PostItem extends HookConsumerWidget {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
final isAuthor = useMemoized( final isAuthor = useMemoized(
() => user.hasValue && user.value?.id == item.publisher.accountId, () => user.value != null && user.value?.id == item.publisher.accountId,
[user], [user],
); );
final hasBackground = final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null; ref.watch(backgroundImageFileProvider).valueOrNull != null;
Widget child;
if (item.type == 1 && isFullPost) {
child = Padding(
padding: renderingPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ProfilePictureWidget(file: item.publisher.picture),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
],
),
),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(context) ?? '',
).fontSize(11),
],
),
),
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.secondary,
),
),
],
).padding(top: 10, bottom: 2),
const Gap(16),
_ArticlePostDisplay(item: item, isFullPost: isFullPost),
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.tags.isNotEmpty)
Wrap(
children: [
for (final tag in item.tags)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.label, size: 13),
Text(tag.name ?? '#${tag.slug}').fontSize(13),
],
),
onTap: () {},
),
],
),
if (item.categories.isNotEmpty)
Wrap(
children: [
for (final category in item.categories)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.category, size: 13),
Text(
category.name ?? '#${category.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
],
),
if ((item.repliedPost != null || item.forwardedPost != null) &&
showReferencePost)
_buildReferencePost(context, item),
if (item.attachments.isNotEmpty && item.type != 1)
CloudFileList(
files: item.attachments,
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
minWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
),
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map(
(embedData) => EmbedLinkWidget(
link: SnEmbedLink.fromJson(
embedData as Map<String, dynamic>,
),
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
margin: EdgeInsets.only(top: 8),
),
)),
const Gap(8),
Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: ActionChip(
avatar: Icon(Symbols.reply, size: 16),
label: Text(
(item.repliesCount > 0)
? 'repliesCount'.plural(item.repliesCount)
: 'reply'.tr(),
),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed: () {
if (isOpenable) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostRepliesSheet(post: item),
);
}
},
),
),
Expanded(
child: PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.zero,
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(
item.copyWith(reactionsCount: reactionsCount),
);
},
),
),
],
),
],
),
);
} else {
child = Padding(
padding: renderingPadding,
child: Column(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
GestureDetector(
child: ProfilePictureWidget(file: item.publisher.picture),
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
),
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
Spacer(),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(context) ??
'',
).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4),
],
),
// Add visibility indicator if not public (visibility != 0)
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.secondary,
),
),
],
).padding(top: 2, bottom: 2),
if (item.type == 1)
_ArticlePostDisplay(
item: item,
isFullPost: isFullPost,
)
else ...[
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (item.description?.isNotEmpty ?? false)
Text(
item.description!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
linesMargin:
item.type == 0
? EdgeInsets.only(bottom: 8)
: null,
attachments: item.attachments,
),
],
// Render tags and categories if they exist
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.tags.isNotEmpty)
Wrap(
children: [
for (final tag in item.tags)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.label, size: 13),
Text(
tag.name ?? '#${tag.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
if (item.categories.isNotEmpty)
Wrap(
children: [
for (final category in item.categories)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(
Symbols.category,
size: 13,
),
Text(
category.name ??
'#${category.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
],
),
// Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost && item.type != 1)
_PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null,
),
if ((item.repliedPost != null ||
item.forwardedPost != null) &&
showReferencePost)
_buildReferencePost(context, item),
if (item.attachments.isNotEmpty && item.type != 1)
CloudFileList(
files: item.attachments,
maxWidth: math.min(
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
minWidth: math.min(
MediaQuery.of(context).size.width * 0.9,
kWideScreenWidth - 160,
),
),
// Render embed links
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map(
(embedData) => EmbedLinkWidget(
link: SnEmbedLink.fromJson(
embedData as Map<String, dynamic>,
),
maxWidth: math.min(
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
margin: EdgeInsets.only(top: 8),
),
)),
],
),
onTap: () {
if (isOpenable) {
context.push('/posts/${item.id}');
}
},
),
),
],
),
Row(
children: [
// Replies count button
Padding(
padding: const EdgeInsets.only(left: 52, right: 12),
child: ActionChip(
avatar: Icon(Symbols.reply, size: 16),
label: Text(
(item.repliesCount > 0)
? 'repliesCount'.plural(item.repliesCount)
: 'reply'.tr(),
),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed: () {
if (isOpenable) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostRepliesSheet(post: item),
);
}
},
),
),
// Reactions list
Expanded(
child: PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.zero,
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(
item.copyWith(reactionsCount: reactionsCount),
);
},
),
),
],
),
],
),
);
}
return ContextMenuWidget( return ContextMenuWidget(
menuProvider: (_) { menuProvider: (_) {
return Menu( return Menu(
@ -161,244 +580,7 @@ class PostItem extends HookConsumerWidget {
}, },
child: Material( child: Material(
color: hasBackground ? Colors.transparent : backgroundColor, color: hasBackground ? Colors.transparent : backgroundColor,
child: Padding( child: child,
padding: renderingPadding,
child: Column(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
GestureDetector(
child: ProfilePictureWidget(file: item.publisher.picture),
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
),
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
Spacer(),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(
context,
) ??
'',
).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4),
],
),
// Add visibility indicator if not public (visibility != 0)
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.secondary,
),
),
],
).padding(top: 2, bottom: 2),
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (item.description?.isNotEmpty ?? false)
Text(
item.description!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
linesMargin:
item.type == 0
? EdgeInsets.only(bottom: 8)
: null,
),
// Render tags and categories if they exist
if (item.tags.isNotEmpty ||
item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.tags.isNotEmpty)
Wrap(
children: [
for (final tag in item.tags)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(
Symbols.label,
size: 13,
),
Text(
tag.name ?? '#${tag.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
if (item.categories.isNotEmpty)
Wrap(
children: [
for (final category in item.categories)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(
Symbols.category,
size: 13,
),
Text(
category.name ??
'#${category.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
],
),
// Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost)
_PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null,
),
if ((item.repliedPost != null ||
item.forwardedPost != null) &&
showReferencePost)
_buildReferencePost(context, item),
if (item.attachments.isNotEmpty)
CloudFileList(
files: item.attachments,
maxWidth: math.min(
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
minWidth: math.min(
MediaQuery.of(context).size.width * 0.9,
kWideScreenWidth - 160,
),
),
// Render embed links
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map(
(embedData) => EmbedLinkWidget(
link: SnEmbedLink.fromJson(
embedData as Map<String, dynamic>,
),
maxWidth: math.min(
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
margin: EdgeInsets.only(top: 8),
),
)),
],
),
onTap: () {
if (isOpenable) {
context.push('/posts/${item.id}');
}
},
),
),
],
),
Row(
children: [
// Replies count button
Padding(
padding: const EdgeInsets.only(left: 52, right: 12),
child: ActionChip(
avatar: Icon(Symbols.reply, size: 16),
label: Text(
(item.repliesCount > 0)
? 'repliesCount'.plural(item.repliesCount)
: 'reply'.tr(),
),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed: () {
if (isOpenable) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostRepliesSheet(post: item),
);
}
},
),
),
// Reactions list
Expanded(
child: PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.zero,
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(
item.copyWith(reactionsCount: reactionsCount),
);
},
),
),
],
),
],
),
),
), ),
); );
} }
@ -508,6 +690,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
referencePost.type == 0 referencePost.type == 0
? EdgeInsets.only(bottom: 4) ? EdgeInsets.only(bottom: 4)
: null, : null,
attachments: item.attachments,
).padding(bottom: 4), ).padding(bottom: 4),
// Truncation hint for referenced post // Truncation hint for referenced post
if (referencePost.isTruncated) if (referencePost.isTruncated)
@ -515,7 +698,8 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
isCompact: true, isCompact: true,
margin: const EdgeInsets.only(top: 4, bottom: 8), margin: const EdgeInsets.only(top: 4, bottom: 8),
), ),
if (referencePost.attachments.isNotEmpty) if (referencePost.attachments.isNotEmpty &&
referencePost.type != 1)
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -812,6 +996,129 @@ class _PostTruncateHint extends StatelessWidget {
} }
} }
class _ArticlePostDisplay extends StatelessWidget {
final SnPost item;
final bool isFullPost;
const _ArticlePostDisplay({required this.item, required this.isFullPost});
@override
Widget build(BuildContext context) {
if (isFullPost) {
// Full article view
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.title?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
item.title!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (item.description?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
item.description!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
textStyle: Theme.of(context).textTheme.bodyLarge,
attachments: item.attachments,
),
],
);
} else {
// Truncated/Card view
String? previewContent;
if (item.description?.isNotEmpty ?? false) {
previewContent = item.description!;
} else if (item.content?.isNotEmpty ?? false) {
previewContent = item.content!;
}
return Card(
elevation: 0,
margin: const EdgeInsets.only(top: 4),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (previewContent != null) ...[
const Gap(8),
Text(
previewContent,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.article,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 6),
Text(
'postArticle'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 12,
),
),
const SizedBox(width: 4),
],
),
),
],
),
),
);
}
}
}
// Helper method to get the appropriate icon for each visibility status // Helper method to get the appropriate icon for each visibility status
IconData _getVisibilityIcon(int visibility) { IconData _getVisibilityIcon(int visibility) {
switch (visibility) { switch (visibility) {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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/userinfo.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_replies.dart'; import 'package:island/widgets/post/post_replies.dart';
import 'package:island/widgets/post/post_quick_reply.dart'; import 'package:island/widgets/post/post_quick_reply.dart';
@ -14,6 +15,8 @@ class PostRepliesSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
return SheetScaffold( return SheetScaffold(
titleText: 'repliesCount'.plural(post.repliesCount), titleText: 'repliesCount'.plural(post.repliesCount),
child: Column( child: Column(
@ -21,26 +24,29 @@ class PostRepliesSheet extends HookConsumerWidget {
// Replies list // Replies list
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [PostRepliesList( slivers: [
postId: post.id.toString(), PostRepliesList(
backgroundColor: Colors.transparent, postId: post.id.toString(),
)], backgroundColor: Colors.transparent,
),
],
), ),
), ),
// Quick reply section // Quick reply section
Material( if (user.value != null)
elevation: 2, Material(
child: PostQuickReply( elevation: 2,
parent: post, child: PostQuickReply(
onPosted: () { parent: post,
ref.invalidate(postRepliesNotifierProvider(post.id)); onPosted: () {
}, ref.invalidate(postRepliesNotifierProvider(post.id));
).padding( },
bottom: MediaQuery.of(context).padding.bottom + 16, ).padding(
top: 16, bottom: MediaQuery.of(context).padding.bottom + 16,
horizontal: 16, top: 16,
horizontal: 16,
),
), ),
),
], ],
), ),
); );

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 3.0.0+110 version: 3.0.0+111
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2