Compare commits
16 Commits
27bc17079e
...
3.0.0+111
Author | SHA1 | Date | |
---|---|---|---|
552b4b2572 | |||
594ac39e3d | |||
23321171f3 | |||
ee72d79c93 | |||
a20c2598fc | |||
2eba871a6d | |||
46919dec31 | |||
9dd6cffe0c | |||
2ea9f5e907 | |||
050750a808 | |||
f479b9fc8b | |||
13ea182707 | |||
14183a7316 | |||
9fc9b87608 | |||
53c2445ba9 | |||
d414695eb3 |
@ -46,8 +46,30 @@
|
||||
"delete": "Delete",
|
||||
"deletePublisher": "Delete Publisher",
|
||||
"deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.",
|
||||
"somethingWentWrong": "Something went wrong...",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"deletePost": "Delete Post",
|
||||
"safetyReport": "Report",
|
||||
"safetyReportTitle": "Safety Report",
|
||||
"safetyReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
|
||||
"safetyReportType": "Report Type",
|
||||
"safetyReportReason": "Additional Details",
|
||||
"safetyReportReasonHint": "Please provide more details about the issue...",
|
||||
"safetyReportSubmit": "Submit Report",
|
||||
"safetyReportSubmitting": "Submitting...",
|
||||
"safetyReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
|
||||
"safetyReportError": "Failed to submit report. Please try again.",
|
||||
"safetyReportReasonRequired": "Please provide details about the issue",
|
||||
"safetyReportTypeSpam": "Spam or Misleading",
|
||||
"safetyReportTypeHarassment": "Harassment or Abuse",
|
||||
"safetyReportTypeHateSpeech": "Hate Speech",
|
||||
"safetyReportTypeViolence": "Violence or Threats",
|
||||
"safetyReportTypeAdultContent": "Adult Content",
|
||||
"safetyReportTypeIntellectualProperty": "Intellectual Property Violation",
|
||||
"safetyReportTypeOther": "Other",
|
||||
"safetyReportTypeInappropriate": "Inappropriate Content",
|
||||
"safetyReportTypeCopyright": "Copyright Violation",
|
||||
"safetyReportSuccessTitle": "Report Submitted",
|
||||
"safetyReportErrorTitle": "Error",
|
||||
"deletePostHint": "Are you sure to delete this post?",
|
||||
"copyLink": "Copy Link",
|
||||
"postCreateAccountTitle": "Thanks for joining!",
|
||||
@ -76,6 +98,8 @@
|
||||
"explore": "Explore",
|
||||
"exploreFilterSubscriptions": "Subscriptions",
|
||||
"exploreFilterFriends": "Friends",
|
||||
"discover": "Discover",
|
||||
"joinRealm": "Join Realm",
|
||||
"account": "Account",
|
||||
"name": "Name",
|
||||
"slug": "Slug",
|
||||
@ -285,6 +309,8 @@
|
||||
"removeChatMemberHint": "Are you sure to remove this member from the room?",
|
||||
"removeRealmMember": "Remove Realm Member",
|
||||
"removeRealmMemberHint": "Are you sure to remove this member from the realm?",
|
||||
"removePublisherMember": "Remove Publisher Member",
|
||||
"removePublisherMemberHint": "Are you sure to remove this member from the publisher?",
|
||||
"memberRole": "Member Role",
|
||||
"memberRoleHint": "Greater number has higher permission.",
|
||||
"memberRoleEdit": "Edit role for @{}",
|
||||
@ -349,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",
|
||||
@ -383,15 +411,15 @@
|
||||
"lastActiveAt": "Last active at {}",
|
||||
"authDeviceLogout": "Logout",
|
||||
"authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.",
|
||||
"typingHint": {
|
||||
"one": "{} is typing...",
|
||||
"other": "{} are typing..."
|
||||
},
|
||||
"authDeviceEditLabel": "Edit Label",
|
||||
"authDeviceLabelTitle": "Edit Device Label",
|
||||
"authDeviceLabelHint": "Enter a name for this device",
|
||||
"authDeviceSwipeEditHint": "Swipe left to edit label",
|
||||
"authDeviceSwipeLogoutHint": "Swipe right to logout device",
|
||||
"typingHint": {
|
||||
"one": "{} is typing...",
|
||||
"other": "{} are typing..."
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsServer": "Server",
|
||||
"settingsBehavior": "Behavior",
|
||||
@ -405,6 +433,27 @@
|
||||
"settingsKeyboardShortcutNewMessage": "New Message",
|
||||
"settingsKeyboardShortcutCloseDialog": "Close Dialog",
|
||||
"close": "Close",
|
||||
"drafts": "Drafts",
|
||||
"noDrafts": "No drafts yet",
|
||||
"articleDrafts": "Article drafts",
|
||||
"postDrafts": "Post drafts",
|
||||
"saveDraft": "Save draft",
|
||||
"draftSaved": "Draft saved",
|
||||
"draftSaveFailed": "Failed to save draft",
|
||||
"clearAllDrafts": "Clear All Drafts",
|
||||
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
|
||||
"clearAll": "Clear All",
|
||||
"untitled": "Untitled",
|
||||
"noContent": "No content",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"daysAgo": "{} days ago",
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"friends": "Friends",
|
||||
"selected": "Selected",
|
||||
"private": "Private",
|
||||
"contactMethod": "Contact Method",
|
||||
"contactMethodType": "Contact Type",
|
||||
"contactMethodTypeEmail": "Email",
|
||||
@ -422,6 +471,7 @@
|
||||
"contactMethodDelete": "Delete Contact",
|
||||
"contactMethodNew": "New Contact Method",
|
||||
"contactMethodContentEmpty": "Contact content cannot be empty",
|
||||
"postContentEmpty": "Post content cannot be empty",
|
||||
"contactMethodVerificationSent": "Verification code sent to your contact method",
|
||||
"contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.",
|
||||
"accountContactMethod": "Contact Methods",
|
||||
@ -490,29 +540,19 @@
|
||||
"paymentError": "Payment failed: {error}",
|
||||
"usePinInstead": "Use PIN Code",
|
||||
"levelProgress": "Level Progress",
|
||||
"unlockedFeatures": "Unlocked Features",
|
||||
"unlockedFeaturesDescription": "Features unlocked at your current level will be displayed here.",
|
||||
"stellarMembership": "Stellar Membership",
|
||||
"upgradeYourPlan": "Upgrade Your Plan",
|
||||
"chooseYourPlan": "Choose Your Plan",
|
||||
"currentMembership": "Current: {}",
|
||||
"currentMembershipMember": "A member of Stellar Program · {}",
|
||||
"membershipExpires": "Expires: {}",
|
||||
"membershipTierStellar": "Stellar",
|
||||
"membershipTierNova": "Nova",
|
||||
"membershipTierSupernova": "Supernova",
|
||||
"membershipTierUnknown": "Unknown",
|
||||
"membershipPriceStellar": "10 NS$ per month",
|
||||
"membershipPriceNova": "20 NS$ per month",
|
||||
"membershipPriceSupernova": "30 NS$ per month",
|
||||
"membershipFeatureBasic": "Basic features",
|
||||
"membershipFeaturePrioritySupport": "Priority support",
|
||||
"membershipFeatureAdFree": "Ad-free experience",
|
||||
"membershipFeatureAllPrimary": "All Primary features",
|
||||
"membershipFeatureAdvancedCustomization": "Advanced customization",
|
||||
"membershipFeatureEarlyAccess": "Early access",
|
||||
"membershipFeatureAllNova": "All Nova features",
|
||||
"membershipFeatureExclusiveContent": "Exclusive content",
|
||||
"membershipFeatureVipSupport": "VIP support",
|
||||
"membershipPriceStellar": "1200 NSP per month, level 3+ required",
|
||||
"membershipPriceNova": "2400 NSP per month, level 6+ required",
|
||||
"membershipPriceSupernova": "3600 NSP per month, level 9+ required",
|
||||
"membershipCurrentBadge": "CURRENT",
|
||||
"restorePurchase": "Restore Purchase",
|
||||
"restorePurchaseDescription": "Enter your payment provider and order ID to restore your purchase.",
|
||||
@ -521,5 +561,141 @@
|
||||
"orderId": "Order ID",
|
||||
"enterOrderId": "Enter your order ID",
|
||||
"restore": "Restore",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts"
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"share": "Share",
|
||||
"sharePost": "Share Post",
|
||||
"quickActions": "Quick Actions",
|
||||
"post": "Post",
|
||||
"copy": "Copy",
|
||||
"sendToChat": "Send to Chat",
|
||||
"failedToShareToPost": "Failed to share to post: {}",
|
||||
"shareToChatComingSoon": "Share to chat functionality coming soon",
|
||||
"failedToShareToChat": "Failed to share to chat: {}",
|
||||
"shareToSpecificChatComingSoon": "Share to {} coming soon",
|
||||
"directChat": "Direct Chat",
|
||||
"systemShareComingSoon": "System share functionality coming soon",
|
||||
"failedToShareToSystem": "Failed to share to system: {}",
|
||||
"failedToCopy": "Failed to copy: {}",
|
||||
"noChatRoomsAvailable": "No chat rooms available",
|
||||
"failedToLoadChats": "Failed to load chats",
|
||||
"contentToShare": "Content to share:",
|
||||
"unknownChat": "Unknown Chat",
|
||||
"addAdditionalMessage": "Add additional message...",
|
||||
"uploadingFiles": "Uploading files...",
|
||||
"sharedSuccessfully": "Shared successfully!",
|
||||
"shareSuccess": "Shared successfully!",
|
||||
"shareToSpecificChatSuccess": "Shared to {} successfully!",
|
||||
"wouldYouLikeToGoToChat": "Would you like to go to the chat?",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
"navigateToChat": "Navigate to Chat",
|
||||
"wouldYouLikeToNavigateToChat": "Would You like to navigate to the chat?",
|
||||
"abuseReport": "Report",
|
||||
"abuseReportTitle": "Report Content",
|
||||
"abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
|
||||
"abuseReportType": "Report Type",
|
||||
"abuseReportReason": "Additional Details",
|
||||
"abuseReportReasonHint": "Please provide more details about the issue...",
|
||||
"abuseReportSubmit": "Submit Report",
|
||||
"abuseReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
|
||||
"abuseReportError": "Failed to submit report. Please try again.",
|
||||
"abuseReportReasonRequired": "Please provide details about the issue",
|
||||
"abuseReportSuccessTitle": "Report Submitted",
|
||||
"abuseReportErrorTitle": "Error",
|
||||
"abuseReportTypeSpam": "Spam or Misleading",
|
||||
"abuseReportTypeHarassment": "Harassment or Abuse",
|
||||
"abuseReportTypeInappropriate": "Inappropriate Content",
|
||||
"abuseReportTypeViolence": "Violence or Threats",
|
||||
"abuseReportTypeCopyright": "Copyright Violation",
|
||||
"abuseReportTypeImpersonation": "Impersonation",
|
||||
"abuseReportTypeOffensiveContent": "Offensive Content",
|
||||
"abuseReportTypePrivacyViolation": "Privacy Violation",
|
||||
"abuseReportTypeIllegalContent": "Illegal Content",
|
||||
"abuseReportTypeOther": "Other",
|
||||
"tags": "Tags",
|
||||
"tagsHint": "Enter tags, separated by commas",
|
||||
"categories": "Categories",
|
||||
"categoriesHint": "Enter categories, separated by commas",
|
||||
"chatNotJoined": "You have not joined this chat yet.",
|
||||
"chatUnableJoin": "You can't join this chat due to it's access control settings.",
|
||||
"chatJoin": "Join the Chat",
|
||||
"realmJoin": "Join the Realm",
|
||||
"realmJoinSuccess": "Successfully joined the realm.",
|
||||
"discoverRealms": "Discover Realms",
|
||||
"discoverPublishers": "Discover Publishers",
|
||||
"search": "Search",
|
||||
"publisherMembers": "Collaborators",
|
||||
"developerHub": "Developer Hub",
|
||||
"developerHubUnselectedHint": "Select a developer to see stats or enroll a new one.",
|
||||
"enrollDeveloper": "Enroll as a Developer",
|
||||
"enrollDeveloperHint": "Enroll one of your publishers to become a developer.",
|
||||
"noPublishersToEnroll": "You don't have any publishers that can be enrolled as a developer.",
|
||||
"totalCustomApps": "Total Custom Apps",
|
||||
"customApps": "Custom Apps",
|
||||
"noCustomApps": "No custom apps yet.",
|
||||
"createCustomApp": "Create Custom App",
|
||||
"editCustomApp": "Edit Custom App",
|
||||
"deleteCustomApp": "Delete Custom App",
|
||||
"deleteCustomAppHint": "Are you sure you want to delete this custom app? This action cannot be undone.",
|
||||
"publicRealm": "Public Realm",
|
||||
"publicRealmDescription": "Anyone can preview the content of this realm.",
|
||||
"communityRealm": "Community Realm",
|
||||
"communityRealmDescription": "Anyone can join this realm and participate in discussions. And will show in the discover page & feed.",
|
||||
"publicChat": "Public Chat",
|
||||
"publicChatDescription": "Anyone can preview the content of this chat. Including unjoined bots.",
|
||||
"communityChat": "Community Chat",
|
||||
"communityChatDescription": "Anyone can join this chat and participate in discussions.",
|
||||
"appLinks": "App Links",
|
||||
"homePageUrl": "Home Page URL",
|
||||
"privacyPolicyUrl": "Privacy Policy URL",
|
||||
"termsOfServiceUrl": "Terms of Service URL",
|
||||
"oauthConfig": "OAuth Configuration",
|
||||
"clientUri": "Client URI",
|
||||
"redirectUris": "Redirect URIs",
|
||||
"addRedirectUri": "Add Redirect URI",
|
||||
"allowedScopes": "Allowed Scopes",
|
||||
"requirePkce": "Require PKCE",
|
||||
"allowOfflineAccess": "Allow Offline Access",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriHint": "The redirect URI is used for OAuth authentication. When the app goes to production, we will validate the redirect URI is match your configuration to reject invalid requests.",
|
||||
"uriRequired": "The URI is required.",
|
||||
"uriInvalid": "The URI is invalid.",
|
||||
"add": "Add",
|
||||
"addScope": "Add Scope",
|
||||
"scope": "Scope",
|
||||
"publisherFeatures": "Features",
|
||||
"publisherFeatureDevelop": "Developer Program",
|
||||
"publisherFeatureDevelopDescription": "Unlock development abilities for your publisher, including custom apps, API keys, and more.",
|
||||
"publisherFeatureDevelopHint": "Currently, this feature is under active development, you need send a request to unlock this feature.",
|
||||
"learnMore": "Learn More",
|
||||
"discoverWebArticles": "Articles from external sites",
|
||||
"webArticlesStand": "Article Stand",
|
||||
"about": "About",
|
||||
"membershipCancel": "Cancel Membership",
|
||||
"membershipCancelConfirm": "Are you sure to cancel your membership?",
|
||||
"membershipCancelHint": "Are you sure to cancel your membership? You will not be charged again. Your membership will remain active until the end of the current billing period. And you will not able to resubscribe until the end of the current subscription ends.",
|
||||
"membershipCancelSuccess": "Your membership has been successfully canceled.",
|
||||
"aboutScreenTitle": "About",
|
||||
"aboutScreenVersionInfo": "Version {} ({})",
|
||||
"aboutScreenAppInfoSectionTitle": "App Information",
|
||||
"aboutScreenPackageNameLabel": "Package Name",
|
||||
"aboutScreenVersionLabel": "Version",
|
||||
"aboutScreenBuildNumberLabel": "Build Number",
|
||||
"aboutScreenLinksSectionTitle": "Links",
|
||||
"aboutScreenPrivacyPolicyTitle": "Privacy Policy",
|
||||
"aboutScreenTermsOfServiceTitle": "Terms of Service",
|
||||
"aboutScreenOpenSourceLicensesTitle": "Open Source Licenses",
|
||||
"aboutScreenDeveloperSectionTitle": "Developer",
|
||||
"aboutScreenContactUsTitle": "Contact Us",
|
||||
"aboutScreenLicenseTitle": "License",
|
||||
"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",
|
||||
"postArticle": "Article"
|
||||
}
|
@ -46,7 +46,7 @@
|
||||
"delete": "删除",
|
||||
"deletePublisher": "删除发布者",
|
||||
"deletePublisherHint": "确定要删除此发布者吗?这也会删除此发布者下的所有帖子和收藏。",
|
||||
"somethingWentWrong": "发生了一些错误……",
|
||||
"somethingWentWrong": "发生了一些错误",
|
||||
"deletePost": "删除帖子",
|
||||
"deletePostHint": "确定要删除这篇帖子吗?",
|
||||
"copyLink": "复制链接",
|
||||
@ -345,7 +345,7 @@
|
||||
"accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。",
|
||||
"unauthorized": "未授权",
|
||||
"unauthorizedHint": "您未登录或会话已过期,请重新登录。",
|
||||
"publisherBelongsTo": "属于",
|
||||
"publisherBelongsTo": "属于 {}",
|
||||
"postContent": "内容",
|
||||
"postSettings": "设置",
|
||||
"postPublisherUnselected": "未指定发布者",
|
||||
@ -473,7 +473,7 @@
|
||||
"description": "描述",
|
||||
"pinCode": "PIN 码",
|
||||
"biometric": "生物识别",
|
||||
"enterPinToConfirm": "请输入您的6位数字 PIN 以确认付款",
|
||||
"enterPinToConfirm": "请输入您的 6 位数字 PIN 以确认付款",
|
||||
"clearPin": "清除 PIN 码",
|
||||
"useBiometricToConfirm": "使用生物特征认证来确认付款",
|
||||
"touchSensorToAuthenticate": "触摸传感器进行身份验证",
|
||||
@ -490,29 +490,20 @@
|
||||
"paymentError": "付款失败: {error}",
|
||||
"usePinInstead": "使用 PIN 码",
|
||||
"levelProgress": "等级进度",
|
||||
"unlockedFeatures": "已解锁的功能",
|
||||
"unlockedFeaturesDescription": "在您当前级别上解锁的功能将显示在这里。",
|
||||
"stellarMembership": "恒星计划",
|
||||
"upgradeYourPlan": "升级您的计划",
|
||||
"chooseYourPlan": "选择你的方案",
|
||||
"currentMembership": "当前:{}",
|
||||
"currentMembershipMember": "恒星计划「{}」级会员",
|
||||
"membershipExpires": "过期于:{}",
|
||||
"membershipTierStellar": "恒星",
|
||||
"membershipTierNova": "新星",
|
||||
"membershipTierSupernova": "超新星",
|
||||
"membershipTierUnknown": "未知",
|
||||
"membershipPriceStellar": "每月 10 金点",
|
||||
"membershipPriceNova": "每月 20 金点",
|
||||
"membershipPriceSupernova": "每月 30 金点",
|
||||
"membershipPriceStellar": "每月 1200 源点,至少需要 3 级",
|
||||
"membershipPriceNova": "每月 2400 源点,至少需要 6 级",
|
||||
"membershipPriceSupernova": "每月 3600 源点,至少需要 9 级",
|
||||
"membershipFeatureBasic": "基础功能",
|
||||
"membershipFeaturePrioritySupport": "优先支持",
|
||||
"membershipFeatureAdFree": "无广告",
|
||||
"membershipFeatureAllPrimary": "所有主要功能",
|
||||
"membershipFeatureAdvancedCustomization": "高级自定义",
|
||||
"membershipFeatureEarlyAccess": "抢先体验",
|
||||
"membershipFeatureAllNova": "所有「新星」功能",
|
||||
"membershipFeatureExclusiveContent": "限定内容",
|
||||
"membershipFeatureVipSupport": "VIP 支持",
|
||||
"membershipCurrentBadge": "当前",
|
||||
"restorePurchase": "恢复购买",
|
||||
"restorePurchaseDescription": "输入您付款的提供商和订单 ID 以恢复您的购买。",
|
||||
@ -521,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": "文章"
|
||||
}
|
@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker_android/image_picker_android.dart';
|
||||
import 'package:island/firebase_options.dart';
|
||||
@ -45,6 +46,10 @@ void main() async {
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await EasyLocalization.ensureInitialized();
|
||||
await Firebase.initializeApp(
|
||||
@ -216,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,6 +65,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder:
|
||||
(context, state) => PostComposeScreen(
|
||||
initialState: state.extra as PostComposeInitialState?,
|
||||
type:
|
||||
int.tryParse(state.uri.queryParameters['type'] ?? '0') ??
|
||||
0,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
@ -1,22 +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: 'Island',
|
||||
packageName: 'com.example.island',
|
||||
appName: 'Solian',
|
||||
packageName: 'dev.solsynth.solian',
|
||||
version: '1.0.0',
|
||||
buildNumber: '1',
|
||||
);
|
||||
BaseDeviceInfo? _deviceInfo;
|
||||
String? _deviceUdid;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@ -24,6 +36,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initPackageInfo();
|
||||
_initDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _initPackageInfo() async {
|
||||
@ -38,13 +51,34 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load package info: $e';
|
||||
_errorMessage = 'aboutScreenFailedToLoadPackageInfo'.tr(
|
||||
args: [e.toString()],
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
@ -57,7 +91,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('About'), elevation: 0),
|
||||
appBar: AppBar(title: Text('about'.tr()), elevation: 0),
|
||||
body:
|
||||
_isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
@ -88,7 +122,9 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Version ${_packageInfo.version} (${_packageInfo.buildNumber})',
|
||||
'aboutScreenVersionInfo'.tr(
|
||||
args: [_packageInfo.version, _packageInfo.buildNumber],
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
@ -98,40 +134,81 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
// App Info Card
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'App Information',
|
||||
title: 'aboutScreenAppInfoSectionTitle'.tr(),
|
||||
children: [
|
||||
_buildInfoItem(
|
||||
context,
|
||||
icon: Icons.info_outline,
|
||||
label: 'Package Name',
|
||||
icon: Symbols.info,
|
||||
label: 'aboutScreenPackageNameLabel'.tr(),
|
||||
value: _packageInfo.packageName,
|
||||
),
|
||||
_buildInfoItem(
|
||||
context,
|
||||
icon: Icons.update,
|
||||
label: 'Version',
|
||||
icon: Symbols.update,
|
||||
label: 'aboutScreenVersionLabel'.tr(),
|
||||
value: _packageInfo.version,
|
||||
),
|
||||
_buildInfoItem(
|
||||
context,
|
||||
icon: Icons.build,
|
||||
label: 'Build Number',
|
||||
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
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'Links',
|
||||
title: 'aboutScreenLinksSectionTitle'.tr(),
|
||||
children: [
|
||||
_buildListTile(
|
||||
context,
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
title: 'Privacy Policy',
|
||||
icon: Symbols.privacy_tip,
|
||||
title: 'aboutScreenPrivacyPolicyTitle'.tr(),
|
||||
onTap:
|
||||
() => _launchURL(
|
||||
'https://solsynth.dev/terms/privacy-policy',
|
||||
@ -139,17 +216,17 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
_buildListTile(
|
||||
context,
|
||||
icon: Icons.description_outlined,
|
||||
title: 'Terms of Service',
|
||||
icon: Symbols.description,
|
||||
title: 'aboutScreenTermsOfServiceTitle'.tr(),
|
||||
onTap:
|
||||
() => _launchURL(
|
||||
'https://example.com/terms/basic-law',
|
||||
'https://solsynth.dev/terms/basic-law',
|
||||
),
|
||||
),
|
||||
_buildListTile(
|
||||
context,
|
||||
icon: Icons.code,
|
||||
title: 'Open Source Licenses',
|
||||
icon: Symbols.code,
|
||||
title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
|
||||
onTap: () {
|
||||
showLicensePage(
|
||||
context: context,
|
||||
@ -167,21 +244,22 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
// Developer Info
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'Developer',
|
||||
title: 'aboutScreenDeveloperSectionTitle'.tr(),
|
||||
children: [
|
||||
_buildListTile(
|
||||
context,
|
||||
icon: Icons.email_outlined,
|
||||
title: 'Contact Us',
|
||||
icon: Symbols.email,
|
||||
title: 'aboutScreenContactUsTitle'.tr(),
|
||||
subtitle: 'lily@solsynth.dev',
|
||||
onTap: () => _launchURL('mailto:lily@solsynth.dev'),
|
||||
),
|
||||
_buildListTile(
|
||||
context,
|
||||
icon: Icons.copyright,
|
||||
title: 'License',
|
||||
subtitle:
|
||||
'Copyright reserved © ${DateTime.now().year} Solsynth\nGNU Affero General Public License v3.0',
|
||||
icon: Symbols.copyright,
|
||||
title: 'aboutScreenLicenseTitle'.tr(),
|
||||
subtitle: 'aboutScreenLicenseContent'.tr(
|
||||
args: [DateTime.now().year.toString()],
|
||||
),
|
||||
onTap:
|
||||
() => _launchURL(
|
||||
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
|
||||
@ -195,12 +273,25 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
// Copyright
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'© ${DateTime.now().year} ${_packageInfo.appName}. All rights reserved.',
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'aboutScreenCopyright'.tr(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -238,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),
|
||||
@ -254,22 +346,23 @@ 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(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
SnackBar(content: Text('copiedToClipboard'.tr())),
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'Copy to clipboard',
|
||||
tooltip: 'copyToClipboardTooltip'.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -283,13 +376,18 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
String? subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final multipleLines = subtitle?.contains('\n') ?? false;
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(icon),
|
||||
leading: Icon(icon).padding(top: multipleLines ? 8 : 0),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle) : null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
isThreeLine: multipleLines,
|
||||
trailing: const Icon(
|
||||
Symbols.chevron_right,
|
||||
).padding(top: multipleLines ? 8 : 0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minLeadingWidth: 24,
|
||||
|
@ -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),
|
||||
],
|
||||
|
@ -1,4 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@ -14,7 +17,9 @@ import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/payment/payment_overlay.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'leveling.g.dart';
|
||||
|
||||
@ -84,35 +89,6 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
// Membership section
|
||||
_buildMembershipSection(context, ref, stellarSubscription),
|
||||
const Gap(16),
|
||||
|
||||
// Unlocked features section
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'unlockedFeatures'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'unlockedFeaturesDescription'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -292,6 +268,31 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
) {
|
||||
final isActive = membership?.isActive ?? false;
|
||||
|
||||
Future<void> membershipCancel() async {
|
||||
if (!isActive || membership == null) return;
|
||||
|
||||
final confirm = await showConfirmAlert(
|
||||
'membershipCancelHint'.tr(),
|
||||
'membershipCancelConfirm'.tr(),
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post('/subscriptions/${membership.identifier}/cancel');
|
||||
ref.invalidate(accountStellarSubscriptionProvider);
|
||||
ref.read(userInfoProvider.notifier).fetchUser();
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
showSnackBar('membershipCancelSuccess'.tr());
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -307,7 +308,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@ -327,19 +328,34 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
|
||||
if (isActive) ...[
|
||||
_buildCurrentMembershipCard(context, membership!),
|
||||
const Gap(16),
|
||||
const Gap(12),
|
||||
FilledButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.error,
|
||||
),
|
||||
foregroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
),
|
||||
onPressed: membershipCancel,
|
||||
icon: const Icon(Symbols.cancel),
|
||||
label: Text('membershipCancel'.tr()),
|
||||
),
|
||||
],
|
||||
|
||||
if (!isActive) ...[
|
||||
Text(
|
||||
isActive ? 'upgradeYourPlan'.tr() : 'chooseYourPlan'.tr(),
|
||||
'chooseYourPlan'.tr(),
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Gap(12),
|
||||
|
||||
_buildMembershipTiers(context, ref, membership),
|
||||
const Gap(12),
|
||||
],
|
||||
|
||||
// Restore Purchase Button
|
||||
// As you know Apple platform need IAP
|
||||
if (kIsWeb || !(Platform.isIOS || Platform.isMacOS))
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showRestorePurchaseSheet(context, ref),
|
||||
icon: const Icon(Icons.restore),
|
||||
@ -347,7 +363,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
).padding(top: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -410,33 +426,18 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
'id': 'solian.stellar.primary',
|
||||
'name': 'membershipTierStellar'.tr(),
|
||||
'price': 'membershipPriceStellar'.tr(),
|
||||
'features': [
|
||||
'membershipFeatureBasic'.tr(),
|
||||
'membershipFeaturePrioritySupport'.tr(),
|
||||
'membershipFeatureAdFree'.tr(),
|
||||
],
|
||||
'color': Colors.blue,
|
||||
},
|
||||
{
|
||||
'id': 'solian.stellar.nova',
|
||||
'name': 'membershipTierNova'.tr(),
|
||||
'price': 'membershipPriceNova'.tr(),
|
||||
'features': [
|
||||
'membershipFeatureAllPrimary'.tr(),
|
||||
'membershipFeatureAdvancedCustomization'.tr(),
|
||||
'membershipFeatureEarlyAccess'.tr(),
|
||||
],
|
||||
'color': Colors.purple,
|
||||
'color': Colors.indigo,
|
||||
},
|
||||
{
|
||||
'id': 'solian.stellar.supernova',
|
||||
'name': 'membershipTierSupernova'.tr(),
|
||||
'price': 'membershipPriceSupernova'.tr(),
|
||||
'features': [
|
||||
'membershipFeatureAllNova'.tr(),
|
||||
'membershipFeatureExclusiveContent'.tr(),
|
||||
'membershipFeatureVipSupport'.tr(),
|
||||
],
|
||||
'color': Colors.orange,
|
||||
},
|
||||
];
|
||||
|
@ -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 {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -108,15 +108,18 @@ class CreatorHubShellScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = isWideScreen(context);
|
||||
if (isWide) {
|
||||
return Row(
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
return AppBackground(isRoot: true, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,7 +201,6 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: !isWide ? const PageBackButton() : null,
|
||||
title: Text('creatorHub').tr(),
|
||||
@ -322,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);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.edit),
|
||||
title: Text('postContent'.tr()),
|
||||
title: Text('Post'),
|
||||
subtitle: Text('Create a regular post'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
|
@ -47,15 +47,21 @@ class DeveloperHubShellScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = isWideScreen(context);
|
||||
if (isWide) {
|
||||
return Row(
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 360, child: const DeveloperHubScreen(isAside: true)),
|
||||
SizedBox(
|
||||
width: 360,
|
||||
child: const DeveloperHubScreen(isAside: true),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
return AppBackground(isRoot: true, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -33,6 +33,8 @@ sealed class PostComposeInitialState with _$PostComposeInitialState {
|
||||
String? content,
|
||||
@Default([]) List<UniversalFile> attachments,
|
||||
int? visibility,
|
||||
SnPost? replyingTo,
|
||||
SnPost? forwardingTo,
|
||||
}) = _PostComposeInitialState;
|
||||
|
||||
factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
|
||||
@ -66,23 +68,22 @@ class PostEditScreen extends HookConsumerWidget {
|
||||
|
||||
class PostComposeScreen extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final SnPost? repliedPost;
|
||||
final SnPost? forwardedPost;
|
||||
final int? type;
|
||||
final PostComposeInitialState? initialState;
|
||||
const PostComposeScreen({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.repliedPost,
|
||||
this.forwardedPost,
|
||||
this.type,
|
||||
this.initialState,
|
||||
this.originalPost,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Determine the compose type: auto-detect from edited post or use query parameter
|
||||
final composeType = originalPost?.type ?? type ?? 0;
|
||||
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
// If type is 1 (article), return ArticleComposeScreen
|
||||
if (composeType == 1) {
|
||||
@ -136,8 +137,11 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
// Initialize publisher once when data is available
|
||||
useEffect(() {
|
||||
if (publishers.value?.isNotEmpty ?? false) {
|
||||
if (state.currentPublisher.value == null) {
|
||||
// If no publisher is set, use the first available one
|
||||
state.currentPublisher.value = publishers.value!.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [publishers]);
|
||||
|
||||
@ -480,8 +484,10 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
|
||||
Widget _buildInfoBanner(BuildContext context) {
|
||||
// When editing, preserve the original replied/forwarded post references
|
||||
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
|
||||
final effectiveRepliedPost =
|
||||
initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
// Show editing banner when editing a post
|
||||
if (originalPost != null) {
|
||||
@ -497,15 +503,15 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const Gap(4),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'edit'.tr(),
|
||||
'postEditing'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(all: 16),
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
),
|
||||
// Show reply/forward banners below editing banner if they exist
|
||||
if (effectiveRepliedPost != null)
|
||||
@ -615,6 +621,7 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder:
|
||||
(context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
|
@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$PostComposeInitialState {
|
||||
|
||||
String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility;
|
||||
String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility; SnPost? get replyingTo; SnPost? get forwardingTo;
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -29,16 +29,16 @@ $PostComposeInitialStateCopyWith<PostComposeInitialState> get copyWith => _$Post
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility);
|
||||
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility,replyingTo,forwardingTo);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)';
|
||||
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)';
|
||||
}
|
||||
|
||||
|
||||
@ -49,11 +49,11 @@ abstract mixin class $PostComposeInitialStateCopyWith<$Res> {
|
||||
factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility
|
||||
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo
|
||||
});
|
||||
|
||||
|
||||
|
||||
$SnPostCopyWith<$Res>? get replyingTo;$SnPostCopyWith<$Res>? get forwardingTo;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@ -66,17 +66,43 @@ class _$PostComposeInitialStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable
|
||||
as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable
|
||||
as SnPost?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnPostCopyWith<$Res>? get replyingTo {
|
||||
if (_self.replyingTo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) {
|
||||
return _then(_self.copyWith(replyingTo: value));
|
||||
});
|
||||
}/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnPostCopyWith<$Res>? get forwardingTo {
|
||||
if (_self.forwardingTo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) {
|
||||
return _then(_self.copyWith(forwardingTo: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -84,7 +110,7 @@ as int?,
|
||||
@JsonSerializable()
|
||||
|
||||
class _PostComposeInitialState implements PostComposeInitialState {
|
||||
const _PostComposeInitialState({this.title, this.description, this.content, final List<UniversalFile> attachments = const [], this.visibility}): _attachments = attachments;
|
||||
const _PostComposeInitialState({this.title, this.description, this.content, final List<UniversalFile> attachments = const [], this.visibility, this.replyingTo, this.forwardingTo}): _attachments = attachments;
|
||||
factory _PostComposeInitialState.fromJson(Map<String, dynamic> json) => _$PostComposeInitialStateFromJson(json);
|
||||
|
||||
@override final String? title;
|
||||
@ -98,6 +124,8 @@ class _PostComposeInitialState implements PostComposeInitialState {
|
||||
}
|
||||
|
||||
@override final int? visibility;
|
||||
@override final SnPost? replyingTo;
|
||||
@override final SnPost? forwardingTo;
|
||||
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -112,16 +140,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility);
|
||||
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility,replyingTo,forwardingTo);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)';
|
||||
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)';
|
||||
}
|
||||
|
||||
|
||||
@ -132,11 +160,11 @@ abstract mixin class _$PostComposeInitialStateCopyWith<$Res> implements $PostCom
|
||||
factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility
|
||||
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo
|
||||
});
|
||||
|
||||
|
||||
|
||||
@override $SnPostCopyWith<$Res>? get replyingTo;@override $SnPostCopyWith<$Res>? get forwardingTo;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@ -149,18 +177,44 @@ class __$PostComposeInitialStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) {
|
||||
return _then(_PostComposeInitialState(
|
||||
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||
as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable
|
||||
as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable
|
||||
as SnPost?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnPostCopyWith<$Res>? get replyingTo {
|
||||
if (_self.replyingTo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) {
|
||||
return _then(_self.copyWith(replyingTo: value));
|
||||
});
|
||||
}/// Create a copy of PostComposeInitialState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnPostCopyWith<$Res>? get forwardingTo {
|
||||
if (_self.forwardingTo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) {
|
||||
return _then(_self.copyWith(forwardingTo: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
@ -18,6 +18,14 @@ _PostComposeInitialState _$PostComposeInitialStateFromJson(
|
||||
.toList() ??
|
||||
const [],
|
||||
visibility: (json['visibility'] as num?)?.toInt(),
|
||||
replyingTo:
|
||||
json['replying_to'] == null
|
||||
? null
|
||||
: SnPost.fromJson(json['replying_to'] as Map<String, dynamic>),
|
||||
forwardingTo:
|
||||
json['forwarding_to'] == null
|
||||
? null
|
||||
: SnPost.fromJson(json['forwarding_to'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PostComposeInitialStateToJson(
|
||||
@ -28,4 +36,6 @@ Map<String, dynamic> _$PostComposeInitialStateToJson(
|
||||
'content': instance.content,
|
||||
'attachments': instance.attachments.map((e) => e.toJson()).toList(),
|
||||
'visibility': instance.visibility,
|
||||
'replying_to': instance.replyingTo?.toJson(),
|
||||
'forwarding_to': instance.forwardingTo?.toJson(),
|
||||
};
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -9,7 +9,6 @@ import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/post_quick_reply.dart';
|
||||
import 'package:island/widgets/post/post_replies.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@ -22,9 +21,10 @@ Future<SnPost?> post(Ref ref, String id) async {
|
||||
return SnPost.fromJson(resp.data);
|
||||
}
|
||||
|
||||
final postStateProvider = StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>(
|
||||
final postStateProvider =
|
||||
StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>(
|
||||
(ref, id) => PostState(ref, id),
|
||||
);
|
||||
);
|
||||
|
||||
class PostState extends StateNotifier<AsyncValue<SnPost?>> {
|
||||
final Ref _ref;
|
||||
@ -75,7 +75,9 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
backgroundColor: isWide ? Colors.transparent : null,
|
||||
onUpdate: (newItem) {
|
||||
// Update the local state with the new post data
|
||||
ref.read(postStateProvider(id).notifier).updatePost(newItem);
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
@ -93,16 +95,21 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
right: 0,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
child: postState.when(
|
||||
data: (post) => PostQuickReply(
|
||||
child: postState
|
||||
.when(
|
||||
data:
|
||||
(post) => PostQuickReply(
|
||||
parent: post!,
|
||||
onPosted: () {
|
||||
ref.invalidate(postRepliesNotifierProvider(id));
|
||||
ref.invalidate(
|
||||
postRepliesNotifierProvider(id),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
).padding(
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
)
|
||||
.padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
top: 16,
|
||||
horizontal: 16,
|
||||
|
@ -341,26 +341,6 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
];
|
||||
|
||||
final behaviorSettings = [
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('creatorHub').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.rocket_launch),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () => context.push('/creators'),
|
||||
),
|
||||
|
||||
// Developer Hub
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('developerHub').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.hub),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () => context.push('/developers'),
|
||||
),
|
||||
|
||||
// Auto translate settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsAutoTranslate').tr(),
|
||||
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -21,11 +21,23 @@ class AccountName extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var nameStyle = (style ?? TextStyle());
|
||||
if (account.profile.stellarMembership != null) {
|
||||
nameStyle = nameStyle.copyWith(
|
||||
color: (switch (account.profile.stellarMembership!.identifier) {
|
||||
'solian.stellar.primary' => Colors.blueAccent,
|
||||
'solian.stellar.nova' => Colors.indigoAccent,
|
||||
'solian.stellar.supernova' => Colors.amberAccent,
|
||||
_ => null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Flexible(child: Text(account.nick, style: style)),
|
||||
Flexible(child: Text(account.nick, style: nameStyle)),
|
||||
if (account.profile.stellarMembership != null)
|
||||
StellarMembershipMark(membership: account.profile.stellarMembership!),
|
||||
if (account.profile.verification != null)
|
||||
@ -87,36 +99,23 @@ class StellarMembershipMark extends StatelessWidget {
|
||||
Color _getMembershipTierColor(String identifier) {
|
||||
switch (identifier) {
|
||||
case 'solian.stellar.primary':
|
||||
return Colors.amber;
|
||||
case 'solian.stellar.nova':
|
||||
return Colors.blue;
|
||||
case 'solian.stellar.nova':
|
||||
return Colors.indigo;
|
||||
case 'solian.stellar.supernova':
|
||||
return Colors.purple;
|
||||
return Colors.amber;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getMembershipTierIcon(String identifier) {
|
||||
switch (identifier) {
|
||||
case 'solian.stellar.primary':
|
||||
return Symbols.star;
|
||||
case 'solian.stellar.nova':
|
||||
return Symbols.auto_awesome;
|
||||
case 'solian.stellar.supernova':
|
||||
return Symbols.diamond;
|
||||
default:
|
||||
return Symbols.workspace_premium;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!membership.isActive) return const SizedBox.shrink();
|
||||
|
||||
final tierName = _getMembershipTierName(membership.identifier);
|
||||
final tierColor = _getMembershipTierColor(membership.identifier);
|
||||
final tierIcon = _getMembershipTierIcon(membership.identifier);
|
||||
final tierIcon = Symbols.award_star;
|
||||
|
||||
return Tooltip(
|
||||
richMessage: TextSpan(
|
||||
@ -124,7 +123,7 @@ class StellarMembershipMark extends StatelessWidget {
|
||||
children: [
|
||||
TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: 'currentMembership'.tr(args: [tierName]),
|
||||
text: 'currentMembershipMember'.tr(args: [tierName]),
|
||||
style: TextStyle(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
|
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -244,7 +244,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
String formatFileSize(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
|
||||
@ -274,7 +274,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
|
||||
buildInfoRow(
|
||||
Icons.storage,
|
||||
'Size',
|
||||
_formatFileSize(item.size),
|
||||
formatFileSize(item.size),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
buildInfoRow(
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -286,7 +286,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
|
||||
}
|
||||
|
||||
String _formatCurrency(int amount, String currency) {
|
||||
final value = amount / 100.0;
|
||||
final value = amount;
|
||||
return '${value.toStringAsFixed(2)} $currency';
|
||||
}
|
||||
|
||||
|
@ -98,19 +98,11 @@ class ComposeLogic {
|
||||
descriptionController: TextEditingController(
|
||||
text: originalPost?.description,
|
||||
),
|
||||
contentController: TextEditingController(
|
||||
text:
|
||||
originalPost?.content ??
|
||||
(forwardedPost != null
|
||||
? '''> ${forwardedPost.content}
|
||||
|
||||
'''
|
||||
: null),
|
||||
),
|
||||
contentController: TextEditingController(text: originalPost?.content),
|
||||
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
|
||||
tagsController: tagsController,
|
||||
categoriesController: categoriesController,
|
||||
draftId: id,
|
||||
@ -482,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,
|
||||
|
@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/account/account_name.dart';
|
||||
@ -55,106 +56,192 @@ 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;
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
Widget child;
|
||||
if (item.type == 1 && isFullPost) {
|
||||
child = Padding(
|
||||
padding: renderingPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context.push('/posts/${item.id}/edit').then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
});
|
||||
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),
|
||||
],
|
||||
),
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (isAuthor) MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'copyLink'.tr(),
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'),
|
||||
);
|
||||
},
|
||||
Text(
|
||||
isFullPost
|
||||
? item.publishedAt?.formatSystem() ?? ''
|
||||
: item.publishedAt?.formatRelative(context) ?? '',
|
||||
).fontSize(11),
|
||||
],
|
||||
),
|
||||
MenuAction(
|
||||
title: 'reply'.tr(),
|
||||
image: MenuImage.icon(Symbols.reply),
|
||||
callback: () {
|
||||
context.push('/posts/compose', extra: {'repliedPost': item});
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'forward'.tr(),
|
||||
image: MenuImage.icon(Symbols.forward),
|
||||
callback: () {
|
||||
context.push('/posts/compose', extra: {'forwardedPost': item});
|
||||
},
|
||||
if (item.visibility != 0)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getVisibilityIcon(item.visibility),
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'share'.tr(),
|
||||
image: MenuImage.icon(Symbols.share),
|
||||
callback: () {
|
||||
showShareSheetLink(
|
||||
context: context,
|
||||
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
|
||||
title: 'sharePost'.tr(),
|
||||
toSystem: true,
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_getVisibilityText(item.visibility).tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
MenuAction(
|
||||
title: 'abuseReport'.tr(),
|
||||
image: MenuImage.icon(Symbols.flag),
|
||||
callback: () {
|
||||
showAbuseReportSheet(
|
||||
context,
|
||||
resourceIdentifier: 'posts:${item.id}',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
).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),
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: hasBackground ? Colors.transparent : backgroundColor,
|
||||
child: Padding(
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = Padding(
|
||||
padding: renderingPadding,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
@ -186,9 +273,7 @@ class PostItem extends HookConsumerWidget {
|
||||
Text(
|
||||
isFullPost
|
||||
? item.publishedAt?.formatSystem() ?? ''
|
||||
: item.publishedAt?.formatRelative(
|
||||
context,
|
||||
) ??
|
||||
: item.publishedAt?.formatRelative(context) ??
|
||||
'',
|
||||
).fontSize(11).alignment(Alignment.bottomRight),
|
||||
const Gap(4),
|
||||
@ -202,8 +287,7 @@ class PostItem extends HookConsumerWidget {
|
||||
Icon(
|
||||
_getVisibilityIcon(item.visibility),
|
||||
size: 14,
|
||||
color:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@ -216,6 +300,12 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
).padding(top: 2, bottom: 2),
|
||||
if (item.type == 1)
|
||||
_ArticlePostDisplay(
|
||||
item: item,
|
||||
isFullPost: isFullPost,
|
||||
)
|
||||
else ...[
|
||||
if (item.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
item.title!,
|
||||
@ -241,10 +331,11 @@ class PostItem extends HookConsumerWidget {
|
||||
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)
|
||||
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -256,10 +347,7 @@ class PostItem extends HookConsumerWidget {
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.label,
|
||||
size: 13,
|
||||
),
|
||||
const Icon(Symbols.label, size: 13),
|
||||
Text(
|
||||
tag.name ?? '#${tag.slug}',
|
||||
).fontSize(13),
|
||||
@ -294,7 +382,7 @@ class PostItem extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
// Show truncation hint if post is truncated
|
||||
if (item.isTruncated && !isFullPost)
|
||||
if (item.isTruncated && !isFullPost && item.type != 1)
|
||||
_PostTruncateHint().padding(
|
||||
bottom: item.attachments.isNotEmpty ? 8 : null,
|
||||
),
|
||||
@ -302,7 +390,7 @@ class PostItem extends HookConsumerWidget {
|
||||
item.forwardedPost != null) &&
|
||||
showReferencePost)
|
||||
_buildReferencePost(context, item),
|
||||
if (item.attachments.isNotEmpty)
|
||||
if (item.attachments.isNotEmpty && item.type != 1)
|
||||
CloudFileList(
|
||||
files: item.attachments,
|
||||
maxWidth: math.min(
|
||||
@ -391,7 +479,108 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
children: [
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context.push('/posts/${item.id}/edit').then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (isAuthor)
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (isAuthor) MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'copyLink'.tr(),
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'reply'.tr(),
|
||||
image: MenuImage.icon(Symbols.reply),
|
||||
callback: () {
|
||||
context.push(
|
||||
'/posts/compose',
|
||||
extra: PostComposeInitialState(replyingTo: item),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'forward'.tr(),
|
||||
image: MenuImage.icon(Symbols.forward),
|
||||
callback: () {
|
||||
context.push(
|
||||
'/posts/compose',
|
||||
extra: PostComposeInitialState(forwardingTo: item),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'share'.tr(),
|
||||
image: MenuImage.icon(Symbols.share),
|
||||
callback: () {
|
||||
showShareSheetLink(
|
||||
context: context,
|
||||
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
|
||||
title: 'sharePost'.tr(),
|
||||
toSystem: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'abuseReport'.tr(),
|
||||
image: MenuImage.icon(Symbols.flag),
|
||||
callback: () {
|
||||
showAbuseReportSheet(
|
||||
context,
|
||||
resourceIdentifier: 'posts:${item.id}',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: hasBackground ? Colors.transparent : backgroundColor,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -501,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)
|
||||
@ -508,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: [
|
||||
@ -805,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) {
|
||||
|
@ -87,7 +87,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
color: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 1,
|
||||
child: InkWell(
|
||||
|
@ -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(
|
||||
|
@ -1025,7 +1025,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
@ -1097,10 +1097,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: ac294be30ba841830cfa146e5a3b22bb09f8dc5a0fdd9ca9332b04b0bde99ebf
|
||||
sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.4"
|
||||
version: "16.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -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
|
||||
@ -30,6 +30,8 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
@ -37,7 +39,7 @@ dependencies:
|
||||
flutter_hooks: ^0.21.2
|
||||
hooks_riverpod: ^2.6.1
|
||||
bitsdojo_window: ^0.1.6
|
||||
go_router: ^15.1.3
|
||||
go_router: ^16.0.0
|
||||
styled_widget: ^0.4.1
|
||||
shared_preferences: ^2.5.3
|
||||
flutter_riverpod: ^2.6.1
|
||||
|
Reference in New Issue
Block a user