Compare commits

...

54 Commits

Author SHA1 Message Date
96fd64d85d 🚀 Launch 2.2.1+42 2024-12-29 22:43:58 +08:00
e236b7f98b 💄 Optimize attachment list width in post 2024-12-29 22:34:17 +08:00
5c7929e618 Post editor on device draft 2024-12-29 22:27:07 +08:00
7ba5260246 Improve image loading 2024-12-29 15:30:31 +08:00
a6d4947a23 🐛 Fix attachment list NaN height 2024-12-29 14:03:19 +08:00
7fbd4e9647 🚀 Launch 2.2.1+41 2024-12-29 12:15:22 +08:00
95d926b29f Bug fixes 2024-12-29 12:09:04 +08:00
f6cf6d0440 💄 Experimental new attachment layout 2024-12-29 12:02:26 +08:00
e503c3f02f Use analyze now for images 2024-12-29 11:09:54 +08:00
d4fbdd397e Create boost 2024-12-29 02:13:31 +08:00
03943a7138 🗑️ Remove link expand from post share 2024-12-28 20:35:43 +08:00
44f2c5fe0e Toggle original or compressed one via video control 2024-12-28 19:59:04 +08:00
bb66d5b684 Able to upload low quality video copy 2024-12-28 19:23:49 +08:00
1fca36293d 🐛 Fix attachment set thumbnail 2024-12-28 18:16:59 +08:00
2c7dc8c2ea 🐛 Fix attachment uploading progress 2024-12-28 17:37:58 +08:00
cf0df91d8c 👽 Fix attachment uploading 2024-12-28 17:19:20 +08:00
91c85e8a58 Compress video 2024-12-26 23:57:43 +08:00
2851780dda 💄 Better displaying of thumbnail on pending list 2024-12-26 23:21:33 +08:00
00fd58fb97 Better countdown on special day widget 2024-12-26 23:08:16 +08:00
ee7d0ddd25 🎨 Fix most of linting notes 2024-12-26 23:01:00 +08:00
7656c08832 ♻️ Refactored attachment loading system 2024-12-26 22:19:01 +08:00
619c90cdd9 Setting attachment thumbnail 2024-12-26 00:02:25 +08:00
168d51c9fe 📝 Add api docs 2024-12-25 00:48:25 +08:00
d4b831f98e Copy, linking attachment RID 2024-12-25 00:48:19 +08:00
4d96a15c31 🐛 Fix context menu mis placed on device which showing the side navigation 2024-12-24 23:07:47 +08:00
06dd3e092a 🚀 Launch 2.1.1+39 2024-12-23 23:08:07 +08:00
82fe9e287a 🐛 Bug fixes on special days 2024-12-23 23:02:47 +08:00
dc1c285de1 🍱 Add more special days 2024-12-23 22:55:03 +08:00
5a3313e94f Days countdown 2024-12-23 22:42:10 +08:00
61032c84f1 🐛 Scale down user image on ios notification extensions 2024-12-23 22:02:29 +08:00
36a5b8fb39 🐛 Bug fixes on something 2024-12-23 21:55:07 +08:00
3eda464e03 🐛 Fix search post did not triggered 2024-12-22 20:28:53 +08:00
7a3ab6fd7d 🚀 Launch 2.1.1+38 2024-12-22 19:50:08 +08:00
3d15c0b9f9 User fortune history 2024-12-22 19:37:44 +08:00
67a29b4305 Show user level 2024-12-22 19:11:53 +08:00
594f57e0d3 Tappable label tags 2024-12-22 17:37:37 +08:00
d1eb51c596 💄 Optimize styling 2024-12-22 17:30:41 +08:00
85d2eff7f8 Explore page filtered by post 2024-12-22 17:19:35 +08:00
2375c46852 Search filtering by categories 2024-12-22 15:57:37 +08:00
fd2eb5cda6 Localized post categories 2024-12-22 15:20:33 +08:00
1256f440bd Show post categories 2024-12-22 15:11:40 +08:00
5b05ca67b6 Editing categories 2024-12-22 14:56:34 +08:00
95af7140cd 💄 Optimize app bar 2024-12-22 13:54:46 +08:00
77e9994204 ;sparkles: Transparent app bar 2024-12-22 13:31:09 +08:00
3f6c186c13 Color scheme 2024-12-22 13:07:22 +08:00
9ac4a940dd 🚀 Launch 2.1.1+37 2024-12-22 01:33:56 +08:00
ec050ab712 Save last time used publisher 2024-12-22 01:29:16 +08:00
77e3ce8bcc 🐛 Fix android icon issue 2024-12-22 01:22:24 +08:00
f5dcf71e10 🐛 Optimize posting progress 2024-12-22 00:48:06 +08:00
7fc18b40db Able to edit post alias 2024-12-22 00:41:41 +08:00
8c8ab24c9e 🐛 Fix share image issue 2024-12-22 00:27:18 +08:00
a319bd7f8c 🐛 Fix android platform related issues 2024-12-22 00:18:09 +08:00
6427ec1f82 🚀 Launch 2.1.1+36 2024-12-21 23:39:04 +08:00
35dc7f4392 💄 Optimize android widget color 2024-12-21 23:36:34 +08:00
76 changed files with 5528 additions and 1645 deletions

View File

@ -15,6 +15,7 @@ analyzer:
- "**/*.freezed.dart" - "**/*.freezed.dart"
errors: errors:
invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980 invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
deprecated_member_use: ignore
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the

View File

@ -10,8 +10,9 @@ plugins {
} }
dependencies { dependencies {
implementation "androidx.glance:glance:1.1.1" implementation 'com.google.android.material:material:1.12.0'
implementation "androidx.glance:glance-appwidget:1.1.1" implementation 'androidx.glance:glance:1.1.1'
implementation 'androidx.glance:glance-appwidget:1.1.1'
implementation 'androidx.compose.foundation:foundation-layout-android:1.7.6' implementation 'androidx.compose.foundation:foundation-layout-android:1.7.6'
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
@ -19,6 +20,12 @@ dependencies {
implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.4' implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.4'
} }
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
buildFeatures { buildFeatures {
compose true compose true
@ -49,6 +56,15 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
release {
keyAlias = keystoreProperties['keyAlias']
keyPassword = keystoreProperties['keyPassword']
storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword = keystoreProperties['storePassword']
}
}
buildTypes { buildTypes {
debug { debug {
debuggable true debuggable true
@ -56,9 +72,7 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.release
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }

View File

@ -1,7 +1,6 @@
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId import androidx.glance.GlanceId
@ -87,11 +86,18 @@ class CheckInWidget : GlanceAppWidget() {
Column { Column {
Text( Text(
text = resultTierSymbols[checkIn.resultTier], text = resultTierSymbols[checkIn.resultTier],
style = TextStyle(fontSize = 17.sp) style = TextStyle(
fontSize = 17.sp,
color = GlanceTheme.colors.onSurface
)
) )
Text( Text(
text = "+${checkIn.resultExperience} EXP", text = "+${checkIn.resultExperience} EXP",
style = TextStyle(fontSize = 13.sp, fontFamily = FontFamily.Monospace) style = TextStyle(
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = GlanceTheme.colors.onSurface
)
) )
} }
Spacer(modifier = GlanceModifier.height(8.dp)) Spacer(modifier = GlanceModifier.height(8.dp))
@ -102,7 +108,10 @@ class CheckInWidget : GlanceAppWidget() {
ZoneId.systemDefault() ZoneId.systemDefault()
) )
.format(dateFormatter), .format(dateFormatter),
style = TextStyle(fontSize = 11.sp) style = TextStyle(
fontSize = 11.sp,
color = GlanceTheme.colors.onSurface
)
) )
} }
@ -112,7 +121,7 @@ class CheckInWidget : GlanceAppWidget() {
Text( Text(
text = "You haven't checked in today", text = "You haven't checked in today",
style = TextStyle(fontSize = 15.sp) style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
) )
} }
} }

View File

@ -87,12 +87,16 @@ class RandomPostWidget : GlanceAppWidget() {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = data.publisher.nick, text = data.publisher.nick,
style = TextStyle(fontSize = 15.sp) style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
) )
Spacer(modifier = GlanceModifier.width(8.dp)) Spacer(modifier = GlanceModifier.width(8.dp))
Text( Text(
text = "@${data.publisher.name}", text = "@${data.publisher.name}",
style = TextStyle(fontSize = 13.sp, fontFamily = FontFamily.Monospace) style = TextStyle(
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = GlanceTheme.colors.onSurface
)
) )
} }
@ -101,13 +105,13 @@ class RandomPostWidget : GlanceAppWidget() {
if (data.body.title != null) { if (data.body.title != null) {
Text( Text(
text = data.body.title, text = data.body.title,
style = TextStyle(fontSize = 25.sp) style = TextStyle(fontSize = 19.sp, color = GlanceTheme.colors.onSurface)
) )
} }
if (data.body.description != null) { if (data.body.description != null) {
Text( Text(
text = data.body.description, text = data.body.description,
style = TextStyle(fontSize = 19.sp) style = TextStyle(fontSize = 17.sp, color = GlanceTheme.colors.onSurface)
) )
} }
@ -117,7 +121,7 @@ class RandomPostWidget : GlanceAppWidget() {
Text( Text(
text = data.body.content ?: "No content", text = data.body.content ?: "No content",
style = TextStyle(fontSize = 15.sp), style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface),
) )
Spacer(modifier = GlanceModifier.height(8.dp)) Spacer(modifier = GlanceModifier.height(8.dp))
@ -126,12 +130,16 @@ class RandomPostWidget : GlanceAppWidget() {
Text( Text(
LocalDateTime.ofInstant(data.createdAt, ZoneId.systemDefault()) LocalDateTime.ofInstant(data.createdAt, ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")),
style = TextStyle(fontSize = 13.sp), style = TextStyle(fontSize = 13.sp, color = GlanceTheme.colors.onSurface),
) )
Text( Text(
"#${data.id}", "#${data.id}",
style = TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Bold), style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onSurface
),
) )
return@Column; return@Column;
@ -143,12 +151,16 @@ class RandomPostWidget : GlanceAppWidget() {
horizontalAlignment = Alignment.Horizontal.CenterHorizontally horizontalAlignment = Alignment.Horizontal.CenterHorizontally
) { ) {
Text( Text(
text = "Unable to fetch post", text = "No Recommendations",
style = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Bold) style = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onSurface
)
) )
Text( Text(
text = "Check your internet connection", text = "Open app to load some posts",
style = TextStyle(fontSize = 15.sp) style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
) )
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFFFF</color> <color name="ic_launcher_background">#FFFFFFFF</color>
<color name="ic_notification_background">#00000000</color>
</resources> </resources>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="LaunchTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
@ -16,7 +16,7 @@
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>

View File

@ -0,0 +1,30 @@
meta {
name: Activate Boost
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/uc/boosts/1/activate
body: none
auth: bearer
}
auth:bearer {
token: {{atk}}
}
body:json {
{
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "Merry Christmas!",
"subtitle": "一条来自 Solar Network 团队的信息",
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
"metadata": {
"image": "6EqsYQwmFRCkbmhR"
},
"priority": 10
}
}

View File

@ -0,0 +1,30 @@
meta {
name: Developer Notify All Users
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/id/dev/notify/all
body: json
auth: bearer
}
auth:bearer {
token: {{atk}}
}
body:json {
{
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "Merry Christmas!",
"subtitle": "一条来自 Solar Network 团队的信息",
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
"metadata": {
"image": "6EqsYQwmFRCkbmhR"
},
"priority": 10
}
}

9
api/bruno.json Normal file
View File

