Compare commits
	
		
			10 Commits
		
	
	
		
			f479b9fc8b
			...
			3.0.0+111
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 552b4b2572 | |||
| 594ac39e3d | |||
| 23321171f3 | |||
| ee72d79c93 | |||
| a20c2598fc | |||
| 2eba871a6d | |||
| 46919dec31 | |||
| 9dd6cffe0c | |||
| 2ea9f5e907 | |||
| 050750a808 | 
@@ -375,7 +375,9 @@
 | 
			
		||||
  "postContent": "Content",
 | 
			
		||||
  "postSettings": "Settings",
 | 
			
		||||
  "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",
 | 
			
		||||
  "postVisibilityFriends": "Friends Only",
 | 
			
		||||
  "postVisibilityUnlisted": "Unlisted",
 | 
			
		||||
@@ -686,12 +688,14 @@
 | 
			
		||||
  "aboutScreenDeveloperSectionTitle": "Developer",
 | 
			
		||||
  "aboutScreenContactUsTitle": "Contact Us",
 | 
			
		||||
  "aboutScreenLicenseTitle": "License",
 | 
			
		||||
  "aboutScreenLicenseContent": "All copyright reserved © {} Solsynth\nOpen-sourced under license GNU AGPL v3.0",
 | 
			
		||||
  "aboutScreenCopyright": "© {} {}. All rights reserved.",
 | 
			
		||||
  "aboutScreenLicenseContent": "GNU Affero General Public License v3.0",
 | 
			
		||||
  "aboutScreenCopyright": "All rights reserved © Solsynth {}",
 | 
			
		||||
  "aboutScreenMadeWith": "Made with ❤︎️ by Solar Network Team",
 | 
			
		||||
  "aboutScreenFailedToLoadPackageInfo": "Failed to load package info: {error}",
 | 
			
		||||
  "copiedToClipboard": "Copied to clipboard",
 | 
			
		||||
  "copyToClipboardTooltip": "Copy to clipboard",
 | 
			
		||||
  "postForwardingTo": "Forwarding to",
 | 
			
		||||
  "postReplyingTo": "Replying to",
 | 
			
		||||
  "postEditing": "You are editing an existing post"
 | 
			
		||||
  "postEditing": "You are editing an existing post",
 | 
			
		||||
  "postArticle": "Article"
 | 
			
		||||
}
 | 
			
		||||