@ -0,0 +1,9 @@
{
"version": "1",
"name": "Solar Network",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@ -0,0 +1,8 @@
vars {
endpoint: https://api.sn.solsynth.dev
third_client_id: alphabot
}
vars:secret [
atk,
third_client_tk
]

View File

@ -57,7 +57,7 @@
"reply": "Reply", "reply": "Reply",
"unset": "Unset", "unset": "Unset",
"untitled": "Untitled", "untitled": "Untitled",
"postDetail": "Post detail", "postDetail": "Post Detail",
"postNoun": "Post", "postNoun": "Post",
"postReadMore": "Read more", "postReadMore": "Read more",
"postReadEstimate": "Est read time {}", "postReadEstimate": "Est read time {}",
@ -139,6 +139,9 @@
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"fieldPostTags": "Tags", "fieldPostTags": "Tags",
"fieldPostCategories": "Categories",
"fieldPostAlias": "Alias",
"fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.",
"postPublish": "Publish", "postPublish": "Publish",
"postPosted": "Post has been posted.", "postPosted": "Post has been posted.",
"postPublishedAt": "Published At", "postPublishedAt": "Published At",
@ -176,12 +179,18 @@
"other": "{} comments" "other": "{} comments"
}, },
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsAppBarTransparent": "Transparent App Bar",
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
"settingsBackgroundImage": "Background Image", "settingsBackgroundImage": "Background Image",
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.", "settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
"settingsBackgroundImageClear": "Clear Existing Background Image", "settingsBackgroundImageClear": "Clear Existing Background Image",
"settingsBackgroundImageClearDescription": "Reset the background image to blank.", "settingsBackgroundImageClearDescription": "Reset the background image to blank.",
"settingsThemeMaterial3": "Use Material You Design", "settingsThemeMaterial3": "Use Material You Design",
"settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.", "settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.",
"settingsColorScheme": "Color Scheme",
"settingsColorSchemeDescription": "Set the application primary color.",
"settingsColorSeed": "Color Seed",
"settingsColorSeedDescription": "Select one of the present color schemes.",
"settingsNetwork": "Network", "settingsNetwork": "Network",
"settingsNetworkServer": "HyperNet Server", "settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.", "settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
@ -272,16 +281,43 @@
"one": "{} attachment", "one": "{} attachment",
"other": "{} attachments" "other": "{} attachments"
}, },
"fieldAttachmentRandomId": "Random ID",
"addAttachmentFromAlbum": "Add from album", "addAttachmentFromAlbum": "Add from album",
"addAttachmentFromClipboard": "Paste file", "addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo", "addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video", "addAttachmentFromCameraVideo": "Take video",
"addAttachmentFromRandomId": "Link via RID",
"attachmentPastedImage": "Pasted Image", "attachmentPastedImage": "Pasted Image",
"attachmentInsertLink": "Insert Link", "attachmentInsertLink": "Insert Link",
"attachmentSetAsPostThumbnail": "Set as post thumbnail", "attachmentSetAsPostThumbnail": "Set as post thumbnail",
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
"attachmentCompressVideo": "Re-encode video",
"attachmentSetThumbnail": "Set thumbnail", "attachmentSetThumbnail": "Set thumbnail",
"attachmentCopyRandomId": "Copy RID",
"attachmentUpload": "Upload", "attachmentUpload": "Upload",
"attachmentInputDialog": "Upload attachments",
"attachmentInputUseRandomId": "Use Random ID",
"attachmentInputNew": "New Upload",
"waitingForUpload": "Waiting for upload",
"attachmentVideoCompressHint": "Compress a copy of this video",
"attachmentVideoCompressHintDescription": "Do you want to upload a compress copy of video {}? It will help your audience to preview this video faster and they still can watch the original video. It will take some while to process the video on your device, so please be patient.",
"attachmentCompressQuality": "Compress quality",
"attachmentCompressQualityHighest": "Highest",
"attachmentCompressQualityDefault": "Default",
"attachmentCompressQualityMedium": "Medium",
"attachmentCompressQualityLow": "Low",
"attachmentCompressQualityHint": "Solar Network doesn't prevent you from uploading large files, high resolution, high bitrate videos. But for your network conditions, we suggest you choose a suitable compression quality.",
"attachmentUploaded": "Uploaded",
"attachmentPending": "Pending",
"attachmentCopyCompressed": "Copy compressed",
"attachmentGotBoosted": "Boosted",
"attachmentBoost": "Boost",
"attachmentCreateBoost": "Create Boost",
"attachmentBoostHint": "Boost is a feature that allows you to upload attachments to a server closer to your audience or a faster content network. This feature is currently in beta and is subject to change. It's all free for now, you can feel free to try, you will get notified when the pricing plan changed.",
"attachmentDestinationRegion": "Destination Region",
"attachmentDestinationRegionAPAC": "Asia Pacific",
"attachmentDestinationRegionNGB": "Ning Bo, China, Zhejiang",
"attachmentDestinationRegionHKG": "Hong Kong",
"notification": "Notification", "notification": "Notification",
"notificationUnreadCount": { "notificationUnreadCount": {
"zero": "All notifications read", "zero": "All notifications read",
@ -369,9 +405,26 @@
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment", "dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
"dailyCheckNegativeHint6": "Going out", "dailyCheckNegativeHint6": "Going out",
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain", "dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
"happyBirthday": "Happy birthday, {}!", "celebrateBirthday": "Happy birthday, {}!",
"celebrateMerryXmas": "Merry christmas, {}", "celebrateMerryXmas": "Merry christmas, {}",
"celebrateNewYear": "Happy new year, {}", "celebrateNewYear": "Happy new year, {}",
"celebrateValentineDay": "Today is valentine's day, {}!",
"celebrateLaborDay": "Today is labor day, {}.",
"celebrateMotherDay": "Today is mother's day, {}.",
"celebrateChildrenDay": "Today is children's day, {}!",
"celebrateFatherDay": "Today is father's day, {}.",
"celebrateHalloween": "Happy halloween, {}!",
"celebrateThanksgiving": "Today is thanksgiving day, {}!",
"pendingBirthday": "Birthday in {}",
"pendingMerryXmas": "Christmas in {}",
"pendingNewYear": "New year in {}",
"pendingValentineDay": "Valentine's day in {}",
"pendingLaborDay": "Labor day in {}",
"pendingMotherDay": "Mother's day in {}",
"pendingChildrenDay": "Children's day in {}",
"pendingFatherDay": "Father's day in {}",
"pendingHalloween": "Halloween in {}",
"pendingThanksgiving": "Thanksgiving day in {}",
"friendNew": "Add Friend", "friendNew": "Add Friend",
"friendRequests": "Friend Requests", "friendRequests": "Friend Requests",
"friendRequestsDescription": { "friendRequestsDescription": {
@ -405,6 +458,7 @@
"accountJoinedAt": "Joined at {}", "accountJoinedAt": "Joined at {}",
"accountBirthday": "Born on {}", "accountBirthday": "Born on {}",
"accountBadge": "Badge", "accountBadge": "Badge",
"accountCheckInNoRecords": "No check-in records",
"badgeCompanyStaff": "Solsynth Staff", "badgeCompanyStaff": "Solsynth Staff",
"badgeSiteMigration": "Solar Network Native", "badgeSiteMigration": "Solar Network Native",
"accountStatus": "Status", "accountStatus": "Status",
@ -413,6 +467,7 @@
"accountStatusLastSeen": "Last seen at {}", "accountStatusLastSeen": "Last seen at {}",
"postArticle": "Article on the Solar Network", "postArticle": "Article on the Solar Network",
"postStory": "Story on the Solar Network", "postStory": "Story on the Solar Network",
"postLocalDraftRestored": "Restored from device",
"articleWrittenAt": "Written at {}", "articleWrittenAt": "Written at {}",
"articleEditedAt": "Edited at {}", "articleEditedAt": "Edited at {}",
"attachmentSaved": "Saved to album", "attachmentSaved": "Saved to album",
@ -448,7 +503,7 @@
"publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.", "publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.",
"userUnblocked": "{} has been unblocked.", "userUnblocked": "{} has been unblocked.",
"userBlocked": "{} has been blocked.", "userBlocked": "{} has been blocked.",
"postSharingViaPicture": "Capturing post as picture, please stand by...", "postSharingViaPicture": "Capturing post as picture, please wait...",
"postImageShareReadMore": "Scan the QR code to read full post", "postImageShareReadMore": "Scan the QR code to read full post",
"postImageShareAds": "Explore posts on the Solar Network", "postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share", "postShare": "Share",
@ -459,5 +514,26 @@
"shareIntentDescription": "What do you want to do with the content you are sharing?", "shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story", "shareIntentPostStory": "Post a Story",
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"updateOngoing": "正在更新,请稍后..." "updateOngoing": "Updating, please wait...",
"custom": "Custom",
"colorSchemeIndigo": "Indigo",
"colorSchemeBlue": "Blue",
"colorSchemeGreen": "Green",
"colorSchemeYellow": "Yellow",
"colorSchemeOrange": "Orange",
"colorSchemeRed": "Red",
"colorSchemeWhite": "White",
"colorSchemeBlack": "Black",
"colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
"postCategoryTechnology": "Technology",
"postCategoryGaming": "Gaming",
"postCategoryLife": "Life",
"postCategoryArts": "Arts",
"postCategorySports": "Sports",
"postCategoryMusic": "Music",
"postCategoryNews": "News",
"postCategoryKnowledge": "Knowledge",
"postCategoryLiterature": "Literature",
"postCategoryFunny": "Funny",
"postCategoryUncategorized": "Uncategorized"
} }

View File

@ -123,6 +123,9 @@
"fieldPostTitle": "标题", "fieldPostTitle": "标题",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "标签", "fieldPostTags": "标签",
"fieldPostCategories": "分类",
"fieldPostAlias": "别名",
"fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。",
"postPublish": "发布", "postPublish": "发布",
"postPublishedAt": "发布于", "postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于", "postPublishedUntil": "取消发布于",
@ -180,6 +183,12 @@
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。", "settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
"settingsThemeMaterial3": "使用 Material You 设计范式", "settingsThemeMaterial3": "使用 Material You 设计范式",
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。", "settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
"settingsAppBarTransparent": "透明顶栏",
"settingsAppBarTransparentDescription": "为顶栏启用透明效果。",
"settingsColorScheme": "主题色",
"settingsColorSchemeDescription": "设置应用主题色。",
"settingsColorSeed": "预设色彩主题",
"settingsColorSeedDescription": "选择一个预设色彩主题。",
"settingsNetwork": "网络", "settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器", "settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。", "settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
@ -270,16 +279,43 @@
"one": "{} 个附件", "one": "{} 个附件",
"other": "{} 个附件" "other": "{} 个附件"
}, },
"fieldAttachmentRandomId": "访问 ID",
"addAttachmentFromAlbum": "从相册中添加附件", "addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件", "addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片", "addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频", "addAttachmentFromCameraVideo": "拍摄视频",
"addAttachmentFromRandomId": "通过访问 ID 链接",
"attachmentPastedImage": "粘贴的图片", "attachmentPastedImage": "粘贴的图片",
"attachmentInsertLink": "插入连接", "attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图", "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
"attachmentCompressVideo": "重新编码视频",
"attachmentSetThumbnail": "设置缩略图", "attachmentSetThumbnail": "设置缩略图",
"attachmentCopyRandomId": "复制访问 ID",
"attachmentUpload": "上传", "attachmentUpload": "上传",
"attachmentInputDialog": "上传附件",
"attachmentInputUseRandomId": "使用访问 ID",
"attachmentInputNew": "新上传附件",
"waitingForUpload": "等待上传",
"attachmentVideoCompressHint": "压缩一份视频的副本",
"attachmentVideoCompressHintDescription": "你想上传压缩视频 {} 的副本吗?它将帮助你的观众快速预览视频,并且他们仍然可以观看原始视频。这将会在在你的设备上处理视频,所以需要一些时间,所以请耐心等待。",
"attachmentCompressQuality": "压缩质量",
"attachmentCompressQualityHighest": "最高",
"attachmentCompressQualityDefault": "默认",
"attachmentCompressQualityMedium": "中等",
"attachmentCompressQualityLow": "低",
"attachmentCompressQualityHint": "Solar Network 并没有阻止你上传大文件、高分辨率、高码率的视频,但是为了你的网络情况观众考虑,我们建议你选择一个合适的压缩质量。",
"attachmentUploaded": "已上传",
"attachmentPending": "未上传",
"attachmentCopyCompressed": "有压缩副本",
"attachmentGotBoosted": "有加速传递",
"attachmentBoost": "加速包",
"attachmentCreateBoost": "加速传递",
"attachmentBoostHint": "加速传递允许您将附件上传到更近的受众或更快的内容网络。该功能目前处于 Beta 阶段。该功能限时免费,当有价格计划更改时,您将会被通知。",
"attachmentDestinationRegion": "目标节点",
"attachmentDestinationRegionAPAC": "亚太地区",
"attachmentDestinationRegionNGB": "中国 · 浙江 · 宁波",
"attachmentDestinationRegionHKG": "香港",
"notification": "通知", "notification": "通知",
"notificationUnreadCount": { "notificationUnreadCount": {
"zero": "无未读通知", "zero": "无未读通知",
@ -367,9 +403,26 @@
"dailyCheckNegativeHint5Description": "关键时刻断网", "dailyCheckNegativeHint5Description": "关键时刻断网",
"dailyCheckNegativeHint6": "出门", "dailyCheckNegativeHint6": "出门",
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨", "dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
"happyBirthday": "生日快乐,{}", "celebrateBirthday": "生日快乐,{}",
"celebrateMerryXmas": "圣诞快乐,{}", "celebrateMerryXmas": "圣诞快乐,{}",
"celebrateNewYear": "新年快乐,{}", "celebrateNewYear": "新年快乐,{}",
"celebrateValentineDay": "今天是情人节,{}",
"celebrateLaborDay": "今天是劳动节,{}。",
"celebrateMotherDay": "今天是母亲节,{}。",
"celebrateChildrenDay": "今天是儿童节,{}",
"celebrateFatherDay": "今天是父亲节,{}。",
"celebrateHalloween": "快乐在圣诞节,{}",
"celebrateThanksgiving": "今天是感恩节,{}",
"pendingBirthday": "{} 过生日",
"pendingMerryXmas": "{} 过圣诞节",
"pendingNewYear": "{} 跨年",
"pendingValentineDay": "{} 过情人节",
"pendingLaborDay": "{} 过劳动节",
"pendingMotherDay": "{} 过母亲节",
"pendingChildrenDay": "{} 过儿童节",
"pendingFatherDay": "{} 过父亲节",
"pendingHalloween": "{} 过圣诞节",
"pendingThanksgiving": "{} 过感恩节",
"friendNew": "添加好友", "friendNew": "添加好友",
"friendRequests": "好友请求", "friendRequests": "好友请求",
"friendRequestsDescription": { "friendRequestsDescription": {
@ -403,14 +456,16 @@
"accountJoinedAt": "加入于 {}", "accountJoinedAt": "加入于 {}",
"accountBirthday": "出生于 {}", "accountBirthday": "出生于 {}",
"accountBadge": "徽章", "accountBadge": "徽章",
"accountCheckInNoRecords": "暂无运势记录",
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工", "badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
"badgeSiteMigration": "Solar Network 原住民", "badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "状态", "accountStatus": "状态",
"accountStatusOnline": "在线", "accountStatusOnline": "在线",
"accountStatusOffline": "离线", "accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线", "accountStatusLastSeen": "最后一次上线于 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "从本地恢复草稿",
"articleWrittenAt": "发表于 {}", "articleWrittenAt": "发表于 {}",
"articleEditedAt": "编辑于 {}", "articleEditedAt": "编辑于 {}",
"attachmentSaved": "已保存到相册", "attachmentSaved": "已保存到相册",
@ -457,5 +512,26 @@
"shareIntentDescription": "您想对您分享的内容做些什么?", "shareIntentDescription": "您想对您分享的内容做些什么?",
"shareIntentPostStory": "发布动态", "shareIntentPostStory": "发布动态",
"updateAvailable": "检测到更新可用", "updateAvailable": "检测到更新可用",
"updateOngoing": "正在更新,请稍后……" "updateOngoing": "正在更新,请稍后……",
"custom": "自定义",
"colorSchemeIndigo": "靛蓝",
"colorSchemeBlue": "蓝色",
"colorSchemeGreen": "绿色",
"colorSchemeYellow": "黄色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "红色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postCategoryTechnology": "技术",
"postCategoryGaming": "游戏",
"postCategoryLife": "生活",
"postCategoryArts": "艺术",
"postCategorySports": "体育",
"postCategoryMusic": "音乐",
"postCategoryNews": "新闻",
"postCategoryKnowledge": "知识",
"postCategoryLiterature": "文学",
"postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分类"
} }

View File

@ -123,6 +123,9 @@
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類",
"fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "發佈", "postPublish": "發佈",
"postPublishedAt": "發佈於", "postPublishedAt": "發佈於",
"postPublishedUntil": "取消發佈於", "postPublishedUntil": "取消發佈於",
@ -180,6 +183,12 @@
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。", "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
"settingsThemeMaterial3": "使用 Material You 設計範式", "settingsThemeMaterial3": "使用 Material You 設計範式",
"settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。", "settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。",
"settingsAppBarTransparent": "透明頂欄",
"settingsAppBarTransparentDescription": "為頂欄啓用透明效果。",
"settingsColorScheme": "主題色",
"settingsColorSchemeDescription": "設置應用主題色。",
"settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsNetwork": "網絡", "settingsNetwork": "網絡",
"settingsNetworkServer": "HyperNet 服務器", "settingsNetworkServer": "HyperNet 服務器",
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
@ -270,16 +279,43 @@
"one": "{} 個附件", "one": "{} 個附件",
"other": "{} 個附件" "other": "{} 個附件"
}, },
"fieldAttachmentRandomId": "訪問 ID",
"addAttachmentFromAlbum": "從相冊中添加附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromClipboard": "粘貼附件", "addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentPastedImage": "粘貼的圖片", "attachmentPastedImage": "粘貼的圖片",
"attachmentInsertLink": "插入連接", "attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
"attachmentCompressVideo": "重新編碼視頻",
"attachmentSetThumbnail": "設置縮略圖", "attachmentSetThumbnail": "設置縮略圖",
"attachmentCopyRandomId": "複製訪問 ID",
"attachmentUpload": "上傳", "attachmentUpload": "上傳",
"attachmentInputDialog": "上傳附件",
"attachmentInputUseRandomId": "使用訪問 ID",
"attachmentInputNew": "新上傳附件",
"waitingForUpload": "等待上傳",
"attachmentVideoCompressHint": "壓縮一份視頻的副本",
"attachmentVideoCompressHintDescription": "你想上傳壓縮視頻 {} 的副本嗎?它將幫助你的觀眾快速預覽視頻,並且他們仍然可以觀看原始視頻。這將會在在你的設備上處理視頻,所以需要一些時間,所以請耐心等待。",
"attachmentCompressQuality": "壓縮質量",
"attachmentCompressQualityHighest": "最高",
"attachmentCompressQualityDefault": "默認",
"attachmentCompressQualityMedium": "中等",
"attachmentCompressQualityLow": "低",
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。",
"attachmentUploaded": "已上傳",
"attachmentPending": "未上傳",
"attachmentCopyCompressed": "有壓縮副本",
"attachmentGotBoosted": "有加速傳遞",
"attachmentBoost": "加速包",
"attachmentCreateBoost": "加速傳遞",
"attachmentBoostHint": "加速傳遞允許您將附件上傳到更近的受眾或更快的內容網絡。該功能目前處於 Beta 階段。該功能限時免費,當有價格計劃更改時,您將會被通知。",
"attachmentDestinationRegion": "目標節點",
"attachmentDestinationRegionAPAC": "亞太地區",
"attachmentDestinationRegionNGB": "中國 · 浙江 · 寧波",
"attachmentDestinationRegionHKG": "香港",
"notification": "通知", "notification": "通知",
"notificationUnreadCount": { "notificationUnreadCount": {
"zero": "無未讀通知", "zero": "無未讀通知",
@ -367,7 +403,26 @@
"dailyCheckNegativeHint5Description": "關鍵時刻斷網", "dailyCheckNegativeHint5Description": "關鍵時刻斷網",
"dailyCheckNegativeHint6": "出門", "dailyCheckNegativeHint6": "出門",
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨", "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
"happyBirthday": "生日快樂,{}", "celebrateBirthday": "生日快樂,{}",
"celebrateMerryXmas": "聖誕快樂,{}",
"celebrateNewYear": "新年快樂,{}",
"celebrateValentineDay": "今天是情人節,{}",
"celebrateLaborDay": "今天是勞動節,{}。",
"celebrateMotherDay": "今天是母親節,{}。",
"celebrateChildrenDay": "今天是兒童節,{}",
"celebrateFatherDay": "今天是父親節,{}。",
"celebrateHalloween": "快樂在聖誕節,{}",
"celebrateThanksgiving": "今天是感恩節,{}",
"pendingBirthday": "{} 過生日",
"pendingMerryXmas": "{} 過聖誕節",
"pendingNewYear": "{} 跨年",
"pendingValentineDay": "{} 過情人節",
"pendingLaborDay": "{} 過勞動節",
"pendingMotherDay": "{} 過母親節",
"pendingChildrenDay": "{} 過兒童節",
"pendingFatherDay": "{} 過父親節",
"pendingHalloween": "{} 過聖誕節",
"pendingThanksgiving": "{} 過感恩節",
"friendNew": "添加好友", "friendNew": "添加好友",
"friendRequests": "好友請求", "friendRequests": "好友請求",
"friendRequestsDescription": { "friendRequestsDescription": {
@ -401,14 +456,16 @@
"accountJoinedAt": "加入於 {}", "accountJoinedAt": "加入於 {}",
"accountBirthday": "出生於 {}", "accountBirthday": "出生於 {}",
"accountBadge": "徽章", "accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工", "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeSiteMigration": "Solar Network 原住民", "badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "狀態", "accountStatus": "狀態",
"accountStatusOnline": "在線", "accountStatusOnline": "在線",
"accountStatusOffline": "離線", "accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線", "accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "從本地恢復草稿",
"articleWrittenAt": "發表於 {}", "articleWrittenAt": "發表於 {}",
"articleEditedAt": "編輯於 {}", "articleEditedAt": "編輯於 {}",
"attachmentSaved": "已保存到相冊", "attachmentSaved": "已保存到相冊",
@ -453,5 +510,28 @@
"poweredBy": "由 {} 提供支持", "poweredBy": "由 {} 提供支持",
"shareIntent": "分享", "shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?", "shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態" "shareIntentPostStory": "發佈動態",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
"colorSchemeIndigo": "靛藍",
"colorSchemeBlue": "藍色",
"colorSchemeGreen": "綠色",
"colorSchemeYellow": "黃色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "紅色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分類"
} }

View File

@ -7,15 +7,15 @@
"screenAuthLogin": "登陸", "screenAuthLogin": "登陸",
"screenAuthLoginSubtitle": "使用 Solarpass 登陸 Solar Network", "screenAuthLoginSubtitle": "使用 Solarpass 登陸 Solar Network",
"screenAuthLoginGreeting": "歡迎回來", "screenAuthLoginGreeting": "歡迎回來",
"screenAuthRegister": "建賬號", "screenAuthRegister": "建賬號",
"screenAuthRegisterSubtitle": "建一個 Solarpass 賬號", "screenAuthRegisterSubtitle": "建一個 Solarpass 賬號",
"screenAccountPublishers": "釋出者", "screenAccountPublishers": "發佈者",
"screenAccountPublisherNew": "新建釋出者", "screenAccountPublisherNew": "新建發佈者",
"screenAccountPublisherEdit": "編輯釋出者", "screenAccountPublisherEdit": "編輯發佈者",
"screenAccountProfileEdit": "編輯資料", "screenAccountProfileEdit": "編輯資料",
"screenAbuseReport": "濫用檢舉", "screenAbuseReport": "濫用檢舉",
"screenSettings": "設", "screenSettings": "設",
"screenAlbum": "相簿", "screenAlbum": "相",
"screenChat": "聊天", "screenChat": "聊天",
"screenChatManage": "編輯聊天頻道", "screenChatManage": "編輯聊天頻道",
"screenChatNew": "新建聊天頻道", "screenChatNew": "新建聊天頻道",
@ -23,37 +23,37 @@
"screenRealmManage": "編輯領域", "screenRealmManage": "編輯領域",
"screenRealmNew": "新建領域", "screenRealmNew": "新建領域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜帖子", "screenPostSearch": "搜帖子",
"screenFriend": "好友", "screenFriend": "好友",
"dialogOkay": "好的", "dialogOkay": "好的",
"dialogCancel": "取消", "dialogCancel": "取消",
"dialogConfirm": "確認", "dialogConfirm": "確認",
"dialogDismiss": "忽略", "dialogDismiss": "忽略",
"dialogError": "出了點問題", "dialogError": "出了點問題",
"errorRequestBad": "服器拒絕了您的請求,請檢查您的輸入。", "errorRequestBad": "服器拒絕了您的請求,請檢查您的輸入。",
"errorRequestUnauthorized": "未授權的請求,請登或者嘗試重新登陸。", "errorRequestUnauthorized": "未授權的請求,請登或者嘗試重新登陸。",
"errorRequestForbidden": "被禁止的請求,您沒有足夠的許可權去做那件事。", "errorRequestForbidden": "被禁止的請求,您沒有足夠的權去做那件事。",
"errorRequestNotFound": "您正查的資源無法被找到。", "errorRequestNotFound": "您正查的資源無法被找到。",
"errorRequestConnection": "網路連線錯誤,請檢查您的網狀態或者檢查我們的服務狀態。", "errorRequestConnection": "網絡連接錯誤,請檢查您的網狀態或者檢查我們的服務狀態。",
"errorRequestUnknown": "未知請求錯誤,您可能想將此對話方塊截圖併發送給我們。", "errorRequestUnknown": "未知請求錯誤,您可能想將此對話截圖併發送給我們。",
"unknown": "未知", "unknown": "未知",
"loading": "載中…", "loading": "載中…",
"prev": "上一步", "prev": "上一步",
"next": "下一步", "next": "下一步",
"edit": "編輯", "edit": "編輯",
"apply": "應用", "apply": "應用",
"cancel": "取消", "cancel": "取消",
"create": "建", "create": "建",
"preview": "預覽", "preview": "預覽",
"delete": "刪除", "delete": "刪除",
"unlink": "解除連結", "unlink": "解除鏈接",
"crop": "裁剪", "crop": "裁剪",
"compress": "壓縮", "compress": "壓縮",
"report": "檢舉", "report": "檢舉",
"repost": "轉帖", "repost": "轉帖",
"replyPost": "回貼", "replyPost": "回貼",
"reply": "回覆", "reply": "回覆",
"unset": "未設", "unset": "未設",
"untitled": "無題", "untitled": "無題",
"postDetail": "帖子詳情", "postDetail": "帖子詳情",
"postNoun": "帖子", "postNoun": "帖子",
@ -64,20 +64,20 @@
"one": "總計 {} 字", "one": "總計 {} 字",
"other": "總計 {} 字" "other": "總計 {} 字"
}, },
"fieldUsername": "使用者名稱", "fieldUsername": "用戶名",
"fieldNickname": "顯示名", "fieldNickname": "顯示名",
"fieldEmail": "電子郵箱地址", "fieldEmail": "電子郵箱地址",
"fieldPassword": "密碼", "fieldPassword": "密碼",
"fieldUsernameAlphanumOnly": "使用者名稱只能包含英文大小寫字母和數字。", "fieldUsernameAlphanumOnly": "用戶名只能包含英文大小寫字母和數字。",
"fieldUsernameLengthLimit": "使用者名稱必須在 {} 和 {} 之間。", "fieldUsernameLengthLimit": "用戶名必須在 {} 和 {} 之間。",
"fieldUsernameCannotEditHint": "使用者名稱在建立後無法修改", "fieldUsernameCannotEditHint": "用戶名在創建後無法修改",
"fieldUsernameLookupHint": "支援使用者名稱、電話號碼或郵箱地址", "fieldUsernameLookupHint": "支持用戶名、電話號碼或郵箱地址",
"fieldNicknameLengthLimit": "暱稱必須在 {} 和 {} 之間。", "fieldNicknameLengthLimit": "暱稱必須在 {} 和 {} 之間。",
"fieldEmailAddressMustBeValid": "電子郵箱地址必須是一個電子郵箱地址。", "fieldEmailAddressMustBeValid": "電子郵箱地址必須是一個電子郵箱地址。",
"fieldFirstName": "名", "fieldFirstName": "名",
"fieldLastName": "姓", "fieldLastName": "姓",
"fieldBirthday": "生日", "fieldBirthday": "生日",
"fieldImageHint": "你可以點這些個人頭像來編輯它們。", "fieldImageHint": "你可以點這些個人頭像來編輯它們。",
"fieldDescription": "簡介", "fieldDescription": "簡介",
"forgotPassword": "忘記密碼", "forgotPassword": "忘記密碼",
"loginPickFactor": "選擇方式驗證", "loginPickFactor": "選擇方式驗證",
@ -85,24 +85,24 @@
"one": "{} 步驗證", "one": "{} 步驗證",
"other": "{} 步驗證" "other": "{} 步驗證"
}, },
"loginEnterPassword": "驗證程式碼", "loginEnterPassword": "驗證碼",
"loginSuccess": "登為 {}", "loginSuccess": "登為 {}",
"authFactorPassword": "密碼", "authFactorPassword": "密碼",
"authFactorEmail": "電郵一次性驗證碼", "authFactorEmail": "電郵一次性驗證碼",
"accountIntroTitle": "喜歡您來!", "accountIntroTitle": "喜歡您來!",
"accountIntroSubtitle": "登陸以探索更廣大的世界。", "accountIntroSubtitle": "登陸以探索更廣大的世界。",
"accountLogout": "退出登", "accountLogout": "退出登",
"accountLogoutSubtitle": "登出當前賬戶的登陸狀態。", "accountLogoutSubtitle": "註銷當前賬戶的登陸狀態。",
"accountLogoutConfirmTitle": "您確定要退出登嗎?", "accountLogoutConfirmTitle": "您確定要退出登嗎?",
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。", "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
"accountPublishers": "你的釋出者", "accountPublishers": "你的發佈者",
"accountPublishersSubtitle": "管理你的公共形象。", "accountPublishersSubtitle": "管理你的公共形象。",
"accountProfileEdit": "編輯資料", "accountProfileEdit": "編輯資料",
"accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。", "accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
"accountProfileEditApplied": "個人資料修改已被應用。", "accountProfileEditApplied": "個人資料修改已被應用。",
"publishersNew": "新發布者", "publishersNew": "新發布者",
"publisherNewSubtitle": "建一個新的公共身份。", "publisherNewSubtitle": "建一個新的公共身份。",
"publisherSyncWithAccount": "同步賬戶資訊", "publisherSyncWithAccount": "同步賬戶信息",
"publisherTotalUpvote": "總頂數", "publisherTotalUpvote": "總頂數",
"publisherTotalDownvote": "總踩數", "publisherTotalDownvote": "總踩數",
"publisherSocialPoint": "社會信用點", "publisherSocialPoint": "社會信用點",
@ -115,34 +115,37 @@
"publisherAffiliatedBy": "隸屬於 {}", "publisherAffiliatedBy": "隸屬於 {}",
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設定釋出者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"fieldPostPublisher": "帖子釋出者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"postPublish": "釋出", "fieldPostCategories": "分類",
"postPublishedAt": "釋出於", "fieldPostAlias": "別名",
"postPublishedUntil": "取消釋出於", "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "發佈",
"postPublishedAt": "發佈於",
"postPublishedUntil": "取消發佈於",
"postVisibility": "可見性", "postVisibility": "可見性",
"postVisibilityDescription": "帖子可見性決定了誰能檢視該篇帖子。", "postVisibilityDescription": "帖子可見性決定了誰能查看該篇帖子。",
"postVisibilityAll": "所有人可見", "postVisibilityAll": "所有人可見",
"postVisibilityFriends": "僅限好友可見", "postVisibilityFriends": "僅限好友可見",
"postVisibilitySelected": "選定的使用者可見", "postVisibilitySelected": "選定的用戶可見",
"postVisibilityFiltered": "選定使用者不可見", "postVisibilityFiltered": "選定用戶不可見",
"postVisibilityNone": "僅自己可見", "postVisibilityNone": "僅自己可見",
"postVisibleUsers": "可見的使用者", "postVisibleUsers": "可見的用戶",
"postInvisibleUsers": "不可見的使用者", "postInvisibleUsers": "不可見的用戶",
"postSelectedUsers": { "postSelectedUsers": {
"zero": "未選擇使用者", "zero": "未選擇用戶",
"one": "選擇了 {} 個使用者", "one": "選擇了 {} 個用戶",
"other": "選擇了 {} 個使用者" "other": "選擇了 {} 個用戶"
}, },
"postEditingNotice": "你正在修改由 {} 釋出的帖子。", "postEditingNotice": "你正在修改由 {} 發佈的帖子。",
"postReplyingNotice": "你正在回覆由 {} 釋出的帖子。", "postReplyingNotice": "你正在回覆由 {} 發佈的帖子。",
"postRepostingNotice": "你正在轉發由 {} 釋出的帖子。", "postRepostingNotice": "你正在轉發由 {} 發佈的帖子。",
"postReact": "反應", "postReact": "反應",
"postPosted": "帖子已經發表。", "postPosted": "帖子已經發表。",
"postReactions": "帖子的反應", "postReactions": "帖子的反應",
@ -161,7 +164,7 @@
"one": "{} 點社會信用點變更", "one": "{} 點社會信用點變更",
"other": "{} 點社會信用點變更" "other": "{} 點社會信用點變更"
}, },
"postReactCompleted": "反應已被新增。", "postReactCompleted": "反應已被添加。",
"postReactUncompleted": "反應已被移除。", "postReactUncompleted": "反應已被移除。",
"postComments": { "postComments": {
"zero": "評論", "zero": "評論",
@ -175,70 +178,76 @@
}, },
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsBackgroundImage": "背景圖片", "settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設應用全域性生效的的背景圖片。", "settingsBackgroundImageDescription": "設應用全生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖", "settingsBackgroundImageClear": "清除現存背景圖",
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。", "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
"settingsThemeMaterial3": "使用 Material You 設計正規化", "settingsThemeMaterial3": "使用 Material You 設計範式",
"settingsThemeMaterial3Description": "將應用主題設為 Material 3 設計正規化的主題。", "settingsThemeMaterial3Description": "將應用主題設為 Material 3 設計範式的主題。",
"settingsNetwork": "網路", "settingsAppBarTransparent": "透明頂欄",
"settingsNetworkServer": "HyperNet 伺服器", "settingsAppBarTransparentDescription": "為頂欄啟用透明效果。",
"settingsNetworkServerDescription": "設定 HyperNet 伺服器地址,選擇我們提供的,或者自己搭建。", "settingsColorScheme": "主題色",
"settingsNetworkServerReset": "重設為官方伺服器", "settingsColorSchemeDescription": "設置應用主題色。",
"settingsNetworkServerResetDescription": "重設為 Solar Network 的伺服器地址。", "settingsColorSeed": "預設色彩主題",
"settingsNetworkServerPreset": "預設的 HyperNet 伺服器", "settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 伺服器地址。", "settingsNetwork": "網絡",
"settingsNetworkServerSaved": "伺服器地址已儲存。", "settingsNetworkServer": "HyperNet 服務器",
"settingsPerformance": "效能", "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
"settingsNetworkServerReset": "重設為官方服務器",
"settingsNetworkServerResetDescription": "重設為 Solar Network 的服務器地址。",
"settingsNetworkServerPreset": "預設的 HyperNet 服務器",
"settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 服務器地址。",
"settingsNetworkServerSaved": "服務器地址已保存。",
"settingsPerformance": "性能",
"settingsImageQuality": "圖片預覽質量", "settingsImageQuality": "圖片預覽質量",
"settingsImageQualityDescription": "設圖片預覽質量,會影響圖片解碼速度。", "settingsImageQualityDescription": "設圖片預覽質量,會影響圖片解碼速度。",
"settingsImageQualityLowest": "極低", "settingsImageQualityLowest": "極低",
"settingsImageQualityLow": "低", "settingsImageQualityLow": "低",
"settingsImageQualityMedium": "中", "settingsImageQualityMedium": "中",
"settingsImageQualityHigh": "高", "settingsImageQualityHigh": "高",
"settingsMisc": "雜項", "settingsMisc": "雜項",
"settingsMiscAbout": "關於", "settingsMiscAbout": "關於",
"settingsMiscAboutDescription": "檢視 Solian 的版本資訊。", "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"sensitiveContent": "敏感內容", "sensitiveContent": "敏感內容",
"sensitiveContentCollapsed": "敏感內容已摺疊。", "sensitiveContentCollapsed": "敏感內容已摺疊。",
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人檢視。", "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
"sensitiveContentReveal": "顯示內容", "sensitiveContentReveal": "顯示內容",
"serverConnecting": "正在連線伺服器…", "serverConnecting": "正在連接服務器…",
"serverDisconnected": "已與服器斷開連", "serverDisconnected": "已與服器斷開連",
"fieldChatAlias": "頻道別名", "fieldChatAlias": "頻道別名",
"fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。", "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
"fieldChatName": "名稱", "fieldChatName": "名稱",
"fieldChatDescription": "描述", "fieldChatDescription": "描述",
"fieldChatBelongToRealm": "所屬領域", "fieldChatBelongToRealm": "所屬領域",
"fieldChatBelongToRealmUnset": "未設頻道所屬領域", "fieldChatBelongToRealmUnset": "未設頻道所屬領域",
"channelEditingNotice": "您正在編輯頻道 {}", "channelEditingNotice": "您正在編輯頻道 {}",
"channelDeleted": "聊天頻道 {} 已被刪除", "channelDeleted": "聊天頻道 {} 已被刪除",
"channelDelete": "刪除聊天頻道 {}", "channelDelete": "刪除聊天頻道 {}",
"channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有息將被永久刪除。", "channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有息將被永久刪除。",
"channelDetailPersonalRegion": "個人區域", "channelDetailPersonalRegion": "個人區域",
"channelDetailMemberRegion": "成員管理", "channelDetailMemberRegion": "成員管理",
"channelMemberManage": "管理成員", "channelMemberManage": "管理成員",
"channelMemberManageDescription": "管理頻道內現有成員。", "channelMemberManageDescription": "管理頻道內現有成員。",
"channelMemberAdd": "新增成員", "channelMemberAdd": "添加成員",
"channelMemberAddDescription": "給當前頻道新增新成員。", "channelMemberAddDescription": "給當前頻道添加新成員。",
"channelMemberAdded": "頻道成員已新增。", "channelMemberAdded": "頻道成員已添加。",
"fieldMemberRelatedName": "成員名 / 賬戶 ID", "fieldMemberRelatedName": "成員名 / 賬戶 ID",
"channelDetailAdminRegion": "管理區域", "channelDetailAdminRegion": "管理區域",
"channelEditProfile": "更改頻道身份", "channelEditProfile": "更改頻道身份",
"channelEdit": "編輯頻道", "channelEdit": "編輯頻道",
"channelEditDescription": "更改頻道基本資訊,元資料等。", "channelEditDescription": "更改頻道基本信息,元數據等。",
"channelProfileEdit": "編輯頻道身份", "channelProfileEdit": "編輯頻道身份",
"channelActionDelete": "刪除頻道", "channelActionDelete": "刪除頻道",
"channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有資訊。", "channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有信息。",
"channelLeave": "退出頻道 {}", "channelLeave": "退出頻道 {}",
"channelLeaveDescription": "退出該頻道,但是你頻道內的資訊不會被移除。", "channelLeaveDescription": "退出該頻道,但是你頻道內的信息不會被移除。",
"channelActionLeave": "退出頻道", "channelActionLeave": "退出頻道",
"channelActionLeaveDescription": "刪除你在這個頻道的身份。", "channelActionLeaveDescription": "刪除你在這個頻道的身份。",
"channelNotifyLevel": "通知級別", "channelNotifyLevel": "通知級別",
"channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的息。", "channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的息。",
"channelNotifyLevelAll": "全部通知", "channelNotifyLevelAll": "全部通知",
"channelNotifyLevelMentioned": "僅提及", "channelNotifyLevelMentioned": "僅提及",
"channelNotifyLevelNone": "全部靜音", "channelNotifyLevelNone": "全部靜音",
"channelNotifyLevelApplied": "已經存並應用頻道通知級別配置。", "channelNotifyLevelApplied": "已經存並應用頻道通知級別配置。",
"fieldChannelProfileNick": "頻道內顯示名", "fieldChannelProfileNick": "頻道內顯示名",
"fieldChannelProfileNickHint": "在頻道內顯示的暱稱,留空則使用賬號顯示名。", "fieldChannelProfileNickHint": "在頻道內顯示的暱稱,留空則使用賬號顯示名。",
"fieldRealmAlias": "領域別名", "fieldRealmAlias": "領域別名",
@ -248,38 +257,65 @@
"realmEditingNotice": "您正在編輯領域 {}", "realmEditingNotice": "您正在編輯領域 {}",
"realmDeleted": "領域 {} 已被刪除", "realmDeleted": "領域 {} 已被刪除",
"realmDelete": "刪除領域 {}", "realmDelete": "刪除領域 {}",
"realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!", "realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
"realmActionDelete": "刪除領域", "realmActionDelete": "刪除領域",
"realmActionDeleteDescription": "刪除整個領域及其附屬的資源。", "realmActionDeleteDescription": "刪除整個領域及其附屬的資源。",
"realmEdit": "編輯領域", "realmEdit": "編輯領域",
"realmEditDescription": "更改領域基本資訊,元資料等。", "realmEditDescription": "更改領域基本信息,元數據等。",
"realmMemberAdd": "新增成員", "realmMemberAdd": "添加成員",
"realmMemberAddDescription": "給當前領域新增新成員。", "realmMemberAddDescription": "給當前領域添加新成員。",
"realmMemberAdded": "領域成員已新增。", "realmMemberAdded": "領域成員已添加。",
"fieldChatMessage": "在 {} 中發息", "fieldChatMessage": "在 {} 中發息",
"fieldChatMessageDirect": "給 {} 發息", "fieldChatMessageDirect": "給 {} 發息",
"eventResourceTag": "息 {}", "eventResourceTag": "息 {}",
"messageDelete": "刪除息 {}", "messageDelete": "刪除息 {}",
"messageDeleteDescription": "你確定要刪除這個息嗎?該操作不可撤銷。同時您將留下一條刪除息的記錄。", "messageDeleteDescription": "你確定要刪除這個息嗎?該操作不可撤銷。同時您將留下一條刪除息的記錄。",
"messageDeleted": "息 {} 已被刪除", "messageDeleted": "息 {} 已被刪除",
"messageEdited": "息 {} 已被編輯", "messageEdited": "息 {} 已被編輯",
"messageEditedHint": "已編輯", "messageEditedHint": "已編輯",
"messageUnsupported": "不支援的訊息 {}", "messageUnsupported": "不支持的消息 {}",
"messageFileHint": { "messageFileHint": {
"zero": "沒有附件", "zero": "沒有附件",
"one": "{} 個附件", "one": "{} 個附件",
"other": "{} 個附件" "other": "{} 個附件"
}, },
"addAttachmentFromAlbum": "從相簿中新增附件", "fieldAttachmentRandomId": "訪問 ID",
"addAttachmentFromClipboard": "貼上附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝影片", "addAttachmentFromCameraVideo": "拍攝視頻",
"attachmentPastedImage": "貼上的圖片", "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentInsertLink": "插入連線", "attachmentPastedImage": "粘貼的圖片",
"attachmentSetAsPostThumbnail": "設定為帖子縮圖", "attachmentInsertLink": "插入連接",
"attachmentUnsetAsPostThumbnail": "取消設定為帖子縮圖", "attachmentSetAsPostThumbnail": "設置為帖子縮圖",
"attachmentSetThumbnail": "設定縮圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
"attachmentCompressVideo": "重新編碼視頻",
"attachmentSetThumbnail": "設置縮略圖",
"attachmentCopyRandomId": "複製訪問 ID",
"attachmentUpload": "上傳", "attachmentUpload": "上傳",
"attachmentInputDialog": "上傳附件",
"attachmentInputUseRandomId": "使用訪問 ID",
"attachmentInputNew": "新上傳附件",
"waitingForUpload": "等待上傳",
"attachmentVideoCompressHint": "壓縮一份視頻的副本",
"attachmentVideoCompressHintDescription": "你想上傳壓縮視頻 {} 的副本嗎?它將幫助你的觀眾快速預覽視頻,並且他們仍然可以觀看原始視頻。這將會在在你的設備上處理視頻,所以需要一些時間,所以請耐心等待。",
"attachmentCompressQuality": "壓縮質量",
"attachmentCompressQualityHighest": "最高",
"attachmentCompressQualityDefault": "默認",
"attachmentCompressQualityMedium": "中等",
"attachmentCompressQualityLow": "低",
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。",
"attachmentUploaded": "已上傳",
"attachmentPending": "未上傳",
"attachmentCopyCompressed": "有壓縮副本",
"attachmentGotBoosted": "有加速傳遞",
"attachmentBoost": "加速包",
"attachmentCreateBoost": "加速傳遞",
"attachmentBoostHint": "加速傳遞允許您將附件上傳到更近的受眾或更快的內容網絡。該功能目前處於 Beta 階段。該功能限時免費,當有價格計劃更改時,您將會被通知。",
"attachmentDestinationRegion": "目標節點",
"attachmentDestinationRegionAPAC": "亞太地區",
"attachmentDestinationRegionNGB": "中國 · 浙江 · 寧波",
"attachmentDestinationRegionHKG": "香港",
"notification": "通知", "notification": "通知",
"notificationUnreadCount": { "notificationUnreadCount": {
"zero": "無未讀通知", "zero": "無未讀通知",
@ -289,18 +325,18 @@
"notificationUnread": "未讀", "notificationUnread": "未讀",
"notificationRead": "已讀", "notificationRead": "已讀",
"notificationMarkAllRead": "已讀所有通知", "notificationMarkAllRead": "已讀所有通知",
"notificationMarkAllReadDescription": "您確定要將所有通知設為已讀嗎?該操作不可撤銷。", "notificationMarkAllReadDescription": "您確定要將所有通知設為已讀嗎?該操作不可撤銷。",
"notificationMarkAllReadPrompt": { "notificationMarkAllReadPrompt": {
"zero": "已將 0 個通知標記為已讀。", "zero": "已將 0 個通知標記為已讀。",
"one": "已將 {} 個通知標記為已讀。", "one": "已將 {} 個通知標記為已讀。",
"other": "已將 {} 個通知標記為已讀。" "other": "已將 {} 個通知標記為已讀。"
}, },
"notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。", "notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。",
"search": "搜", "search": "搜",
"postSearchResult": { "postSearchResult": {
"zero": "沒有搜到結果", "zero": "沒有搜到結果",
"one": "搜到 {} 個結果", "one": "搜到 {} 個結果",
"other": "搜到 {} 個結果" "other": "搜到 {} 個結果"
}, },
"postSearchTook": "耗時 {}", "postSearchTook": "耗時 {}",
"postDelete": "刪除帖子 {}", "postDelete": "刪除帖子 {}",
@ -312,26 +348,26 @@
"callResume": "恢復", "callResume": "恢復",
"callMicrophone": "麥克風", "callMicrophone": "麥克風",
"callCamera": "攝像頭", "callCamera": "攝像頭",
"callMicrophoneDisabled": "麥克風已用", "callMicrophoneDisabled": "麥克風已用",
"callMicrophoneSelect": "選擇麥克風", "callMicrophoneSelect": "選擇麥克風",
"callCameraDisabled": "攝像頭已用", "callCameraDisabled": "攝像頭已用",
"callCameraSelect": "選擇攝像頭", "callCameraSelect": "選擇攝像頭",
"callDisconnected": "通話已斷開", "callDisconnected": "通話已斷開",
"callEnded": "通話已結束", "callEnded": "通話已結束",
"callStatusConnected": "已連", "callStatusConnected": "已連",
"callStatusDisconnected": "未連", "callStatusDisconnected": "未連",
"callStatusConnecting": "正在連", "callStatusConnecting": "正在連",
"callStatusReconnecting": "正在重連", "callStatusReconnecting": "正在重連",
"callDisconnect": "斷開連", "callDisconnect": "斷開連",
"callDisconnectDescription": "您確定要與通話斷開連嗎?", "callDisconnectDescription": "您確定要與通話斷開連嗎?",
"callMicrophoneOff": "關閉麥克風", "callMicrophoneOff": "關閉麥克風",
"callMicrophoneOn": "開麥克風", "callMicrophoneOn": "開麥克風",
"callCameraOff": "關閉攝像頭", "callCameraOff": "關閉攝像頭",
"callCameraOn": "開攝像頭", "callCameraOn": "開攝像頭",
"callVideoFlip": "映象畫面", "callVideoFlip": "鏡像畫面",
"callSpeakerphoneToggle": "切換揚聲器", "callSpeakerphoneToggle": "切換揚聲器",
"callScreenOff": "關閉幕共享", "callScreenOff": "關閉幕共享",
"callScreenOn": "開啟幕共享", "callScreenOn": "開啟幕共享",
"callMessageEnded": "通話持續了 {}", "callMessageEnded": "通話持續了 {}",
"callMessageStarted": "通話開始了", "callMessageStarted": "通話開始了",
"dailyCheckIn": "每日簽到", "dailyCheckIn": "每日簽到",
@ -367,28 +403,47 @@
"dailyCheckNegativeHint5Description": "關鍵時刻斷網", "dailyCheckNegativeHint5Description": "關鍵時刻斷網",
"dailyCheckNegativeHint6": "出門", "dailyCheckNegativeHint6": "出門",
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨", "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
"happyBirthday": "生日快樂,{}", "celebrateBirthday": "生日快樂,{}",
"friendNew": "新增好友", "celebrateMerryXmas": "聖誕快樂,{}",
"celebrateNewYear": "新年快樂,{}",
"celebrateValentineDay": "今天是情人節,{}",
"celebrateLaborDay": "今天是勞動節,{}。",
"celebrateMotherDay": "今天是母親節,{}。",
"celebrateChildrenDay": "今天是兒童節,{}",
"celebrateFatherDay": "今天是父親節,{}。",
"celebrateHalloween": "快樂在聖誕節,{}",
"celebrateThanksgiving": "今天是感恩節,{}",
"pendingBirthday": "{} 過生日",
"pendingMerryXmas": "{} 過聖誕節",
"pendingNewYear": "{} 跨年",
"pendingValentineDay": "{} 過情人節",
"pendingLaborDay": "{} 過勞動節",
"pendingMotherDay": "{} 過母親節",
"pendingChildrenDay": "{} 過兒童節",
"pendingFatherDay": "{} 過父親節",
"pendingHalloween": "{} 過聖誕節",
"pendingThanksgiving": "{} 過感恩節",
"friendNew": "添加好友",
"friendRequests": "好友請求", "friendRequests": "好友請求",
"friendRequestsDescription": { "friendRequestsDescription": {
"zero": "你沒有好友請求", "zero": "你沒有好友請求",
"one": "你有 {} 個好友請求", "one": "你有 {} 個好友請求",
"other": "你有 {} 個好友請求" "other": "你有 {} 個好友請求"
}, },
"friendBlocklist": "蔽列表", "friendBlocklist": "蔽列表",
"friendBlocklistDescription": { "friendBlocklistDescription": {
"zero": "你沒有蔽任何人", "zero": "你沒有蔽任何人",
"one": "你蔽了 {} 個使用者", "one": "你蔽了 {} 個用戶",
"other": "你蔽了 {} 個使用者" "other": "你蔽了 {} 個用戶"
}, },
"friendStatusPending": "待處理", "friendStatusPending": "待處理",
"friendStatusWaiting": "等待中", "friendStatusWaiting": "等待中",
"friendStatusActive": "正活躍", "friendStatusActive": "正活躍",
"friendStatusBlocked": "已蔽", "friendStatusBlocked": "已蔽",
"friendRequestSent": "好友請求已送。", "friendRequestSent": "好友請求已送。",
"fieldFriendRelatedName": "好友名 / 賬戶 ID", "fieldFriendRelatedName": "好友名 / 賬戶 ID",
"friendBlock": "蔽", "friendBlock": "蔽",
"friendUnblock": "解除蔽", "friendUnblock": "解除蔽",
"friendDeleteAction": "遺忘", "friendDeleteAction": "遺忘",
"friendDelete": "遺忘跟 {} 的關係", "friendDelete": "遺忘跟 {} 的關係",
"friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。", "friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
@ -401,23 +456,25 @@
"accountJoinedAt": "加入於 {}", "accountJoinedAt": "加入於 {}",
"accountBirthday": "出生於 {}", "accountBirthday": "出生於 {}",
"accountBadge": "徽章", "accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工", "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeSiteMigration": "Solar Network 原住民", "badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "狀態", "accountStatus": "狀態",
"accountStatusOnline": "線", "accountStatusOnline": "線",
"accountStatusOffline": "離線", "accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線", "accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "從本地恢復草稿",
"articleWrittenAt": "發表於 {}", "articleWrittenAt": "發表於 {}",
"articleEditedAt": "編輯於 {}", "articleEditedAt": "編輯於 {}",
"attachmentSaved": "已存到相簿", "attachmentSaved": "已存到相",
"attachmentSavedDesktop": "已存到下載目錄", "attachmentSavedDesktop": "已存到下載目錄",
"openInAlbum": "在相簿中開啟", "openInAlbum": "在相冊中打開",
"postAbuseReport": "檢舉帖子", "postAbuseReport": "檢舉帖子",
"postAbuseReportDescription": "檢舉不符合我們使用者協議以及社準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。", "postAbuseReportDescription": "檢舉不符合我們用戶協議以及社準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
"abuseReport": "檢舉", "abuseReport": "檢舉",
"abuseReportDescription": "檢舉不符合我們使用者協議以及社準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。", "abuseReportDescription": "檢舉不符合我們用戶協議以及社準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
"abuseReportAction": "提交檢舉", "abuseReportAction": "提交檢舉",
"abuseReportActionDescription": "檢舉不合規行為。", "abuseReportActionDescription": "檢舉不合規行為。",
"abuseReportResource": "資源位置 / ID", "abuseReportResource": "資源位置 / ID",
@ -425,33 +482,56 @@
"abuseReportSubmitted": "檢舉已提交,感謝你的貢獻。", "abuseReportSubmitted": "檢舉已提交,感謝你的貢獻。",
"submit": "提交", "submit": "提交",
"accountDeletion": "刪除帳戶", "accountDeletion": "刪除帳戶",
"accountDeletionDescription": "你確定要刪除這個帳戶嗎?該操作不可撤銷,其隸屬於該帳戶的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!", "accountDeletionDescription": "你確定要刪除這個帳戶嗎?該操作不可撤銷,其隸屬於該帳戶的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
"accountDeletionActionDescription": "刪除你的 Solarpass 帳戶。", "accountDeletionActionDescription": "刪除你的 Solarpass 帳戶。",
"accountDeletionSubmitted": "帳戶刪除申請已發出,你可以檢查你的收件箱並根據郵件內的指示完成刪除操作。", "accountDeletionSubmitted": "帳戶刪除申請已發出,你可以檢查你的收件箱並根據郵件內的指示完成刪除操作。",
"channelNewChannel": "新建頻道", "channelNewChannel": "新建頻道",
"channelNewDirectMessage": "發起私信", "channelNewDirectMessage": "發起私信",
"channelDirectMessageDescription": "與 {} 的私聊", "channelDirectMessageDescription": "與 {} 的私聊",
"fieldCannotBeEmpty": "此欄位不能為空。", "fieldCannotBeEmpty": "此字段不能為空。",
"termAcceptLink": "瀏覽條款", "termAcceptLink": "瀏覽條款",
"termAcceptNextWithAgree": "點 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。", "termAcceptNextWithAgree": "點 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
"unauthorized": "未登陸", "unauthorized": "未登陸",
"unauthorizedDescription": "登陸以探索整個 Solar Network。", "unauthorizedDescription": "登陸以探索整個 Solar Network。",
"serviceStatus": "服務狀態", "serviceStatus": "服務狀態",
"termRelated": "相關條款", "termRelated": "相關條款",
"appDetails": "應用程詳情", "appDetails": "應用程詳情",
"postRecommendation": "推薦帖子", "postRecommendation": "推薦帖子",
"publisherBlockHint": "蔽 {}", "publisherBlockHint": "蔽 {}",
"publisherBlockHintDescription": "你正要蔽此釋出者的運營者,該操作也將蔽由同一使用者運營的釋出者。", "publisherBlockHintDescription": "你正要蔽此發佈者的運營者,該操作也將蔽由同一用戶運營的發佈者。",
"userUnblocked": "已解除遮蔽使用者 {}", "userUnblocked": "已解除屏蔽用戶 {}",
"userBlocked": "已遮蔽使用者 {}", "userBlocked": "已屏蔽用戶 {}",
"postSharingViaPicture": "正在生成帖子截圖,請稍等片刻……", "postSharingViaPicture": "正在生成帖子截圖,請稍等片刻……",
"postImageShareReadMore": "掃描右側 QRCode 檢視全文", "postImageShareReadMore": "掃描右側 QRCode 查看全文",
"postImageShareAds": "來 Solar Network 探索更多有趣帖子", "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
"postShare": "分享", "postShare": "分享",
"postShareImage": "分享帖圖", "postShareImage": "分享帖圖",
"appInitializing": "正在初始化", "appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支", "poweredBy": "由 {} 提供支",
"shareIntent": "分享", "shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?", "shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "釋出動態" "shareIntentPostStory": "發佈動態",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
"colorSchemeIndigo": "靛藍",
"colorSchemeBlue": "藍色",
"colorSchemeGreen": "綠色",
"colorSchemeYellow": "黃色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "紅色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryFunny": "搞笑",
"postCategoryUncategorized": "未分類"
} }

View File

@ -103,6 +103,8 @@ PODS:
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_update (0.0.1):
- Flutter
- flutter_native_splash (2.4.3): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
@ -168,8 +170,10 @@ PODS:
- Flutter - Flutter
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- in_app_review (2.0.0):
- Flutter
- Kingfisher (8.1.3) - Kingfisher (8.1.3)
- livekit_client (2.3.2): - livekit_client (2.3.4):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@ -207,12 +211,11 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- video_compress (0.3.0):
- Flutter
- volume_controller (0.0.1): - volume_controller (0.0.1):
- Flutter - Flutter
- wakelock_plus (0.0.1): - wakelock_plus (0.0.1):
@ -232,12 +235,14 @@ DEPENDENCIES:
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`) - gal (from `.symlinks/plugins/gal/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
@ -251,8 +256,8 @@ DEPENDENCIES:
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_compress (from `.symlinks/plugins/video_compress/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager (from `.symlinks/plugins/workmanager/ios`) - workmanager (from `.symlinks/plugins/workmanager/ios`)
@ -298,6 +303,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios"
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_udid: flutter_udid:
@ -310,6 +317,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/home_widget/ios" :path: ".symlinks/plugins/home_widget/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client: livekit_client:
:path: ".symlinks/plugins/livekit_client/ios" :path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video: media_kit_libs_ios_video:
@ -334,10 +343,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
video_compress:
:path: ".symlinks/plugins/video_compress/ios"
volume_controller: volume_controller:
:path: ".symlinks/plugins/volume_controller/ios" :path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus: wakelock_plus:
@ -364,8 +373,9 @@ SPEC CHECKSUMS:
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563 flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
@ -373,8 +383,9 @@ SPEC CHECKSUMS:
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd livekit_client: 4eaa7a2968fc7e7c57888f43d90394547cc8d9e9
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -390,9 +401,9 @@ SPEC CHECKSUMS:
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db

View File

@ -80,7 +80,11 @@ class NotificationService: UNNotificationServiceExtension {
let metadataCopy = metadata as? [String: String] ?? [:] let metadataCopy = metadata as? [String: String] ?? [:]
let avatarUrl = getAttachmentUrl(for: avatarIdentifier) let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, completionHandler: { result in
let targetSize = 640
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, options: [.processor(scaleProcessor)], completionHandler: { result in
var image: Data? var image: Data?
switch result { switch result {
case .success(let value): case .success(let value):

View File

@ -1,11 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
@ -13,17 +19,11 @@ import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:video_compress/video_compress.dart';
enum PostWriteMediaType {
image,
video,
audio,
file,
}
class PostWriteMedia { class PostWriteMedia {
late String name; late String name;
late PostWriteMediaType type; late SnMediaType type;
final SnAttachment? attachment; final SnAttachment? attachment;
final XFile? file; final XFile? file;
final Uint8List? raw; final Uint8List? raw;
@ -35,16 +35,16 @@ class PostWriteMedia {
switch (attachment?.mimetype.split('/').firstOrNull) { switch (attachment?.mimetype.split('/').firstOrNull) {
case 'image': case 'image':
type = PostWriteMediaType.image; type = SnMediaType.image;
break; break;
case 'video': case 'video':
type = PostWriteMediaType.video; type = SnMediaType.video;
break; break;
case 'audio': case 'audio':
type = PostWriteMediaType.audio; type = SnMediaType.audio;
break; break;
default: default:
type = PostWriteMediaType.file; type = SnMediaType.file;
} }
} }
@ -56,16 +56,16 @@ class PostWriteMedia {
switch (mimetype?.split('/').firstOrNull) { switch (mimetype?.split('/').firstOrNull) {
case 'image': case 'image':
type = PostWriteMediaType.image; type = SnMediaType.image;
break; break;
case 'video': case 'video':
type = PostWriteMediaType.video; type = SnMediaType.video;
break; break;
case 'audio': case 'audio':
type = PostWriteMediaType.audio; type = SnMediaType.audio;
break; break;
default: default:
type = PostWriteMediaType.file; type = SnMediaType.file;
} }
} }
@ -152,10 +152,21 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController contentController = TextEditingController(); final TextEditingController contentController = TextEditingController();
final TextEditingController titleController = TextEditingController(); final TextEditingController titleController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final TextEditingController aliasController = TextEditingController();
PostWriteController() { PostWriteController() {
titleController.addListener(() => notifyListeners()); titleController.addListener(() {
descriptionController.addListener(() => notifyListeners()); _temporaryPlanSave();
notifyListeners();
});
descriptionController.addListener(() {
_temporaryPlanSave();
notifyListeners();
});
contentController.addListener(() {
_temporaryPlanSave();
});
_temporaryLoad();
} }
String mode = kTitleMap.keys.first; String mode = kTitleMap.keys.first;
@ -176,6 +187,7 @@ class PostWriteController extends ChangeNotifier {
List<int> visibleUsers = List.empty(); List<int> visibleUsers = List.empty();
List<int> invisibleUsers = List.empty(); List<int> invisibleUsers = List.empty();
List<String> tags = List.empty(); List<String> tags = List.empty();
List<String> categories = List.empty();
PostWriteMedia? thumbnail; PostWriteMedia? thumbnail;
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
@ -198,12 +210,14 @@ class PostWriteController extends ChangeNotifier {
titleController.text = post.body['title'] ?? ''; titleController.text = post.body['title'] ?? '';
descriptionController.text = post.body['description'] ?? ''; descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? '';
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? []); visibleUsers = List.from(post.visibleUsersList ?? []);
invisibleUsers = List.from(post.invisibleUsersList ?? []); invisibleUsers = List.from(post.invisibleUsersList ?? []);
visibility = post.visibility; visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias)); tags = List.from(post.tags.map((ele) => ele.alias));
categories = List.from(post.categories.map((ele) => ele.alias));
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
@ -231,7 +245,8 @@ class PostWriteController extends ChangeNotifier {
} }
} }
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async { Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
{bool isCompressed = false}) async {
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
final place = await attach.chunkedUploadInitialize( final place = await attach.chunkedUploadInitialize(
@ -239,22 +254,139 @@ class PostWriteController extends ChangeNotifier {
media.name, media.name,
'interactive', 'interactive',
null, null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
); );
final item = await attach.chunkedUploadParts( var item = await attach.chunkedUploadParts(
media.toFile()!, media.toFile()!,
place.$1, place.$1,
place.$2, place.$2,
onProgress: (progress) { analyzeNow: media.type == SnMediaType.image,
progress = progress; onProgress: (value) {
progress = value;
notifyListeners(); notifyListeners();
}, },
); );
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
try {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
}
} catch (err) {
if (context.mounted) context.showErrorDialog(err);
}
}
return item; return item;
} }
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
if (media.type != SnMediaType.video) return null;
if (media.file == null) return null;
if (VideoCompress.isCompressing) return null;
final confirm = await context.showConfirmDialog(
'attachmentVideoCompressHint'.tr(),
'attachmentVideoCompressHintDescription'.tr(args: [media.file!.name]),
);
if (!confirm) return null;
progress = null;
notifyListeners();
final mediaInfo = await VideoCompress.compressVideo(
media.file!.path,
quality: VideoQuality.LowQuality,
frameRate: 30,
deleteOrigin: false,
);
if (mediaInfo == null) return null;
if (!context.mounted) return null;
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
return compressedAttachment;
}
static const kTemporaryStorageKey = 'int_draft_post';
Timer? _temporarySaveTimer;
void _temporaryPlanSave() {
_temporarySaveTimer?.cancel();
_temporarySaveTimer = Timer(const Duration(seconds: 1), () {
_temporarySave();
log("[PostWriter] Temporary save saved.");
});
}
void _temporarySave() {
SharedPreferences.getInstance().then((prefs) {
if (titleController.text.isEmpty &&
descriptionController.text.isEmpty &&
contentController.text.isEmpty &&
thumbnail == null &&
attachments.isEmpty) {
prefs.remove(kTemporaryStorageKey);
return;
}
prefs.setString(
kTemporaryStorageKey,
jsonEncode({
'publisher': publisher,
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
}),
);
});
}
bool temporaryRestored = false;
void _temporaryLoad() {
SharedPreferences.getInstance().then((prefs) {
final raw = prefs.getString(kTemporaryStorageKey);
if (raw == null) return;
final data = jsonDecode(raw);
contentController.text = data['content'];
aliasController.text = data['alias'] ?? '';
titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
tags = List.from(data['tags'].map((ele) => ele['alias']));
categories = List.from(data['categories'].map((ele) => ele['alias']));
visibility = data['visibility'];
visibleUsers = List.from(data['visible_users_list'] ?? []);
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
temporaryRestored = true;
notifyListeners();
});
}
Future<void> uploadSingleAttachment(BuildContext context, int idx) async { Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
if (isBusy) return; if (isBusy) return;
@ -269,7 +401,7 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> post(BuildContext context) async { Future<void> sendPost(BuildContext context) async {
if (isBusy || publisher == null) return; if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
@ -296,21 +428,34 @@ class PostWriteController extends ChangeNotifier {
media.name, media.name,
'interactive', 'interactive',
null, null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
); );
final item = await attach.chunkedUploadParts( var item = await attach.chunkedUploadParts(
media.toFile()!, media.toFile()!,
place.$1, place.$1,
place.$2, place.$2,
onProgress: (progress) { onProgress: (value) {
// Calculate overall progress for attachments // Calculate overall progress for attachments
progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight; progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
notifyListeners(); notifyListeners();
}, },
); );
try {
if (context.mounted) {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
}
}
} catch (err) {
if (context.mounted) context.showErrorDialog(err);
}
progress = (i + 1) / attachments.length * kAttachmentProgressWeight;
attachments[i] = PostWriteMedia(item); attachments[i] = PostWriteMedia(item);
notifyListeners();
} }
} catch (err) { } catch (err) {
isBusy = false; isBusy = false;
@ -334,11 +479,13 @@ class PostWriteController extends ChangeNotifier {
data: { data: {
'publisher': publisher!.id, 'publisher': publisher!.id,
'content': contentController.text, 'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text, if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility, 'visibility': visibility,
'visible_users_list': visibleUsers, 'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers, 'invisible_users_list': invisibleUsers,
@ -359,6 +506,7 @@ class PostWriteController extends ChangeNotifier {
method: editingPost != null ? 'PUT' : 'POST', method: editingPost != null ? 'PUT' : 'POST',
), ),
); );
reset();
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -407,51 +555,67 @@ class PostWriteController extends ChangeNotifier {
void setPublisher(SnPublisher? item) { void setPublisher(SnPublisher? item) {
publisher = item; publisher = item;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setPublishedAt(DateTime? value) { void setPublishedAt(DateTime? value) {
publishedAt = value; publishedAt = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setPublishedUntil(DateTime? value) { void setPublishedUntil(DateTime? value) {
publishedUntil = value; publishedUntil = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setTags(List<String> value) { void setTags(List<String> value) {
tags = value; tags = value;
_temporaryPlanSave();
notifyListeners();
}
void setCategories(List<String> value) {
categories = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setVisibility(int value) { void setVisibility(int value) {
visibility = value; visibility = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setVisibleUsers(List<int> value) { void setVisibleUsers(List<int> value) {
visibleUsers = value; visibleUsers = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setInvisibleUsers(List<int> value) { void setInvisibleUsers(List<int> value) {
invisibleUsers = value; invisibleUsers = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setProgress(double? value) { void setProgress(double? value) {
progress = value; progress = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setIsBusy(bool value) { void setIsBusy(bool value) {
isBusy = value; isBusy = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
void setMode(String value) { void setMode(String value) {
mode = value; mode = value;
_temporaryPlanSave();
notifyListeners(); notifyListeners();
} }
@ -461,11 +625,16 @@ class PostWriteController extends ChangeNotifier {
titleController.clear(); titleController.clear();
descriptionController.clear(); descriptionController.clear();
contentController.clear(); contentController.clear();
aliasController.clear();
tags.clear();
categories.clear();
attachments.clear(); attachments.clear();
editingPost = null; editingPost = null;
replyingPost = null; replyingPost = null;
repostingPost = null; repostingPost = null;
mode = kTitleMap.keys.first; mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners(); notifyListeners();
} }
@ -474,6 +643,7 @@ class PostWriteController extends ChangeNotifier {
contentController.dispose(); contentController.dispose();
titleController.dispose(); titleController.dispose();
descriptionController.dispose(); descriptionController.dispose();
aliasController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@ -30,6 +30,7 @@ import 'package:surface/providers/post.dart';
import 'package:surface/providers/relationship.dart'; import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
@ -148,6 +149,9 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
// Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)),
], ],
child: _AppDelegate(), child: _AppDelegate(),
), ),
@ -265,6 +269,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
// The Network initialization will also save initialize the Config, so it not need to be initialized again // The Network initialization will also save initialize the Config, so it not need to be initialized again
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent(); await sn.initializeUserAgent();
await sn.setConfigWithNative();
if (!mounted) return; if (!mounted) return;
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await ua.initialize(); await ua.initialize();

View File

@ -9,6 +9,10 @@ const kRtkStoreKey = 'nex_user_rtk';
const kNetworkServerDefault = 'https://api.sn.solsynth.dev'; const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
const kNetworkServerStoreKey = 'app_server_url'; const kNetworkServerStoreKey = 'app_server_url';
const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background';
const kAppColorSchemeStoreKey = 'app_color_scheme';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
'settingsImageQualityLow': FilterQuality.low, 'settingsImageQualityLow': FilterQuality.low,

View File

@ -0,0 +1,41 @@
import 'package:intl/intl.dart';
const List<int> kExperienceToLevelRequirements = [
0, // Level 0
1000, // Level 1
4000, // Level 2
9000, // Level 3
16000, // Level 4
25000, // Level 5
36000, // Level 6
49000, // Level 7
64000, // Level 8
81000, // Level 9
100000, // Level 10
121000, // Level 11
144000, // Level 12
368000 // Level 13
];
int getLevelFromExp(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
return idx;
}
double calcLevelUpProgress(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 1;
final nextExp = kExperienceToLevelRequirements[idx + 1];
return (experience - exp).abs() / (exp - nextExp).abs();
}
String calcLevelUpProgressLevel(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 'Infinity';
final nextExp = exp - kExperienceToLevelRequirements[idx + 1];
final formatter = NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
}

View File

@ -83,12 +83,16 @@ class SnPostContentProvider {
int offset = 0, int offset = 0,
String? type, String? type,
String? author, String? author,
Iterable<String>? categories,
Iterable<String>? tags,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
if (type != null) 'type': type, if (type != null) 'type': type,
if (author != null) 'author': author, if (author != null) 'author': author,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
}); });
final List<SnPost> out = await _preloadRelatedDataInBatch( final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
@ -118,12 +122,14 @@ class SnPostContentProvider {
int take = 10, int take = 10,
int offset = 0, int offset = 0,
Iterable<String>? tags, Iterable<String>? tags,
Iterable<String>? categories,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: { final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
'probe': searchTerm, 'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
}); });
final List<SnPost> out = await _preloadRelatedDataInBatch( final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),

View File

@ -21,7 +21,7 @@ class SnAttachmentProvider {
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
for (final item in items) { for (final item in items) {
if ((item.isAnalyzed && item.isUploaded) || noCheck) { if (item.isAnalyzed || noCheck) {
_cache[item.rid] = item; _cache[item.rid] = item;
} }
} }
@ -34,15 +34,14 @@ class SnAttachmentProvider {
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
final out = SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
if (out.isAnalyzed && out.isUploaded) { if (out.isAnalyzed) {
_cache[rid] = out; _cache[rid] = out;
} }
return out; return out;
} }
Future<List<SnAttachment?>> getMultiple(List<String> rids, Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async {
{noCache = false}) async {
final result = List<SnAttachment?>.filled(rids.length, null); final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> randomMapping = {}; final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) { for (int i = 0; i < rids.length; i++) {
@ -63,13 +62,12 @@ class SnAttachmentProvider {
'id': pendingFetch.join(','), 'id': pendingFetch.join(','),
}, },
); );
final out = resp.data['data'] final List<SnAttachment?> out =
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList();
.toList();
for (final item in out) { for (final item in out) {
if (item == null) continue; if (item == null) continue;
if (item.isAnalyzed && item.isUploaded) { if (item.isAnalyzed) {
_cache[item.rid] = item; _cache[item.rid] = item;
} }
result[randomMapping[item.rid]!] = item; result[randomMapping[item.rid]!] = item;
@ -79,10 +77,7 @@ class SnAttachmentProvider {
return result; return result;
} }
static Map<String, String> mimetypeOverrides = { static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'};
'mov': 'video/quicktime',
'mp4': 'video/mp4'
};
Future<SnAttachment> directUploadOne( Future<SnAttachment> directUploadOne(
Uint8List data, Uint8List data,
@ -91,13 +86,11 @@ class SnAttachmentProvider {
Map<String, dynamic>? metadata, { Map<String, dynamic>? metadata, {
String? mimetype, String? mimetype,
Function(double progress)? onProgress, Function(double progress)? onProgress,
bool analyzeNow = false,
}) async { }) async {
final filePayload = MultipartFile.fromBytes(data, filename: filename); final filePayload = MultipartFile.fromBytes(data, filename: filename);
final fileAlt = filename.contains('.') final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
? filename.substring(0, filename.lastIndexOf('.')) final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride; String? mimetypeOverride;
if (mimetype != null) { if (mimetype != null) {
@ -116,6 +109,7 @@ class SnAttachmentProvider {
final resp = await _sn.client.post( final resp = await _sn.client.post(
'/cgi/uc/attachments', '/cgi/uc/attachments',
data: formData, data: formData,
queryParameters: {'analyzeNow': analyzeNow},
onSendProgress: (count, total) { onSendProgress: (count, total) {
if (onProgress != null) { if (onProgress != null) {
onProgress(count / total); onProgress(count / total);
@ -126,18 +120,15 @@ class SnAttachmentProvider {
return SnAttachment.fromJson(resp.data); return SnAttachment.fromJson(resp.data);
} }
Future<(SnAttachment, int)> chunkedUploadInitialize( Future<(SnAttachmentFragment, int)> chunkedUploadInitialize(
int size, int size,
String filename, String filename,
String pool, String pool,
Map<String, dynamic>? metadata, { Map<String, dynamic>? metadata, {
String? mimetype, String? mimetype,
}) async { }) async {
final fileAlt = filename.contains('.') final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
? filename.substring(0, filename.lastIndexOf('.')) final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride; String? mimetypeOverride;
if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
@ -146,7 +137,7 @@ class SnAttachmentProvider {
mimetypeOverride = mimetype; mimetypeOverride = mimetype;
} }
final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: { final resp = await _sn.client.post('/cgi/uc/fragments', data: {
'alt': fileAlt, 'alt': fileAlt,
'name': filename, 'name': filename,
'pool': pool, 'pool': pool,
@ -155,21 +146,20 @@ class SnAttachmentProvider {
if (mimetypeOverride != null) 'mimetype': mimetypeOverride, if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
}); });
return ( return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
SnAttachment.fromJson(resp.data['meta']),
resp.data['chunk_size'] as int
);
} }
Future<SnAttachment> _chunkedUploadOnePart( Future<dynamic> _chunkedUploadOnePart(
Uint8List data, Uint8List data,
String rid, String rid,
String cid, { String cid, {
Function(double progress)? onProgress, Function(double progress)? onProgress,
bool analyzeNow = false,
}) async { }) async {
final resp = await _sn.client.post( final resp = await _sn.client.post(
'/cgi/uc/attachments/multipart/$rid/$cid', '/cgi/uc/fragments/$rid/$cid',
data: data, data: data,
queryParameters: {'analyzeNow': analyzeNow},
options: Options(headers: {'Content-Type': 'application/octet-stream'}), options: Options(headers: {'Content-Type': 'application/octet-stream'}),
onSendProgress: (count, total) { onSendProgress: (count, total) {
if (onProgress != null) { if (onProgress != null) {
@ -178,21 +168,28 @@ class SnAttachmentProvider {
}, },
); );
return SnAttachment.fromJson(resp.data); if (resp.data['attachment'] != null) {
return SnAttachment.fromJson(resp.data['attachment']);
} else {
return SnAttachmentFragment.fromJson(resp.data['fragment']);
}
} }
Future<SnAttachment> chunkedUploadParts( Future<SnAttachment> chunkedUploadParts(
XFile file, XFile file,
SnAttachment place, SnAttachmentFragment place,
int chunkSize, { int chunkSize, {
Function(double progress)? onProgress, Function(double progress)? onProgress,
bool analyzeNow = false,
}) async { }) async {
final Map<String, dynamic> chunks = place.fileChunks ?? {}; final Map<String, dynamic> chunks = place.fileChunks;
var currentTask = 0; var completedTasks = 0;
final queue = Queue<Future<void>>(); final queue = Queue<Future<void>>();
final activeTasks = <Future<void>>[]; final activeTasks = <Future<void>>[];
late SnAttachment out;
for (final entry in chunks.entries) { for (final entry in chunks.entries) {
queue.add(() async { queue.add(() async {
final beginCursor = entry.value * chunkSize; final beginCursor = entry.value * chunkSize;
@ -200,25 +197,28 @@ class SnAttachmentProvider {
(entry.value + 1) * chunkSize, (entry.value + 1) * chunkSize,
await file.length(), await file.length(),
); );
final data = Uint8List.fromList(await file final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList());
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
place = await _chunkedUploadOnePart( final result = await _chunkedUploadOnePart(
data, data,
place.rid, place.rid,
entry.key, entry.key,
onProgress: (chunkProgress) { analyzeNow: analyzeNow,
final overallProgress = onProgress: (progress) {
(currentTask + chunkProgress) / chunks.length; final overallProgress = (completedTasks + progress) / chunks.length;
if (onProgress != null) { onProgress?.call(overallProgress);
onProgress(overallProgress);
}
}, },
); );
currentTask++; completedTasks++;
final overallProgress = completedTasks / chunks.length;
onProgress?.call(overallProgress);
if (result is SnAttachmentFragment) {
place = result;
} else {
out = result as SnAttachment;
}
}()); }());
} }
@ -235,6 +235,24 @@ class SnAttachmentProvider {
} }
} }
return place; return out;
}
Future<SnAttachment> updateOne(
SnAttachment item, {
String? alt,
int? thumbnailId,
int? compressedId,
Map<String, dynamic>? metadata,
bool? isIndexable,
}) async {
final resp = await _sn.client.put('/cgi/uc/attachments/${item.id}', data: {
'alt': alt ?? item.alt,
'thumbnail': thumbnailId ?? item.thumbnailId,
'compressed': compressedId ?? item.compressedId,
'metadata': metadata ?? item.usermeta,
'is_indexable': isIndexable ?? item.isIndexable,
});
return SnAttachment.fromJson(resp.data);
} }
} }

View File

@ -68,9 +68,8 @@ class SnNetworkProvider {
_config.initialize().then((_) { _config.initialize().then((_) {
_prefs = _config.prefs; _prefs = _config.prefs;
client.options.baseUrl = _config.serverUrl; client.options.baseUrl = _config.serverUrl;
if (!context.mounted) return;
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
}); });
} }
static Future<Dio> createOffContextClient() async { static Future<Dio> createOffContextClient() async {
@ -109,6 +108,10 @@ class SnNetworkProvider {
return client; return client;
} }
Future<void> setConfigWithNative() async {
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
}
static Future<String> _getUserAgent() async { static Future<String> _getUserAgent() async {
final String platformInfo; final String platformInfo;
if (kIsWeb) { if (kIsWeb) {

View File

@ -0,0 +1,136 @@
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/userinfo.dart';
// Stored as key: month, day
const Map<String, (int, int)> kSpecialDays = {
// Birthday is dynamically generated according to the user's profile
'NewYear': (1, 1),
'ValentineDay': (2, 14),
'LaborDay': (5, 1),
'MotherDay': (5, 11),
'ChildrenDay': (6, 1),
'FatherDay': (8, 8),
'Halloween': (10, 31),
'Thanksgiving': (11, 28),
'MerryXmas': (12, 25),
};
const Map<String, String> kSpecialDaysSymbol = {
'Birthday': '🎂',
'NewYear': '🎉',
'MerryXmas': '🎄',
'ValentineDay': '💑',
'LaborDay': '🏋️',
'MotherDay': '👩',
'ChildrenDay': '👶',
'FatherDay': '👨',
'Halloween': '🎃',
'Thanksgiving': '🎅',
};
class SpecialDayProvider {
late final UserProvider _user;
SpecialDayProvider(BuildContext context) {
_user = context.read<UserProvider>();
}
List<String> getSpecialDays() {
final now = DateTime.now().toLocal();
final birthday = _user.user?.profile?.birthday?.toLocal();
final isBirthday = birthday != null && birthday.day == now.day && birthday.month == now.month;
return [
if (isBirthday) 'Birthday',
...kSpecialDays.keys.where(
(key) => kSpecialDays[key]!.$1 == now.month && kSpecialDays[key]!.$2 == now.day,
),
];
}
(String, DateTime)? getLastSpecialDay() {
final now = DateTime.now().toLocal();
final birthday = _user.user?.profile?.birthday?.toLocal();
final Map<String, (int, int)> specialDays = {
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
...kSpecialDays,
};
DateTime? lastDate;
String? lastEvent;
for (final entry in specialDays.entries) {
final eventName = entry.key;
final (month, day) = entry.value;
var specialDayThisYear = DateTime(now.year, month, day);
var specialDayLastYear = DateTime(now.year - 1, month, day);
if (specialDayThisYear.isBefore(now)) {
if (lastDate == null || specialDayThisYear.isAfter(lastDate)) {
lastDate = specialDayThisYear;
lastEvent = eventName;
}
} else if (specialDayLastYear.isBefore(now)) {
if (lastDate == null || specialDayLastYear.isAfter(lastDate)) {
lastDate = specialDayLastYear;
lastEvent = eventName;
}
}
}
if (lastEvent != null && lastDate != null) {
return (lastEvent, lastDate);
}
return null;
}
(String, DateTime)? getNextSpecialDay() {
final now = DateTime.now().toLocal();
final birthday = _user.user?.profile?.birthday?.toLocal();
// Stored as key: month, day
final Map<String, (int, int)> specialDays = {
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
...kSpecialDays,
};
DateTime? closestDate;
String? closestEvent;
for (final entry in specialDays.entries) {
final eventName = entry.key;
final (month, day) = entry.value;
// Calculate the special day's DateTime in the current year
var specialDay = DateTime(now.year, month, day);
// If the special day has already passed this year, consider it for the next year
if (specialDay.isBefore(now)) {
specialDay = DateTime(now.year + 1, month, day);
}
// Check if this special day is closer than the previously found one
if (closestDate == null || specialDay.isBefore(closestDate)) {
closestDate = specialDay;
closestEvent = eventName;
}
}
if (closestEvent != null && closestDate != null) {
return (closestEvent, closestDate);
}
// No special day found
return null;
}
double getSpecialDayProgress(DateTime last, DateTime next) {
final totalDuration = next.add(-const Duration(days: 1)).difference(last).inSeconds.toDouble();
final elapsedDuration = DateTime.now().difference(last).inSeconds.toDouble();
return (elapsedDuration / totalDuration).clamp(0.0, 1.0);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
@ -11,8 +13,8 @@ class ThemeProvider extends ChangeNotifier {
}); });
} }
void reloadTheme({bool? useMaterial3}) { void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
createAppThemeSet().then((value) { createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
theme = value; theme = value;
notifyListeners(); notifyListeners();
}); });

View File

@ -1,12 +1,10 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
class UserProvider extends ChangeNotifier { class UserProvider extends ChangeNotifier {
@ -14,12 +12,10 @@ class UserProvider extends ChangeNotifier {
SnAccount? user; SnAccount? user;
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final HomeWidgetProvider _home;
late final ConfigProvider _config; late final ConfigProvider _config;
UserProvider(BuildContext context) { UserProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_home = context.read<HomeWidgetProvider>();
_config = context.read<ConfigProvider>(); _config = context.read<ConfigProvider>();
} }
@ -32,10 +28,10 @@ class UserProvider extends ChangeNotifier {
final value = _config.prefs.getString(kAtkStoreKey); final value = _config.prefs.getString(kAtkStoreKey);
isAuthorized = value != null; isAuthorized = value != null;
notifyListeners(); notifyListeners();
refreshUser().then((value) { refreshUser().then((value) async {
if (value != null) { if (value != null) {
log('Logged in as @${value.name}'); log('Logged in as @${value.name}');
_home.saveWidgetData('user', value.toJson()); log('Atk: ${await atk}');
} }
}); });
} }

View File

@ -13,7 +13,7 @@ class HomeWidgetProvider {
Future<void> initialize() async { Future<void> initialize() async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
if (!kIsWeb && Platform.isIOS) { if (Platform.isIOS) {
await HomeWidget.setAppGroupId("group.solsynth.solian"); await HomeWidget.setAppGroupId("group.solsynth.solian");
} }
} }

View File

@ -77,8 +77,11 @@ final _appRoutes = [
GoRoute( GoRoute(
path: '/search', path: '/search',
name: 'postSearch', name: 'postSearch',
builder: (context, state) => const AppBackground( builder: (context, state) => AppBackground(
child: PostSearchScreen(), child: PostSearchScreen(
initialTags: state.uri.queryParameters['tags']?.split(','),
initialCategories: state.uri.queryParameters['categories']?.split(','),
),
), ),
), ),
GoRoute( GoRoute(

View File

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -9,10 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/relationship.dart'; import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/abuse_report.dart'; import 'package:surface/screens/abuse_report.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -61,6 +64,19 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
} }
} }
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
return List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
}
}
SnAccountStatusInfo? _status; SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async { Future<void> _fetchStatus() async {
@ -228,7 +244,13 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
SliverAppBar( Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
),
child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
title: _account == null title: _account == null
? Text('loading').tr() ? Text('loading').tr()
@ -238,7 +260,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
TextSpan( TextSpan(
text: _account!.nick, text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -246,7 +268,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
TextSpan( TextSpan(
text: '@${_account!.name}', text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -288,6 +310,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
) )
: null, : null,
), ),
),
if (_account != null) if (_account != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
@ -430,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Column( Column(
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.calendar_add_on), const Icon(Symbols.calendar_add_on),
const Gap(8), const Gap(8),
@ -437,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.cake), const Icon(Symbols.cake),
const Gap(8), const Gap(8),
@ -450,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.identity_platform), const Icon(Symbols.identity_platform),
const Gap(8), const Gap(8),
@ -459,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
).opacity(0.8), ).opacity(0.8),
], ],
), ),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.star),
const Gap(8),
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
const Gap(8),
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
const Gap(8),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator(
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
).alignment(Alignment.centerLeft),
),
],
),
], ],
).padding(horizontal: 8), ).padding(horizontal: 8),
], ],
@ -466,6 +512,33 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
), ),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter(
child: FutureBuilder<List<SnCheckInRecord>>(
future: _getCheckInRecords(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
if (snapshot.data!.length <= 1) {
return Text(
'accountCheckInNoRecords',
textAlign: TextAlign.center,
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
}
final records = snapshot.data!;
return SizedBox(
width: double.infinity,
height: 240,
child: CheckInRecordChart(records: records),
).padding(
right: 24,
left: 16,
top: 12,
);
},
),
),
const SliverGap(12),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -534,3 +607,105 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
); );
} }
} }
class CheckInRecordChart extends StatelessWidget {
const CheckInRecordChart({
super.key,
required this.records,
});
final List<SnCheckInRecord> records;
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
color: Theme.of(context).colorScheme.primary,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: List.filled(
records.length,
Theme.of(context).colorScheme.primary.withOpacity(0.3),
).toList(),
),
),
spots: records
.map(
(x) => FlSpot(
x.createdAt
.copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
)
.millisecondsSinceEpoch
.toDouble(),
x.resultTier.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map(
(spot) => LineTooltipItem(
'${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
.toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 1,
getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
kCheckInResultTierSymbols[value.toInt()],
textAlign: TextAlign.right,
).padding(right: 8),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 86400000,
getTitlesWidget: (value, _) => Text(
DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch(
value.toInt(),
),
),
textAlign: TextAlign.center,
).padding(top: 8),
),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@ -22,15 +22,14 @@ import 'package:surface/widgets/universal_image.dart';
class AccountPublisherEditScreen extends StatefulWidget { class AccountPublisherEditScreen extends StatefulWidget {
final String name; final String name;
const AccountPublisherEditScreen({super.key, required this.name}); const AccountPublisherEditScreen({super.key, required this.name});
@override @override
State<AccountPublisherEditScreen> createState() => State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
_AccountPublisherEditScreenState();
} }
class _AccountPublisherEditScreenState class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
extends State<AccountPublisherEditScreen> {
bool _isBusy = false; bool _isBusy = false;
SnPublisher? _publisher; SnPublisher? _publisher;
@ -54,7 +53,7 @@ class _AccountPublisherEditScreenState
_publisher = SnPublisher.fromJson(resp.data); _publisher = SnPublisher.fromJson(resp.data);
_syncWidget(); _syncWidget();
} catch (err) { } catch (err) {
context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -75,9 +74,9 @@ class _AccountPublisherEditScreenState
'name': _nameController.text, 'name': _nameController.text,
'description': _descriptionController.text, 'description': _descriptionController.text,
}); });
Navigator.pop(context, true); if (mounted) Navigator.pop(context, true);
} catch (err) { } catch (err) {
context.showErrorDialog(err); if(mounted) context.showErrorDialog(err);
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -108,11 +107,9 @@ class _AccountPublisherEditScreenState
if (image == null) return; if (image == null) return;
if (!mounted) return; if (!mounted) return;
final ImageProvider imageProvider = final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); final aspectRatios =
final aspectRatios = place == 'banner' place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper( ? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
@ -134,10 +131,7 @@ class _AccountPublisherEditScreenState
setState(() => _isBusy = true); setState(() => _isBusy = true);
final rawBytes = final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
try { try {
final attachment = await attach.directUploadOne( final attachment = await attach.directUploadOne(
@ -199,9 +193,7 @@ class _AccountPublisherEditScreenState
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: Container( child: Container(
color: Theme.of(context) color: Theme.of(context).colorScheme.surfaceContainerHigh,
.colorScheme
.surfaceContainerHigh,
child: _banner != null child: _banner != null
? AutoResizeUniversalImage( ? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!), sn.getAttachmentUrl(_banner!),
@ -240,8 +232,7 @@ class _AccountPublisherEditScreenState
labelText: 'fieldUsername'.tr(), labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(), helperText: 'fieldUsernameCannotEditHint'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -249,8 +240,7 @@ class _AccountPublisherEditScreenState
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'fieldNickname'.tr(), labelText: 'fieldNickname'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -260,8 +250,7 @@ class _AccountPublisherEditScreenState
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'fieldDescription'.tr(), labelText: 'fieldDescription'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
Row( Row(

View File

@ -201,7 +201,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
} }
class _PublisherNewOrganization extends StatefulWidget { class _PublisherNewOrganization extends StatefulWidget {
const _PublisherNewOrganization({super.key}); const _PublisherNewOrganization();
@override @override
State<_PublisherNewOrganization> createState() => State<_PublisherNewOrganization> createState() =>

View File

@ -105,6 +105,7 @@ class _LoginCheckScreen extends StatefulWidget {
final SnAuthFactor? factor; final SnAuthFactor? factor;
final Function(SnAuthTicket?) onTicket; final Function(SnAuthTicket?) onTicket;
final Function onNext; final Function onNext;
const _LoginCheckScreen({ const _LoginCheckScreen({
super.key, super.key,
required this.ticket, required this.ticket,
@ -204,9 +205,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
autofillHints: [ autofillHints: [
(_factorLabelMap[widget.factor!.type]?.$3 ?? true) (_factorLabelMap[widget.factor!.type]?.$3 ?? true) ? AutofillHints.password : AutofillHints.oneTimeCode
? AutofillHints.password
: AutofillHints.oneTimeCode
], ],
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
@ -243,6 +242,7 @@ class _LoginPickerScreen extends StatefulWidget {
final Function(SnAuthTicket?) onTicket; final Function(SnAuthTicket?) onTicket;
final Function(SnAuthFactor) onPickFactor; final Function(SnAuthFactor) onPickFactor;
final Function onNext; final Function onNext;
const _LoginPickerScreen({ const _LoginPickerScreen({
super.key, super.key,
required this.ticket, required this.ticket,
@ -260,8 +260,7 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
bool _isBusy = false; bool _isBusy = false;
int? _factorPicked; int? _factorPicked;
Color get _unFocusColor => Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
void _performGetFactorCode() async { void _performGetFactorCode() async {
if (_factorPicked == null) return; if (_factorPicked == null) return;
@ -373,6 +372,7 @@ class _LoginLookupScreen extends StatefulWidget {
final Function(SnAuthTicket?) onTicket; final Function(SnAuthTicket?) onTicket;
final Function(List<SnAuthFactor>?) onFactor; final Function(List<SnAuthFactor>?) onFactor;
final Function onNext; final Function onNext;
const _LoginLookupScreen({ const _LoginLookupScreen({
super.key, super.key,
required this.ticket, required this.ticket,
@ -401,14 +401,13 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final lookupResp = final lookupResp = await sn.client.get('/cgi/id/users/lookup?probe=$username');
await sn.client.get('/cgi/id/users/lookup?probe=$username');
await sn.client.post('/cgi/id/users/me/password-reset', data: { await sn.client.post('/cgi/id/users/me/password-reset', data: {
'user_id': lookupResp.data['id'], 'user_id': lookupResp.data['id'],
}); });
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr()); if (mounted) context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
} catch (err) { } catch (err) {
context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -431,8 +430,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
widget.onTicket(result.ticket); widget.onTicket(result.ticket);
// Pull factors // Pull factors
final factorResp = final factorResp = await sn.client.get('/cgi/id/auth/factors', queryParameters: {
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
'ticketId': result.ticket!.id.toString(), 'ticketId': result.ticket!.id.toString(),
}); });
widget.onFactor( widget.onFactor(
@ -443,7 +441,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
widget.onNext(); widget.onNext();
} catch (err) { } catch (err) {
context.showErrorDialog(err); if(mounted) context.showErrorDialog(err);
return; return;
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
@ -526,10 +524,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
'termAcceptNextWithAgree'.tr(), 'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end, textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context) color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
), ),
), ),
Material( Material(

View File

@ -443,7 +443,7 @@ class _ChannelProfileDetailDialogState
class _ChannelMemberListWidget extends StatefulWidget { class _ChannelMemberListWidget extends StatefulWidget {
final SnChannel channel; final SnChannel channel;
const _ChannelMemberListWidget({super.key, required this.channel}); const _ChannelMemberListWidget({required this.channel});
@override @override
State<_ChannelMemberListWidget> createState() => State<_ChannelMemberListWidget> createState() =>
@ -580,7 +580,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
class _NewChannelMemberWidget extends StatefulWidget { class _NewChannelMemberWidget extends StatefulWidget {
final SnChannel channel; final SnChannel channel;
const _NewChannelMemberWidget({super.key, required this.channel}); const _NewChannelMemberWidget({required this.channel});
@override @override
State<_NewChannelMemberWidget> createState() => State<_NewChannelMemberWidget> createState() =>

View File

@ -97,7 +97,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
} }
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
print((err as DioException).response?.data);
context.showErrorDialog(err); context.showErrorDialog(err);
} finally { } finally {
setState(() => _isCalling = false); setState(() => _isCalling = false);

View File

@ -5,12 +5,28 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad,
'life': Symbols.nightlife,
'arts': Symbols.format_paint,
'sports': Symbols.sports_soccer,
'music': Symbols.music_note,
'news': Symbols.newspaper,
'knowledge': Symbols.library_books,
'literature': Symbols.book,
'funny': Symbols.attractions,
};
class ExploreScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key}); const ExploreScreen({super.key});
@ -24,15 +40,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
bool _isBusy = true; bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true); final List<SnPost> _posts = List.empty(growable: true);
final List<SnPostCategory> _categories = List.empty(growable: true);
int? _postCount; int? _postCount;
String? _selectedCategory;
Future<void> _fetchCategories() async {
_categories.clear();
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/categories?take=100');
_categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(take: 10, offset: _posts.length); final result = await pt.listPosts(
take: 10,
offset: _posts.length,
categories: _selectedCategory != null ? [_selectedCategory!] : null,
);
final out = result.$1; final out = result.$1;
if (!mounted) return; if (!mounted) return;
@ -43,10 +78,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
Future<void> _refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchPosts(); _fetchPosts();
_fetchCategories();
} }
@override @override
@ -59,27 +101,20 @@ class _ExploreScreenState extends State<ExploreScreen> {
type: ExpandableFabType.up, type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none, childrenAnimation: ExpandableFabAnimation.none,
overlayStyle: ExpandableFabOverlayStyle( overlayStyle: ExpandableFabOverlayStyle(
color: Theme.of(context) color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
.colorScheme
.surface
.withAlpha((255 * 0.5).round()),
), ),
openButtonBuilder: RotateFloatingActionButtonBuilder( openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Symbols.add, size: 28), child: const Icon(Symbols.add, size: 28),
fabSize: ExpandableFabSize.regular, fabSize: ExpandableFabSize.regular,
foregroundColor: foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
Theme.of(context).floatingActionButtonTheme.foregroundColor, backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(), shape: const CircleBorder(),
), ),
closeButtonBuilder: DefaultFloatingActionButtonBuilder( closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28), child: const Icon(Symbols.close, size: 28),
fabSize: ExpandableFabSize.regular, fabSize: ExpandableFabSize.regular,
foregroundColor: foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
Theme.of(context).floatingActionButtonTheme.foregroundColor, backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(), shape: const CircleBorder(),
), ),
children: [ children: [
@ -95,8 +130,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'stories', 'mode': 'stories',
}).then((value) { }).then((value) {
if (value == true) { if (value == true) {
_posts.clear(); _refreshPosts();
_fetchPosts();
} }
}); });
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
@ -117,8 +151,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'articles', 'mode': 'articles',
}).then((value) { }).then((value) {
if (value == true) { if (value == true) {
_posts.clear(); _refreshPosts();
_fetchPosts();
} }
}); });
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
@ -131,10 +164,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
), ),
body: RefreshIndicator( body: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top, displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () { onRefresh: () => _refreshPosts(),
_posts.clear();
return _fetchPosts();
},
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
@ -151,6 +181,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
), ),
const Gap(8), const Gap(8),
], ],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50),
child: SizedBox(
height: 50,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _categories.map((ele) {
return StyledWidget(ChoiceChip(
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
label: Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
),
selected: _selectedCategory == ele.alias,
onSelected: (value) {
_selectedCategory = value ? ele.alias : null;
_refreshPosts();
},
)).padding(horizontal: 4);
}).toList(),
),
),
),
),
), ),
SliverInfiniteList( SliverInfiniteList(
itemCount: _posts.length, itemCount: _posts.length,
@ -167,8 +225,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
setState(() => _posts[idx] = data); setState(() => _posts[idx] = data);
}, },
onDeleted: () { onDeleted: () {
_posts.clear(); _refreshPosts();
_fetchPosts();
}, },
), ),
onTap: () { onTap: () {

View File

@ -289,7 +289,7 @@ class _FriendScreenState extends State<FriendScreen> {
} }
class _NewFriendWidget extends StatefulWidget { class _NewFriendWidget extends StatefulWidget {
const _NewFriendWidget({super.key}); const _NewFriendWidget();
@override @override
State<_NewFriendWidget> createState() => _NewFriendWidgetState(); State<_NewFriendWidget> createState() => _NewFriendWidgetState();
@ -365,7 +365,7 @@ class _NewFriendWidgetState extends State<_NewFriendWidget> {
class _FriendshipListWidget extends StatefulWidget { class _FriendshipListWidget extends StatefulWidget {
final List<SnRelationship> relations; final List<SnRelationship> relations;
const _FriendshipListWidget({super.key, required this.relations}); const _FriendshipListWidget({required this.relations});
@override @override
State<_FriendshipListWidget> createState() => _FriendshipListWidgetState(); State<_FriendshipListWidget> createState() => _FriendshipListWidgetState();

View File

@ -11,20 +11,22 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:slide_countdown/slide_countdown.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import '../providers/widget.dart';
class HomeScreenDashEntry { class HomeScreenDashEntry {
final String name; final String name;
final Widget child; final Widget child;
@ -80,8 +82,8 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column( child: Column(
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start, mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [ children: [
_HomeDashSpecialDayWidget().padding(bottom: 8, horizontal: 8),
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)), _HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
_HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent( StaggeredGrid.extent(
maxCrossAxisExtent: 280, maxCrossAxisExtent: 280,
mainAxisSpacing: 8, mainAxisSpacing: 8,
@ -108,7 +110,7 @@ class _HomeScreenState extends State<HomeScreen> {
class _HomeDashUpdateWidget extends StatelessWidget { class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding; final EdgeInsets? padding;
const _HomeDashUpdateWidget({super.key, this.padding}); const _HomeDashUpdateWidget({this.padding});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -133,7 +135,7 @@ class _HomeDashUpdateWidget extends StatelessWidget {
final model = UpdateModel( final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk', 'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-release-${config.updatableVersion!}.apk', 'solian-app-release-${config.updatableVersion!}.apk',
'ic_notification', 'ic_launcher',
'https://apps.apple.com/us/app/solian/id6499032345', 'https://apps.apple.com/us/app/solian/id6499032345',
); );
AzhonAppUpdate.update(model); AzhonAppUpdate.update(model);
@ -152,46 +154,76 @@ class _HomeDashUpdateWidget extends StatelessWidget {
} }
class _HomeDashSpecialDayWidget extends StatelessWidget { class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget({super.key}); const _HomeDashSpecialDayWidget();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
final today = DateTime.now(); final dayz = context.watch<SpecialDayProvider>();
final birthday = ua.user?.profile?.birthday?.toLocal();
final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month;
final days = dayz.getSpecialDays();
if (days.isNotEmpty) {
return Column( return Column(
spacing: 8, spacing: 8,
children: days.map((ele) {
return Card(
child: ListTile(
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
subtitle: Text(
DateFormat('y/M/d').format(DateTime.now().copyWith(
month: kSpecialDays[ele]!.$1,
day: kSpecialDays[ele]!.$2,
)),
),
),
).padding(bottom: 8);
}).toList());
}
final nextOne = dayz.getNextSpecialDay();
final lastOne = dayz.getLastSpecialDay();
if (nextOne != null && lastOne != null) {
var (name, date) = nextOne;
date = date.add(Duration(days: 1));
final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
final diff = nextOne.$2.difference(DateTime.now());
return Card(
child: ListTile(
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
title: Text('pending$name').tr(args: [RelativeTime(context).format(date)]),
subtitle: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (isBirthday) SlideCountdown(
Card( duration: diff,
child: ListTile( style: GoogleFonts.robotoMono(fontSize: 13),
leading: Text('🎂').fontSize(24), separatorStyle: GoogleFonts.robotoMono(fontSize: 13),
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']), separatorType: SeparatorType.symbol,
decoration: BoxDecoration(),
padding: EdgeInsets.zero,
), ),
).padding(bottom: 8), const Gap(12),
if (today.month == 12 && today.day == 25) Expanded(
Card( child: LinearProgressIndicator(
child: ListTile( value: progress,
leading: Text('🎄').fontSize(24), borderRadius: BorderRadius.circular(8),
title: Text('celebrateMerryXmas').tr(args: [ua.user?.nick ?? 'user']),
),
),
if (today.month == 1 && today.day == 1)
Card(
child: ListTile(
leading: Text('🎉').fontSize(24),
title: Text('celebrateNewYear').tr(args: [ua.user?.nick ?? 'user']),
), ),
), ),
], ],
); ),
),
).padding(bottom: 8);
}
return const SizedBox.shrink();
} }
} }
class _HomeDashCheckInWidget extends StatefulWidget { class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget({super.key}); const _HomeDashCheckInWidget();
@override @override
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState(); State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
@ -212,7 +244,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>(); final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.get('/cgi/id/check-in/today'); final resp = await sn.client.get('/cgi/id/check-in/today');
_todayRecord = SnCheckInRecord.fromJson(resp.data); _todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} finally { } finally {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
@ -225,7 +257,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>(); final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in'); final resp = await sn.client.post('/cgi/id/check-in');
_todayRecord = SnCheckInRecord.fromJson(resp.data); _todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -409,7 +441,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
} }
class _HomeDashNotificationWidget extends StatefulWidget { class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget({super.key}); const _HomeDashNotificationWidget();
@override @override
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState(); State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
@ -480,7 +512,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
} }
class _HomeDashRecommendationPostWidget extends StatefulWidget { class _HomeDashRecommendationPostWidget extends StatefulWidget {
const _HomeDashRecommendationPostWidget({super.key}); const _HomeDashRecommendationPostWidget();
@override @override
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState(); State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
@ -494,9 +526,7 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final home = context.read<HomeWidgetProvider>();
_posts = await pt.listRecommendations(); _posts = await pt.listRecommendations();
home.saveWidgetData('post_featured', _posts!.first.toJson());
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);

View File

@ -1,18 +1,14 @@
import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
@ -71,11 +67,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final config = context.read<ConfigProvider>();
final resp = await sn.client.get('/cgi/co/publishers/me'); final resp = await sn.client.get('/cgi/co/publishers/me');
_publishers = List<SnPublisher>.from( _publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
_writeController.setPublisher(_publishers?.firstOrNull); final beforeId = config.prefs.getInt('int_last_publisher_id');
_writeController
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -92,38 +91,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
); );
} }
final _imagePicker = ImagePicker();
void _takeMedia(bool isVideo) async {
final result = isVideo
? await _imagePicker.pickVideo(source: ImageSource.camera)
: await _imagePicker.pickImage(source: ImageSource.camera);
if (result == null) return;
_writeController.addAttachments([
PostWriteMedia.fromFile(result),
]);
}
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_writeController.addAttachments(
result.map((e) => PostWriteMedia.fromFile(e)),
);
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_writeController.addAttachments([
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
]);
}
@override @override
void dispose() { void dispose() {
_writeController.dispose(); _writeController.dispose();
@ -265,6 +232,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
}); });
} else { } else {
_writeController.setPublisher(value); _writeController.setPublisher(value);
final config = context.read<ConfigProvider>();
config.prefs.setInt('int_last_publisher_id', value.id);
} }
}, },
buttonStyleData: const ButtonStyleData( buttonStyleData: const ButtonStyleData(
@ -286,11 +255,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.replyingPost != null) if (_writeController.replyingPost != null)
Column( Column(
children: [ children: [
Theme( ExpansionTile(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48, minTileHeight: 48,
leading: const Icon(Symbols.reply).padding(left: 4), leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice') title: Text('postReplyingNotice')
@ -298,7 +263,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']), .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)], children: <Widget>[PostItem(data: _writeController.replyingPost!)],
), ),
),
const Divider(height: 1), const Divider(height: 1),
], ],
), ),
@ -306,11 +270,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.repostingPost != null) if (_writeController.repostingPost != null)
Column( Column(
children: [ children: [
Theme( ExpansionTile(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48, minTileHeight: 48,
leading: const Icon(Symbols.forward).padding(left: 4), leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice') title: Text('postRepostingNotice')
@ -322,7 +282,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
) )
], ],
), ),
),
const Divider(height: 1), const Divider(height: 1),
], ],
), ),
@ -330,11 +289,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.editingPost != null) if (_writeController.editingPost != null)
Column( Column(
children: [ children: [
Theme( ExpansionTile(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48, minTileHeight: 48,
leading: const Icon(Symbols.edit_note).padding(left: 4), leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice') title: Text('postEditingNotice')
@ -342,7 +297,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
.tr(args: ['@${_writeController.editingPost!.publisher.name}']), .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)], children: <Widget>[PostItem(data: _writeController.editingPost!)],
), ),
),
const Divider(height: 1), const Divider(height: 1),
], ],
), ),
@ -410,6 +364,35 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 28, right: 22),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
),
child: _writeController.temporaryRestored
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.restore, size: 20),
const Gap(8),
Expanded(child: Text('postLocalDraftRestored').tr()),
InkWell(
child: Text('dialogDismiss').tr(),
onTap: () {
_writeController.reset();
},
),
],
)
: const SizedBox.shrink(),
)
.height(_writeController.temporaryRestored ? 32 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
LoadingIndicator(isActive: _isLoading), LoadingIndicator(isActive: _isLoading),
if (_writeController.isBusy && _writeController.progress != null) if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
@ -429,64 +412,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
child: Row( child: Row(
children: [ children: [
PopupMenuButton( AddPostMediaButton(
icon: Icon( onAdd: (items) {
Symbols.add_photo_alternate, setState(() {
color: Theme.of(context).colorScheme.primary, _writeController.addAttachments(items);
), });
itemBuilder: (context) => [
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_camera),
const Gap(16),
Text('addAttachmentFromCameraPhoto').tr(),
],
),
onTap: () {
_takeMedia(false);
}, },
), ),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.videocam),
const Gap(16),
Text('addAttachmentFromCameraVideo').tr(),
],
),
onTap: () {
_takeMedia(true);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
),
], ],
), ),
), ),
@ -496,7 +428,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onPressed: (_writeController.isBusy || _writeController.publisher == null) onPressed: (_writeController.isBusy || _writeController.publisher == null)
? null ? null
: () { : () {
_writeController.post(context).then((_) { _writeController.sendPost(context).then((_) {
if (!context.mounted) return; if (!context.mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
}); });

View File

@ -13,7 +13,10 @@ import 'package:surface/widgets/post/post_tags_field.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostSearchScreen extends StatefulWidget { class PostSearchScreen extends StatefulWidget {
const PostSearchScreen({super.key}); final Iterable<String>? initialTags;
final Iterable<String>? initialCategories;
const PostSearchScreen({super.key, this.initialTags, this.initialCategories});
@override @override
State<PostSearchScreen> createState() => _PostSearchScreenState(); State<PostSearchScreen> createState() => _PostSearchScreenState();
@ -23,6 +26,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
bool _isBusy = false; bool _isBusy = false;
List<String> _searchTags = List.empty(growable: true); List<String> _searchTags = List.empty(growable: true);
List<String> _searchCategories = List.empty(growable: true);
final List<SnPost> _posts = List.empty(growable: true); final List<SnPost> _posts = List.empty(growable: true);
int? _postCount; int? _postCount;
@ -30,8 +34,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
String _searchTerm = ''; String _searchTerm = '';
Duration? _lastTook; Duration? _lastTook;
@override
void initState() {
super.initState();
_searchTags.addAll(widget.initialTags ?? []);
_searchCategories.addAll(widget.initialCategories ?? []);
if (_searchTags.isNotEmpty || _searchCategories.isNotEmpty) {
_fetchPosts();
}
}
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchTags.isEmpty) return; if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -45,6 +59,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
take: 10, take: 10,
offset: _posts.length, offset: _posts.length,
tags: _searchTags, tags: _searchTags,
categories: _searchCategories,
); );
final List<SnPost> out = result.$1; final List<SnPost> out = result.$1;
_postCount = result.$2; _postCount = result.$2;
@ -73,9 +88,25 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
setState(() => _searchTags = value); setState(() => _searchTags = value);
}, },
), ),
const Gap(4),
PostCategoriesField(
labelText: 'fieldPostCategories'.tr(),
initialCategories: _searchCategories,
onUpdate: (value) {
setState(() => _searchCategories = value);
},
),
], ],
).padding(horizontal: 24, vertical: 16), ).padding(horizontal: 24, vertical: 16),
); ).then((_) {
_refreshPosts();
});
}
Future<void> _refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
} }
@override @override
@ -118,8 +149,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
setState(() => _posts[idx] = data); setState(() => _posts[idx] = data);
}, },
onDeleted: () { onDeleted: () {
_posts.clear(); _refreshPosts();
_fetchPosts();
}, },
), ),
onTap: () { onTap: () {
@ -150,10 +180,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
_searchTerm = value; _searchTerm = value;
}, },
onSubmitted: (value) { onSubmitted: (value) {
setState(() => _posts.clear());
_searchTerm = value; _searchTerm = value;
_fetchPosts(); _refreshPosts();
}, },
), ),
if (_lastTook != null) if (_lastTook != null)

View File

@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Future<void> _fetchPublisher() async { Future<void> _fetchPublisher() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
if (!mounted) return; if (!mounted) return;
_publisher = SnPublisher.fromJson(resp.data); _publisher = SnPublisher.fromJson(resp.data);
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err).then((_) { context.showErrorDialog(err).then((_) {
@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
} finally { } finally {
setState(() {}); setState(() {});
} }
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (_) {
// ignore
}
} }
bool _isSubscribing = false; bool _isSubscribing = false;
@ -277,7 +283,13 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: MultiSliver( sliver: MultiSliver(
children: [ children: [
SliverAppBar( Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
),
child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
title: _publisher == null title: _publisher == null
? Text('loading').tr() ? Text('loading').tr()
@ -287,7 +299,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
TextSpan( TextSpan(
text: _publisher!.nick, text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -342,6 +354,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
) )
: null, : null,
), ),
),
if (_publisher != null) if (_publisher != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
@ -567,7 +580,6 @@ class _PublisherPostList extends StatelessWidget {
final void Function() onDeleted; final void Function() onDeleted;
const _PublisherPostList({ const _PublisherPostList({
super.key,
required this.isBusy, required this.isBusy,
required this.postCount, required this.postCount,
required this.posts, required this.posts,

View File

@ -119,7 +119,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
final SnRealm? realm; final SnRealm? realm;
final List<SnPublisher>? publishers; final List<SnPublisher>? publishers;
const _RealmDetailHomeWidget({super.key, required this.realm, this.publishers}); const _RealmDetailHomeWidget({required this.realm, this.publishers});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -175,7 +175,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
class _RealmMemberListWidget extends StatefulWidget { class _RealmMemberListWidget extends StatefulWidget {
final SnRealm? realm; final SnRealm? realm;
const _RealmMemberListWidget({super.key, this.realm}); const _RealmMemberListWidget({this.realm});
@override @override
State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState(); State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState();
@ -304,7 +304,7 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
class _NewRealmMemberWidget extends StatefulWidget { class _NewRealmMemberWidget extends StatefulWidget {
final SnRealm realm; final SnRealm realm;
const _NewRealmMemberWidget({super.key, required this.realm}); const _NewRealmMemberWidget({required this.realm});
@override @override
State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState(); State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
@ -384,7 +384,7 @@ class _RealmSettingsWidget extends StatefulWidget {
final SnRealm? realm; final SnRealm? realm;
final Function() onUpdate; final Function() onUpdate;
const _RealmSettingsWidget({super.key, required this.realm, required this.onUpdate}); const _RealmSettingsWidget({required this.realm, required this.onUpdate});
@override @override
State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState(); State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState();

View File

@ -5,6 +5,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -18,6 +19,17 @@ import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
const Map<String, Color> kColorSchemes = {
'colorSchemeIndigo': Colors.indigo,
'colorSchemeBlue': Colors.blue,
'colorSchemeGreen': Colors.green,
'colorSchemeYellow': Colors.yellow,
'colorSchemeOrange': Colors.orange,
'colorSchemeRed': Colors.red,
'colorSchemeWhite': Colors.white,
'colorSchemeBlack': Colors.black,
};
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -77,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (image == null) return; if (image == null) return;
await File(image.path).copy('$_docBasepath/app_background_image'); await File(image.path).copy('$_docBasepath/app_background_image');
_prefs.setBool('has_background_image', true); _prefs.setBool(kAppBackgroundStoreKey, true);
setState(() {}); setState(() {});
}, },
@ -98,7 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
File('$_docBasepath/app_background_image').deleteSync(); File('$_docBasepath/app_background_image').deleteSync();
_prefs.remove('has_background_image'); _prefs.remove(kAppBackgroundStoreKey);
setState(() {}); setState(() {});
}, },
); );
@ -116,10 +128,118 @@ class _SettingsScreenState extends State<SettingsScreen> {
value ?? false, value ?? false,
); );
}); });
final th = context.watch<ThemeProvider>(); final th = context.read<ThemeProvider>();
th.reloadTheme(useMaterial3: value ?? false); th.reloadTheme(useMaterial3: value ?? false);
}, },
), ),
ListTile(
leading: const Icon(Symbols.format_paint),
title: Text('settingsColorScheme').tr(),
subtitle: Text('settingsColorSchemeDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
final color = await showDialog<Color?>(
context: context,
builder: (context) => AlertDialog(
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
enableAlpha: false,
hexInputBar: true,
),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
),
);
if (color == null || !context.mounted) return;
_prefs.setInt(kAppColorSchemeStoreKey, color.value);
final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: color);
setState(() {});
context.showSnackbar('colorSchemeApplied'.tr());
},
),
ListTile(
leading: const Icon(Symbols.palette),
title: Text('settingsColorSeed').tr(),
subtitle: Text('settingsColorSeedDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int?>(
isExpanded: true,
items: [
...kColorSchemes.entries.mapIndexed((idx, ele) {
return DropdownMenuItem<int>(
value: idx,
child: Text(ele.key).tr(),
);
}),
DropdownMenuItem<int>(
value: -1,
child: Text('custom').tr(),
),
],
value: _prefs.getInt(kAppColorSchemeStoreKey) == null
? 1
: kColorSchemes.values
.toList()
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
onChanged: (int? value) {
if (value != null && value != -1) {
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value);
final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
setState(() {});
context.showSnackbar('colorSchemeApplied'.tr());
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.blur_on),
title: Text('settingsAppBarTransparent').tr(),
subtitle: Text('settingsAppBarTransparentDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppbarTransparentStoreKey) ?? false,
onChanged: (value) {
_prefs.setBool(kAppbarTransparentStoreKey, value ?? false);
final th = context.read<ThemeProvider>();
th.reloadTheme();
setState(() {});
},
),
], ],
), ),
Column( Column(
@ -189,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
horizontal: 16, horizontal: 16,
vertical: 5, vertical: 5,
), ),
height: 40, height: 56,
width: 160, width: 160,
), ),
menuItemStyleData: const MenuItemStyleData( menuItemStyleData: const MenuItemStyleData(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/config.dart';
const kMaterialYouToggleStoreKey = 'app_theme_material_you'; const kMaterialYouToggleStoreKey = 'app_theme_material_you';
@ -10,7 +11,7 @@ class ThemeSet {
ThemeSet({required this.light, required this.dark}); ThemeSet({required this.light, required this.dark});
} }
Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async { Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
return ThemeSet( return ThemeSet(
light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3), light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3), dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
@ -19,16 +20,20 @@ Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async {
Future<ThemeData> createAppTheme( Future<ThemeData> createAppTheme(
Brightness brightness, { Brightness brightness, {
Color? seedColorOverride,
bool? useMaterial3, bool? useMaterial3,
}) async { }) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
final colorScheme = ColorScheme.fromSeed( final colorScheme = ColorScheme.fromSeed(
seedColor: Colors.indigo, seedColor: seedColorOverride ?? seedColor,
brightness: brightness, brightness: brightness,
); );
final hasBackground = prefs.getBool('has_background_image') ?? false; final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
return ThemeData( return ThemeData(
useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false), useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
@ -42,8 +47,9 @@ Future<ThemeData> createAppTheme(
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
centerTitle: true, centerTitle: true,
backgroundColor: hasBackground ? colorScheme.primary.withOpacity(0.75) : colorScheme.primary, elevation: hasAppBarBlurry ? 0 : null,
foregroundColor: colorScheme.onPrimary, backgroundColor: hasAppBarBlurry ? colorScheme.surfaceContainer.withAlpha(200) : colorScheme.primary,
foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary,
), ),
scaffoldBackgroundColor: Colors.transparent, scaffoldBackgroundColor: Colors.transparent,
); );

View File

@ -1,15 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'attachment.freezed.dart'; part 'attachment.freezed.dart';
part 'attachment.g.dart'; part 'attachment.g.dart';
enum SnMediaType {
image,
video,
audio,
file,
}
@freezed @freezed
class SnAttachment with _$SnAttachment { class SnAttachment with _$SnAttachment {
const SnAttachment._();
const factory SnAttachment({ const factory SnAttachment({
required int id, required int id,
required DateTime createdAt, required DateTime createdAt,
required DateTime updatedAt, required DateTime updatedAt,
required dynamic deletedAt, required DateTime? deletedAt,
required String rid, required String rid,
required String uuid, required String uuid,
required int size, required int size,
@ -19,22 +29,70 @@ class SnAttachment with _$SnAttachment {
required String hash, required String hash,
required int destination, required int destination,
required int refCount, required int refCount,
required dynamic fileChunks, @Default(0) int contentRating,
required dynamic cleanedAt, @Default(0) int qualityRating,
required bool isMature, required DateTime? cleanedAt,
required bool isAnalyzed, required bool isAnalyzed,
required bool isUploaded,
required bool isSelfRef, required bool isSelfRef,
required dynamic ref, required bool isIndexable,
required dynamic refId, required SnAttachment? ref,
required int? refId,
required SnAttachmentPool? pool, required SnAttachmentPool? pool,
required int poolId, required int? poolId,
required int accountId, required int accountId,
int? thumbnailId,
SnAttachment? thumbnail,
int? compressedId,
SnAttachment? compressed,
@Default([]) List<SnAttachmentBoost> boosts,
@Default({}) Map<String, dynamic> usermeta,
@Default({}) Map<String, dynamic> metadata, @Default({}) Map<String, dynamic> metadata,
}) = _SnAttachment; }) = _SnAttachment;
factory SnAttachment.fromJson(Map<String, Object?> json) => factory SnAttachment.fromJson(Map<String, Object?> json) => _$SnAttachmentFromJson(json);
_$SnAttachmentFromJson(json);
Map<String, dynamic> get data => {
...metadata,
...usermeta,
};
SnMediaType get mediaType => switch (mimetype.split('/').firstOrNull) {
'image' => SnMediaType.image,
'video' => SnMediaType.video,
'audio' => SnMediaType.audio,
_ => SnMediaType.file,
};
}
@freezed
class SnAttachmentFragment with _$SnAttachmentFragment {
const SnAttachmentFragment._();
const factory SnAttachmentFragment({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String rid,
required String uuid,
required int size,
required String name,
required String alt,
required String mimetype,
required String hash,
String? fingerprint,
@Default({}) Map<String, int> fileChunks,
@Default([]) List<String> fileChunksMissing,
}) = _SnAttachmentFragment;
factory SnAttachmentFragment.fromJson(Map<String, Object?> json) => _$SnAttachmentFragmentFromJson(json);
SnMediaType get mediaType => switch (mimetype.split('/').firstOrNull) {
'image' => SnMediaType.image,
'video' => SnMediaType.video,
'audio' => SnMediaType.audio,
_ => SnMediaType.file,
};
} }
@freezed @freezed
@ -51,6 +109,35 @@ class SnAttachmentPool with _$SnAttachmentPool {
required int? accountId, required int? accountId,
}) = _SnAttachmentPool; }) = _SnAttachmentPool;
factory SnAttachmentPool.fromJson(Map<String, Object?> json) => factory SnAttachmentPool.fromJson(Map<String, Object?> json) => _$SnAttachmentPoolFromJson(json);
_$SnAttachmentPoolFromJson(json); }
@freezed
class SnAttachmentDestination with _$SnAttachmentDestination {
const factory SnAttachmentDestination({
@Default(0) int id,
required String type,
required String label,
required String region,
required bool isBoost,
}) = _SnAttachmentDestination;
factory SnAttachmentDestination.fromJson(Map<String, Object?> json) => _$SnAttachmentDestinationFromJson(json);
}
@freezed
class SnAttachmentBoost with _$SnAttachmentBoost {
const factory SnAttachmentBoost({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int status,
required int destination,
required int attachmentId,
required SnAttachment attachment,
required int account,
}) = _SnAttachmentBoost;
factory SnAttachmentBoost.fromJson(Map<String, Object?> json) => _$SnAttachmentBoostFromJson(json);
} }

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,9 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'], deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
rid: json['rid'] as String, rid: json['rid'] as String,
uuid: json['uuid'] as String, uuid: json['uuid'] as String,
size: (json['size'] as num).toInt(), size: (json['size'] as num).toInt(),
@ -21,19 +23,37 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
hash: json['hash'] as String, hash: json['hash'] as String,
destination: (json['destination'] as num).toInt(), destination: (json['destination'] as num).toInt(),
refCount: (json['ref_count'] as num).toInt(), refCount: (json['ref_count'] as num).toInt(),
fileChunks: json['file_chunks'], contentRating: (json['content_rating'] as num?)?.toInt() ?? 0,
cleanedAt: json['cleaned_at'], qualityRating: (json['quality_rating'] as num?)?.toInt() ?? 0,
isMature: json['is_mature'] as bool, cleanedAt: json['cleaned_at'] == null
? null
: DateTime.parse(json['cleaned_at'] as String),
isAnalyzed: json['is_analyzed'] as bool, isAnalyzed: json['is_analyzed'] as bool,
isUploaded: json['is_uploaded'] as bool,
isSelfRef: json['is_self_ref'] as bool, isSelfRef: json['is_self_ref'] as bool,
ref: json['ref'], isIndexable: json['is_indexable'] as bool,
refId: json['ref_id'], ref: json['ref'] == null
? null
: SnAttachment.fromJson(json['ref'] as Map<String, dynamic>),
refId: (json['ref_id'] as num?)?.toInt(),
pool: json['pool'] == null pool: json['pool'] == null
? null ? null
: SnAttachmentPool.fromJson(json['pool'] as Map<String, dynamic>), : SnAttachmentPool.fromJson(json['pool'] as Map<String, dynamic>),
poolId: (json['pool_id'] as num).toInt(), poolId: (json['pool_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(),
thumbnailId: (json['thumbnail_id'] as num?)?.toInt(),
thumbnail: json['thumbnail'] == null
? null
: SnAttachment.fromJson(json['thumbnail'] as Map<String, dynamic>),
compressedId: (json['compressed_id'] as num?)?.toInt(),
compressed: json['compressed'] == null
? null
: SnAttachment.fromJson(json['compressed'] as Map<String, dynamic>),
boosts: (json['boosts'] as List<dynamic>?)
?.map(
(e) => SnAttachmentBoost.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
usermeta: json['usermeta'] as Map<String, dynamic>? ?? const {},
metadata: json['metadata'] as Map<String, dynamic>? ?? const {}, metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
); );
@ -42,7 +62,7 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
'id': instance.id, 'id': instance.id,
'created_at': instance.createdAt.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt, 'deleted_at': instance.deletedAt?.toIso8601String(),
'rid': instance.rid, 'rid': instance.rid,
'uuid': instance.uuid, 'uuid': instance.uuid,
'size': instance.size, 'size': instance.size,
@ -52,20 +72,72 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
'hash': instance.hash, 'hash': instance.hash,
'destination': instance.destination, 'destination': instance.destination,
'ref_count': instance.refCount, 'ref_count': instance.refCount,
'file_chunks': instance.fileChunks, 'content_rating': instance.contentRating,
'cleaned_at': instance.cleanedAt, 'quality_rating': instance.qualityRating,
'is_mature': instance.isMature, 'cleaned_at': instance.cleanedAt?.toIso8601String(),
'is_analyzed': instance.isAnalyzed, 'is_analyzed': instance.isAnalyzed,
'is_uploaded': instance.isUploaded,
'is_self_ref': instance.isSelfRef, 'is_self_ref': instance.isSelfRef,
'ref': instance.ref, 'is_indexable': instance.isIndexable,
'ref': instance.ref?.toJson(),
'ref_id': instance.refId, 'ref_id': instance.refId,
'pool': instance.pool?.toJson(), 'pool': instance.pool?.toJson(),
'pool_id': instance.poolId, 'pool_id': instance.poolId,
'account_id': instance.accountId, 'account_id': instance.accountId,
'thumbnail_id': instance.thumbnailId,
'thumbnail': instance.thumbnail?.toJson(),
'compressed_id': instance.compressedId,
'compressed': instance.compressed?.toJson(),
'boosts': instance.boosts.map((e) => e.toJson()).toList(),
'usermeta': instance.usermeta,
'metadata': instance.metadata, 'metadata': instance.metadata,
}; };
_$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentFragmentImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
rid: json['rid'] as String,
uuid: json['uuid'] as String,
size: (json['size'] as num).toInt(),
name: json['name'] as String,
alt: json['alt'] as String,
mimetype: json['mimetype'] as String,
hash: json['hash'] as String,
fingerprint: json['fingerprint'] as String?,
fileChunks: (json['file_chunks'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
) ??
const {},
fileChunksMissing: (json['file_chunks_missing'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
);
Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
_$SnAttachmentFragmentImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'rid': instance.rid,
'uuid': instance.uuid,
'size': instance.size,
'name': instance.name,
'alt': instance.alt,
'mimetype': instance.mimetype,
'hash': instance.hash,
'fingerprint': instance.fingerprint,
'file_chunks': instance.fileChunks,
'file_chunks_missing': instance.fileChunksMissing,
};
_$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson( _$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
Map<String, dynamic> json) => Map<String, dynamic> json) =>
_$SnAttachmentPoolImpl( _$SnAttachmentPoolImpl(
@ -95,3 +167,54 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
'config': instance.config, 'config': instance.config,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };
_$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentDestinationImpl(
id: (json['id'] as num?)?.toInt() ?? 0,
type: json['type'] as String,
label: json['label'] as String,
region: json['region'] as String,
isBoost: json['is_boost'] as bool,
);
Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
_$SnAttachmentDestinationImpl instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'label': instance.label,
'region': instance.region,
'is_boost': instance.isBoost,
};
_$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentBoostImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
status: (json['status'] as num).toInt(),
destination: (json['destination'] as num).toInt(),
attachmentId: (json['attachment_id'] as num).toInt(),
attachment:
SnAttachment.fromJson(json['attachment'] as Map<String, dynamic>),
account: (json['account'] as num).toInt(),
);
Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
_$SnAttachmentBoostImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'status': instance.status,
'destination': instance.destination,
'attachment_id': instance.attachmentId,
'attachment': instance.attachment.toJson(),
'account': instance.account,
};

View File

@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart'; part 'check_in.freezed.dart';
part 'check_in.g.dart'; part 'check_in.g.dart';
const List<String> kCheckInResultTierSymbols = ['大凶', '', '中平', '', '大吉'];
@freezed @freezed
class SnCheckInRecord with _$SnCheckInRecord { class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._(); const SnCheckInRecord._();
@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord {
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json); _$SnCheckInRecordFromJson(json);
String get symbol => switch (resultTier) { String get symbol => kCheckInResultTierSymbols[resultTier];
0 => '大凶',
1 => '',
2 => '中平',
3 => '',
_ => '大吉',
};
} }

View File

@ -19,7 +19,7 @@ class SnPost with _$SnPost {
required String? alias, required String? alias,
required String? aliasPrefix, required String? aliasPrefix,
@Default([]) List<SnPostTag> tags, @Default([]) List<SnPostTag> tags,
@Default([]) List<dynamic> categories, @Default([]) List<SnPostCategory> categories,
required List<SnPost>? replies, required List<SnPost>? replies,
required int? replyId, required int? replyId,
required int? repostId, required int? repostId,
@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag {
_$SnPostTagFromJson(json); _$SnPostTagFromJson(json);
} }
@freezed
class SnPostCategory with _$SnPostCategory {
const factory SnPostCategory({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required String alias,
required String name,
required String description,
required dynamic posts,
}) = _SnPostCategory;
factory SnPostCategory.fromJson(Map<String, Object?> json) =>
_$SnPostCategoryFromJson(json);
}
@freezed @freezed
class SnPostPreload with _$SnPostPreload { class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({ const factory SnPostPreload({

View File

@ -30,7 +30,7 @@ mixin _$SnPost {
String? get alias => throw _privateConstructorUsedError; String? get alias => throw _privateConstructorUsedError;
String? get aliasPrefix => throw _privateConstructorUsedError; String? get aliasPrefix => throw _privateConstructorUsedError;
List<SnPostTag> get tags => throw _privateConstructorUsedError; List<SnPostTag> get tags => throw _privateConstructorUsedError;
List<dynamic> get categories => throw _privateConstructorUsedError; List<SnPostCategory> get categories => throw _privateConstructorUsedError;
List<SnPost>? get replies => throw _privateConstructorUsedError; List<SnPost>? get replies => throw _privateConstructorUsedError;
int? get replyId => throw _privateConstructorUsedError; int? get replyId => throw _privateConstructorUsedError;
int? get repostId => throw _privateConstructorUsedError; int? get repostId => throw _privateConstructorUsedError;
@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> {
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<SnPostTag> tags, List<SnPostTag> tags,
List<dynamic> categories, List<SnPostCategory> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
int? repostId, int? repostId,
@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
categories: null == categories categories: null == categories
? _value.categories ? _value.categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostCategory>,
replies: freezed == replies replies: freezed == replies
? _value.replies ? _value.replies
: replies // ignore: cast_nullable_to_non_nullable : replies // ignore: cast_nullable_to_non_nullable
@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<SnPostTag> tags, List<SnPostTag> tags,
List<dynamic> categories, List<SnPostCategory> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
int? repostId, int? repostId,
@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
categories: null == categories categories: null == categories
? _value._categories ? _value._categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostCategory>,
replies: freezed == replies replies: freezed == replies
? _value._replies ? _value._replies
: replies // ignore: cast_nullable_to_non_nullable : replies // ignore: cast_nullable_to_non_nullable
@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost {
required this.alias, required this.alias,
required this.aliasPrefix, required this.aliasPrefix,
final List<SnPostTag> tags = const [], final List<SnPostTag> tags = const [],
final List<dynamic> categories = const [], final List<SnPostCategory> categories = const [],
required final List<SnPost>? replies, required final List<SnPost>? replies,
required this.replyId, required this.replyId,
required this.repostId, required this.repostId,
@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost {
return EqualUnmodifiableListView(_tags); return EqualUnmodifiableListView(_tags);
} }
final List<dynamic> _categories; final List<SnPostCategory> _categories;
@override @override
@JsonKey() @JsonKey()
List<dynamic> get categories { List<SnPostCategory> get categories {
if (_categories is EqualUnmodifiableListView) return _categories; if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_categories); return EqualUnmodifiableListView(_categories);
@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost {
required final String? alias, required final String? alias,
required final String? aliasPrefix, required final String? aliasPrefix,
final List<SnPostTag> tags, final List<SnPostTag> tags,
final List<dynamic> categories, final List<SnPostCategory> categories,
required final List<SnPost>? replies, required final List<SnPost>? replies,
required final int? replyId, required final int? replyId,
required final int? repostId, required final int? repostId,
@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost {
@override @override
List<SnPostTag> get tags; List<SnPostTag> get tags;
@override @override
List<dynamic> get categories; List<SnPostCategory> get categories;
@override @override
List<SnPost>? get replies; List<SnPost>? get replies;
@override @override
@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
SnPostCategory _$SnPostCategoryFromJson(Map<String, dynamic> json) {
return _SnPostCategory.fromJson(json);
}
/// @nodoc
mixin _$SnPostCategory {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
String get alias => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
dynamic get posts => throw _privateConstructorUsedError;
/// Serializes this SnPostCategory to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPostCategoryCopyWith<SnPostCategory> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPostCategoryCopyWith<$Res> {
factory $SnPostCategoryCopyWith(
SnPostCategory value, $Res Function(SnPostCategory) then) =
_$SnPostCategoryCopyWithImpl<$Res, SnPostCategory>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class _$SnPostCategoryCopyWithImpl<$Res, $Val extends SnPostCategory>
implements $SnPostCategoryCopyWith<$Res> {
_$SnPostCategoryCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPostCategoryImplCopyWith<$Res>
implements $SnPostCategoryCopyWith<$Res> {
factory _$$SnPostCategoryImplCopyWith(_$SnPostCategoryImpl value,
$Res Function(_$SnPostCategoryImpl) then) =
__$$SnPostCategoryImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class __$$SnPostCategoryImplCopyWithImpl<$Res>
extends _$SnPostCategoryCopyWithImpl<$Res, _$SnPostCategoryImpl>
implements _$$SnPostCategoryImplCopyWith<$Res> {
__$$SnPostCategoryImplCopyWithImpl(
_$SnPostCategoryImpl _value, $Res Function(_$SnPostCategoryImpl) _then)
: super(_value, _then);
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_$SnPostCategoryImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPostCategoryImpl implements _SnPostCategory {
const _$SnPostCategoryImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.posts});
factory _$SnPostCategoryImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostCategoryImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final String alias;
@override
final String name;
@override
final String description;
@override
final dynamic posts;
@override
String toString() {
return 'SnPostCategory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPostCategoryImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
const DeepCollectionEquality().equals(other.posts, posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
alias,
name,
description,
const DeepCollectionEquality().hash(posts));
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
__$$SnPostCategoryImplCopyWithImpl<_$SnPostCategoryImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPostCategoryImplToJson(
this,
);
}
}
abstract class _SnPostCategory implements SnPostCategory {
const factory _SnPostCategory(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final String alias,
required final String name,
required final String description,
required final dynamic posts}) = _$SnPostCategoryImpl;
factory _SnPostCategory.fromJson(Map<String, dynamic> json) =
_$SnPostCategoryImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
String get alias;
@override
String get name;
@override
String get description;
@override
dynamic get posts;
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
return _SnPostPreload.fromJson(json); return _SnPostPreload.fromJson(json);
} }

View File

@ -22,7 +22,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
categories: json['categories'] as List<dynamic>? ?? const [], categories: (json['categories'] as List<dynamic>?)
?.map((e) => SnPostCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
replies: (json['replies'] as List<dynamic>?) replies: (json['replies'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'alias': instance.alias, 'alias': instance.alias,
'alias_prefix': instance.aliasPrefix, 'alias_prefix': instance.aliasPrefix,
'tags': instance.tags.map((e) => e.toJson()).toList(), 'tags': instance.tags.map((e) => e.toJson()).toList(),
'categories': instance.categories, 'categories': instance.categories.map((e) => e.toJson()).toList(),
'replies': instance.replies?.map((e) => e.toJson()).toList(), 'replies': instance.replies?.map((e) => e.toJson()).toList(),
'reply_id': instance.replyId, 'reply_id': instance.replyId,
'repost_id': instance.repostId, 'repost_id': instance.repostId,
@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
'posts': instance.posts, 'posts': instance.posts,
}; };
_$SnPostCategoryImpl _$$SnPostCategoryImplFromJson(Map<String, dynamic> json) =>
_$SnPostCategoryImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
posts: json['posts'],
);
Map<String, dynamic> _$$SnPostCategoryImplToJson(
_$SnPostCategoryImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'alias': instance.alias,
'name': instance.name,
'description': instance.description,
'posts': instance.posts,
};
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
_$SnPostPreloadImpl( _$SnPostPreloadImpl(
thumbnail: json['thumbnail'] == null thumbnail: json['thumbnail'] == null

View File

@ -32,7 +32,7 @@ class _AccountSelectState extends State<AccountSelect> {
final List<SnAccount> _pendingUsers = List.empty(growable: true); final List<SnAccount> _pendingUsers = List.empty(growable: true);
final List<SnAccount> _selectedUsers = List.empty(growable: true); final List<SnAccount> _selectedUsers = List.empty(growable: true);
int _accountId = 0; final int _accountId = 0;
Future<void> _revertSelectedUsers() async { Future<void> _revertSelectedUsers() async {
if (widget.initialSelection?.isEmpty ?? true) return; if (widget.initialSelection?.isEmpty ?? true) return;

View File

@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/widgets/dialog.dart';
class AttachmentInputDialog extends StatefulWidget {
final String? title;
final bool? analyzeNow;
const AttachmentInputDialog({super.key, required this.title, this.analyzeNow = false});
@override
State<AttachmentInputDialog> createState() => _AttachmentInputDialogState();
}
class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
final _randomIdController = TextEditingController();
XFile? _thumbnailFile;
void _pickImage() async {
final picker = ImagePicker();
final result = await picker.pickImage(source: ImageSource.gallery);
if (result == null) return;
setState(() => _thumbnailFile = result);
}
bool _isBusy = false;
void _finishUp() async {
if (_isBusy) return;
setState(() => _isBusy = true);
final attach = context.read<SnAttachmentProvider>();
if (_randomIdController.text.isNotEmpty) {
try {
final attachment = await attach.getOne(_randomIdController.text);
if (!mounted) return;
Navigator.pop(context, attachment);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
} else if (_thumbnailFile != null) {
try {
final attachment = await attach.directUploadOne(
(await _thumbnailFile!.readAsBytes()).buffer.asUint8List(),
_thumbnailFile!.path,
'interactive',
null,
analyzeNow: widget.analyzeNow ?? false,
);
if (!mounted) return;
Navigator.pop(context, attachment);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title ?? 'attachmentInputDialog').tr(),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('attachmentInputUseRandomId').tr().fontSize(14),
const Gap(8),
TextField(
controller: _randomIdController,
decoration: InputDecoration(
labelText: 'fieldAttachmentRandomId'.tr(),
border: const UnderlineInputBorder(),
isDense: true,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(24),
Text('attachmentInputNew').tr().fontSize(14),
Card(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: const Icon(Symbols.add_photo_alternate),
trailing: const Icon(Symbols.chevron_right),
title: Text('addAttachmentFromAlbum').tr(),
subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
onTap: () {
_pickImage();
},
),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () {
Navigator.pop(context);
},
child: Text('dialogDismiss').tr(),
),
TextButton(
onPressed: _isBusy ? null : () => _finishUp(),
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -1,7 +1,9 @@
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@ -18,8 +20,11 @@ import 'package:uuid/uuid.dart';
class AttachmentItem extends StatelessWidget { class AttachmentItem extends StatelessWidget {
final SnAttachment? data; final SnAttachment? data;
final String? heroTag; final String? heroTag;
final BoxFit fit;
const AttachmentItem({ const AttachmentItem({
super.key, super.key,
this.fit = BoxFit.cover,
required this.data, required this.data,
required this.heroTag, required this.heroTag,
}); });
@ -40,7 +45,7 @@ class AttachmentItem extends StatelessWidget {
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid), sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'), key: Key('attachment-${data!.rid}-$tag'),
fit: BoxFit.cover, fit: fit,
), ),
); );
case 'video': case 'video':
@ -60,10 +65,13 @@ class AttachmentItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (data!.isMature) { if (data!.contentRating > 0) {
return LayoutBuilder(builder: (context, constraints) {
return _AttachmentItemSensitiveBlur( return _AttachmentItemSensitiveBlur(
isCompact: constraints.maxHeight < 360,
child: _buildContent(context), child: _buildContent(context),
); );
});
} }
return _buildContent(context); return _buildContent(context);
@ -72,15 +80,15 @@ class AttachmentItem extends StatelessWidget {
class _AttachmentItemSensitiveBlur extends StatefulWidget { class _AttachmentItemSensitiveBlur extends StatefulWidget {
final Widget child; final Widget child;
const _AttachmentItemSensitiveBlur({super.key, required this.child}); final bool isCompact;
const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false});
@override @override
State<_AttachmentItemSensitiveBlur> createState() => State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState();
_AttachmentItemSensitiveBlurState();
} }
class _AttachmentItemSensitiveBlurState class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> {
extends State<_AttachmentItemSensitiveBlur> {
bool _doesShow = false; bool _doesShow = false;
@override @override
@ -104,24 +112,21 @@ class _AttachmentItemSensitiveBlurState
color: Colors.white, color: Colors.white,
size: 32, size: 32,
), ),
const Gap(8), if (!widget.isCompact) const Gap(8),
if (!widget.isCompact)
Text('sensitiveContent', textAlign: TextAlign.center) Text('sensitiveContent', textAlign: TextAlign.center)
.tr() .tr()
.fontSize(20) .fontSize(20)
.textColor(Colors.white) .textColor(Colors.white)
.bold(), .bold(),
if (!widget.isCompact)
Text( Text(
'sensitiveContentDescription', 'sensitiveContentDescription',
textAlign: TextAlign.center, textAlign: TextAlign.center,
) ).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
.tr() if (!widget.isCompact) const Gap(16),
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
InkWell( InkWell(
child: Text('sensitiveContentReveal') child: Text('sensitiveContentReveal').tr().textColor(Colors.white),
.tr()
.textColor(Colors.white),
onTap: () { onTap: () {
setState(() => _doesShow = !_doesShow); setState(() => _doesShow = !_doesShow);
}, },
@ -131,9 +136,7 @@ class _AttachmentItemSensitiveBlurState
).center(), ).center(),
), ),
), ),
) ).opacity(_doesShow ? 0 : 1, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut),
.opacity(_doesShow ? 0 : 1, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
if (_doesShow) if (_doesShow)
Positioned( Positioned(
top: 0, top: 0,
@ -163,20 +166,19 @@ class _AttachmentItemSensitiveBlurState
class _AttachmentItemContentVideo extends StatefulWidget { class _AttachmentItemContentVideo extends StatefulWidget {
final SnAttachment data; final SnAttachment data;
final bool isAutoload; final bool isAutoload;
const _AttachmentItemContentVideo({ const _AttachmentItemContentVideo({
super.key,
required this.data, required this.data,
this.isAutoload = false, this.isAutoload = false,
}); });
@override @override
State<_AttachmentItemContentVideo> createState() => State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState();
_AttachmentItemContentVideoState();
} }
class _AttachmentItemContentVideoState class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> {
extends State<_AttachmentItemContentVideo> {
bool _showContent = false; bool _showContent = false;
bool _showOriginal = false;
Player? _videoPlayer; Player? _videoPlayer;
VideoController? _videoController; VideoController? _videoController;
@ -185,15 +187,29 @@ class _AttachmentItemContentVideoState
setState(() => _showContent = true); setState(() => _showContent = true);
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final url = sn.getAttachmentUrl(widget.data.rid); final url = _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid);
_videoPlayer = Player(); _videoPlayer = Player();
_videoController = VideoController(_videoPlayer!); _videoController = VideoController(_videoPlayer!);
_videoPlayer!.open(Media(url), play: !widget.isAutoload); _videoPlayer!.open(Media(url), play: !widget.isAutoload);
} }
void _toggleOriginal() {
if (!mounted) return;
if (widget.data.compressedId == null) return;
setState(() => _showOriginal = !_showOriginal);
final sn = context.read<SnNetworkProvider>();
_videoPlayer?.open(
Media(
_showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid),
),
play: true,
);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showOriginal = widget.data.compressedId == null;
if (widget.isAutoload) _startLoad(); if (widget.isAutoload) _startLoad();
} }
@ -207,7 +223,7 @@ class _AttachmentItemContentVideoState
), ),
]; ];
final ratio = widget.data.metadata['ratio'] ?? 16 / 9; final ratio = widget.data.data['ratio'] ?? 16 / 9;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
@ -216,9 +232,9 @@ class _AttachmentItemContentVideoState
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
if (widget.data.metadata['thumbnail'] != null) if (widget.data.thumbnail != null)
AutoResizeUniversalImage( AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']), sn.getAttachmentUrl(widget.data.thumbnail!.rid),
fit: BoxFit.cover, fit: BoxFit.cover,
) )
else else
@ -266,10 +282,7 @@ class _AttachmentItemContentVideoState
), ),
Text( Text(
Duration( Duration(
milliseconds: milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000,
(widget.data.metadata['duration'] ?? 0)
.toInt() *
1000,
).toString(), ).toString(),
style: GoogleFonts.robotoMono( style: GoogleFonts.robotoMono(
fontSize: 12, fontSize: 12,
@ -301,9 +314,45 @@ class _AttachmentItemContentVideoState
); );
} }
return Video( return MaterialDesktopVideoControlsTheme(
normal: MaterialDesktopVideoControlsThemeData(
buttonBarButtonSize: 24,
buttonBarButtonColor: Colors.white,
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 12, vertical: 2),
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
iconSize: 24,
onPressed: _toggleOriginal,
icon: Builder(builder: (context) {
return _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24);
}),
),
],
),
fullscreen: const MaterialDesktopVideoControlsThemeData(),
child: MaterialVideoControlsTheme(
normal: MaterialVideoControlsThemeData(
buttonBarButtonSize: 24,
buttonBarButtonColor: Colors.white,
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
iconSize: 24,
onPressed: _toggleOriginal,
icon: _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24),
),
],
),
fullscreen: const MaterialVideoControlsThemeData(),
child: Video(
controller: _videoController!, controller: _videoController!,
aspectRatio: ratio, aspectRatio: ratio,
controls:
!kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls,
),
),
); );
} }
@ -317,19 +366,17 @@ class _AttachmentItemContentVideoState
class _AttachmentItemContentAudio extends StatefulWidget { class _AttachmentItemContentAudio extends StatefulWidget {
final SnAttachment data; final SnAttachment data;
final bool isAutoload; final bool isAutoload;
const _AttachmentItemContentAudio({ const _AttachmentItemContentAudio({
super.key,
required this.data, required this.data,
this.isAutoload = false, this.isAutoload = false,
}); });
@override @override
State<_AttachmentItemContentAudio> createState() => State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState();
_AttachmentItemContentAudioState();
} }
class _AttachmentItemContentAudioState class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> {
extends State<_AttachmentItemContentAudio> {
bool _showContent = false; bool _showContent = false;
double? _draggingValue; double? _draggingValue;
@ -378,11 +425,11 @@ class _AttachmentItemContentAudioState
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
if (widget.data.metadata['thumbnail'] != null) if (widget.data.thumbnail != null)
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']), sn.getAttachmentUrl(widget.data.data['thumbnail']),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
) )
@ -463,11 +510,11 @@ class _AttachmentItemContentAudioState
return Stack( return Stack(
children: [ children: [
if (widget.data.metadata['thumbnail'] != null) if (widget.data.data['thumbnail'] != null)
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: AutoResizeUniversalImage( child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']), sn.getAttachmentUrl(widget.data.data['thumbnail']),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
@ -499,12 +546,8 @@ class _AttachmentItemContentAudioState
overlayShape: SliderComponentShape.noOverlay, overlayShape: SliderComponentShape.noOverlay,
), ),
child: Slider( child: Slider(
secondaryTrackValue: _bufferedPosition secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(),
.inMilliseconds value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(),
.abs()
.toDouble(),
value: _draggingValue?.abs() ??
_position.inMilliseconds.toDouble().abs(),
min: 0, min: 0,
max: math max: math
.max( .max(
@ -544,9 +587,7 @@ class _AttachmentItemContentAudioState
), ),
const Gap(16), const Gap(16),
IconButton.filled( IconButton.filled(
icon: _isPlaying icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow),
? const Icon(Symbols.pause)
: const Icon(Symbols.play_arrow),
onPressed: () { onPressed: () {
_audioPlayer!.playOrPause(); _audioPlayer!.playOrPause();
}, },

View File

@ -4,8 +4,8 @@ import 'package:collection/collection.dart';
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_zoom.dart'; import 'package:surface/widgets/attachment/attachment_zoom.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
@ -14,19 +14,23 @@ import 'package:uuid/uuid.dart';
class AttachmentList extends StatefulWidget { class AttachmentList extends StatefulWidget {
final List<SnAttachment?> data; final List<SnAttachment?> data;
final bool bordered; final bool bordered;
final bool gridded;
final bool noGrow; final bool noGrow;
final bool isFlatted; final BoxFit fit;
final double? maxHeight; final double? maxHeight;
final EdgeInsets? listPadding; final double? minWidth;
final EdgeInsets? padding;
const AttachmentList({ const AttachmentList({
super.key, super.key,
required this.data, required this.data,
this.bordered = false, this.bordered = false,
this.gridded = false,
this.noGrow = false, this.noGrow = false,
this.isFlatted = false, this.fit = BoxFit.cover,
this.maxHeight, this.maxHeight,
this.listPadding, this.minWidth,
this.padding,
}); });
static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8)); static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8));
@ -41,8 +45,6 @@ class _AttachmentListState extends State<AttachmentList> {
(_) => const Uuid().v4(), (_) => const Uuid().v4(),
); );
static const double kAttachmentMaxWidth = 640;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
@ -51,14 +53,13 @@ class _AttachmentListState extends State<AttachmentList> {
widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none; widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer; final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints( final constraints = BoxConstraints(
minWidth: 80, minWidth: widget.minWidth ?? 80,
maxHeight: widget.maxHeight ?? double.infinity, maxHeight: widget.maxHeight ?? MediaQuery.of(context).size.height,
maxWidth: layoutConstraints.maxWidth - 20,
); );
if (widget.data.isEmpty) return const SizedBox.shrink(); if (widget.data.isEmpty) return const SizedBox.shrink();
if (widget.data.length == 1) { if (widget.data.length == 1) {
final singleAspectRatio = widget.data[0]?.metadata['ratio']?.toDouble() ?? final singleAspectRatio = widget.data[0]?.data['ratio']?.toDouble() ??
switch (widget.data[0]?.mimetype.split('/').firstOrNull) { switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
'audio' => 16 / 9, 'audio' => 16 / 9,
'video' => 16 / 9, 'video' => 16 / 9,
@ -67,27 +68,15 @@ class _AttachmentListState extends State<AttachmentList> {
.toDouble(); .toDouble();
return Container( return Container(
constraints: ResponsiveBreakpoints.of(context).largerThan(MOBILE) padding: widget.padding ?? EdgeInsets.zero,
? constraints.copyWith( constraints: constraints,
maxWidth: math.min( child: GestureDetector(
constraints.maxWidth,
kAttachmentMaxWidth,
),
)
: null,
child: AspectRatio( child: AspectRatio(
aspectRatio: singleAspectRatio, aspectRatio: singleAspectRatio,
child: GestureDetector(
child: Builder(
builder: (context) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || widget.noGrow) {
return Padding(
// Single child list-like displaying
padding: widget.listPadding ?? EdgeInsets.zero,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide), border: Border.fromBorderSide(borderSide),
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
), ),
child: ClipRRect( child: ClipRRect(
@ -95,25 +84,13 @@ class _AttachmentListState extends State<AttachmentList> {
child: AttachmentItem( child: AttachmentItem(
data: widget.data[0], data: widget.data[0],
heroTag: heroTags[0], heroTag: heroTags[0],
fit: widget.fit,
), ),
), ),
), ),
);
}
return Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
),
child: AttachmentItem(
data: widget.data[0],
heroTag: heroTags.first,
),
);
},
), ),
onTap: () { onTap: () {
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(), data: widget.data.where((ele) => ele != null).cast(),
@ -125,18 +102,12 @@ class _AttachmentListState extends State<AttachmentList> {
); );
}, },
), ),
),
); );
} }
if (widget.isFlatted) { if (widget.gridded) {
return Wrap( return Padding(
spacing: 4, padding: widget.padding ?? EdgeInsets.zero,
runSpacing: 4,
children: widget.data
.mapIndexed(
(idx, ele) => AspectRatio(
aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
@ -148,20 +119,44 @@ class _AttachmentListState extends State<AttachmentList> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: StaggeredGrid.count(
crossAxisCount: math.min(widget.data.length, 2),
crossAxisSpacing: 4,
mainAxisSpacing: 4,
children: widget.data
.mapIndexed(
(idx, ele) => GestureDetector(
child: Container(
constraints: constraints,
child: AttachmentItem( child: AttachmentItem(
data: ele, data: ele,
heroTag: heroTags[idx], heroTag: heroTags[idx],
fit: BoxFit.cover,
), ),
), ),
onTap: () {
if (widget.data[idx]!.mediaType != SnMediaType.image) return;
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
), ),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
), ),
) )
.toList(), .toList(),
),
),
),
); );
} }
return AspectRatio( return AspectRatio(
aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(), aspectRatio: (widget.data.firstOrNull?.data['ratio'] ?? 1).toDouble(),
child: Container( child: Container(
constraints: BoxConstraints(maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxHeight: constraints.maxHeight),
child: ScrollConfiguration( child: ScrollConfiguration(
@ -173,12 +168,14 @@ class _AttachmentListState extends State<AttachmentList> {
return Container( return Container(
constraints: constraints, constraints: constraints,
child: AspectRatio( child: AspectRatio(
aspectRatio: (widget.data[idx]?.metadata['ratio'] ?? 1).toDouble(), aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
if (widget.data[idx]?.mediaType != SnMediaType.image) return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(), data:
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
initialIndex: idx, initialIndex: idx,
heroTags: heroTags, heroTags: heroTags,
), ),
@ -220,7 +217,7 @@ class _AttachmentListState extends State<AttachmentList> {
); );
}, },
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
padding: widget.listPadding, padding: widget.padding,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
), ),

View File

@ -0,0 +1,120 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/dialog.dart';
class PendingAttachmentBoostDialog extends StatefulWidget {
final PostWriteMedia media;
const PendingAttachmentBoostDialog({super.key, required this.media});
@override
State<PendingAttachmentBoostDialog> createState() => _PendingAttachmentBoostDialogState();
}
class _PendingAttachmentBoostDialogState extends State<PendingAttachmentBoostDialog> {
List<SnAttachmentDestination>? _regions;
SnAttachmentDestination? _selectedRegion;
Future<void> _fetchRegions() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/uc/destinations');
setState(() {
_regions = List<SnAttachmentDestination>.from(
resp.data?.map((e) => SnAttachmentDestination.fromJson(e)) ?? [],
).cast<SnAttachmentDestination>().where((ele) => ele.isBoost).toList();
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
bool _isBusy = false;
Future<void> _performAction() async {
if (_isBusy) return;
if (_selectedRegion == null) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.post('/cgi/uc/boosts', data: {
'attachment': widget.media.attachment!.id,
'destination': _selectedRegion!.id,
});
if (!mounted) return;
Navigator.pop(context, SnAttachmentBoost.fromJson(resp.data));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRegions();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('attachmentBoost').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('attachmentBoostHint').tr(),
const Gap(16),
Text('attachmentDestinationRegion').tr().fontSize(18),
const Gap(8),
Card(
child: _regions == null
? const CircularProgressIndicator().center().padding(all: 16)
: Column(
children: _regions!.map(
(ele) {
return RadioListTile(
title: Text(ele.label).tr(),
subtitle: Text(
'attachmentDestinationRegion${ele.region}'.trExists()
? 'attachmentDestinationRegion${ele.region}'.tr()
: ele.region,
),
selected: _selectedRegion == ele,
value: ele,
groupValue: _selectedRegion,
onChanged: (value) {
if (value != null) setState(() => _selectedRegion = value);
},
);
},
).toList(),
),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () {
Navigator.pop(context);
},
child: Text('dialogDismiss'.tr()),
),
TextButton(
onPressed: _isBusy ? null : () => _performAction(),
child: Text('dialogConfirm'.tr()),
),
],
);
}
}

View File

@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:developer';
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:video_compress/video_compress.dart';
class PendingVideoCompressDialog extends StatefulWidget {
final PostWriteMedia media;
const PendingVideoCompressDialog({super.key, required this.media});
@override
State<PendingVideoCompressDialog> createState() => _PendingVideoCompressDialogState();
}
class _PendingVideoCompressDialogState extends State<PendingVideoCompressDialog> {
VideoQuality _quality = VideoQuality.DefaultQuality;
bool _isBusy = false;
double? _progress;
MediaInfo? _mediaInfo;
Subscription? _progressSubscription;
Future<void> _startCompress() async {
_mediaInfo = await VideoCompress.compressVideo(
widget.media.file!.path,
quality: _quality,
deleteOrigin: false,
frameRate: switch (_quality) {
VideoQuality.HighestQuality => 60,
VideoQuality.DefaultQuality => 60,
_ => 30,
},
);
if (_mediaInfo == null) return;
setState(() => _isBusy = true);
if (!mounted || _mediaInfo == null) return;
Navigator.pop(context, PostWriteMedia.fromFile(XFile(_mediaInfo!.path!)));
}
@override
void initState() {
super.initState();
_progressSubscription = VideoCompress.compressProgress$.subscribe((event) {
log('[Compress] Progress: $event');
setState(() {
_progress = event / 100;
_isBusy = event < 100;
});
});
}
@override
void dispose() {
_progressSubscription?.unsubscribe();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('attachmentCompressVideo').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FutureBuilder(
future: widget.media.file?.length(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
return Text(
snapshot.data!.formatBytes(),
style: GoogleFonts.robotoMono(fontSize: 13),
);
},
),
Text('attachmentCompressQuality').tr(),
const Gap(8),
Card(
child: Column(
children: [
RadioListTile(
title: Text('attachmentCompressQualityHighest').tr(),
value: VideoQuality.HighestQuality,
groupValue: _quality,
selected: _quality == VideoQuality.HighestQuality,
onChanged: (val) {
if (val != null) {
setState(() => _quality = val);
}
},
),
RadioListTile(
title: Text('attachmentCompressQualityDefault').tr(),
value: VideoQuality.DefaultQuality,
groupValue: _quality,
onChanged: (val) {
if (val != null) {
setState(() => _quality = val);
}
},
),
RadioListTile(
title: Text('attachmentCompressQualityMedium').tr(),
value: VideoQuality.MediumQuality,
groupValue: _quality,
onChanged: (val) {
if (val != null) {
setState(() => _quality = val);
}
},
),
RadioListTile(
title: Text('attachmentCompressQualityLow').tr(),
value: VideoQuality.LowQuality,
groupValue: _quality,
onChanged: (val) {
if (val != null) {
setState(() => _quality = val);
}
},
),
],
),
),
const Gap(8),
Text('attachmentCompressQualityHint', style: Theme.of(context).textTheme.bodySmall!).tr(),
if (_isBusy)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress ?? 0),
duration: Duration(milliseconds: 100),
builder: (context, value, _) => LinearProgressIndicator(
value: value,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
).padding(top: 16),
],
),
actions: [
TextButton(
onPressed: _isBusy
? null
: () {
Navigator.pop(context);
},
child: Text('dialogDismiss').tr(),
),
TextButton(
onPressed: _isBusy ? null : _startCompress,
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:swipe_to/swipe_to.dart'; import 'package:swipe_to/swipe_to.dart';
@ -53,7 +54,7 @@ class ChatMessage extends StatelessWidget {
swipeSensitivity: 20, swipeSensitivity: 20,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null, onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null,
child: ContextMenuRegion( child: ContextMenuArea(
contextMenu: ContextMenu( contextMenu: ContextMenu(
entries: [ entries: [
MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])), MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])),
@ -142,7 +143,7 @@ class ChatMessage extends StatelessWidget {
onEdit: onEdit, onEdit: onEdit,
onDelete: onDelete, onDelete: onDelete,
), ),
)).padding(bottom: 4, top: isMerged ? 4 : 2), )).padding(bottom: 4, top: 4),
switch (data.type) { switch (data.type) {
'messages.new' => _ChatMessageText(data: data), 'messages.new' => _ChatMessageText(data: data),
_ => _ChatMessageSystemNotify(data: data), _ => _ChatMessageSystemNotify(data: data),
@ -158,9 +159,10 @@ class ChatMessage extends StatelessWidget {
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
bordered: true, bordered: true,
gridded: true,
noGrow: true, noGrow: true,
maxHeight: 520, maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
), ),
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6), if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
], ],
@ -173,7 +175,7 @@ class ChatMessage extends StatelessWidget {
class _ChatMessageText extends StatelessWidget { class _ChatMessageText extends StatelessWidget {
final SnChatMessage data; final SnChatMessage data;
const _ChatMessageText({super.key, required this.data}); const _ChatMessageText({required this.data});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -213,7 +215,7 @@ class _ChatMessageText extends StatelessWidget {
class _ChatMessageSystemNotify extends StatelessWidget { class _ChatMessageSystemNotify extends StatelessWidget {
final SnChatMessage data; final SnChatMessage data;
const _ChatMessageSystemNotify({super.key, required this.data}); const _ChatMessageSystemNotify({required this.data});
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : ''; String negativeSign = duration.isNegative ? '-' : '';

View File

@ -1,18 +1,14 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart'; import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
@ -80,13 +76,14 @@ class ChatMessageInputState extends State<ChatMessageInput> {
media.name, media.name,
'messaging', 'messaging',
null, null,
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
); );
final item = await attach.chunkedUploadParts( final item = await attach.chunkedUploadParts(
media.toFile()!, media.toFile()!,
place.$1, place.$1,
place.$2, place.$2,
analyzeNow: media.type == SnMediaType.image,
onProgress: (progress) { onProgress: (progress) {
// Calculate overall progress for attachments // Calculate overall progress for attachments
setState(() { setState(() {
@ -123,40 +120,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
} }
final List<PostWriteMedia> _attachments = List.empty(growable: true); final List<PostWriteMedia> _attachments = List.empty(growable: true);
final _imagePicker = ImagePicker();
void _takeMedia(bool isVideo) async {
final result = isVideo
? await _imagePicker.pickVideo(source: ImageSource.camera)
: await _imagePicker.pickImage(source: ImageSource.camera);
if (result == null) return;
_attachments.add(
PostWriteMedia.fromFile(result),
);
setState(() {});
}
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_attachments.addAll(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_attachments.add(
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
);
setState(() {});
}
@override @override
void dispose() { void dispose() {
@ -294,64 +257,13 @@ class ChatMessageInputState extends State<ChatMessageInput> {
), ),
), ),
const Gap(8), const Gap(8),
PopupMenuButton( AddPostMediaButton(
icon: Icon( onAdd: (items) {
Symbols.add_photo_alternate, setState(() {
color: Theme.of(context).colorScheme.primary, _attachments.addAll(items);
), });
itemBuilder: (context) => [
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_camera),
const Gap(16),
Text('addAttachmentFromCameraPhoto').tr(),
],
),
onTap: () {
_takeMedia(false);
}, },
), ),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.videocam),
const Gap(16),
Text('addAttachmentFromCameraVideo').tr(),
],
),
onTap: () {
_takeMedia(true);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
),
IconButton( IconButton(
onPressed: _isBusy ? null : _sendMessage, onPressed: _isBusy ? null : _sendMessage,
icon: Icon( icon: Icon(

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:responsive_framework/responsive_framework.dart';
class ContextMenuArea extends StatelessWidget {
final ContextMenu contextMenu;
final Widget child;
final ValueChanged<dynamic>? onItemSelected;
const ContextMenuArea({
super.key,
required this.contextMenu,
required this.child,
this.onItemSelected,
});
@override
Widget build(BuildContext context) {
Offset mousePosition = Offset.zero;
return Listener(
onPointerDown: (event) {
mousePosition = event.position;
final isCollapseDrawer = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
if (!isCollapseDrawer) {
final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET);
// Leave padding for side navigation
mousePosition = isExpandDrawer
? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
: mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
}
},
child: GestureDetector(
onLongPress: () => _showMenu(context, mousePosition),
onSecondaryTap: () => _showMenu(context, mousePosition),
child: child,
),
);
}
void _showMenu(BuildContext context, Offset mousePosition) async {
final menu = contextMenu.copyWith(position: contextMenu.position ?? mousePosition);
final value = await showContextMenu(context, contextMenu: menu);
onItemSelected?.call(value);
}
}

View File

@ -60,7 +60,6 @@ class _LinkPreviewEntry extends StatelessWidget {
final SnLinkMeta meta; final SnLinkMeta meta;
const _LinkPreviewEntry({ const _LinkPreviewEntry({
super.key,
required this.meta, required this.meta,
}); });

View File

@ -17,6 +17,7 @@ import 'package:responsive_framework/responsive_framework.dart';
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
@ -83,6 +84,7 @@ class PostItem extends StatelessWidget {
child: MultiProvider( child: MultiProvider(
providers: [ providers: [
Provider<SnNetworkProvider>(create: (_) => context.read()), Provider<SnNetworkProvider>(create: (_) => context.read()),
ChangeNotifierProvider<ConfigProvider>(create: (_) => context.read()),
], ],
child: ResponsiveBreakpoints.builder( child: ResponsiveBreakpoints.builder(
breakpoints: ResponsiveBreakpoints.of(context).breakpoints, breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
@ -175,6 +177,7 @@ class PostItem extends StatelessWidget {
children: [ children: [
if (data.visibility > 0) _PostVisibilityHint(data: data), if (data.visibility > 0) _PostVisibilityHint(data: data),
_PostTruncatedHint(data: data), _PostTruncatedHint(data: data),
if (data.tags.isNotEmpty) _PostTagsList(data: data),
], ],
).padding(horizontal: 12), ).padding(horizontal: 12),
const Gap(8), const Gap(8),
@ -182,7 +185,6 @@ class PostItem extends StatelessWidget {
), ),
), ),
Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8), Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
_PostBottomAction( _PostBottomAction(
data: data, data: data,
showComments: showComments, showComments: showComments,
@ -241,7 +243,7 @@ class PostItem extends StatelessWidget {
horizontal: 16, horizontal: 16,
vertical: 4, vertical: 4,
), ),
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, top: 4, bottom: 6),
], ],
), ),
), ),
@ -249,8 +251,11 @@ class PostItem extends StatelessWidget {
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
bordered: true, bordered: true,
maxHeight: 560, gridded: true,
listPadding: const EdgeInsets.symmetric(horizontal: 12), maxHeight: showFullPost ? null : 480,
minWidth: 640,
fit: showFullPost ? BoxFit.cover : BoxFit.contain,
padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
if (data.body['content'] != null) if (data.body['content'] != null)
LinkPreviewWidget( LinkPreviewWidget(
@ -330,17 +335,12 @@ class PostShareImageWidget extends StatelessWidget {
_PostQuoteContent( _PostQuoteContent(
child: data.repostTo!, child: data.repostTo!,
isRelativeDate: false, isRelativeDate: false,
isFlatted: true,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false)) if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false))
AttachmentList( StyledWidget(AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
isFlatted: true, gridded: true,
).padding(horizontal: 16, bottom: 8), )).padding(horizontal: 16, bottom: 8),
if (data.body['content'] != null)
LinkPreviewWidget(
text: data.body['content'],
).padding(horizontal: 4),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -410,7 +410,7 @@ class PostShareImageWidget extends StatelessWidget {
size: Size(28, 28), size: Size(28, 28),
), ),
eyeStyle: QrEyeStyle( eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square, eyeShape: QrEyeShape.circle,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
dataModuleStyle: QrDataModuleStyle( dataModuleStyle: QrDataModuleStyle(
@ -545,7 +545,6 @@ class _PostHeadline extends StatelessWidget {
final bool isEnlarge; final bool isEnlarge;
const _PostHeadline({ const _PostHeadline({
super.key,
required this.data, required this.data,
this.isEnlarge = false, this.isEnlarge = false,
}); });
@ -887,12 +886,9 @@ class _PostContentBody extends StatelessWidget {
class _PostQuoteContent extends StatelessWidget { class _PostQuoteContent extends StatelessWidget {
final SnPost child; final SnPost child;
final bool isRelativeDate; final bool isRelativeDate;
final bool isFlatted;
const _PostQuoteContent({ const _PostQuoteContent({
super.key,
this.isRelativeDate = true, this.isRelativeDate = true,
this.isFlatted = false,
required this.child, required this.child,
}); });
@ -934,12 +930,15 @@ class _PostQuoteContent extends StatelessWidget {
), ),
child: AttachmentList( child: AttachmentList(
data: child.preload!.attachments!, data: child.preload!.attachments!,
isFlatted: isFlatted, maxHeight: 360,
listPadding: const EdgeInsets.symmetric(horizontal: 12), minWidth: 640,
fit: BoxFit.contain,
gridded: true,
padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
).padding( ).padding(
top: 8, top: 8,
bottom: (child.preload?.attachments?.length ?? 0) > 1 ? 12 : 0, bottom: 12,
) )
else else
const Gap(8), const Gap(8),
@ -958,34 +957,80 @@ class _PostQuoteContent extends StatelessWidget {
class _PostTagsList extends StatelessWidget { class _PostTagsList extends StatelessWidget {
final SnPost data; final SnPost data;
const _PostTagsList({super.key, required this.data}); const _PostTagsList({required this.data});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 4,
runSpacing: 4,
children: data.categories
.map(
(ele) => InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.category, size: 20),
const Gap(4),
Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.alias,
style: GoogleFonts.robotoMono(),
),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'categories': ele.alias,
},
);
},
),
)
.toList(),
).opacity(0.8),
Wrap(
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: data.tags children: data.tags
.map( .map(
(ele) => InkWell( (ele) => InkWell(
child: Text( child: Row(
'#${ele.alias}', mainAxisSize: MainAxisSize.min,
style: TextStyle( children: [
decoration: TextDecoration.underline, const Icon(Symbols.label, size: 20),
const Gap(4),
Text(ele.alias, style: GoogleFonts.robotoMono()),
],
), ),
).fontSize(13), onTap: () {
onTap: () {}, GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'tags': ele.alias,
},
);
},
), ),
) )
.toList(), .toList(),
).opacity(0.8); ).opacity(0.8),
],
);
} }
} }
class _PostVisibilityHint extends StatelessWidget { class _PostVisibilityHint extends StatelessWidget {
final SnPost data; final SnPost data;
const _PostVisibilityHint({super.key, required this.data}); const _PostVisibilityHint({required this.data});
static const List<IconData> kVisibilityIcons = [ static const List<IconData> kVisibilityIcons = [
Symbols.public, Symbols.public,
@ -1010,7 +1055,7 @@ class _PostVisibilityHint extends StatelessWidget {
class _PostTruncatedHint extends StatelessWidget { class _PostTruncatedHint extends StatelessWidget {
final SnPost data; final SnPost data;
const _PostTruncatedHint({super.key, required this.data}); const _PostTruncatedHint({required this.data});
static const int kHumanReadSpeed = 238; static const int kHumanReadSpeed = 238;
@ -1019,6 +1064,7 @@ class _PostTruncatedHint extends StatelessWidget {
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
spacing: 8,
children: [ children: [
if (data.body['content_length'] != null) if (data.body['content_length'] != null)
Row( Row(
@ -1031,7 +1077,7 @@ class _PostTruncatedHint extends StatelessWidget {
).inSeconds}s', ).inSeconds}s',
]), ]),
], ],
).padding(right: 8), ),
if (data.body['content_length'] != null) if (data.body['content_length'] != null)
Row( Row(
children: [ children: [
@ -1051,7 +1097,7 @@ class _PostTruncatedHint extends StatelessWidget {
class _PostAbuseReportDialog extends StatefulWidget { class _PostAbuseReportDialog extends StatefulWidget {
final SnPost data; final SnPost data;
const _PostAbuseReportDialog({super.key, required this.data}); const _PostAbuseReportDialog({required this.data});
@override @override
State<_PostAbuseReportDialog> createState() => _PostAbuseReportDialogState(); State<_PostAbuseReportDialog> createState() => _PostAbuseReportDialogState();

View File

@ -6,15 +6,27 @@ import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_input.dart';
import 'package:surface/widgets/attachment/attachment_zoom.dart'; import 'package:surface/widgets/attachment/attachment_zoom.dart';
import 'package:surface/widgets/attachment/pending_attachment_boost.dart';
import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart';
import '../attachment/pending_attachment_compress.dart';
class PostMediaPendingList extends StatelessWidget { class PostMediaPendingList extends StatelessWidget {
final PostWriteMedia? thumbnail; final PostWriteMedia? thumbnail;
@ -70,6 +82,37 @@ class PostMediaPendingList extends StatelessWidget {
} }
} }
Future<void> _setThumbnail(BuildContext context, int idx) async {
if (idx == -1) {
// Thumbnail only can set on video or audio. And thumbnail of the post must be an image, so it's not possible to set thumbnail on the post thumbnail.
return;
} else if (attachments[idx].attachment == null) {
return;
}
final thumbnail = await showDialog<SnAttachment?>(
context: context,
builder: (context) => AttachmentInputDialog(
title: 'attachmentSetThumbnail'.tr(),
analyzeNow: true,
),
);
if (thumbnail == null) return;
if (!context.mounted) return;
try {
final attach = context.read<SnAttachmentProvider>();
final newAttach = await attach.updateOne(
attachments[idx].attachment!,
thumbnailId: thumbnail.id,
);
onUpdate!(idx, PostWriteMedia(newAttach));
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _deleteAttachment(BuildContext context, int idx) async { Future<void> _deleteAttachment(BuildContext context, int idx) async {
final media = idx == -1 ? thumbnail! : attachments[idx]; final media = idx == -1 ? thumbnail! : attachments[idx];
if (media.attachment == null) return; if (media.attachment == null) return;
@ -87,9 +130,61 @@ class PostMediaPendingList extends StatelessWidget {
} }
} }
ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) { Future<void> _createBoost(BuildContext context, int idx) async {
if (attachments[idx].attachment == null) return;
final result = await showDialog<SnAttachmentBoost?>(
context: context,
builder: (context) => PendingAttachmentBoostDialog(media: attachments[idx]),
);
if (result == null) return;
final newAttach = attachments[idx].attachment!.copyWith(
boosts: [...attachments[idx].attachment!.boosts, result],
);
final newMedia = PostWriteMedia(newAttach);
onUpdate!(idx, newMedia);
}
Future<void> _compressVideo(BuildContext context, int idx) async {
final result = await showDialog<PostWriteMedia?>(
context: context,
builder: (context) => PendingVideoCompressDialog(media: attachments[idx]),
);
if (result == null) return;
onUpdate!(idx, result);
}
ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) {
final canCompressVideo = !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
return ContextMenu( return ContextMenu(
entries: [ entries: [
if (media.attachment == null && media.type == SnMediaType.video && canCompressVideo)
MenuItem(
label: 'attachmentCompressVideo'.tr(),
icon: Symbols.compress,
onSelected: () {
_compressVideo(context, idx);
},
),
if (media.attachment != null)
MenuItem(
label: 'attachmentBoost'.tr(),
icon: Symbols.bolt,
onSelected: () {
_createBoost(context, idx);
},
),
if (media.attachment != null && media.type == SnMediaType.video)
MenuItem(
label: 'attachmentSetThumbnail'.tr(),
icon: Symbols.image,
onSelected: () {
_setThumbnail(context, idx);
},
),
if (media.attachment == null && onUpload != null) if (media.attachment == null && onUpload != null)
MenuItem( MenuItem(
label: 'attachmentUpload'.tr(), label: 'attachmentUpload'.tr(),
@ -97,7 +192,7 @@ class PostMediaPendingList extends StatelessWidget {
onSelected: () { onSelected: () {
onUpload!(idx); onUpload!(idx);
}), }),
if (media.attachment != null && onPostSetThumbnail != null && idx != -1) if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null && idx != -1)
MenuItem( MenuItem(
label: 'attachmentSetAsPostThumbnail'.tr(), label: 'attachmentSetAsPostThumbnail'.tr(),
icon: Symbols.gallery_thumbnail, icon: Symbols.gallery_thumbnail,
@ -105,7 +200,7 @@ class PostMediaPendingList extends StatelessWidget {
onPostSetThumbnail!(idx); onPostSetThumbnail!(idx);
}, },
) )
else if (media.attachment != null && onPostSetThumbnail != null) else if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null)
MenuItem( MenuItem(
label: 'attachmentUnsetAsPostThumbnail'.tr(), label: 'attachmentUnsetAsPostThumbnail'.tr(),
icon: Symbols.cancel, icon: Symbols.cancel,
@ -121,7 +216,7 @@ class PostMediaPendingList extends StatelessWidget {
onInsertLink!(idx); onInsertLink!(idx);
}, },
), ),
if (media.type == PostWriteMediaType.image && media.attachment != null) if (media.type == SnMediaType.image && media.attachment != null)
MenuItem( MenuItem(
label: 'preview'.tr(), label: 'preview'.tr(),
icon: Symbols.preview, icon: Symbols.preview,
@ -132,12 +227,20 @@ class PostMediaPendingList extends StatelessWidget {
); );
}, },
), ),
if (media.type == PostWriteMediaType.image && media.attachment == null) if (media.type == SnMediaType.image && media.attachment == null)
MenuItem( MenuItem(
label: 'crop'.tr(), label: 'crop'.tr(),
icon: Symbols.crop, icon: Symbols.crop,
onSelected: () => _cropImage(context, idx), onSelected: () => _cropImage(context, idx),
), ),
if (media.attachment != null)
MenuItem(
label: 'attachmentCopyRandomId'.tr(),
icon: Symbols.content_copy,
onSelected: () {
Clipboard.setData(ClipboardData(text: media.attachment!.rid));
},
),
if (media.attachment != null && onRemove != null) if (media.attachment != null && onRemove != null)
MenuItem( MenuItem(
label: 'delete'.tr(), label: 'delete'.tr(),
@ -166,47 +269,15 @@ class PostMediaPendingList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container( return Container(
constraints: const BoxConstraints(maxHeight: 120), constraints: const BoxConstraints(maxHeight: 120),
child: Row( child: Row(
children: [ children: [
const Gap(8), const Gap(8),
if (thumbnail != null) if (thumbnail != null)
ContextMenuRegion( ContextMenuArea(
contextMenu: _buildContextMenu(context, -1, thumbnail!), contextMenu: _createContextMenu(context, -1, thumbnail!),
child: Container( child: _PostMediaPendingItem(media: thumbnail!),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 1,
child: switch (thumbnail!.type) {
PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
return Image(
image: thumbnail!.getImageProvider(
context,
width: (constraints.maxWidth * devicePixelRatio).round(),
height: (constraints.maxHeight * devicePixelRatio).round(),
)!,
fit: BoxFit.cover,
);
}),
_ => Container(
color: Theme.of(context).colorScheme.surface,
child: const Icon(Symbols.docs).center(),
),
},
),
),
),
), ),
if (thumbnail != null) if (thumbnail != null)
const VerticalDivider(width: 1, thickness: 1).padding( const VerticalDivider(width: 1, thickness: 1).padding(
@ -221,39 +292,9 @@ class PostMediaPendingList extends StatelessWidget {
itemCount: attachments.length, itemCount: attachments.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final media = attachments[idx]; final media = attachments[idx];
return ContextMenuRegion( return ContextMenuArea(
contextMenu: _buildContextMenu(context, idx, media), contextMenu: _createContextMenu(context, idx, media),
child: Container( child: _PostMediaPendingItem(media: media),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 1,
child: switch (media.type) {
PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
return Image(
image: media.getImageProvider(
context,
width: (constraints.maxWidth * devicePixelRatio).round(),
height: (constraints.maxHeight * devicePixelRatio).round(),
)!,
fit: BoxFit.cover,
);
}),
_ => Container(
color: Theme.of(context).colorScheme.surface,
child: const Icon(Symbols.docs).center(),
),
},
),
),
),
); );
}, },
), ),
@ -263,3 +304,319 @@ class PostMediaPendingList extends StatelessWidget {
); );
} }
} }
class _PostMediaPendingItem extends StatelessWidget {
final PostWriteMedia media;
const _PostMediaPendingItem({
required this.media,
});
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final sn = context.read<SnNetworkProvider>();
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Row(
children: [
AspectRatio(
aspectRatio: 1,
child: switch (media.type) {
SnMediaType.image => LayoutBuilder(builder: (context, constraints) {
return Image(
image: media.getImageProvider(
context,
width: (constraints.maxWidth * devicePixelRatio).round(),
height: (constraints.maxHeight * devicePixelRatio).round(),
)!,
fit: BoxFit.contain,
);
}),
SnMediaType.video => Stack(
fit: StackFit.expand,
children: [
if (media.attachment?.thumbnail != null)
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)),
const Icon(Symbols.videocam, color: Colors.white, shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
]),
],
),
SnMediaType.audio => Stack(
fit: StackFit.expand,
children: [
if (media.attachment?.thumbnail != null)
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)),
const Icon(Symbols.audio_file, color: Colors.white, shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
]),
],
),
_ => Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: const Icon(Symbols.docs).center(),
),
},
),
if (media.type != SnMediaType.image) const VerticalDivider(width: 1, thickness: 1),
if (media.type != SnMediaType.image)
SizedBox(
width: 160,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (media.attachment != null)
Text(
media.attachment!.alt,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
else if (media.file != null)
Text(media.file!.name, maxLines: 1, overflow: TextOverflow.ellipsis)
else
Text('unknown'.tr()),
if (media.attachment != null)
Text(
media.attachment!.size.formatBytes(),
style: GoogleFonts.robotoMono(fontSize: 13),
maxLines: 1,
)
else if (media.file != null)
FutureBuilder<int?>(
future: media.length(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
return Text(
snapshot.data!.formatBytes(),
style: GoogleFonts.robotoMono(fontSize: 13),
maxLines: 1,
);
},
),
],
),
),
if (media.attachment != null && media.attachment!.boosts.isNotEmpty)
Row(
children: [
Icon(Symbols.bolt, size: 16),
const Gap(4),
Text('attachmentGotBoosted').tr().fontSize(13),
],
),
if (media.attachment != null && media.attachment!.compressedId != null)
Row(
children: [
Icon(Symbols.compress, size: 16),
const Gap(4),
Text('attachmentCopyCompressed').tr().fontSize(13),
],
),
if (media.attachment != null)
Row(
children: [
Icon(Symbols.cloud, size: 16),
const Gap(4),
Text('attachmentUploaded').tr().fontSize(13),
],
)
else
Row(
children: [
Icon(Symbols.cloud_off, size: 16),
const Gap(4),
Text('attachmentPending').tr().fontSize(13),
],
),
],
),
).padding(horizontal: 12, vertical: 12),
],
),
),
);
}
}
class AddPostMediaButton extends StatelessWidget {
final Function(Iterable<PostWriteMedia>) onAdd;
const AddPostMediaButton({super.key, required this.onAdd});
void _takeMedia(bool isVideo) async {
final picker = ImagePicker();
final result = isVideo
? await picker.pickVideo(source: ImageSource.camera)
: await picker.pickImage(source: ImageSource.camera);
if (result == null) return;
onAdd([PostWriteMedia.fromFile(result)]);
}
void _selectMedia() async {
final picker = ImagePicker();
final result = await picker.pickMultipleMedia();
if (result.isEmpty) return;
onAdd(
result.map((e) => PostWriteMedia.fromFile(e)),
);
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
onAdd([
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
SnMediaType.image,
),
]);
}
void _linkRandomId(BuildContext context) async {
final randomIdController = TextEditingController();
final randomId = await showDialog<String?>(
context: context,
builder: (context) => AlertDialog(
title: Text('addAttachmentFromRandomId').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: randomIdController,
decoration: InputDecoration(
labelText: 'fieldAttachmentRandomId'.tr(),
border: const UnderlineInputBorder(),
),
),
const Gap(8),
],
),
actions: [
TextButton(
child: Text('dialogDismiss').tr(),
onPressed: () {
Navigator.pop(context);
},
),
TextButton(
child: Text('dialogConfirm').tr(),
onPressed: () {
Navigator.pop(context, randomIdController.text);
},
),
],
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
randomIdController.dispose();
});
if (randomId == null || randomId.isEmpty) return;
if (!context.mounted) return;
final attach = context.read<SnAttachmentProvider>();
final attachment = await attach.getOne(randomId);
onAdd([
PostWriteMedia(attachment),
]);
}
@override
Widget build(BuildContext context) {
return PopupMenuButton(
icon: Icon(
Symbols.add_photo_alternate,
color: Theme.of(context).colorScheme.primary,
),
itemBuilder: (context) => [
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_camera),
const Gap(16),
Text('addAttachmentFromCameraPhoto').tr(),
],
),
onTap: () {
_takeMedia(false);
},
),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.videocam),
const Gap(16),
Text('addAttachmentFromCameraVideo').tr(),
],
),
onTap: () {
_takeMedia(true);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.link),
const Gap(16),
Text('addAttachmentFromRandomId').tr(),
],
),
onTap: () {
_linkRandomId(context);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
);
}
}

View File

@ -83,7 +83,9 @@ class PostMetaEditor extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
return Column( return SingleChildScrollView(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
child: Column(
children: [ children: [
TextField( TextField(
controller: controller.titleController, controller: controller.titleController,
@ -114,6 +116,26 @@ class PostMetaEditor extends StatelessWidget {
controller.setTags(value); controller.setTags(value);
}, },
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(4),
PostCategoriesField(
initialCategories: controller.categories,
labelText: 'fieldPostCategories'.tr(),
onUpdate: (value) {
controller.setCategories(value);
},
).padding(horizontal: 24),
const Gap(4),
TextField(
controller: controller.aliasController,
decoration: InputDecoration(
labelText: 'fieldPostAlias'.tr(),
helperText: 'fieldPostAliasHint'.tr(),
helperMaxLines: 2,
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
const Gap(12), const Gap(12),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
@ -231,7 +253,8 @@ class PostMetaEditor extends StatelessWidget {
}, },
), ),
], ],
).padding(vertical: 8); ).padding(vertical: 8),
);
}, },
); );
} }

View File

@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
@ -16,6 +17,7 @@ import 'package:surface/widgets/loading_indicator.dart';
class PostMiniEditor extends StatefulWidget { class PostMiniEditor extends StatefulWidget {
final int? postReplyId; final int? postReplyId;
final Function? onPost; final Function? onPost;
const PostMiniEditor({super.key, this.postReplyId, this.onPost}); const PostMiniEditor({super.key, this.postReplyId, this.onPost});
@override @override
@ -26,6 +28,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController(); final PostWriteController _writeController = PostWriteController();
bool _isFetching = false; bool _isFetching = false;
bool get _isLoading => _isFetching || _writeController.isLoading; bool get _isLoading => _isFetching || _writeController.isLoading;
List<SnPublisher>? _publishers; List<SnPublisher>? _publishers;
@ -35,11 +38,14 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final config = context.read<ConfigProvider>();
final resp = await sn.client.get('/cgi/co/publishers/me'); final resp = await sn.client.get('/cgi/co/publishers/me');
_publishers = List<SnPublisher>.from( _publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
_writeController.setPublisher(_publishers?.firstOrNull); final beforeId = config.prefs.getInt('int_last_publisher_id');
_writeController
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -93,17 +99,11 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Text(item.nick).textStyle( Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
Theme.of(context)
.textTheme
.bodyMedium!),
Text('@${item.name}') Text('@${item.name}')
.textStyle(Theme.of(context) .textStyle(Theme.of(context).textTheme.bodySmall!)
.textTheme
.bodySmall!)
.fontSize(12), .fontSize(12),
], ],
), ),
@ -120,8 +120,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: foregroundColor: Theme.of(context).colorScheme.onSurface,
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
const Gap(8), const Gap(8),
@ -130,8 +129,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('publishersNew').tr().textStyle( Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
Theme.of(context).textTheme.bodyMedium!),
], ],
), ),
), ),
@ -142,9 +140,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
value: _writeController.publisher, value: _writeController.publisher,
onChanged: (SnPublisher? value) { onChanged: (SnPublisher? value) {
if (value == null) { if (value == null) {
GoRouter.of(context) GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) { if (value == true) {
_publishers = null; _publishers = null;
_fetchPublishers(); _fetchPublishers();
@ -152,6 +148,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
}); });
} else { } else {
_writeController.setPublisher(value); _writeController.setPublisher(value);
final config = context.read<ConfigProvider>();
config.prefs.setInt('int_last_publisher_id', value.id);
} }
}, },
buttonStyleData: const ButtonStyleData( buttonStyleData: const ButtonStyleData(
@ -178,8 +176,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
), ),
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
const Gap(8), const Gap(8),
@ -188,8 +185,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress), tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300), duration: Duration(milliseconds: 300),
builder: (context, value, _) => builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
LinearProgressIndicator(value: value, minHeight: 2),
) )
else if (_writeController.isBusy) else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2), const LinearProgressIndicator(value: null, minHeight: 2),
@ -206,18 +202,16 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
'postEditor', 'postEditor',
pathParameters: {'mode': 'stories'}, pathParameters: {'mode': 'stories'},
queryParameters: { queryParameters: {
if (widget.postReplyId != null) if (widget.postReplyId != null) 'replying': widget.postReplyId.toString(),
'replying': widget.postReplyId.toString(),
}, },
); );
}, },
), ),
TextButton.icon( TextButton.icon(
onPressed: (_writeController.isBusy || onPressed: (_writeController.isBusy || _writeController.publisher == null)
_writeController.publisher == null)
? null ? null
: () { : () {
_writeController.post(context).then((_) { _writeController.sendPost(context).then((_) {
if (!context.mounted) return; if (!context.mounted) return;
if (widget.onPost != null) widget.onPost!(); if (widget.onPost != null) widget.onPost!();
context.showSnackbar('postPosted'.tr()); context.showSnackbar('postPosted'.tr());

View File

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
class PostTagsField extends StatefulWidget { class PostTagsField extends StatefulWidget {
final List<String>? initialTags; final List<String>? initialTags;
@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget {
State<PostTagsField> createState() => _PostTagsFieldState(); State<PostTagsField> createState() => _PostTagsFieldState();
} }
class _PostTagsFieldState extends State<PostTagsField> { const List<String> kTagsDividers = [' ', ','];
static const List<String> kTagsDividers = [' ', ','];
class _PostTagsFieldState extends State<PostTagsField> {
late final _Debounceable<List<String>?, String> _debouncedSearch; late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentTags = List.empty(growable: true); final List<String> _currentTags = List.empty(growable: true);
@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
margin: const EdgeInsets.only(right: 8), margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
horizontal: 10.0, vertical: 4.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -155,6 +156,141 @@ class _PostTagsFieldState extends State<PostTagsField> {
} }
} }
class PostCategoriesField extends StatefulWidget {
final List<String>? initialCategories;
final String labelText;
final Function(List<String>) onUpdate;
const PostCategoriesField({
super.key,
this.initialCategories,
required this.labelText,
required this.onUpdate,
});
@override
State<PostCategoriesField> createState() => _PostCategoriesFieldState();
}
class _PostCategoriesFieldState extends State<PostCategoriesField> {
late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentCategories = List.empty(growable: true);
String? _currentSearchProbe;
List<String> _lastAutocompleteResult = List.empty();
TextEditingController? _textEditingController;
Future<List<String>?> _searchCategories(String probe) async {
_currentSearchProbe = probe;
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/co/categories?take=10&probe=$_currentSearchProbe',
);
if (_currentSearchProbe != probe) {
return null;
}
_currentSearchProbe = null;
return resp.data.map((x) => x['alias']).toList().cast<String>();
}
@override
void initState() {
super.initState();
_debouncedSearch = _debounce<List<String>?, String>(_searchCategories);
if (widget.initialCategories != null) {
_currentCategories.addAll(widget.initialCategories!);
}
}
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) async {
final result = await _debouncedSearch(textEditingValue.text);
if (result == null) {
return _lastAutocompleteResult;
}
_lastAutocompleteResult = result;
return result;
},
onSelected: (String value) {
if (value.isEmpty) return;
if (!_currentCategories.contains(value)) {
setState(() => _currentCategories.add(value));
}
_textEditingController?.clear();
widget.onUpdate(_currentCategories);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
_textEditingController = controller;
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
label: Text(widget.labelText),
border: const UnderlineInputBorder(),
prefixIconConstraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
prefixIcon: _currentCategories.isNotEmpty
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _currentCategories.map((String category) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(20.0),
),
color: Theme.of(context).colorScheme.primary,
),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
child: Text(
'postCategory${category.capitalize()}'.trExists()
? 'postCategory${category.capitalize()}'.tr()
: '#$category',
style: const TextStyle(color: Colors.white),
),
),
const Gap(4),
InkWell(
child: const Icon(
Icons.cancel,
size: 14.0,
color: Color.fromARGB(255, 233, 233, 233),
),
onTap: () {
setState(() => _currentCategories.remove(category));
widget.onUpdate(_currentCategories);
},
)
],
),
);
}).toList(),
),
)
: null,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) {
onSubmitted();
},
);
},
);
}
}
typedef _Debounceable<S, T> = Future<S?> Function(T parameter); typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { _Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {

View File

@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:extended_image/extended_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -7,7 +7,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
// Keep this import to make the web image render work // Keep this import to make the web image render work
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
class UniversalImage extends StatelessWidget { class UniversalImage extends StatelessWidget {
@ -34,45 +33,27 @@ class UniversalImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final quality = filterQuality ?? context.read<ConfigProvider>().imageQuality;
final double? resizeHeight = cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null;
final double? resizeWidth = cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null;
return Image( return ExtendedImage.network(
filterQuality: filterQuality ?? context.read<ConfigProvider>().imageQuality, url,
image: kIsWeb
? UniversalImage.provider(url)
: ResizeImage(
UniversalImage.provider(url),
width: resizeWidth?.round(),
height: resizeHeight?.round(),
policy: ResizeImagePolicy.fit,
),
width: width, width: width,
height: height, height: height,
fit: fit, fit: fit,
loadingBuilder: noProgressIndicator cache: true,
? null compressionRatio: kIsWeb ? 1 : switch(quality) {
: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { FilterQuality.high => 1,
if (loadingProgress == null) return child; FilterQuality.medium => 0.75,
return Center( FilterQuality.low => 0.5,
child: TweenAnimationBuilder( FilterQuality.none => 0.25,
tween: Tween(
begin: 0,
end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null ? value.toDouble() : null,
),
),
);
}, },
errorBuilder: noErrorWidget filterQuality: quality,
? null enableLoadState: true,
: (context, error, stackTrace) { retries: 3,
loadStateChanged: (ExtendedImageState state) {
if (state.extendedImageLoadState == LoadState.completed) {
return state.completedWidget;
} else if (state.extendedImageLoadState == LoadState.failed) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Container( child: Container(
@ -84,13 +65,21 @@ class UniversalImage extends StatelessWidget {
.animate(onPlay: (e) => e.repeat(reverse: true)) .animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms), .fade(duration: 500.ms),
Text( Text(
error.toString(), state.lastException.toString(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
).center(), ).center(),
), ),
); );
}
return Center(
child: CircularProgressIndicator(
value: state.loadingProgress != null
? state.loadingProgress!.cumulativeBytesLoaded / state.loadingProgress!.expectedTotalBytes!
: null,
),
);
}, },
); );
} }
@ -99,9 +88,10 @@ class UniversalImage extends StatelessWidget {
// This place used to use network image or cached network image depending on the platform. // This place used to use network image or cached network image depending on the platform.
// But now the cached network image is working on every platform. // But now the cached network image is working on every platform.
// So we just use it now. // So we just use it now.
return CachedNetworkImageProvider( return ExtendedNetworkImageProvider(
url, url,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, cache: true,
retries: 3,
); );
} }
} }

View File

@ -26,8 +26,8 @@ import path_provider_foundation
import screen_brightness_macos import screen_brightness_macos
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import video_compress
import wakelock_plus import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@ -52,7 +52,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
} }

View File

@ -132,7 +132,9 @@ PODS:
- GoogleUtilities/UserDefaults (8.0.2): - GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- livekit_client (2.3.2): - in_app_review (2.0.0):
- FlutterMacOS
- livekit_client (2.3.4):
- flutter_webrtc - flutter_webrtc
- FlutterMacOS - FlutterMacOS
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@ -163,11 +165,10 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1): - url_launcher_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- video_compress (0.3.0):
- FlutterMacOS
- wakelock_plus (0.0.1): - wakelock_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- WebRTC-SDK (125.6422.06) - WebRTC-SDK (125.6422.06)
@ -186,6 +187,7 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
- media_kit_native_event_loop (from `Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos`) - media_kit_native_event_loop (from `Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos`)
@ -196,8 +198,8 @@ DEPENDENCIES:
- screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`) - screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
SPEC REPOS: SPEC REPOS:
@ -243,6 +245,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral :path: Flutter/ephemeral
gal: gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
in_app_review:
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
livekit_client: livekit_client:
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
media_kit_libs_macos_video: media_kit_libs_macos_video:
@ -263,10 +267,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation: shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos: url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
video_compress:
:path: Flutter/ephemeral/.symlinks/plugins/video_compress/macos
wakelock_plus: wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
@ -286,14 +290,15 @@ SPEC CHECKSUMS:
FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
flutter_udid: 6b2b89780c3dfeecf0047bdf93f622d6416b1c07 flutter_udid: 2e7b3da4b5fdfba86a396b97898f5fe8f4ec1a52
flutter_webrtc: 53c9e1285ab32dfb58afb1e1471416a877e23d7a flutter_webrtc: 53c9e1285ab32dfb58afb1e1471416a877e23d7a
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
livekit_client: 9fdcb22df3de55e6d4b24bdc3b5eb1c0269d774a in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
livekit_client: b7ab91e79e657d7d40da16cb2f90d517cb72d406
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82 media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5 media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5 media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
@ -306,8 +311,8 @@ SPEC CHECKSUMS:
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
share_plus: 1fa619de8392a4398bfaf176d441853922614e89 share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db