@@ -512,5 +512,33 @@
 | 
			
		||||
  "orderId": "订单 ID",
 | 
			
		||||
  "enterOrderId": "输入您的订单 ID",
 | 
			
		||||
  "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": "文章"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -221,7 +221,7 @@ class IslandApp extends HookConsumerWidget {
 | 
			
		||||
      Future(() {
 | 
			
		||||
        userNotifier.fetchUser().then((_) {
 | 
			
		||||
          final user = ref.watch(userInfoProvider);
 | 
			
		||||
          if (user.hasValue) {
 | 
			
		||||
          if (user.value != null) {
 | 
			
		||||
            final apiClient = ref.read(apiClientProvider);
 | 
			
		||||
            subscribePushNotification(apiClient);
 | 
			
		||||
            final wsNotifier = ref.read(websocketStateProvider.notifier);
 | 
			
		||||
 
 | 
			
		||||
@@ -18,8 +18,13 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
 | 
			
		||||
      final user = SnAccount.fromJson(response.data);
 | 
			
		||||
      state = AsyncValue.data(user);
 | 
			
		||||
    } catch (error, stackTrace) {
 | 
			
		||||
      log("[UserInfo] Failed to fetch user info: $error");
 | 
			
		||||
      state = AsyncValue.error(error, stackTrace);
 | 
			
		||||
      log(
 | 
			
		||||
        "[UserInfo] Failed to fetch user info...",
 | 
			
		||||
        name: 'UserInfoNotifier',
 | 
			
		||||
        error: error,
 | 
			
		||||
        stackTrace: stackTrace,
 | 
			
		||||
      );
 | 
			
		||||
      state = AsyncValue.data(null);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,34 @@
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:flutter/material.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:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
 | 
			
		||||
class AboutScreen extends StatefulWidget {
 | 
			
		||||
class AboutScreen extends ConsumerStatefulWidget {
 | 
			
		||||
  const AboutScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AboutScreen> createState() => _AboutScreenState();
 | 
			
		||||
  ConsumerState<AboutScreen> createState() => _AboutScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
 | 
			
		||||
  PackageInfo _packageInfo = PackageInfo(
 | 
			
		||||
    appName: 'Solian',
 | 
			
		||||
    packageName: 'dev.solsynth.solian',
 | 
			
		||||
    version: '1.0.0',
 | 
			
		||||
    buildNumber: '1',
 | 
			
		||||
  );
 | 
			
		||||
  BaseDeviceInfo? _deviceInfo;
 | 
			
		||||
  String? _deviceUdid;
 | 
			
		||||
  bool _isLoading = true;
 | 
			
		||||
  String? _errorMessage;
 | 
			
		||||
 | 
			
		||||
@@ -26,6 +36,7 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _initPackageInfo();
 | 
			
		||||
    _initDeviceInfo();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    final uri = Uri.parse(url);
 | 
			
		||||
    if (await canLaunchUrl(uri)) {
 | 
			
		||||
@@ -108,25 +138,66 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                      children: [
 | 
			
		||||
                        _buildInfoItem(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.info_outline,
 | 
			
		||||
                          icon: Symbols.info,
 | 
			
		||||
                          label: 'aboutScreenPackageNameLabel'.tr(),
 | 
			
		||||
                          value: _packageInfo.packageName,
 | 
			
		||||
                        ),
 | 
			
		||||
                        _buildInfoItem(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.update,
 | 
			
		||||
                          icon: Symbols.update,
 | 
			
		||||
                          label: 'aboutScreenVersionLabel'.tr(),
 | 
			
		||||
                          value: _packageInfo.version,
 | 
			
		||||
                        ),
 | 
			
		||||
                        _buildInfoItem(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.build,
 | 
			
		||||
                          icon: Symbols.build,
 | 
			
		||||
                          label: 'aboutScreenBuildNumberLabel'.tr(),
 | 
			
		||||
                          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),
 | 
			
		||||
 | 
			
		||||
                    // Links Card
 | 
			
		||||
@@ -136,7 +207,7 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                      children: [
 | 
			
		||||
                        _buildListTile(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.privacy_tip_outlined,
 | 
			
		||||
                          icon: Symbols.privacy_tip,
 | 
			
		||||
                          title: 'aboutScreenPrivacyPolicyTitle'.tr(),
 | 
			
		||||
                          onTap:
 | 
			
		||||
                              () => _launchURL(
 | 
			
		||||
@@ -145,7 +216,7 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                        ),
 | 
			
		||||
                        _buildListTile(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.description_outlined,
 | 
			
		||||
                          icon: Symbols.description,
 | 
			
		||||
                          title: 'aboutScreenTermsOfServiceTitle'.tr(),
 | 
			
		||||
                          onTap:
 | 
			
		||||
                              () => _launchURL(
 | 
			
		||||
@@ -154,7 +225,7 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                        ),
 | 
			
		||||
                        _buildListTile(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.code,
 | 
			
		||||
                          icon: Symbols.code,
 | 
			
		||||
                          title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                            showLicensePage(
 | 
			
		||||
@@ -177,14 +248,14 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                      children: [
 | 
			
		||||
                        _buildListTile(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.email_outlined,
 | 
			
		||||
                          icon: Symbols.email,
 | 
			
		||||
                          title: 'aboutScreenContactUsTitle'.tr(),
 | 
			
		||||
                          subtitle: 'lily@solsynth.dev',
 | 
			
		||||
                          onTap: () => _launchURL('mailto:lily@solsynth.dev'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        _buildListTile(
 | 
			
		||||
                          context,
 | 
			
		||||
                          icon: Icons.copyright,
 | 
			
		||||
                          icon: Symbols.copyright,
 | 
			
		||||
                          title: 'aboutScreenLicenseTitle'.tr(),
 | 
			
		||||
                          subtitle: 'aboutScreenLicenseContent'.tr(
 | 
			
		||||
                            args: [DateTime.now().year.toString()],
 | 
			
		||||
@@ -202,14 +273,25 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                    // Copyright
 | 
			
		||||
                    Padding(
 | 
			
		||||
                      padding: const EdgeInsets.all(16.0),
 | 
			
		||||
                      child: Text(
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Text(
 | 
			
		||||
                            'aboutScreenCopyright'.tr(
 | 
			
		||||
                          args: [DateTime.now().year.toString(), "Solsynth"],
 | 
			
		||||
                              args: [DateTime.now().year.toString()],
 | 
			
		||||
                            ),
 | 
			
		||||
                            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 String label,
 | 
			
		||||
    required String value,
 | 
			
		||||
    bool copyable = false,
 | 
			
		||||
  }) {
 | 
			
		||||
    return Padding(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
 | 
			
		||||
@@ -263,13 +346,14 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
                SelectableText(
 | 
			
		||||
                  value,
 | 
			
		||||
                  style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                  maxLines: copyable ? 1 : null,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (value.startsWith('http') || value.contains('@'))
 | 
			
		||||
          if (value.startsWith('http') || value.contains('@') || copyable)
 | 
			
		||||
            IconButton(
 | 
			
		||||
              icon: const Icon(Icons.copy, size: 16),
 | 
			
		||||
              icon: const Icon(Symbols.content_copy, size: 16),
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                Clipboard.setData(ClipboardData(text: value));
 | 
			
		||||
                ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
@@ -301,7 +385,7 @@ class _AboutScreenState extends State<AboutScreen> {
 | 
			
		||||
          subtitle: subtitle != null ? Text(subtitle) : null,
 | 
			
		||||
          isThreeLine: multipleLines,
 | 
			
		||||
          trailing: const Icon(
 | 
			
		||||
            Icons.chevron_right,
 | 
			
		||||
            Symbols.chevron_right,
 | 
			
		||||
          ).padding(top: multipleLines ? 8 : 0),
 | 
			
		||||
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
 | 
			
		||||
          onTap: onTap,
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ class AccountScreen extends HookConsumerWidget {
 | 
			
		||||
      notificationUnreadCountNotifierProvider,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!user.hasValue || user.value == null) {
 | 
			
		||||
    if (user.value == null || user.value == null) {
 | 
			
		||||
      return _UnauthorizedAccountScreen();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -367,12 +367,23 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Row(
 | 
			
		||||
                  mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    TextButton(
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                        context.push('/about');
 | 
			
		||||
                      },
 | 
			
		||||
                      child: Text('about').tr(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    TextButton(
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                        context.push('/settings');
 | 
			
		||||
                      },
 | 
			
		||||
                      child: Text('appSettings').tr(),
 | 
			
		||||
                ).center(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ).center(),
 | 
			
		||||
 
 | 
			
		||||
@@ -82,7 +82,7 @@ class EventCalanderScreen extends HookConsumerWidget {
 | 
			
		||||
                      ),
 | 
			
		||||
 | 
			
		||||
                      // Show user profile if viewing someone else's calendar
 | 
			
		||||
                      if (name != 'me' && user.hasValue)
 | 
			
		||||
                      if (name != 'me' && user.value != null)
 | 
			
		||||
                        AccountNameplate(name: name),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
@@ -106,7 +106,7 @@ class EventCalanderScreen extends HookConsumerWidget {
 | 
			
		||||
                    ).padding(horizontal: 8, vertical: 4),
 | 
			
		||||
 | 
			
		||||
                    // Show user profile if viewing someone else's calendar
 | 
			
		||||
                    if (name != 'me' && user.hasValue)
 | 
			
		||||
                    if (name != 'me' && user.value != null)
 | 
			
		||||
                      AccountNameplate(name: name),
 | 
			
		||||
                    Gap(MediaQuery.of(context).padding.bottom + 16),
 | 
			
		||||
                  ],
 | 
			
		||||
 
 | 
			
		||||
@@ -72,6 +72,8 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async {
 | 
			
		||||
 | 
			
		||||
@riverpod
 | 
			
		||||
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 apiClient = ref.watch(apiClientProvider);
 | 
			
		||||
  try {
 | 
			
		||||
@@ -87,6 +89,8 @@ Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
 | 
			
		||||
 | 
			
		||||
@riverpod
 | 
			
		||||
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 apiClient = ref.watch(apiClientProvider);
 | 
			
		||||
  try {
 | 
			
		||||
@@ -219,6 +223,8 @@ class AccountProfileScreen extends HookConsumerWidget {
 | 
			
		||||
      ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final user = ref.watch(userInfoProvider);
 | 
			
		||||
 | 
			
		||||
    return account.when(
 | 
			
		||||
      data:
 | 
			
		||||
          (data) => AppScaffold(
 | 
			
		||||
@@ -379,9 +385,13 @@ class AccountProfileScreen extends HookConsumerWidget {
 | 
			
		||||
                  ).padding(horizontal: 24),
 | 
			
		||||
                ),
 | 
			
		||||
 | 
			
		||||
                if (user.value != null)
 | 
			
		||||
                  SliverToBoxAdapter(
 | 
			
		||||
                  child: const Divider(height: 1).padding(top: 24, bottom: 12),
 | 
			
		||||
                    child: const Divider(
 | 
			
		||||
                      height: 1,
 | 
			
		||||
                    ).padding(top: 24, bottom: 12),
 | 
			
		||||
                  ),
 | 
			
		||||
                if (user.value != null)
 | 
			
		||||
                  SliverToBoxAdapter(
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      spacing: 8,
 | 
			
		||||
 
 | 
			
		||||
@@ -51,6 +51,9 @@ class _ArticleDetailContent extends HookConsumerWidget {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return SingleChildScrollView(
 | 
			
		||||
      child: Center(
 | 
			
		||||
        child: ConstrainedBox(
 | 
			
		||||
          constraints: const BoxConstraints(maxWidth: 560),
 | 
			
		||||
          child: Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
            children: [
 | 
			
		||||
@@ -100,6 +103,8 @@ class _ArticleDetailContent extends HookConsumerWidget {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -324,9 +324,7 @@ class CreatorHubScreen extends HookConsumerWidget {
 | 
			
		||||
                            subtitle: Text('createPublisherHint').tr(),
 | 
			
		||||
                            trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              context.push('/creators/publishers/new').then((
 | 
			
		||||
                                value,
 | 
			
		||||
                              ) {
 | 
			
		||||
                              context.push('/creators/new').then((value) {
 | 
			
		||||
                                if (value != null) {
 | 
			
		||||
                                  ref.invalidate(publishersManagedProvider);
 | 
			
		||||
                                }
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,10 @@ class ArticlesScreen extends ConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(title: Text(title ?? 'Articles')),
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
      body: Center(
 | 
			
		||||
        child: ConstrainedBox(
 | 
			
		||||
          constraints: const BoxConstraints(maxWidth: 560),
 | 
			
		||||
          child: CustomScrollView(
 | 
			
		||||
            slivers: [
 | 
			
		||||
              SliverPadding(
 | 
			
		||||
                padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
 | 
			
		||||
@@ -137,6 +140,8 @@ class ArticlesScreen extends ConsumerWidget {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -307,7 +307,7 @@ class _ActivityListView extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    return CustomScrollView(
 | 
			
		||||
      slivers: [
 | 
			
		||||
        if (user.hasValue && !contentOnly)
 | 
			
		||||
        if (user.value != null && !contentOnly)
 | 
			
		||||
          SliverToBoxAdapter(child: CheckInWidget()),
 | 
			
		||||
        SliverList.builder(
 | 
			
		||||
          itemCount: widgetCount,
 | 
			
		||||
 
 | 
			
		||||
@@ -238,7 +238,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
 | 
			
		||||
        children: [
 | 
			
		||||
          // Publisher row
 | 
			
		||||
          Card(
 | 
			
		||||
            margin: EdgeInsets.only(bottom: 8),
 | 
			
		||||
            margin: EdgeInsets.only(top: 8),
 | 
			
		||||
            elevation: 1,
 | 
			
		||||
            child: Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(12),
 | 
			
		||||
@@ -265,11 +265,21 @@ class ArticleComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  const Gap(16),
 | 
			
		||||
                  if (state.currentPublisher.value == null)
 | 
			
		||||
                    Text(
 | 
			
		||||
                    state.currentPublisher.value?.name ??
 | 
			
		||||
                      'postPublisherUnselected'.tr(),
 | 
			
		||||
                      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, _) {
 | 
			
		||||
              if (attachments.isEmpty) return const SizedBox.shrink();
 | 
			
		||||
              return Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  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>>(
 | 
			
		||||
                    valueListenable: state.attachmentProgress,
 | 
			
		||||
                    builder: (context, progressMap, _) {
 | 
			
		||||
@@ -322,8 +339,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                        children: [
 | 
			
		||||
                          for (var idx = 0; idx < attachments.length; idx++)
 | 
			
		||||
                            SizedBox(
 | 
			
		||||
                              width: 120,
 | 
			
		||||
                              height: 120,
 | 
			
		||||
                              width: 280,
 | 
			
		||||
                              height: 280,
 | 
			
		||||
                              child: AttachmentPreview(
 | 
			
		||||
                                item: attachments[idx],
 | 
			
		||||
                                progress: progressMap[idx],
 | 
			
		||||
@@ -348,6 +365,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                                    delta,
 | 
			
		||||
                                  );
 | 
			
		||||
                                },
 | 
			
		||||
                                onInsert:
 | 
			
		||||
                                    () => ComposeLogic.insertAttachment(
 | 
			
		||||
                                      ref,
 | 
			
		||||
                                      state,
 | 
			
		||||
                                      idx,
 | 
			
		||||
                                    ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                        ],
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
    provisional: true,
 | 
			
		||||
    alert: true,
 | 
			
		||||
@@ -97,6 +100,8 @@ Future<void> subscribePushNotification(Dio apiClient) async {
 | 
			
		||||
      deviceToken,
 | 
			
		||||
      !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
 | 
			
		||||
    );
 | 
			
		||||
  } else if (detailedErrors) {
 | 
			
		||||
    throw Exception("Failed to get device token for push notifications.");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
 | 
			
		||||
          },
 | 
			
		||||
          options: Options(method: initialStatus == null ? 'POST' : 'PATCH'),
 | 
			
		||||
        );
 | 
			
		||||
        if (user.hasValue) {
 | 
			
		||||
        if (user.value != null) {
 | 
			
		||||
          ref.invalidate(accountStatusProvider(user.value!.name));
 | 
			
		||||
        }
 | 
			
		||||
        if (!context.mounted) return;
 | 
			
		||||
 
 | 
			
		||||
@@ -350,7 +350,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
 | 
			
		||||
    return AnimatedPositioned(
 | 
			
		||||
      duration: Duration(milliseconds: 1850),
 | 
			
		||||
      top:
 | 
			
		||||
          !user.hasValue ||
 | 
			
		||||
          user.value == null ||
 | 
			
		||||
                  user.value == null ||
 | 
			
		||||
                  websocketState == WebSocketState.connected()
 | 
			
		||||
              ? -indicatorHeight
 | 
			
		||||
@@ -362,7 +362,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
 | 
			
		||||
      child: IgnorePointer(
 | 
			
		||||
        child: Material(
 | 
			
		||||
          elevation:
 | 
			
		||||
              !user.hasValue || websocketState == WebSocketState.connected()
 | 
			
		||||
              user.value == null || websocketState == WebSocketState.connected()
 | 
			
		||||
                  ? 0
 | 
			
		||||
                  : 4,
 | 
			
		||||
          child: AnimatedContainer(
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ class AttachmentPreview extends StatelessWidget {
 | 
			
		||||
  final double? progress;
 | 
			
		||||
  final Function(int)? onMove;
 | 
			
		||||
  final Function? onDelete;
 | 
			
		||||
  final Function? onInsert;
 | 
			
		||||
  final Function? onRequestUpload;
 | 
			
		||||
  const AttachmentPreview({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -23,6 +24,7 @@ class AttachmentPreview extends StatelessWidget {
 | 
			
		||||
    this.onRequestUpload,
 | 
			
		||||
    this.onMove,
 | 
			
		||||
    this.onDelete,
 | 
			
		||||
    this.onInsert,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -104,7 +106,11 @@ class AttachmentPreview extends StatelessWidget {
 | 
			
		||||
                          style: TextStyle(color: Colors.white),
 | 
			
		||||
                        ),
 | 
			
		||||
                      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);
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                        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();
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.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_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/file.dart';
 | 
			
		||||
import 'package:island/pods/config.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:markdown/markdown.dart' as markdown;
 | 
			
		||||
import 'package:markdown_widget/markdown_widget.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher.dart';
 | 
			
		||||
 | 
			
		||||
import 'image.dart';
 | 
			
		||||
@@ -23,6 +27,7 @@ class MarkdownTextContent extends HookConsumerWidget {
 | 
			
		||||
  final TextStyle? linkStyle;
 | 
			
		||||
  final EdgeInsets? linesMargin;
 | 
			
		||||
  final bool isSelectable;
 | 
			
		||||
  final List<SnCloudFile>? attachments;
 | 
			
		||||
 | 
			
		||||
  const MarkdownTextContent({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -33,6 +38,7 @@ class MarkdownTextContent extends HookConsumerWidget {
 | 
			
		||||
    this.linkStyle,
 | 
			
		||||
    this.isSelectable = false,
 | 
			
		||||
    this.linesMargin,
 | 
			
		||||
    this.attachments,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -109,6 +115,29 @@ class MarkdownTextContent extends HookConsumerWidget {
 | 
			
		||||
              final uri = Uri.parse(url);
 | 
			
		||||
              if (uri.scheme == 'solian') {
 | 
			
		||||
                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':
 | 
			
		||||
                    final size = doesEnlargeSticker ? 96.0 : 24.0;
 | 
			
		||||
                    return ClipRRect(
 | 
			
		||||
@@ -132,9 +161,9 @@ class MarkdownTextContent extends HookConsumerWidget {
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
              final content = UniversalImage(
 | 
			
		||||
                uri: uri.toString(),
 | 
			
		||||
                fit: BoxFit.cover,
 | 
			
		||||
              final content = ConstrainedBox(
 | 
			
		||||
                constraints: BoxConstraints(maxHeight: 360),
 | 
			
		||||
                child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain),
 | 
			
		||||
              );
 | 
			
		||||
              return content;
 | 
			
		||||
            },
 | 
			
		||||
 
 | 
			
		||||
@@ -474,6 +474,23 @@ class ComposeLogic {
 | 
			
		||||
    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 = '';
 | 
			
		||||
    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(
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    ComposeState state,
 | 
			
		||||
 
 | 
			
		||||
@@ -56,13 +56,432 @@ class PostItem extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    final user = ref.watch(userInfoProvider);
 | 
			
		||||
    final isAuthor = useMemoized(
 | 
			
		||||
      () => user.hasValue && user.value?.id == item.publisher.accountId,
 | 
			
		||||
      () => user.value != null && user.value?.id == item.publisher.accountId,
 | 
			
		||||
      [user],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final hasBackground =
 | 
			
		||||
        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(
 | 
			
		||||
      menuProvider: (_) {
 | 
			
		||||
        return Menu(
 | 
			
		||||
@@ -161,244 +580,7 @@ class PostItem extends HookConsumerWidget {
 | 
			
		||||
      },
 | 
			
		||||
      child: Material(
 | 
			
		||||
        color: hasBackground ? Colors.transparent : backgroundColor,
 | 
			
		||||
        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.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),
 | 
			
		||||
                        );
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        child: child,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@@ -508,6 +690,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
 | 
			
		||||
                          referencePost.type == 0
 | 
			
		||||
                              ? EdgeInsets.only(bottom: 4)
 | 
			
		||||
                              : null,
 | 
			
		||||
                      attachments: item.attachments,
 | 
			
		||||
                    ).padding(bottom: 4),
 | 
			
		||||
                  // Truncation hint for referenced post
 | 
			
		||||
                  if (referencePost.isTruncated)
 | 
			
		||||
@@ -515,7 +698,8 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
 | 
			
		||||
                      isCompact: true,
 | 
			
		||||
                      margin: const EdgeInsets.only(top: 4, bottom: 8),
 | 
			
		||||
                    ),
 | 
			
		||||
                  if (referencePost.attachments.isNotEmpty)
 | 
			
		||||
                  if (referencePost.attachments.isNotEmpty &&
 | 
			
		||||
                      referencePost.type != 1)
 | 
			
		||||
                    Row(
 | 
			
		||||
                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                      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
 | 
			
		||||
IconData _getVisibilityIcon(int visibility) {
 | 
			
		||||
  switch (visibility) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/userinfo.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_replies.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_quick_reply.dart';
 | 
			
		||||
@@ -14,6 +15,8 @@ class PostRepliesSheet extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final user = ref.watch(userInfoProvider);
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'repliesCount'.plural(post.repliesCount),
 | 
			
		||||
      child: Column(
 | 
			
		||||
@@ -21,13 +24,16 @@ class PostRepliesSheet extends HookConsumerWidget {
 | 
			
		||||
          // Replies list
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: CustomScrollView(
 | 
			
		||||
              slivers: [PostRepliesList(
 | 
			
		||||
              slivers: [
 | 
			
		||||
                PostRepliesList(
 | 
			
		||||
                  postId: post.id.toString(),
 | 
			
		||||
                  backgroundColor: Colors.transparent,
 | 
			
		||||
              )],
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          // Quick reply section
 | 
			
		||||
          if (user.value != null)
 | 
			
		||||
            Material(
 | 
			
		||||
              elevation: 2,
 | 
			
		||||
              child: PostQuickReply(
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
# 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.
 | 
			
		||||
version: 3.0.0+110
 | 
			
		||||
version: 3.0.0+111
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: ^3.7.2
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user