View File

@ -182,30 +182,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.3" version: "8.9.3"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
cassowary: cassowary:
dependency: transitive dependency: transitive
description: description:
@ -454,6 +430,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.7" version: "2.0.7"
extended_image:
dependency: "direct main"
description:
name: extended_image
sha256: "93890a88d89ce017789f6c031c32ad8d2c685f1a5c25c169550746d973ca5e44"
url: "https://pub.dev"
source: hosted
version: "9.0.9"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "9a94ec9314aa206cfa35f16145c3cd6e2c924badcc670eaaca8a3a8063a68cd7"
url: "https://pub.dev"
source: hosted
version: "4.0.5"
fading_edge_scrollview: fading_edge_scrollview:
dependency: transitive dependency: transitive
description: description:
@ -490,10 +482,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: c2376a6aae82358a9f9ccdd7d1f4006d08faa39a2767cce01031d9f593a8bd3b sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" version: "8.1.7"
file_saver: file_saver:
dependency: "direct main" dependency: "direct main"
description: description:
@ -614,6 +606,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864
url: "https://pub.dev"
source: hosted
version: "0.70.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -635,14 +635,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
flutter_cache_manager: flutter_colorpicker:
dependency: transitive dependency: "direct main"
description: description:
name: flutter_cache_manager name: flutter_colorpicker
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "1.1.0"
flutter_context_menu: flutter_context_menu:
dependency: "direct main" dependency: "direct main"
description: description:
@ -737,23 +737,23 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_udid name: flutter_udid
sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84 sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "4.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_webrtc: flutter_webrtc:
dependency: transitive dependency: "direct main"
description: description:
name: flutter_webrtc name: flutter_webrtc
sha256: "430859fb5b763d7556d06ef287cfca582e17d9a2dc36da26017f25a5c0b2523e" sha256: "3efe9828f19a07d29a51a726759ad0c70a840d231548a1c7d0332075a94db1df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.4" version: "0.12.5+hotfix.1"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -874,6 +874,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -918,10 +926,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e sha256: aa6f1280b670861ac45220cc95adc59bb6ae130259d36f980ccb62220dc5e59f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+18" version: "0.8.12+19"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -1070,10 +1078,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: livekit_client name: livekit_client
sha256: "7802b5de1cae2ee3439db730d24d31c6dcbce173c5e6db2fc5774039a290bc2d" sha256: "7cdeb3eaeec7fb70a4cf88d9caabccbef9e3bd5f0b23c086320bc5c9acb2770b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.4"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@ -1234,14 +1242,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@ -1338,6 +1338,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pausable_timer:
dependency: transitive
description:
name: pausable_timer
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
url: "https://pub.dev"
source: hosted
version: "3.1.0+3"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1522,14 +1530,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
safe_local_storage: safe_local_storage:
dependency: transitive dependency: transitive
description: description:
@ -1695,6 +1695,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
slide_countdown:
dependency: "direct main"
description:
name: slide_countdown
sha256: "363914f96389502467d4dc9c0f26e88f93df3d8e37de2d5ff05b16d981fe973d"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
sliver_tools: sliver_tools:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1735,46 +1743,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.0"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709"
url: "https://pub.dev"
source: hosted
version: "2.5.4+6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -2015,6 +1983,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.0" version: "0.9.0"
video_compress:
dependency: "direct main"
description:
name: video_compress
sha256: "5b42d89f3970c956bad7a86c29682b0892c11a4ddf95ae6e29897ee28788e377"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -2137,4 +2113,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.27.0"

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.1.1+35 version: 2.2.1+42
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -53,7 +53,6 @@ dependencies:
markdown: ^7.2.2 markdown: ^7.2.2
flutter_markdown: ^0.7.4+1 flutter_markdown: ^0.7.4+1
url_launcher: ^6.3.1 url_launcher: ^6.3.1
cached_network_image: ^3.4.1
flutter_animate: ^4.5.0 flutter_animate: ^4.5.0
syntax_highlight: ^0.4.0 syntax_highlight: ^0.4.0
google_fonts: ^6.2.1 google_fonts: ^6.2.1
@ -80,7 +79,7 @@ dependencies:
firebase_core: ^3.8.0 firebase_core: ^3.8.0
firebase_messaging: ^15.1.5 firebase_messaging: ^15.1.5
firebase_analytics: ^11.3.5 firebase_analytics: ^11.3.5
flutter_udid: ^3.0.0 flutter_udid: ^4.0.0
media_kit: ^1.1.11 media_kit: ^1.1.11
media_kit_video: ^1.2.5 media_kit_video: ^1.2.5
media_kit_libs_video: ^1.0.5 media_kit_libs_video: ^1.0.5
@ -111,6 +110,12 @@ dependencies:
flutter_app_update: ^3.2.2 flutter_app_update: ^3.2.2
in_app_review: ^2.0.10 in_app_review: ^2.0.10
version: ^3.0.2 version: ^3.0.2
flutter_colorpicker: ^1.1.0
fl_chart: ^0.70.0
flutter_webrtc: ^0.12.5+hotfix.1
slide_countdown: ^2.0.2
video_compress: ^3.1.3
extended_image: ^9.0.9
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,4 +1,6 @@
<!DOCTYPE html><html><head> <!DOCTYPE html>
<html lang="en" oncontextmenu="event.preventDefault();">
<head>
<!-- <!--
If you are serving your web app in a path other than the root, change the If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from. href value below to reflect the base path you are serving from.
@ -31,9 +33,8 @@
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"> <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
name="viewport">
<style id="splash-screen-style"> <style id="splash-screen-style">
@ -110,21 +111,23 @@
</head> </head>
<body> <body>
<picture id="splash-branding"> <picture id="splash-branding">
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" media="(prefers-color-scheme: light)"> <source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x"
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" media="(prefers-color-scheme: dark)"> media="(prefers-color-scheme: light)">
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x"
media="(prefers-color-scheme: dark)">
<img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt=""> <img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt="">
</picture> </picture>
<picture id="splash"> <picture id="splash">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)"> <source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x"
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)"> media="(prefers-color-scheme: light)">
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x"
media="(prefers-color-scheme: dark)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
</picture> </picture>
<script src="flutter_bootstrap.js" async=""></script> <script src="flutter_bootstrap.js" async=""></script>
</body></html> </body>
</html>