Compare commits

..

86 Commits

Author SHA1 Message Date
f26edce071 🚀 Launch 2.2.1+43 2024-12-29 23:58:31 +08:00
603799ea32 🐛 Fix high quality icon issue 2024-12-29 23:52:48 +08:00
a32baf7798 Able to set attachment alt text 2024-12-29 23:50:56 +08:00
498c9af663 💄 Optimize chatting input
 Rollback universal image
2024-12-29 23:30:29 +08:00
202dbff6d3 🐛 Bug fixes 2024-12-29 23:11:50 +08:00
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
b50191970e 🚀 Launch 2.1.1+35 2024-12-21 23:30:59 +08:00
1b69e6dd42 📝 Remove todo 2024-12-21 23:26:58 +08:00
39fb4d474f App updates & web deeplink 2024-12-21 23:26:42 +08:00
392aebcad7 🐛 Fix android widgets 2024-12-21 22:55:35 +08:00
e9e3a4c474 🐛 Bug fixes on iOS widget 2024-12-21 22:38:22 +08:00
7182336a0d iOS quick reply (finished) 2024-12-21 22:19:27 +08:00
be98fe133d iOS quick response (w.i.p) 2024-12-21 21:06:14 +08:00
e458943f56 ♻️ Refactor Sn Network Provider to use without context 2024-12-21 17:23:46 +08:00
eb125fc436 Replyable message notification (w.i.p) 2024-12-21 17:15:14 +08:00
dc78f39969 Add attachment onto iOS attachment 2024-12-21 16:56:55 +08:00
f5c06bc89c 🐛 Bug fixes on iOS native image 2024-12-21 16:10:53 +08:00
d6d60e60a9 🐛 Fix ios widget image 2024-12-21 13:21:44 +08:00
435b730f3b ♻️ Android use background info too 2024-12-21 13:03:07 +08:00
73468c5c6d iOS background widget fetching 2024-12-21 11:56:18 +08:00
8db6513eef Show random post instead of featured post 2024-12-21 04:12:52 +08:00
65a8f1e6c3 🐛 Bug fixes on config refactor 2024-12-21 01:58:49 +08:00
2671ffad4b Allow setting image preview quality 2024-12-21 01:47:52 +08:00
8a628823e0 ♻️ Optimize the image
🐛 Fix image render on the web
2024-12-20 00:15:12 +08:00
94d19a1524 ⬆️ Upgrade deps to fix web build failed 2024-12-16 21:41:41 +08:00
d98f6c8d18 🐛 Bug fixes in call 2024-12-16 19:57:00 +08:00
6d0f62016a 🚀 Launch 2.1.1+31 2024-12-15 23:26:13 +08:00
7e0faba5db 🐛 Fix splash screen 2024-12-15 22:54:00 +08:00
7508a54907 🐛 Fix android widget don't work fine in release mode 2024-12-15 21:27:49 +08:00
2eb1f4b52b Android widget finishing up 2024-12-15 19:09:28 +08:00
00678c0ac8 Android featured post widget 2024-12-15 18:57:54 +08:00
abc21f858b Android check in widget 2024-12-15 18:23:12 +08:00
d67e33a41d Add basic android widget deps 2024-12-15 17:11:19 +08:00
108 changed files with 7335 additions and 2096 deletions

3
.fvmrc
View File

@ -1,3 +0,0 @@
{
"flutter": "stable"
}

View File

@ -1,16 +0,0 @@
# surface
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@ -15,6 +15,7 @@ analyzer:
- "**/*.freezed.dart"
errors:
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:
# The lint rules applied to this project can be customized in the

View File

@ -9,14 +9,39 @@ plugins {
id "dev.flutter.flutter-gradle-plugin"
}
dependencies {
implementation 'com.google.android.material:material:1.12.0'
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 'com.google.code.gson:gson:2.10.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'io.coil-kt.coil3:coil-compose: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 {
buildFeatures {
compose true
}
namespace = "dev.solsynth.solian"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.8"
}
kotlinOptions {
@ -24,21 +49,32 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "dev.solsynth.solian"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
signingConfigs {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
keyAlias = keystoreProperties['keyAlias']
keyPassword = keystoreProperties['keyPassword']
storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword = keystoreProperties['storePassword']
}
}
buildTypes {
debug {
debuggable true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
signingConfig = signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

View File

@ -27,23 +27,12 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Widgets Indents -->
<intent-filter>
<action android:name="es.antonborri.home_widget.action.LAUNCH" />
</intent-filter>
<!-- Sharing Intents -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="sn.solsynth.dev"
android:pathPrefix="/invite"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:mimeType="*/*"
android:scheme="content" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@ -52,12 +41,22 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
@ -78,7 +77,30 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Widgets -->
<receiver android:name=".widgets.CheckInWidgetReceiver"
android:label="Check In"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/check_in_widget" />
</receiver>
<receiver android:name=".widgets.RandomPostWidgetReceiver"
android:label="Random Post"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/random_post_widget" />
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

View File

@ -0,0 +1,6 @@
package dev.solsynth.solian.data
import androidx.annotation.Keep
@Keep
data class SolarPagination<T>(val count: Int, val data: List<T>)

View File

@ -0,0 +1,35 @@
package dev.solsynth.solian.data
import androidx.annotation.Keep
import java.time.Instant
@Keep
data class SolarPost(
val id: Int,
val body: SolarPostBody,
val publisher: SolarPublisher,
val publisherId: Int,
val createdAt: Instant,
val updatedAt: Instant,
val editedAt: Instant?,
val publishedAt: Instant?
)
@Keep
data class SolarPostBody(
val content: String?,
val title: String?,
val description: String?,
)
@Keep
data class SolarPublisher(
val id: Int,
val name: String,
val nick: String,
val description: String?,
val avatar: String?,
val banner: String?,
val createdAt: Instant,
val updatedAt: Instant
)

View File

@ -0,0 +1,38 @@
package dev.solsynth.solian.data
import androidx.annotation.Keep
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
import java.time.Instant
import java.time.format.DateTimeFormatter
@Keep
class InstantAdapter : JsonSerializer<Instant?>,
JsonDeserializer<Instant?> {
override fun serialize(
src: Instant?,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonPrimitive(formatter.format(src))
}
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Instant {
return Instant.parse(json.asString)
}
companion object {
private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT
}
}

View File

@ -0,0 +1,19 @@
package dev.solsynth.solian.data
import androidx.annotation.Keep
import java.time.Instant
@Keep
data class SolarUser(
val id: Int,
val name: String,
val nick: String
)
@Keep
data class SolarCheckInRecord(
val id: Int,
val resultTier: Int,
val resultExperience: Int,
val createdAt: Instant
)

View File

@ -0,0 +1,128 @@
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.text.FontFamily
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import dev.solsynth.solian.MainActivity
import dev.solsynth.solian.data.InstantAdapter
import dev.solsynth.solian.data.SolarCheckInRecord
import es.antonborri.home_widget.actionStartActivity
import java.time.Instant
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class CheckInWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*>?
get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
GlanceContent(context, currentState())
}
}
}
@Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
val gson =
GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Instant::class.java, InstantAdapter())
.create()
val resultTierSymbols = listOf("大凶", "", "中平", "", "大吉")
val prefs = currentState.preferences
val checkInRaw: String? = prefs.getString("pas_check_in_record", null)
val checkIn: SolarCheckInRecord? =
checkInRaw?.let { checkInRaw ->
gson.fromJson(checkInRaw, SolarCheckInRecord::class.java)
} ?: null;
Column(
modifier = GlanceModifier
.fillMaxWidth()
.fillMaxHeight()
.background(GlanceTheme.colors.widgetBackground)
.padding(16.dp)
.clickable(
onClick = actionStartActivity<MainActivity>(
context,
Uri.parse("https://sn.solsynth.dev")
)
)
) {
if (checkIn != null) {
val dateFormatter = DateTimeFormatter.ofPattern("EEE, MM/dd")
val checkDate = checkIn.createdAt.atZone(ZoneId.of("UTC")).toLocalDate()
val currentDate = LocalDate.now()
if (checkDate.isEqual(currentDate)) {
Column {
Text(
text = resultTierSymbols[checkIn.resultTier],
style = TextStyle(
fontSize = 17.sp,
color = GlanceTheme.colors.onSurface
)
)
Text(
text = "+${checkIn.resultExperience} EXP",
style = TextStyle(
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = GlanceTheme.colors.onSurface
)
)
}
Spacer(modifier = GlanceModifier.height(8.dp))
Row(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = OffsetDateTime.ofInstant(
checkIn.createdAt,
ZoneId.systemDefault()
)
.format(dateFormatter),
style = TextStyle(
fontSize = 11.sp,
color = GlanceTheme.colors.onSurface
)
)
}
return@Column;
}
}
Text(
text = "You haven't checked in today",
style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
)
}
}
}

View File

@ -0,0 +1,8 @@
package dev.solsynth.solian.widgets
import CheckInWidget
import HomeWidgetGlanceWidgetReceiver
class CheckInWidgetReceiver : HomeWidgetGlanceWidgetReceiver<CheckInWidget>() {
override val glanceAppWidget = CheckInWidget()
}

View File

@ -0,0 +1,168 @@
import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import dev.solsynth.solian.MainActivity
import dev.solsynth.solian.data.InstantAdapter
import dev.solsynth.solian.data.SolarPost
import es.antonborri.home_widget.actionStartActivity
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class RandomPostWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*>?
get() = HomeWidgetGlanceStateDefinition()
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
GlanceContent(context, currentState())
}
}
}
@Composable
private fun GlanceContent(
context: Context,
currentState: HomeWidgetGlanceState,
) {
val prefs = currentState.preferences
val postRaw = prefs.getString("int_random_post", null)
val gson =
GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Instant::class.java, InstantAdapter())
.create()
val data: SolarPost? = postRaw?.let { postRaw ->
gson.fromJson(postRaw, SolarPost::class.java)
} ?: null;
Column(
modifier = GlanceModifier
.fillMaxWidth()
.fillMaxHeight()
.background(GlanceTheme.colors.widgetBackground)
.padding(16.dp)
.clickable(
onClick = actionStartActivity<MainActivity>(
context,
Uri.parse("https://sn.solsynth.dev/posts/${data!!.id}")
)
)
) {
if (data != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = data.publisher.nick,
style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = "@${data.publisher.name}",
style = TextStyle(
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = GlanceTheme.colors.onSurface
)
)
}
Spacer(modifier = GlanceModifier.height(8.dp))
if (data.body.title != null) {
Text(
text = data.body.title,
style = TextStyle(fontSize = 19.sp, color = GlanceTheme.colors.onSurface)
)
}
if (data.body.description != null) {
Text(
text = data.body.description,
style = TextStyle(fontSize = 17.sp, color = GlanceTheme.colors.onSurface)
)
}
if (data.body.title != null || data.body.description != null) {
Spacer(modifier = GlanceModifier.height(8.dp))
}
Text(
text = data.body.content ?: "No content",
style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface),
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
LocalDateTime.ofInstant(data.createdAt, ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")),
style = TextStyle(fontSize = 13.sp, color = GlanceTheme.colors.onSurface),
)
Text(
"#${data.id}",
style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onSurface
),
)
return@Column;
}
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
horizontalAlignment = Alignment.Horizontal.CenterHorizontally
) {
Text(
text = "No Recommendations",
style = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onSurface
)
)
Text(
text = "Open app to load some posts",
style = TextStyle(fontSize = 15.sp, color = GlanceTheme.colors.onSurface)
)
}
}
}
}

View File

@ -0,0 +1,8 @@
package dev.solsynth.solian.widgets
import RandomPostWidget
import HomeWidgetGlanceWidgetReceiver
class RandomPostWidgetReceiver : HomeWidgetGlanceWidgetReceiver<RandomPostWidget>() {
override val glanceAppWidget = RandomPostWidget()
}

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"?>
<resources>
<color name="ic_launcher_background">#FFFFFFFF</color>
<color name="ic_notification_background">#00000000</color>
</resources>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
@ -16,7 +16,7 @@
running.
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>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="40dp"
android:minHeight="40dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="10000">
</appwidget-provider>

View File

@ -0,0 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="240dp"
android:minHeight="40dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="10000">
</appwidget-provider>

14
android/app/src/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,14 @@
-keepclassmembers class kotlin.Metadata { *; }
-keep class dev.solsynth.solian.** { *; }
-keep public class dev.solsynth.solian.data.** { public *; }
-keepclassmembers class dev.solsynth.solian.data.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes EnclosingMethod
-keep class com.google.gson.** { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}

View File

@ -3,6 +3,15 @@ allprojects {
google()
mavenCentral()
}
configurations.all {
resolutionStrategy {
eachDependency {
if ((requested.group == "androidx.work") && (requested.name.startsWith("work-runtime"))) {
useVersion("2.9.1")
}
}
}
}
}
rootProject.buildDir = "../build"

View File

@ -18,7 +18,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.7.2' apply false
id "com.android.application" version '8.7.3' apply false
// START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false
id "com.google.firebase.crashlytics" version "2.8.1" apply false

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",
"unset": "Unset",
"untitled": "Untitled",
"postDetail": "Post detail",
"postDetail": "Post Detail",
"postNoun": "Post",
"postReadMore": "Read more",
"postReadEstimate": "Est read time {}",
@ -139,6 +139,9 @@
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"fieldPostTags": "Tags",
"fieldPostCategories": "Categories",
"fieldPostAlias": "Alias",
"fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.",
"postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At",
@ -176,12 +179,18 @@
"other": "{} comments"
},
"settingsAppearance": "Appearance",
"settingsAppBarTransparent": "Transparent App Bar",
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
"settingsBackgroundImage": "Background Image",
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
"settingsBackgroundImageClear": "Clear Existing Background Image",
"settingsBackgroundImageClearDescription": "Reset the background image to blank.",
"settingsThemeMaterial3": "Use Material You 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",
"settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
@ -190,6 +199,13 @@
"settingsNetworkServerPreset": "Present HyperNet Server",
"settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
"settingsNetworkServerSaved": "Server address saved.",
"settingsPerformance": "Performance",
"settingsImageQuality": "Image Quality",
"settingsImageQualityDescription": "Set the image quality, it will affect the decoding speed of the image.",
"settingsImageQualityLowest": "Lowest",
"settingsImageQualityLow": "Low",
"settingsImageQualityMedium": "Medium",
"settingsImageQualityHigh": "High",
"settingsMisc": "Misc",
"settingsMiscAbout": "About",
"settingsMiscAboutDescription": "View the version information of Solian.",
@ -265,16 +281,45 @@
"one": "{} attachment",
"other": "{} attachments"
},
"fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video",
"addAttachmentFromRandomId": "Link via RID",
"attachmentPastedImage": "Pasted Image",
"attachmentInsertLink": "Insert Link",
"attachmentSetAsPostThumbnail": "Set as post thumbnail",
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
"attachmentCompressVideo": "Re-encode video",
"attachmentSetThumbnail": "Set thumbnail",
"attachmentSetAlt": "Set alternative text",
"attachmentCopyRandomId": "Copy RID",
"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",
"notificationUnreadCount": {
"zero": "All notifications read",
@ -362,7 +407,26 @@
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
"dailyCheckNegativeHint6": "Going out",
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
"happyBirthday": "Happy birthday, {}!",
"celebrateBirthday": "Happy birthday, {}!",
"celebrateMerryXmas": "Merry christmas, {}",
"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",
"friendRequests": "Friend Requests",
"friendRequestsDescription": {
@ -396,6 +460,7 @@
"accountJoinedAt": "Joined at {}",
"accountBirthday": "Born on {}",
"accountBadge": "Badge",
"accountCheckInNoRecords": "No check-in records",
"badgeCompanyStaff": "Solsynth Staff",
"badgeSiteMigration": "Solar Network Native",
"accountStatus": "Status",
@ -404,6 +469,7 @@
"accountStatusLastSeen": "Last seen at {}",
"postArticle": "Article on the Solar Network",
"postStory": "Story on the Solar Network",
"postLocalDraftRestored": "Restored from device",
"articleWrittenAt": "Written at {}",
"articleEditedAt": "Edited at {}",
"attachmentSaved": "Saved to album",
@ -439,7 +505,7 @@
"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.",
"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",
"postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share",
@ -447,6 +513,29 @@
"appInitializing": "Initializing",
"poweredBy": "Powered by {}",
"shareIntent": "Share",
"shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story"
"shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story",
"updateAvailable": "Update Available",
"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": "标题",
"fieldPostDescription": "描述",
"fieldPostTags": "标签",
"fieldPostCategories": "分类",
"fieldPostAlias": "别名",
"fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。",
"postPublish": "发布",
"postPublishedAt": "发布于",
"postPublishedUntil": "取消发布于",
@ -180,6 +183,12 @@
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
"settingsThemeMaterial3": "使用 Material You 设计范式",
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
"settingsAppBarTransparent": "透明顶栏",
"settingsAppBarTransparentDescription": "为顶栏启用透明效果。",
"settingsColorScheme": "主题色",
"settingsColorSchemeDescription": "设置应用主题色。",
"settingsColorSeed": "预设色彩主题",
"settingsColorSeedDescription": "选择一个预设色彩主题。",
"settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
@ -188,6 +197,13 @@
"settingsNetworkServerPreset": "预设的 HyperNet 服务器",
"settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
"settingsNetworkServerSaved": "服务器地址已保存。",
"settingsPerformance": "性能",
"settingsImageQuality": "图片预览质量",
"settingsImageQualityDescription": "设置图片预览质量,会影响图片解码速度。",
"settingsImageQualityLowest": "极低",
"settingsImageQualityLow": "低",
"settingsImageQualityMedium": "中",
"settingsImageQualityHigh": "高",
"settingsMisc": "杂项",
"settingsMiscAbout": "关于",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
@ -263,16 +279,45 @@
"one": "{} 个附件",
"other": "{} 个附件"
},
"fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
"addAttachmentFromRandomId": "通过访问 ID 链接",
"attachmentPastedImage": "粘贴的图片",
"attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
"attachmentCompressVideo": "重新编码视频",
"attachmentSetThumbnail": "设置缩略图",
"attachmentSetAlt": "设置概述文字",
"attachmentCopyRandomId": "复制访问 ID",
"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": "通知",
"notificationUnreadCount": {
"zero": "无未读通知",
@ -360,7 +405,26 @@
"dailyCheckNegativeHint5Description": "关键时刻断网",
"dailyCheckNegativeHint6": "出门",
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
"happyBirthday": "生日快乐,{}",
"celebrateBirthday": "生日快乐,{}",
"celebrateMerryXmas": "圣诞快乐,{}",
"celebrateNewYear": "新年快乐,{}",
"celebrateValentineDay": "今天是情人节,{}",
"celebrateLaborDay": "今天是劳动节,{}。",
"celebrateMotherDay": "今天是母亲节,{}。",
"celebrateChildrenDay": "今天是儿童节,{}",
"celebrateFatherDay": "今天是父亲节,{}。",
"celebrateHalloween": "快乐在圣诞节,{}",
"celebrateThanksgiving": "今天是感恩节,{}",
"pendingBirthday": "{} 过生日",
"pendingMerryXmas": "{} 过圣诞节",
"pendingNewYear": "{} 跨年",
"pendingValentineDay": "{} 过情人节",
"pendingLaborDay": "{} 过劳动节",
"pendingMotherDay": "{} 过母亲节",
"pendingChildrenDay": "{} 过儿童节",
"pendingFatherDay": "{} 过父亲节",
"pendingHalloween": "{} 过圣诞节",
"pendingThanksgiving": "{} 过感恩节",
"friendNew": "添加好友",
"friendRequests": "好友请求",
"friendRequestsDescription": {
@ -394,14 +458,16 @@
"accountJoinedAt": "加入于 {}",
"accountBirthday": "出生于 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暂无运势记录",
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
"badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "状态",
"accountStatusOnline": "在线",
"accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线",
"accountStatusLastSeen": "最后一次上线于 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "从本地恢复草稿",
"articleWrittenAt": "发表于 {}",
"articleEditedAt": "编辑于 {}",
"attachmentSaved": "已保存到相册",
@ -446,5 +512,28 @@
"poweredBy": "由 {} 提供支持",
"shareIntent": "分享",
"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

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

@ -36,6 +36,24 @@ target 'Runner' do
inherit! :search_paths
end
target 'SolarNotifyService' do
inherit! :search_paths
pod 'home_widget', :path => '.symlinks/plugins/home_widget/ios'
pod 'Kingfisher', '~> 8.0'
pod 'Alamofire'
end
target 'SolarWidgetExtension' do
inherit! :search_paths
use_frameworks!
use_modular_headers!
pod 'home_widget', :path => '.symlinks/plugins/home_widget/ios'
pod 'Kingfisher', '~> 8.0'
end
target 'SolarShare' do
inherit! :search_paths
end

View File

@ -1,4 +1,5 @@
PODS:
- Alamofire (5.10.2)
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
@ -56,7 +57,7 @@ PODS:
- Firebase/Analytics (= 11.4.0)
- firebase_core
- Flutter
- firebase_core (3.8.1):
- firebase_core (3.9.0):
- Firebase/CoreOnly (= 11.4.0)
- Flutter
- firebase_messaging (15.1.6):
@ -102,6 +103,8 @@ PODS:
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_app_update (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_udid (0.0.1):
@ -167,7 +170,10 @@ PODS:
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- livekit_client (2.3.2):
- in_app_review (2.0.0):
- Flutter
- Kingfisher (8.1.3)
- livekit_client (2.3.4):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.06)
@ -211,13 +217,18 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- video_compress (0.3.0):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (125.6422.06)
- workmanager (0.0.1):
- Flutter
DEPENDENCIES:
- Alamofire
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@ -227,12 +238,15 @@ DEPENDENCIES:
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
@ -247,11 +261,14 @@ DEPENDENCIES:
- 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`)
- video_compress (from `.symlinks/plugins/video_compress/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager (from `.symlinks/plugins/workmanager/ios`)
SPEC REPOS:
trunk:
- Alamofire
- DKImagePickerController
- DKPhotoGallery
- Firebase
@ -263,6 +280,7 @@ SPEC REPOS:
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- Kingfisher
- nanopb
- PromisesObjC
- SAMKeychain
@ -289,6 +307,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_udid:
@ -301,6 +321,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video:
@ -329,12 +351,17 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_compress:
:path: ".symlinks/plugins/video_compress/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
@ -344,7 +371,7 @@ SPEC CHECKSUMS:
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75
firebase_core: 418aed674e9a0b8b6088aec16cde82a811f6261f
firebase_core: b62a5080210edad3f2934314a8b2c6f5124e8e10
firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9
FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
@ -352,16 +379,19 @@ SPEC CHECKSUMS:
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
livekit_client: 4eaa7a2968fc7e7c57888f43d90394547cc8d9e9
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -380,10 +410,12 @@ SPEC CHECKSUMS:
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: 23d35ad686cacf9103d1e85035ee4f3e9750630d
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
COCOAPODS: 1.16.2

View File

@ -14,6 +14,7 @@
738C1EAC2D0D76A400A215F3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 731B7B6B2D0D6CE000CEB9B7 /* WidgetKit.framework */; };
738C1EAD2D0D76A400A215F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 731B7B6D2D0D6CE000CEB9B7 /* SwiftUI.framework */; };
738C1EB82D0D76A500A215F3 /* SolarWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 738C1EAB2D0D76A400A215F3 /* SolarWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7396A3522D16BD890095F4A8 /* NotifyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396A3512D16BD890095F4A8 /* NotifyDelegate.swift */; };
73B7746E2D0E869200A789CE /* SolarShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73B774642D0E869200A789CE /* SolarShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73DA8A012D05C7620024A03E /* SolarNotifyService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@ -22,6 +23,8 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
CED170BFB6A72CDDAC285637 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */; };
D5125CF12F159F0B8BC7641D /* Pods_SolarNotifyService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02469D286F48D84300484B1E /* Pods_SolarNotifyService.framework */; };
D962B51F682FBDEC00AC7281 /* Pods_SolarWidgetExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B1A159F5551E280D0EFC129 /* Pods_SolarWidgetExtension.framework */; };
F51C4E3C8FA95426C91FC0A4 /* Pods_SolarShare.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 16F41E029731EA30268EDE2A /* Pods_SolarShare.framework */; };
/* End PBXBuildFile section */
@ -83,30 +86,40 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
02469D286F48D84300484B1E /* Pods_SolarNotifyService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolarNotifyService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1077EFD9ACF793E9DA5D5B63 /* Pods-Runner-SolarNotifyService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarNotifyService.release.xcconfig"; path = "Target Support Files/Pods-Runner-SolarNotifyService/Pods-Runner-SolarNotifyService.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
16F41E029731EA30268EDE2A /* Pods_SolarShare.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolarShare.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2134F3903A0E8EB8CC2670BE /* Pods-SolarWidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarWidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-SolarWidgetExtension/Pods-SolarWidgetExtension.debug.xcconfig"; sourceTree = "<group>"; };
26CC8DE2338798EAB472B62D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2DA1B873D39B9FD33298BBCE /* Pods-SolarShare.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarShare.profile.xcconfig"; path = "Target Support Files/Pods-SolarShare/Pods-SolarShare.profile.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
40B53769EB464E54DACA7CE4 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
430F31F96B82659CBEAD4326 /* Pods-Runner-SolarWidgetExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarWidgetExtension.profile.xcconfig"; path = "Target Support Files/Pods-Runner-SolarWidgetExtension/Pods-Runner-SolarWidgetExtension.profile.xcconfig"; sourceTree = "<group>"; };
48AE73F9950AF4FB02B5E9F4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
4A2F84B6033057E3BD2C7CB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
4CBF45ABD292EE527D0A4D1E /* Pods-SolarNotifyService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarNotifyService.profile.xcconfig"; path = "Target Support Files/Pods-SolarNotifyService/Pods-SolarNotifyService.profile.xcconfig"; sourceTree = "<group>"; };
5922A50B1231B06B92E31F20 /* Pods-SolarShare.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarShare.debug.xcconfig"; path = "Target Support Files/Pods-SolarShare/Pods-SolarShare.debug.xcconfig"; sourceTree = "<group>"; };
64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
6618E2E3015264643175B43D /* Pods-SolarWidgetExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarWidgetExtension.release.xcconfig"; path = "Target Support Files/Pods-SolarWidgetExtension/Pods-SolarWidgetExtension.release.xcconfig"; sourceTree = "<group>"; };
72E9279EFA6DAC00BBAC493C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
731B7B6B2D0D6CE000CEB9B7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
731B7B6D2D0D6CE000CEB9B7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
738C1EAB2D0D76A400A215F3 /* SolarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
738C1F132D0D7DDC00A215F3 /* SolarWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SolarWidgetExtension.entitlements; sourceTree = "<group>"; };
7396A3512D16BD890095F4A8 /* NotifyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyDelegate.swift; sourceTree = "<group>"; };
73B774642D0E869200A789CE /* SolarShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolarShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolarNotifyService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7B1A159F5551E280D0EFC129 /* Pods_SolarWidgetExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolarWidgetExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8E44A071621D5CAF864FB2F1 /* Pods-Runner-SolarNotifyService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarNotifyService.debug.xcconfig"; path = "Target Support Files/Pods-Runner-SolarNotifyService/Pods-Runner-SolarNotifyService.debug.xcconfig"; sourceTree = "<group>"; };
931FBE9EDB99B3AD8B1FFB00 /* Pods-Runner-SolarWidgetExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarWidgetExtension.release.xcconfig"; path = "Target Support Files/Pods-Runner-SolarWidgetExtension/Pods-Runner-SolarWidgetExtension.release.xcconfig"; sourceTree = "<group>"; };
96081771773FA019A97CCC3F /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@ -117,6 +130,11 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
B1763F1D7318A2745CA7EDFE /* Pods-SolarShare.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarShare.release.xcconfig"; path = "Target Support Files/Pods-SolarShare/Pods-SolarShare.release.xcconfig"; sourceTree = "<group>"; };
B4550C68292419CDC580808B /* Pods-Runner-SolarNotifyService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarNotifyService.profile.xcconfig"; path = "Target Support Files/Pods-Runner-SolarNotifyService/Pods-Runner-SolarNotifyService.profile.xcconfig"; sourceTree = "<group>"; };
BCE0C4086B776A27B202B373 /* Pods-SolarWidgetExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarWidgetExtension.profile.xcconfig"; path = "Target Support Files/Pods-SolarWidgetExtension/Pods-SolarWidgetExtension.profile.xcconfig"; sourceTree = "<group>"; };
BFF3B436D74FA8CBFFE34A27 /* Pods-Runner-SolarWidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-SolarWidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-Runner-SolarWidgetExtension/Pods-Runner-SolarWidgetExtension.debug.xcconfig"; sourceTree = "<group>"; };
D7E1FA77FDA53439DB2C0E75 /* Pods-SolarNotifyService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarNotifyService.release.xcconfig"; path = "Target Support Files/Pods-SolarNotifyService/Pods-SolarNotifyService.release.xcconfig"; sourceTree = "<group>"; };
D96D1DB4ED46A2640C1B9D34 /* Pods-SolarNotifyService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolarNotifyService.debug.xcconfig"; path = "Target Support Files/Pods-SolarNotifyService/Pods-SolarNotifyService.debug.xcconfig"; sourceTree = "<group>"; };
EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -217,6 +235,7 @@
files = (
738C1EAD2D0D76A400A215F3 /* SwiftUI.framework in Frameworks */,
738C1EAC2D0D76A400A215F3 /* WidgetKit.framework in Frameworks */,
D962B51F682FBDEC00AC7281 /* Pods_SolarWidgetExtension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -232,6 +251,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D5125CF12F159F0B8BC7641D /* Pods_SolarNotifyService.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -262,6 +282,8 @@
731B7B6B2D0D6CE000CEB9B7 /* WidgetKit.framework */,
731B7B6D2D0D6CE000CEB9B7 /* SwiftUI.framework */,
16F41E029731EA30268EDE2A /* Pods_SolarShare.framework */,
02469D286F48D84300484B1E /* Pods_SolarNotifyService.framework */,
7B1A159F5551E280D0EFC129 /* Pods_SolarWidgetExtension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -328,6 +350,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
7396A3512D16BD890095F4A8 /* NotifyDelegate.swift */,
);
path = Runner;
sourceTree = "<group>";
@ -344,6 +367,18 @@
5922A50B1231B06B92E31F20 /* Pods-SolarShare.debug.xcconfig */,
B1763F1D7318A2745CA7EDFE /* Pods-SolarShare.release.xcconfig */,
2DA1B873D39B9FD33298BBCE /* Pods-SolarShare.profile.xcconfig */,
2134F3903A0E8EB8CC2670BE /* Pods-SolarWidgetExtension.debug.xcconfig */,
6618E2E3015264643175B43D /* Pods-SolarWidgetExtension.release.xcconfig */,
BCE0C4086B776A27B202B373 /* Pods-SolarWidgetExtension.profile.xcconfig */,
D96D1DB4ED46A2640C1B9D34 /* Pods-SolarNotifyService.debug.xcconfig */,
D7E1FA77FDA53439DB2C0E75 /* Pods-SolarNotifyService.release.xcconfig */,
4CBF45ABD292EE527D0A4D1E /* Pods-SolarNotifyService.profile.xcconfig */,
8E44A071621D5CAF864FB2F1 /* Pods-Runner-SolarNotifyService.debug.xcconfig */,
1077EFD9ACF793E9DA5D5B63 /* Pods-Runner-SolarNotifyService.release.xcconfig */,
B4550C68292419CDC580808B /* Pods-Runner-SolarNotifyService.profile.xcconfig */,
BFF3B436D74FA8CBFFE34A27 /* Pods-Runner-SolarWidgetExtension.debug.xcconfig */,
931FBE9EDB99B3AD8B1FFB00 /* Pods-Runner-SolarWidgetExtension.release.xcconfig */,
430F31F96B82659CBEAD4326 /* Pods-Runner-SolarWidgetExtension.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@ -374,6 +409,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 738C1EBA2D0D76A500A215F3 /* Build configuration list for PBXNativeTarget "SolarWidgetExtension" */;
buildPhases = (
F2FCDA0E1BD434BF4883AFFD /* [CP] Check Pods Manifest.lock */,
738C1EA72D0D76A400A215F3 /* Sources */,
738C1EA82D0D76A400A215F3 /* Frameworks */,
738C1EA92D0D76A400A215F3 /* Resources */,
@ -416,6 +452,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 73DA8A072D05C7620024A03E /* Build configuration list for PBXNativeTarget "SolarNotifyService" */;
buildPhases = (
50F5704AB2E7309C916CA2E7 /* [CP] Check Pods Manifest.lock */,
73DA89F62D05C7620024A03E /* Sources */,
73DA89F72D05C7620024A03E /* Frameworks */,
73DA89F82D05C7620024A03E /* Resources */,
@ -611,6 +648,28 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
50F5704AB2E7309C916CA2E7 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-SolarNotifyService-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
738C1EBE2D0D76C500A215F3 /* Copy Bundle Version */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@ -710,6 +769,28 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F2FCDA0E1BD434BF4883AFFD /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-SolarWidgetExtension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@ -765,6 +846,7 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7396A3522D16BD890095F4A8 /* NotifyDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -879,7 +961,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -947,6 +1029,7 @@
};
738C1EBB2D0D76A500A215F3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2134F3903A0E8EB8CC2670BE /* Pods-SolarWidgetExtension.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -990,6 +1073,7 @@
};
738C1EBC2D0D76A500A215F3 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6618E2E3015264643175B43D /* Pods-SolarWidgetExtension.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -1030,6 +1114,7 @@
};
738C1EBD2D0D76A500A215F3 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BCE0C4086B776A27B202B373 /* Pods-SolarWidgetExtension.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -1193,6 +1278,7 @@
};
73DA8A032D05C7620024A03E /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D96D1DB4ED46A2640C1B9D34 /* Pods-SolarNotifyService.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@ -1234,6 +1320,7 @@
};
73DA8A042D05C7620024A03E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D7E1FA77FDA53439DB2C0E75 /* Pods-SolarNotifyService.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@ -1272,6 +1359,7 @@
};
73DA8A052D05C7620024A03E /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4CBF45ABD292EE527D0A4D1E /* Pods-SolarNotifyService.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@ -1433,7 +1521,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1461,7 +1549,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@ -1,14 +1,26 @@
import Flutter
import UIKit
import workmanager
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
let notifyDelegate = NotifyDelegate()
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*5))
UNUserNotificationCenter.current().delegate = notifyDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,39 @@
//
// AppIntent.swift
// Runner
//
// Created by LittleSheep on 2024/12/21.
//
import AppIntents
import Flutter
import Foundation
import home_widget
@available(iOS 17, *)
public struct AppBackgroundIntent: AppIntent {
static public var title: LocalizedStringResource = "Solar Network Background Intent"
@Parameter(title: "Widget URI")
var url: URL?
@Parameter(title: "AppGroup")
var appGroup: String?
public init() {}
public init(url: URL?, appGroup: String?) {
self.url = url
self.appGroup = appGroup
}
public func perform() async throws -> some IntentResult {
await HomeWidgetBackgroundWorker.run(url: url, appGroup: appGroup!)
return .result()
}
}
@available(iOS 17, *)
@available(iOSApplicationExtension, unavailable)
extension AppBackgroundIntent: ForegroundContinuableIntent {}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>group.solsynth.solian</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@ -27,6 +29,17 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
@ -34,9 +47,9 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string>
<string>Grant access to Camera will allow Solian take photo or video for your post.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Photo Library will allow Solian record audio for your post.</string>
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
<key>NSPhotoLibraryUsageDescription</key>
@ -66,8 +79,6 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>AppGroupId</key>
<string>group.solsynth.solian</string>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -75,16 +86,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@ -0,0 +1,55 @@
//
// NotifyDelegate.swift
// Runner
//
// Created by LittleSheep on 2024/12/21.
//
import Foundation
import home_widget
import Alamofire
class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
if let textResponse = response as? UNTextInputNotificationResponse {
let content = response.notification.request.content
guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
return
}
let channelId = metadata["channel_id"] as? Int
let eventId = metadata["event_id"] as? Int
let replyToken = metadata["reply_token"] as? String
if (channelId == nil || eventId == nil || replyToken == nil) {
return;
}
let serverUrl = "https://api.sn.solsynth.dev"
let url = "\(serverUrl)/cgi/im/quick/\(channelId!)/reply/\(eventId!)?replyToken=\(replyToken!)"
let parameters: [String: Any] = [
"type": "messages.new",
"body": [
"text": textResponse.userText,
"algorithm": "plain"
]
]
AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default)
.validate()
.responseString { response in
switch response.result {
case .success(_):
break
case .failure(let error):
print("Failed to send chat reply message: \(error)")
break
}
}
}
completionHandler()
}
}

View File

@ -7,6 +7,8 @@
import UserNotifications
import Intents
import Kingfisher
import UniformTypeIdentifiers
enum ParseNotificationPayloadError: Error {
case missingMetadata(String)
@ -18,58 +20,6 @@ class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
private func fetchAvatarImage(from url: String, completion: @escaping (INImage?) -> Void) {
guard let imageURL = URL(string: url) else {
completion(nil)
return
}
// Define a cache location based on the URL hash
let cacheFileName = imageURL.lastPathComponent
let tempDirectory = FileManager.default.temporaryDirectory
let cachedFileUrl = tempDirectory.appendingPathComponent(cacheFileName)
// Check if the image is already cached
if FileManager.default.fileExists(atPath: cachedFileUrl.path) {
do {
let data = try Data(contentsOf: cachedFileUrl)
let cachedImage = INImage(imageData: data) // No optional binding here
completion(cachedImage)
return
} catch {
print("Failed to load cached avatar image: \(error.localizedDescription)")
try? FileManager.default.removeItem(at: cachedFileUrl) // Clear corrupted cache
}
}
// Download the image if not cached
let session = URLSession(configuration: .default)
session.downloadTask(with: imageURL) { localUrl, response, error in
if let error = error {
print("Failed to fetch avatar image: \(error.localizedDescription)")
completion(nil)
return
}
guard let localUrl = localUrl, let data = try? Data(contentsOf: localUrl) else {
print("Failed to fetch data for avatar image.")
completion(nil)
return
}
do {
// Cache the downloaded file
try FileManager.default.moveItem(at: localUrl, to: cachedFileUrl)
} catch {
print("Failed to cache avatar image: \(error.localizedDescription)")
}
// Create INImage from the downloaded data
let inImage = INImage(imageData: data) // Create directly
completion(inImage)
}.resume()
}
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
@ -112,16 +62,43 @@ class NotificationService: UNNotificationServiceExtension {
throw ParseNotificationPayloadError.missingAvatarUrl("The notification has no avatar.")
}
let replyableMessageCategory = UNNotificationCategory(
identifier: content.categoryIdentifier,
actions: [
UNTextInputNotificationAction(
identifier: "reply_action",
title: "Reply",
options: []
),
],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
content.categoryIdentifier = replyableMessageCategory.identifier
let metadataCopy = metadata as? [String: String] ?? [:]
let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
fetchAvatarImage(from: avatarUrl) { [weak self] inImage in
guard let self = self else { return }
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?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get avatar url: \(error)")
}
let handle = INPersonHandle(value: "\(metadata["user_id"] ?? "")", type: .unknown)
let handle = INPersonHandle(value: "\(metadataCopy["user_id"] ?? "")", type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: content.title,
image: inImage,
image: image == nil ? nil : INImage(imageData: image!),
contactIdentifier: nil,
customIdentifier: nil
)
@ -132,12 +109,12 @@ class NotificationService: UNNotificationServiceExtension {
let updatedContent = try? request.content.updating(from: intent)
self.contentHandler?(updatedContent ?? content)
} else {
let intent = self.createMessageIntent(with: sender, metadata: metadata, body: content.body)
let intent = self.createMessageIntent(with: sender, metadata: metadataCopy, body: content.body)
self.donateInteraction(for: intent)
let updatedContent = try? request.content.updating(from: intent)
self.contentHandler?(updatedContent ?? content)
}
}
})
}
private func handleDefaultNotification(content: UNMutableNotificationContent) throws {
@ -146,15 +123,15 @@ class NotificationService: UNNotificationServiceExtension {
}
if let imageIdentifier = metadata["image"] as? String {
attachMedia(to: content, withIdentifier: imageIdentifier)
attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true)
} else if let avatarIdentifier = metadata["avatar"] as? String {
attachMedia(to: content, withIdentifier: avatarIdentifier)
attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true)
} else {
contentHandler?(content)
}
contentHandler?(content)
}
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String) {
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
let attachmentUrl = getAttachmentUrl(for: identifier)
guard let remoteUrl = URL(string: attachmentUrl) else {
@ -162,49 +139,62 @@ class NotificationService: UNNotificationServiceExtension {
return
}
// Define a cache location based on the identifier
let tempDirectory = FileManager.default.temporaryDirectory
let cachedFileUrl = tempDirectory.appendingPathComponent(identifier)
let targetSize = 800
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
if FileManager.default.fileExists(atPath: cachedFileUrl.path) {
// Use cached file
attachLocalMedia(to: content, from: cachedFileUrl, withIdentifier: identifier)
} else {
// Download and cache the file
let session = URLSession(configuration: .default)
session.downloadTask(with: remoteUrl) { [weak content] localUrl, response, error in
guard let content = content else { return }
if let error = error {
print("Failed to download media: \(error.localizedDescription)")
self.contentHandler?(content)
return
}
guard let localUrl = localUrl else {
print("No local file URL after download")
self.contentHandler?(content)
return
}
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
.processor(scaleProcessor)
] : nil) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let retrievalResult):
// The image is either retrieved from cache or downloaded
let tempDirectory = FileManager.default.temporaryDirectory
let cachedFileUrl = tempDirectory.appendingPathComponent(identifier)
do {
// Move the downloaded file to the cache
try FileManager.default.moveItem(at: localUrl, to: cachedFileUrl)
self.attachLocalMedia(to: content, from: cachedFileUrl, withIdentifier: identifier)
// Write the image data to a temporary file for UNNotificationAttachment
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier)
} catch {
print("Failed to cache media file: \(error.localizedDescription)")
print("Failed to write media to temporary file: \(error.localizedDescription)")
self.contentHandler?(content)
}
}.resume()
case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)")
self.contentHandler?(content)
}
}
}
private func attachLocalMedia(to content: UNMutableNotificationContent, from localUrl: URL, withIdentifier identifier: String) {
if let attachment = try? UNNotificationAttachment(identifier: identifier, url: localUrl) {
private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) {
do {
let attachment = try UNNotificationAttachment(identifier: identifier, url: localUrl, options: [
UNNotificationAttachmentOptionsTypeHintKey: type as Any,
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
])
content.attachments = [attachment]
} else {
print("Failed to create attachment from cached file: \(localUrl.path)")
} catch let error as NSError {
// Log detailed error information
print("Failed to create attachment from file at \(localUrl.path)")
print("Error: \(error.localizedDescription)")
// Check specific error codes if needed
if error.domain == NSCocoaErrorDomain {
switch error.code {
case NSFileReadNoSuchFileError:
print("File does not exist at \(localUrl.path)")
case NSFileReadNoPermissionError:
print("No permission to read file at \(localUrl.path)")
default:
print("Unhandled file error: \(error.code)")
}
}
}
// Call content handler regardless of success or failure
self.contentHandler?(content)
}

View File

@ -10,7 +10,7 @@ import SwiftUI
struct CheckInProvider: TimelineProvider {
func placeholder(in context: Context) -> CheckInEntry {
CheckInEntry(date: Date(), user: nil, checkIn: nil)
CheckInEntry(date: Date(), checkIn: nil)
}
func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) {
@ -23,21 +23,17 @@ struct CheckInProvider: TimelineProvider {
jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter)
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
let userRaw = prefs?.string(forKey: "user")
var user: SolarUser?
if let userRaw = userRaw {
user = try! jsonDecoder.decode(SolarUser.self, from: userRaw.data(using: .utf8)!)
}
let checkInRaw = prefs?.string(forKey: "today_check_in")
let checkInRaw = prefs?.string(forKey: "pas_check_in_record")
var checkIn: SolarCheckInRecord?
if let checkInRaw = checkInRaw {
checkIn = try! jsonDecoder.decode(SolarCheckInRecord.self, from: checkInRaw.data(using: .utf8)!)
if checkIn != nil && !Calendar.current.isDate(checkIn!.createdAt, inSameDayAs: Date()) {
checkIn = nil
}
}
let entry = CheckInEntry(
date: Date(),
user: user,
checkIn: checkIn
)
completion(entry)
@ -53,14 +49,13 @@ struct CheckInProvider: TimelineProvider {
struct CheckInEntry: TimelineEntry {
let date: Date
let user: SolarUser?
let checkIn: SolarCheckInRecord?
}
struct CheckInWidgetEntryView : View {
var entry: CheckInProvider.Entry
private let resultTierSymbols: [String] = ["大凶", "", "中平", "", ""]
private let resultTierSymbols: [String] = ["大凶", "", "中平", "", ""]
func checkIn() -> Void {}
@ -105,7 +100,7 @@ struct CheckInWidgetEntryView : View {
Button("Check In", systemImage: "checkmark", action: checkIn).labelStyle(.iconOnly).buttonBorderShape(.circle).frame(maxWidth: .infinity, alignment: .trailing)
}
}
}.padding(8)
}.padding(8).widgetURL(URL(string: "https://sn.solsynth.dev"))
}
}
@ -132,10 +127,9 @@ struct CheckInWidget: Widget {
#Preview(as: .systemSmall) {
CheckInWidget()
} timeline: {
CheckInEntry(date: .now, user: nil, checkIn: nil)
CheckInEntry(date: .now, checkIn: nil)
CheckInEntry(
date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
checkIn: SolarCheckInRecord(id: 1, resultTier: 1, resultExperience: 100, createdAt: Date.now)
)
}

View File

@ -1,241 +0,0 @@
//
// FeaturedPostWidget.swift
// Runner
//
// Created by LittleSheep on 2024/12/14.
//
import SwiftUI
import WidgetKit
struct FeaturedPostProvider: TimelineProvider {
func placeholder(in context: Context) -> FeaturedPostEntry {
FeaturedPostEntry(date: Date(), user: nil, featuredPost: nil, family: .systemMedium)
}
func getSnapshot(in context: Context, completion: @escaping (FeaturedPostEntry) -> ()) {
let prefs = UserDefaults(suiteName: "group.solsynth.solian")
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter)
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
let userRaw = prefs?.string(forKey: "user")
var user: SolarUser?
if let userRaw = userRaw {
user = try! jsonDecoder.decode(SolarUser.self, from: userRaw.data(using: .utf8)!)
}
let featuredPostRaw = prefs?.string(forKey: "post_featured")
var featuredPosts: [SolarPost]?
if let featuredPostRaw = featuredPostRaw {
featuredPosts = try! jsonDecoder.decode([SolarPost].self, from: featuredPostRaw.data(using: .utf8)!)
}
let entry = FeaturedPostEntry(
date: Date(),
user: user,
featuredPost: featuredPosts?.first,
family: context.family
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
getSnapshot(in: context) { (entry) in
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}
}
struct FeaturedPostEntry: TimelineEntry {
let date: Date
let user: SolarUser?
let featuredPost: SolarPost?
let family: WidgetFamily
}
struct FeaturedPostWidgetEntryView : View {
var entry: FeaturedPostProvider.Entry
private let resultTierSymbols: [String] = ["大凶", "", "中平", "大吉", ""]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let featuredPost = entry.featuredPost {
HStack(alignment: .center) {
if let avatar = featuredPost.publisher.avatar {
let avatarUrl = getAttachmentUrl(for: avatar)
let size: CGFloat = 24
AsyncImage(url: URL(string: avatarUrl)) { image in
image.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.cornerRadius(size / 2)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 4)
.frame(width: size, height: size)
)
.shadow(radius: 10)
.frame(width: 24, height: 24, alignment: .center)
} placeholder: {
ProgressView().frame(width: 24, height: 24, alignment: .center)
}
}
Text("@\(featuredPost.publisher.name)")
.font(.system(size: 13, design: .monospaced))
.opacity(0.9)
Spacer()
}.frame(maxWidth: .infinity).padding(.bottom, 12)
if featuredPost.body.title != nil || featuredPost.body.description != nil {
VStack(alignment: .leading) {
if let title = featuredPost.body.title {
Text(title)
.font(.system(size: 17))
}
if let description = featuredPost.body.description {
Text(description)
.font(.system(size: 15))
}
}.padding(.bottom, 8)
}
if let content = featuredPost.body.content {
if (featuredPost.body.title == nil && featuredPost.body.description == nil) || entry.family == .systemLarge || entry.family == .systemExtraLarge {
Text(
(entry.family == .systemLarge || entry.family == .systemExtraLarge) ? content : content.replacingOccurrences(of: "\n", with: " ")
)
.font(.system(size: 15))
} else {
Text("\(Image(systemName: "plus")) total \(content.count) characters")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 1)
}
}
if let attachment = featuredPost.body.attachments {
if attachment.count == 1 {
Text("\(Image(systemName: "document.fill")) \(attachment.count) attachment")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 1)
} else if attachment.count > 1 {
Text("\(Image(systemName: "document.fill")) \(attachment.count) attachments")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 1)
}
}
Spacer()
Text(featuredPost.publishedAt!, format: .dateTime)
.font(.system(size: 11))
Text("Solar Network Featured Posts")
.font(.system(size: 9))
} else {
VStack(alignment: .center) {
Text("No Recommendations").font(.system(size: 19, weight: .bold))
Text("Click the widget to open the app to load featured posts")
.font(.system(size: 15))
.multilineTextAlignment(.center)
}.frame(alignment: .center)
}
}.padding(8).frame(maxWidth: .infinity)
}
}
struct FeaturedPostWidget: Widget {
let kind: String = "SolarFeaturedPostWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FeaturedPostProvider()) { entry in
if #available(iOS 17.0, *) {
FeaturedPostWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
FeaturedPostWidgetEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("Featured Posts")
.description("View the featured posts on the Solar Network")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge])
}
}
#Preview(as: .systemSmall) {
FeaturedPostWidget()
} timeline: {
FeaturedPostEntry(date: Date.now, user: nil, featuredPost: nil, family: .systemLarge)
FeaturedPostEntry(
date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
featuredPost: SolarPost(
id: 1,
body: SolarPostBody(
content: "Hello, World",
title: nil,
description: nil,
attachments: ["zb2hiUEmYcnpHfVN"]
),
publisher: SolarPublisher(
id: 1,
name: "demo",
nick: "Deemo",
description: nil,
avatar: "IZxCFkJUPKRijFCx",
banner: nil,
createdAt: .now,
updatedAt: .now
),
publisherId: 1,
createdAt: .now,
updatedAt: .now,
editedAt: nil,
publishedAt: .now
),
family: .systemSmall
)
FeaturedPostEntry(
date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
featuredPost: SolarPost(
id: 1,
body: SolarPostBody(
content: "Hello, World\nOh wow",
title: "Title",
description: "Description",
attachments: ["zb2hiUEmYcnpHfVN"]
),
publisher: SolarPublisher(
id: 1,
name: "demo",
nick: "Deemo",
description: nil,
avatar: "IZxCFkJUPKRijFCx",
banner: nil,
createdAt: .now,
updatedAt: .now
),
publisherId: 1,
createdAt: .now,
updatedAt: .now,
editedAt: nil,
publishedAt: .now
),
family: .systemLarge
)
}

View File

@ -0,0 +1,246 @@
//
// RandomPostWidget.swift
// Runner
//
// Created by LittleSheep on 2024/12/14.
//
import SwiftUI
import WidgetKit
import Kingfisher
struct RandomPostProvider: TimelineProvider {
func placeholder(in context: Context) -> RandomPostEntry {
RandomPostEntry(date: Date(), user: nil, randomPost: nil, family: .systemMedium)
}
func getSnapshot(in context: Context, completion: @escaping (RandomPostEntry) -> ()) {
let prefs = UserDefaults(suiteName: "group.solsynth.solian")
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter)
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
let userRaw = prefs?.string(forKey: "user")
var user: SolarUser?
if let userRaw = userRaw {
user = try! jsonDecoder.decode(SolarUser.self, from: userRaw.data(using: .utf8)!)
}
let randomPostRaw = prefs?.string(forKey: "int_random_post")
var randomPost: SolarPost?
if let randomPostRaw = randomPostRaw {
randomPost = try! jsonDecoder.decode(SolarPost.self, from: randomPostRaw.data(using: .utf8)!)
}
let entry = RandomPostEntry(
date: Date(),
user: user,
randomPost: randomPost,
family: context.family
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
getSnapshot(in: context) { (entry) in
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}
}
struct RandomPostEntry: TimelineEntry {
let date: Date
let user: SolarUser?
let randomPost: SolarPost?
let family: WidgetFamily
}
struct RandomPostWidgetEntryView : View {
var entry: RandomPostProvider.Entry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let randomPost = entry.randomPost {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center) {
if let avatar = randomPost.publisher.avatar {
let avatarUrl = getAttachmentUrl(for: avatar)
let size: CGFloat = 28
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: size, height: size), mode: .aspectFill)
KFImage.url(URL(string: avatarUrl))
.resizable()
.setProcessor(scaleProcessor)
.fade(duration: 0.25)
.placeholder{
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.cornerRadius(size / 2)
.frame(width: size, height: size, alignment: .center)
}
Text(randomPost.publisher.nick)
.font(.system(size: 15))
.opacity(0.9)
Text("@\(randomPost.publisher.name)")
.font(.system(size: 13, design: .monospaced))
.opacity(0.9)
Spacer()
}.frame(maxWidth: .infinity).padding(.bottom, 12)
if randomPost.body.title != nil || randomPost.body.description != nil {
VStack(alignment: .leading) {
if let title = randomPost.body.title {
Text(title)
.font(.system(size: 17))
}
if let description = randomPost.body.description {
Text(description)
.font(.system(size: 15))
}
}.padding(.bottom, 8)
}
if let content = randomPost.body.content {
if (randomPost.body.title == nil && randomPost.body.description == nil) || entry.family == .systemLarge || entry.family == .systemExtraLarge {
Text(
(entry.family == .systemLarge || entry.family == .systemExtraLarge) ? content : content.replacingOccurrences(of: "\n", with: " ")
)
.font(.system(size: 15))
} else {
Text("\(Image(systemName: "plus")) total \(content.count) characters")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 1)
}
}
if let attachment = randomPost.body.attachments {
if attachment.count == 1 {
Text("\(Image(systemName: "document.fill")) \(attachment.count) attachment")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 2)
} else if attachment.count > 1 {
Text("\(Image(systemName: "document.fill")) \(attachment.count) attachments")
.font(.system(size: 11, design: .monospaced))
.opacity(0.75)
.padding(.top, 2)
}
}
Spacer()
Text(randomPost.publishedAt!, format: .dateTime)
.font(.system(size: 11))
Text("#\(randomPost.id)")
.font(.system(size: 9))
}.widgetURL(URL(string: "https://sn.solsynth.dev/posts/\(randomPost.id)"))
} else {
VStack(alignment: .center) {
Text("No Recommendations").font(.system(size: 19, weight: .bold))
Text("Open the app to load some random post")
.font(.system(size: 15))
.multilineTextAlignment(.center)
}.frame(alignment: .center)
}
}.padding(8).frame(maxWidth: .infinity)
}
}
struct RandomPostWidget: Widget {
let kind: String = "SolarRandomPostWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: RandomPostProvider()) { entry in
if #available(iOS 17.0, *) {
RandomPostWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
RandomPostWidgetEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("Random Post")
.description("View the random post on the Solar Network")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge])
}
}
#Preview(as: .systemSmall) {
RandomPostWidget()
} timeline: {
RandomPostEntry(date: Date.now, user: nil, randomPost: nil, family: .systemLarge)
RandomPostEntry(
date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
randomPost: SolarPost(
id: 1,
body: SolarPostBody(
content: "Hello, World",
title: nil,
description: nil,
attachments: ["zb2hiUEmYcnpHfVN"]
),
publisher: SolarPublisher(
id: 1,
name: "demo",
nick: "Deemo",
description: nil,
avatar: "IZxCFkJUPKRijFCx",
banner: nil,
createdAt: .now,
updatedAt: .now
),
publisherId: 1,
createdAt: .now,
updatedAt: .now,
editedAt: nil,
publishedAt: .now
),
family: .systemSmall
)
RandomPostEntry(
date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
randomPost: SolarPost(
id: 1,
body: SolarPostBody(
content: "Hello, World\nOh wow",
title: "Title",
description: "Description",
attachments: ["zb2hiUEmYcnpHfVN"]
),
publisher: SolarPublisher(
id: 1,
name: "demo",
nick: "Deemo",
description: nil,
avatar: "IZxCFkJUPKRijFCx",
banner: nil,
createdAt: .now,
updatedAt: .now
),
publisherId: 1,
createdAt: .now,
updatedAt: .now,
editedAt: nil,
publishedAt: .now
),
family: .systemLarge
)
}

View File

@ -12,6 +12,6 @@ import SwiftUI
struct SolarWidgetBundle: WidgetBundle {
var body: some Widget {
CheckInWidget()
FeaturedPostWidget()
RandomPostWidget()
}
}

View File

@ -1,11 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.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/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart';
enum PostWriteMediaType {
image,
video,
audio,
file,
}
import 'package:video_compress/video_compress.dart';
class PostWriteMedia {
late String name;
late PostWriteMediaType type;
late SnMediaType type;
final SnAttachment? attachment;
final XFile? file;
final Uint8List? raw;
@ -35,16 +35,16 @@ class PostWriteMedia {
switch (attachment?.mimetype.split('/').firstOrNull) {
case 'image':
type = PostWriteMediaType.image;
type = SnMediaType.image;
break;
case 'video':
type = PostWriteMediaType.video;
type = SnMediaType.video;
break;
case 'audio':
type = PostWriteMediaType.audio;
type = SnMediaType.audio;
break;
default:
type = PostWriteMediaType.file;
type = SnMediaType.file;
}
}
@ -56,16 +56,16 @@ class PostWriteMedia {
switch (mimetype?.split('/').firstOrNull) {
case 'image':
type = PostWriteMediaType.image;
type = SnMediaType.image;
break;
case 'video':
type = PostWriteMediaType.video;
type = SnMediaType.video;
break;
case 'audio':
type = PostWriteMediaType.audio;
type = SnMediaType.audio;
break;
default:
type = PostWriteMediaType.file;
type = SnMediaType.file;
}
}
@ -152,10 +152,21 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController contentController = TextEditingController();
final TextEditingController titleController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final TextEditingController aliasController = TextEditingController();
PostWriteController() {
titleController.addListener(() => notifyListeners());
descriptionController.addListener(() => notifyListeners());
titleController.addListener(() {
_temporaryPlanSave();
notifyListeners();
});
descriptionController.addListener(() {
_temporaryPlanSave();
notifyListeners();
});
contentController.addListener(() {
_temporaryPlanSave();
});
_temporaryLoad();
}
String mode = kTitleMap.keys.first;
@ -176,6 +187,7 @@ class PostWriteController extends ChangeNotifier {
List<int> visibleUsers = List.empty();
List<int> invisibleUsers = List.empty();
List<String> tags = List.empty();
List<String> categories = List.empty();
PostWriteMedia? thumbnail;
List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil;
@ -198,12 +210,14 @@ class PostWriteController extends ChangeNotifier {
titleController.text = post.body['title'] ?? '';
descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? '';
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? []);
invisibleUsers = List.from(post.invisibleUsersList ?? []);
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias));
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
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 place = await attach.chunkedUploadInitialize(
@ -239,22 +254,140 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
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()!,
place.$1,
place.$2,
onProgress: (progress) {
progress = progress;
analyzeNow: media.type == SnMediaType.image,
onProgress: (value) {
progress = value;
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;
}
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(growable: true),
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
'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 {
if (isBusy) return;
@ -269,7 +402,7 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
Future<void> post(BuildContext context) async {
Future<void> sendPost(BuildContext context) async {
if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>();
@ -296,21 +429,34 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
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()!,
place.$1,
place.$2,
onProgress: (progress) {
onProgress: (value) {
// Calculate overall progress for attachments
progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
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);
notifyListeners();
}
} catch (err) {
isBusy = false;
@ -334,11 +480,13 @@ class PostWriteController extends ChangeNotifier {
data: {
'publisher': publisher!.id,
'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!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).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,
@ -359,6 +507,7 @@ class PostWriteController extends ChangeNotifier {
method: editingPost != null ? 'PUT' : 'POST',
),
);
reset();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
@ -407,51 +556,67 @@ class PostWriteController extends ChangeNotifier {
void setPublisher(SnPublisher? item) {
publisher = item;
_temporaryPlanSave();
notifyListeners();
}
void setPublishedAt(DateTime? value) {
publishedAt = value;
_temporaryPlanSave();
notifyListeners();
}
void setPublishedUntil(DateTime? value) {
publishedUntil = value;
_temporaryPlanSave();
notifyListeners();
}
void setTags(List<String> value) {
tags = value;
_temporaryPlanSave();
notifyListeners();
}
void setCategories(List<String> value) {
categories = value;
_temporaryPlanSave();
notifyListeners();
}
void setVisibility(int value) {
visibility = value;
_temporaryPlanSave();
notifyListeners();
}
void setVisibleUsers(List<int> value) {
visibleUsers = value;
_temporaryPlanSave();
notifyListeners();
}
void setInvisibleUsers(List<int> value) {
invisibleUsers = value;
_temporaryPlanSave();
notifyListeners();
}
void setProgress(double? value) {
progress = value;
_temporaryPlanSave();
notifyListeners();
}
void setIsBusy(bool value) {
isBusy = value;
_temporaryPlanSave();
notifyListeners();
}
void setMode(String value) {
mode = value;
_temporaryPlanSave();
notifyListeners();
}
@ -461,11 +626,16 @@ class PostWriteController extends ChangeNotifier {
titleController.clear();
descriptionController.clear();
contentController.clear();
aliasController.clear();
tags.clear();
categories.clear();
attachments.clear();
editingPost = null;
replyingPost = null;
repostingPost = null;
mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners();
}
@ -474,6 +644,7 @@ class PostWriteController extends ChangeNotifier {
contentController.dispose();
titleController.dispose();
descriptionController.dispose();
aliasController.dispose();
super.dispose();
}
}

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:firebase_core/firebase_core.dart';
@ -11,13 +13,16 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/link_preview.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
@ -25,6 +30,7 @@ import 'package:surface/providers/post.dart';
import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_attachment.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/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
@ -36,7 +42,26 @@ import 'package:surface/types/realm.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/version_label.dart';
import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart';
import 'package:in_app_review/in_app_review.dart';
@pragma('vm:entry-point')
void appBackgroundDispatcher() {
Workmanager().executeTask((task, inputData) async {
log("[WorkManager] Native called background task: $task");
switch (task) {
case Workmanager.iOSBackgroundTask:
await Future.wait([widgetUpdateRandomPost()]);
return true;
case "WidgetUpdateRandomPost":
await widgetUpdateRandomPost();
return true;
default:
return true;
}
});
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -64,6 +89,22 @@ void main() async {
});
}
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize(
appBackgroundDispatcher,
isInDebugMode: kDebugMode,
);
if (Platform.isAndroid) {
Workmanager().registerPeriodicTask(
"widget-update-random-post",
"WidgetUpdateRandomPost",
frequency: Duration(minutes: 1),
constraints: Constraints(networkType: NetworkType.connected),
tag: "widget-update",
);
}
}
runApp(const SolianApp());
}
@ -86,15 +127,18 @@ class SolianApp extends StatelessWidget {
assetLoader: JsonAssetLoader(),
child: MultiProvider(
providers: [
// System extensions layer
Provider(create: (ctx) => HomeWidgetProvider(ctx)),
// Preferences layer
ChangeNotifierProvider(create: (ctx) => ConfigProvider(ctx)),
// Display layer
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
// System extensions layer
Provider(create: (ctx) => HomeWidgetProvider(ctx)),
// Data layer
Provider(create: (_) => SnNetworkProvider()),
Provider(create: (ctx) => SnNetworkProvider(ctx)),
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
Provider(create: (ctx) => SnPostContentProvider(ctx)),
@ -105,6 +149,9 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
// Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)),
],
child: _AppDelegate(),
),
@ -163,15 +210,66 @@ class _AppSplashScreen extends StatefulWidget {
class _AppSplashScreenState extends State<_AppSplashScreen> {
bool _isReady = false;
late StreamSubscription _shareIntentSubscription;
void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) {
final rawTime = prefs.getString('first_boot_time');
final time = DateTime.tryParse(rawTime ?? '');
if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
final inAppReview = InAppReview.instance;
if (prefs.getBool('rating_requested') == true) return;
if (await inAppReview.isAvailable()) {
await inAppReview.requestReview();
prefs.setBool('rating_requested', true);
} else {
log('Unable request app review, unavailable');
}
}
} else {
prefs.setString('first_boot_time', DateTime.now().toIso8601String());
}
}
Future<void> _checkForUpdate() async {
if (kIsWeb) return;
try {
final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await Dio(
BaseOptions(
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
),
).get(
'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1',
);
final remoteVersionString = (resp.data as List).firstOrNull?['name'] ?? '0.0.0+0';
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
final localVersion = Version.parse(localVersionString.split('+').first);
final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
final config = context.read<ConfigProvider>();
config.setUpdate(remoteVersionString);
log("[Update] Update available: $remoteVersionString");
}
} catch (e) {
if (mounted) context.showErrorDialog('Unable to check update: $e');
}
}
Future<void> _initialize() async {
try {
final home = context.read<HomeWidgetProvider>();
await home.initialize();
if (!mounted) return;
// The Network initialization must be done after the HomeWidget initialization
// The Network initialization will save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again
final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent();
await sn.setConfigWithNative();
if (!mounted) return;
final ua = context.read<UserProvider>();
await ua.initialize();
@ -189,10 +287,18 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
}
}
Future<void> _postInitialization() async {
await widgetUpdateRandomPost();
}
@override
void initState() {
super.initState();
_initialize();
_initialize().then((_) {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
});
}
@override
@ -206,7 +312,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Image.asset("assets/icon/icon.png", width: 64, height: 64),
if (MediaQuery.of(context).platformBrightness == Brightness.dark)
Image.asset("assets/icon/icon-dark.png", width: 64, height: 64)
else
Image.asset("assets/icon/icon.png", width: 64, height: 64),
const Gap(6),
LinearProgressIndicator(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,

View File

@ -125,10 +125,8 @@ class ChatChannelProvider extends ChangeNotifier {
final channelBox = await Hive.openBox<SnChatMessage>(
'${ChatMessageController.kChatMessageBoxPrefix}${channel.id}',
);
final lastMessage = channelBox.isNotEmpty
? channelBox.values
.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b)
: null;
final lastMessage =
channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null;
if (lastMessage != null) result.add(lastMessage);
channelBox.close();
}

55
lib/providers/config.dart Normal file
View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/widget.dart';
const kAtkStoreKey = 'nex_user_atk';
const kRtkStoreKey = 'nex_user_rtk';
const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
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 = {
'settingsImageQualityLowest': FilterQuality.none,
'settingsImageQualityLow': FilterQuality.low,
'settingsImageQualityMedium': FilterQuality.medium,
'settingsImageQualityHigh': FilterQuality.high,
};
class ConfigProvider extends ChangeNotifier {
late final SharedPreferences prefs;
late final HomeWidgetProvider _home;
ConfigProvider(BuildContext context) {
_home = context.read<HomeWidgetProvider>();
}
Future<void> initialize() async {
prefs = await SharedPreferences.getInstance();
}
FilterQuality get imageQuality {
return kImageQualityLevel.values.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? FilterQuality.high;
}
String get serverUrl {
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
}
set serverUrl(String url) {
prefs.setString(kNetworkServerStoreKey, url);
_home.saveWidgetData("nex_server_url", url);
}
String? updatableVersion;
void setUpdate(String newVersion) {
updatableVersion = newVersion;
notifyListeners();
}
}

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

@ -6,30 +6,34 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/widget.dart';
import 'package:synchronized/synchronized.dart';
const kAtkStoreKey = 'nex_user_atk';
const kRtkStoreKey = 'nex_user_rtk';
const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
const kNetworkServerStoreKey = 'app_server_url';
const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'),
];
Completer<String?>? _refreshCompleter;
class SnNetworkProvider {
late final Dio client;
late final SharedPreferences _prefs;
late final ConfigProvider _config;
late final HomeWidgetProvider _home;
String? _userAgent;
SnNetworkProvider() {
SnNetworkProvider(BuildContext context) {
_home = context.read<HomeWidgetProvider>();
client = Dio();
client.interceptors.add(RetryInterceptor(
@ -60,13 +64,55 @@ class SnNetworkProvider {
),
);
SharedPreferences.getInstance().then((prefs) {
_prefs = prefs;
client.options.baseUrl = _prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
_config = context.read<ConfigProvider>();
_config.initialize().then((_) {
_prefs = _config.prefs;
client.options.baseUrl = _config.serverUrl;
});
}
Future<void> initializeUserAgent() async {
static Future<Dio> createOffContextClient() async {
final prefs = await SharedPreferences.getInstance();
final client = Dio();
client.interceptors.add(RetryInterceptor(
dio: client,
retries: 3,
retryDelays: const [
Duration(milliseconds: 300),
Duration(milliseconds: 1000),
Duration(milliseconds: 3000),
],
));
final ua = await _getUserAgent();
client.interceptors.add(
InterceptorsWrapper(
onRequest: (
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) {
prefs.setString(kAtkStoreKey, atk);
prefs.setString(kRtkStoreKey, rtk);
});
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
}
options.headers['User-Agent'] = ua;
return handler.next(options);
},
),
);
client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
return client;
}
Future<void> setConfigWithNative() async {
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
}
static Future<String> _getUserAgent() async {
final String platformInfo;
if (kIsWeb) {
final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
@ -92,14 +138,22 @@ class SnNetworkProvider {
final packageInfo = await PackageInfo.fromPlatform();
_userAgent = 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
}
Future<void> initializeUserAgent() async {
_userAgent = await _getUserAgent();
}
final tkLock = Lock();
Completer<String?>? _refreshCompleter;
Future<String?> getFreshAtk() async {
return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) {
setTokenPair(atk, rtk);
});
}
static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async {
if (_refreshCompleter != null) {
return await _refreshCompleter!.future;
} else {
@ -107,7 +161,6 @@ class SnNetworkProvider {
}
try {
var atk = _prefs.getString(kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
@ -133,7 +186,13 @@ class SnNetworkProvider {
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
final result = await _refreshToken(client.options.baseUrl, rtk);
if (result == null) {
atk = null;
} else {
atk = result.$1;
onRefresh?.call(atk, result.$2);
}
}
if (atk != null) {
@ -171,24 +230,32 @@ class SnNetworkProvider {
Future<String?> refreshToken() async {
final rtk = _prefs.getString(kRtkStoreKey);
final result = await _refreshToken(client.options.baseUrl, rtk);
if (result == null) return null;
_prefs.setString(kAtkStoreKey, result.$1);
_prefs.setString(kRtkStoreKey, result.$2);
return result.$1;
}
static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async {
if (rtk == null) return null;
final dio = Dio();
dio.options.baseUrl = client.options.baseUrl;
dio.options.baseUrl = baseUrl;
final resp = await dio.post('/cgi/id/auth/token', data: {
'grant_type': 'refresh_token',
'refresh_token': rtk,
});
final atk = resp.data['access_token'];
final nRtk = resp.data['refresh_token'];
setTokenPair(atk, nRtk);
final String atk = resp.data['access_token'];
final String nRtk = resp.data['refresh_token'];
return atk;
return (atk, nRtk);
}
void setBaseUrl(String url) {
_config.serverUrl = url;
client.options.baseUrl = url;
}
}

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:surface/theme.dart';
@ -11,8 +13,8 @@ class ThemeProvider extends ChangeNotifier {
});
}
void reloadTheme({bool? useMaterial3}) {
createAppThemeSet().then((value) {
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
theme = value;
notifyListeners();
});

View File

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

View File

@ -1,16 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:home_widget/home_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
class HomeWidgetProvider {
HomeWidgetProvider(BuildContext context);
Future<void> initialize() async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
if (!kIsWeb && Platform.isIOS) {
if (Platform.isIOS) {
await HomeWidget.setAppGroupId("group.solsynth.solian");
}
}
@ -24,15 +27,34 @@ class HomeWidgetProvider {
Future<void> updateWidget() async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
if (Platform.isIOS) {
const widgets = ["SolarFeaturedPostWidget", "SolarCheckInWidget"];
const widgets = ["SolarRandomPostWidget", "SolarCheckInWidget"];
for (final widget in widgets) {
await HomeWidget.updateWidget(
name: widget,
iOSName: widget,
androidName: "com.solsynth.solian.$widget",
qualifiedAndroidName: "group.solsynth.solian.$widget",
);
}
} else if (Platform.isAndroid) {
const widgets = ["RandomPostWidget", "CheckInWidget"];
for (final widget in widgets) {
await HomeWidget.updateWidget(
androidName: "${widget}Receiver",
qualifiedAndroidName: "dev.solsynth.solian.widgets.${widget}Receiver",
);
}
}
}
}
Future<void> widgetUpdateRandomPost() async {
final snc = await SnNetworkProvider.createOffContextClient();
final resp = await snc.get('/cgi/co/recommendations/shuffle?take=1');
final post = SnPost.fromJson(resp.data['data'][0]);
await HomeWidget.saveWidgetData("int_random_post", jsonEncode(post.toJson()));
await HomeWidget.updateWidget(
name: "SolarRandomPostWidget",
iOSName: "SolarRandomPostWidget",
androidName: "RandomPostWidgetReceiver",
qualifiedAndroidName: "dev.solsynth.solian.widgets.RandomPostWidgetReceiver",
);
}

View File

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

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.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:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.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;
Future<void> _fetchStatus() async {
@ -228,65 +244,72 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: _appBarHeight,
title: _account == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
shadows: labelShadows,
),
),
]),
Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
pinned: true,
flexibleSpace: _account != null
? Stack(
fit: StackFit.expand,
children: [
UniversalImage(
sn.getAttachmentUrl(_account!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
),
child: SliverAppBar(
expandedHeight: _appBarHeight,
title: _account == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
pinned: true,
flexibleSpace: _account != null
? Stack(
fit: StackFit.expand,
children: [
UniversalImage(
sn.getAttachmentUrl(_account!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
),
),
),
),
),
),
],
)
: null,
],
)
: null,
),
),
if (_account != null)
SliverToBoxAdapter(
@ -430,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.calendar_add_on),
const Gap(8),
@ -437,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.cake),
const Gap(8),
@ -450,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.identity_platform),
const Gap(8),
@ -459,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
).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),
],
@ -466,6 +512,33 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
),
SliverToBoxAdapter(child: const Divider()),
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(
child: Column(
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 {
final String name;
const AccountPublisherEditScreen({super.key, required this.name});
@override
State<AccountPublisherEditScreen> createState() =>
_AccountPublisherEditScreenState();
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
}
class _AccountPublisherEditScreenState
extends State<AccountPublisherEditScreen> {
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
bool _isBusy = false;
SnPublisher? _publisher;
@ -54,7 +53,7 @@ class _AccountPublisherEditScreenState
_publisher = SnPublisher.fromJson(resp.data);
_syncWidget();
} catch (err) {
context.showErrorDialog(err);
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
@ -75,9 +74,9 @@ class _AccountPublisherEditScreenState
'name': _nameController.text,
'description': _descriptionController.text,
});
Navigator.pop(context, true);
if (mounted) Navigator.pop(context, true);
} catch (err) {
context.showErrorDialog(err);
if(mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
@ -108,11 +107,9 @@ class _AccountPublisherEditScreenState
if (image == null) return;
if (!mounted) return;
final ImageProvider imageProvider =
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios = place == 'banner'
? [CropAspectRatio(width: 16, height: 7)]
: [CropAspectRatio(width: 1, height: 1)];
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
@ -134,10 +131,7 @@ class _AccountPublisherEditScreenState
setState(() => _isBusy = true);
final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try {
final attachment = await attach.directUploadOne(
@ -199,9 +193,7 @@ class _AccountPublisherEditScreenState
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
@ -240,8 +232,7 @@ class _AccountPublisherEditScreenState
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
@ -249,8 +240,7 @@ class _AccountPublisherEditScreenState
decoration: InputDecoration(
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
@ -260,8 +250,7 @@ class _AccountPublisherEditScreenState
decoration: InputDecoration(
labelText: 'fieldDescription'.tr(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
Row(

View File

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

View File

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

View File

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

View File

@ -97,7 +97,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}
} catch (err) {
if (!mounted) return;
print((err as DioException).response?.data);
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);
@ -158,7 +157,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
GoRouter.of(context).pushNamed(
'chatCallRoom',
pathParameters: {
'scope': _channel!.realm!.alias,
'scope': _channel!.realm?.alias ?? 'global',
'alias': _channel!.alias,
},
);

View File

@ -5,12 +5,28 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.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: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 {
const ExploreScreen({super.key});
@ -24,15 +40,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnPostCategory> _categories = List.empty(growable: true);
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 {
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
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;
if (!mounted) return;
@ -43,10 +78,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
if (mounted) setState(() => _isBusy = false);
}
Future<void> _refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
}
@override
void initState() {
super.initState();
_fetchPosts();
_fetchCategories();
}
@override
@ -59,27 +101,20 @@ class _ExploreScreenState extends State<ExploreScreen> {
type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none,
overlayStyle: ExpandableFabOverlayStyle(
color: Theme.of(context)
.colorScheme
.surface
.withAlpha((255 * 0.5).round()),
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
),
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Symbols.add, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor:
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor:
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
@ -95,8 +130,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'stories',
}).then((value) {
if (value == true) {
_posts.clear();
_fetchPosts();
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
@ -117,8 +151,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'articles',
}).then((value) {
if (value == true) {
_posts.clear();
_fetchPosts();
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
@ -131,10 +164,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
),
body: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
onRefresh: () => _refreshPosts(),
child: CustomScrollView(
slivers: [
SliverAppBar(
@ -151,6 +181,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
),
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(
itemCount: _posts.length,
@ -167,8 +225,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
_refreshPosts();
},
),
onTap: () {

View File

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

View File

@ -1,26 +1,32 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.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:flutter/material.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.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/widget.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
import '../providers/widget.dart';
class HomeScreenDashEntry {
final String name;
final Widget child;
@ -76,7 +82,8 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
_HomeDashSpecialDayWidget().padding(top: 8, horizontal: 8),
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
_HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent(
maxCrossAxisExtent: 280,
mainAxisSpacing: 8,
@ -100,31 +107,123 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget({super.key});
class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding;
const _HomeDashUpdateWidget({this.padding});
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final today = DateTime.now();
final birthday = ua.user?.profile?.birthday?.toLocal();
final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month;
return Column(
children: [
if (isBirthday)
Card(
child: ListTile(
leading: Text('🎂').fontSize(24),
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']),
final config = context.watch<ConfigProvider>();
return ListenableBuilder(
listenable: config,
builder: (context, _) {
if (config.updatableVersion != null) {
return Container(
padding: padding,
child: Card(
child: ListTile(
leading: Icon(Symbols.update),
title: Text('updateAvailable').tr(),
subtitle: Text(config.updatableVersion!),
trailing: (kIsWeb || Platform.isWindows || Platform.isLinux)
? null
: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {
final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-release-${config.updatableVersion!}.apk',
'ic_launcher',
'https://apps.apple.com/us/app/solian/id6499032345',
);
AzhonAppUpdate.update(model);
context.showSnackbar('updateOngoing'.tr());
},
),
),
),
).padding(bottom: 8),
],
);
}
return SizedBox.shrink();
},
);
}
}
class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget();
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final dayz = context.watch<SpecialDayProvider>();
final days = dayz.getSpecialDays();
if (days.isNotEmpty) {
return Column(
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: [
SlideCountdown(
duration: diff,
style: GoogleFonts.robotoMono(fontSize: 13),
separatorStyle: GoogleFonts.robotoMono(fontSize: 13),
separatorType: SeparatorType.symbol,
decoration: BoxDecoration(),
padding: EdgeInsets.zero,
),
const Gap(12),
Expanded(
child: LinearProgressIndicator(
value: progress,
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
).padding(bottom: 8);
}
return const SizedBox.shrink();
}
}
class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget({super.key});
const _HomeDashCheckInWidget();
@override
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
@ -145,7 +244,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.get('/cgi/id/check-in/today');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('today_check_in', _todayRecord!.toJson());
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} finally {
setState(() => _isBusy = false);
}
@ -158,7 +257,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('today_check_in', _todayRecord!.toJson());
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -342,7 +441,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
}
class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget({super.key});
const _HomeDashNotificationWidget();
@override
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
@ -413,7 +512,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
}
class _HomeDashRecommendationPostWidget extends StatefulWidget {
const _HomeDashRecommendationPostWidget({super.key});
const _HomeDashRecommendationPostWidget();
@override
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
@ -427,9 +526,7 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final home = context.read<HomeWidgetProvider>();
_posts = await pt.listRecommendations();
home.saveWidgetData('post_featured', _posts!.map((e) => e.toJson()).toList());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);

View File

@ -1,18 +1,14 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.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/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
@ -71,11 +67,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
try {
final sn = context.read<SnNetworkProvider>();
final config = context.read<ConfigProvider>();
final resp = await sn.client.get('/cgi/co/publishers/me');
_publishers = List<SnPublisher>.from(
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) {
if (!mounted) return;
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
void dispose() {
_writeController.dispose();
@ -265,6 +232,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
});
} else {
_writeController.setPublisher(value);
final config = context.read<ConfigProvider>();
config.prefs.setInt('int_last_publisher_id', value.id);
}
},
buttonStyleData: const ButtonStyleData(
@ -286,18 +255,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.replyingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
),
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
),
const Divider(height: 1),
],
@ -306,22 +270,17 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.repostingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
children: <Widget>[
PostItem(
data: _writeController.repostingPost!,
)
],
),
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
children: <Widget>[
PostItem(
data: _writeController.repostingPost!,
)
],
),
const Divider(height: 1),
],
@ -330,18 +289,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.editingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)],
),
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)],
),
const Divider(height: 1),
],
@ -410,6 +364,36 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
child: _writeController.temporaryRestored
? 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: 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),
if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>(
@ -429,63 +413,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
scrollDirection: Axis.vertical,
child: Row(
children: [
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.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
AddPostMediaButton(
onAdd: (items) {
setState(() {
_writeController.addAttachments(items);
});
},
),
],
),
@ -496,7 +429,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onPressed: (_writeController.isBusy || _writeController.publisher == null)
? null
: () {
_writeController.post(context).then((_) {
_writeController.sendPost(context).then((_) {
if (!context.mounted) return;
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';
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
State<PostSearchScreen> createState() => _PostSearchScreenState();
@ -23,6 +26,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
bool _isBusy = false;
List<String> _searchTags = List.empty(growable: true);
List<String> _searchCategories = List.empty(growable: true);
final List<SnPost> _posts = List.empty(growable: true);
int? _postCount;
@ -30,8 +34,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
String _searchTerm = '';
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 {
if (_searchTerm.isEmpty && _searchTags.isEmpty) return;
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
@ -45,6 +59,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
take: 10,
offset: _posts.length,
tags: _searchTags,
categories: _searchCategories,
);
final List<SnPost> out = result.$1;
_postCount = result.$2;
@ -73,9 +88,25 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
setState(() => _searchTags = value);
},
),
const Gap(4),
PostCategoriesField(
labelText: 'fieldPostCategories'.tr(),
initialCategories: _searchCategories,
onUpdate: (value) {
setState(() => _searchCategories = value);
},
),
],
).padding(horizontal: 24, vertical: 16),
);
).then((_) {
_refreshPosts();
});
}
Future<void> _refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
}
@override
@ -118,8 +149,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
_refreshPosts();
},
),
onTap: () {
@ -150,10 +180,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
_searchTerm = value;
},
onSubmitted: (value) {
setState(() => _posts.clear());
_searchTerm = value;
_fetchPosts();
_refreshPosts();
},
),
if (_lastTook != null)

View File

@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Future<void> _fetchPublisher() async {
try {
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}');
if (!mounted) return;
_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) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
} finally {
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;
@ -277,70 +283,77 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: MultiSliver(
children: [
SliverAppBar(
expandedHeight: _appBarHeight,
title: _publisher == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_publisher!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
pinned: true,
flexibleSpace: _publisher != null
? Stack(
fit: StackFit.expand,
children: [
if (_publisher!.banner.isNotEmpty)
UniversalImage(
sn.getAttachmentUrl(_publisher!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
),
child: SliverAppBar(
expandedHeight: _appBarHeight,
title: _publisher == null
? Text('loading').tr()
: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_publisher!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
pinned: true,
flexibleSpace: _publisher != null
? Stack(
fit: StackFit.expand,
children: [
if (_publisher!.banner.isNotEmpty)
UniversalImage(
sn.getAttachmentUrl(_publisher!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 56 + MediaQuery.of(context).padding.top,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: _appBarBlur,
sigmaY: _appBarBlur,
),
child: Container(
color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5),
),
),
),
),
),
),
],
)
: null,
],
)
: null,
),
),
if (_publisher != null)
SliverToBoxAdapter(
@ -567,7 +580,6 @@ class _PublisherPostList extends StatelessWidget {
final void Function() onDeleted;
const _PublisherPostList({
super.key,
required this.isBusy,
required this.postCount,
required this.posts,

View File

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

View File

@ -1,10 +1,11 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -12,11 +13,23 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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/theme.dart';
import 'package:surface/theme.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 {
const SettingsScreen({super.key});
@ -25,7 +38,7 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
SharedPreferences? _prefs;
late final SharedPreferences _prefs;
String _docBasepath = '/';
final TextEditingController _serverUrlController = TextEditingController();
@ -39,12 +52,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() {});
}
});
SharedPreferences.getInstance().then((prefs) {
setState(() {
_prefs = prefs;
_serverUrlController.text = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
});
});
final config = context.read<ConfigProvider>();
_prefs = config.prefs;
_serverUrlController.text = config.serverUrl;
}
@override
@ -60,6 +70,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
body: SingleChildScrollView(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
@ -78,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (image == null) return;
await File(image.path).copy('$_docBasepath/app_background_image');
_prefs?.setBool('has_background_image', true);
_prefs.setBool(kAppBackgroundStoreKey, true);
setState(() {});
},
@ -99,29 +110,136 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Symbols.chevron_right),
onTap: () {
File('$_docBasepath/app_background_image').deleteSync();
_prefs?.remove('has_background_image');
_prefs.remove(kAppBackgroundStoreKey);
setState(() {});
},
);
}),
if (_prefs != null)
CheckboxListTile(
title: Text('settingsThemeMaterial3').tr(),
subtitle: Text('settingsThemeMaterial3Description').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
secondary: const Icon(Symbols.new_releases),
value: _prefs!.getBool(kMaterialYouToggleStoreKey) ?? false,
onChanged: (value) {
setState(() {
_prefs!.setBool(
kMaterialYouToggleStoreKey,
value ?? false,
);
});
final th = context.watch<ThemeProvider>();
th.reloadTheme(useMaterial3: value ?? false);
},
CheckboxListTile(
title: Text('settingsThemeMaterial3').tr(),
subtitle: Text('settingsThemeMaterial3Description').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
secondary: const Icon(Symbols.new_releases),
value: _prefs.getBool(kMaterialYouToggleStoreKey) ?? false,
onChanged: (value) {
setState(() {
_prefs.setBool(
kMaterialYouToggleStoreKey,
value ?? false,
);
});
final th = context.read<ThemeProvider>();
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(
@ -139,7 +257,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: const Icon(Symbols.save),
onPressed: () {
sn.setBaseUrl(_serverUrlController.text);
_prefs?.setString(
_prefs.setString(
kNetworkServerStoreKey,
_serverUrlController.text,
);
@ -182,7 +300,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChanged: (String? value) {
if (value == null) return;
_serverUrlController.text = value;
_prefs?.setString(kNetworkServerStoreKey, value);
_prefs.setString(kNetworkServerStoreKey, value);
context.showSnackbar('settingsNetworkServerSaved'.tr());
setState(() {});
},
@ -191,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
horizontal: 16,
vertical: 5,
),
height: 40,
height: 56,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
@ -208,13 +326,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Symbols.chevron_right),
onTap: () {
_serverUrlController.text = kNetworkServerDefault;
_prefs?.remove(kNetworkServerStoreKey);
_prefs.remove(kNetworkServerStoreKey);
context.showSnackbar('settingsNetworkServerSaved'.tr());
setState(() {});
},
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsPerformance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
ListTile(
title: Text('settingsImageQuality').tr(),
subtitle: Text('settingsImageQualityDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.image),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<FilterQuality>(
value: kImageQualityLevel.values.elementAtOrNull(_prefs.getInt('app_image_quality') ?? 3) ??
FilterQuality.high,
isExpanded: true,
items: kImageQualityLevel.entries
.map(
(item) => DropdownMenuItem<FilterQuality>(
value: item.value,
child: Text(item.key).tr().fontSize(14),
),
)
.toList(),
onChanged: (FilterQuality? value) {
if (value == null) return;
_prefs.setInt('app_image_quality', kImageQualityLevel.values.toList().indexOf(value));
setState(() {});
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 60,
),
),
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -231,7 +392,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
].expand((ele) => [ele, const Gap(16)]).toList(),
],
).padding(vertical: 20),
),
);

View File

@ -1,16 +1,16 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/widgets/dialog.dart';
class AppSharingListener extends StatefulWidget {
final Widget child;
@ -103,8 +103,10 @@ class _AppSharingListenerState extends State<AppSharingListener> {
@override
void initState() {
super.initState();
_initialize();
_initialHandle();
if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
_initialize();
_initialHandle();
}
}
@override

View File

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

View File

@ -1,15 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'attachment.freezed.dart';
part 'attachment.g.dart';
enum SnMediaType {
image,
video,
audio,
file,
}
@freezed
class SnAttachment with _$SnAttachment {
const SnAttachment._();
const factory SnAttachment({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required DateTime? deletedAt,
required String rid,
required String uuid,
required int size,
@ -19,22 +29,70 @@ class SnAttachment with _$SnAttachment {
required String hash,
required int destination,
required int refCount,
required dynamic fileChunks,
required dynamic cleanedAt,
required bool isMature,
@Default(0) int contentRating,
@Default(0) int qualityRating,
required DateTime? cleanedAt,
required bool isAnalyzed,
required bool isUploaded,
required bool isSelfRef,
required dynamic ref,
required dynamic refId,
required bool isIndexable,
required SnAttachment? ref,
required int? refId,
required SnAttachmentPool? pool,
required int poolId,
required int? poolId,
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,
}) = _SnAttachment;
factory SnAttachment.fromJson(Map<String, Object?> json) =>
_$SnAttachmentFromJson(json);
factory SnAttachment.fromJson(Map<String, Object?> 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
@ -51,6 +109,35 @@ class SnAttachmentPool with _$SnAttachmentPool {
required int? accountId,
}) = _SnAttachmentPool;
factory SnAttachmentPool.fromJson(Map<String, Object?> json) =>
_$SnAttachmentPoolFromJson(json);
factory SnAttachmentPool.fromJson(Map<String, Object?> 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(),
createdAt: DateTime.parse(json['created_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,
uuid: json['uuid'] as String,
size: (json['size'] as num).toInt(),
@ -21,19 +23,37 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
hash: json['hash'] as String,
destination: (json['destination'] as num).toInt(),
refCount: (json['ref_count'] as num).toInt(),
fileChunks: json['file_chunks'],
cleanedAt: json['cleaned_at'],
isMature: json['is_mature'] as bool,
contentRating: (json['content_rating'] as num?)?.toInt() ?? 0,
qualityRating: (json['quality_rating'] as num?)?.toInt() ?? 0,
cleanedAt: json['cleaned_at'] == null
? null
: DateTime.parse(json['cleaned_at'] as String),
isAnalyzed: json['is_analyzed'] as bool,
isUploaded: json['is_uploaded'] as bool,
isSelfRef: json['is_self_ref'] as bool,
ref: json['ref'],
refId: json['ref_id'],
isIndexable: json['is_indexable'] as bool,
ref: json['ref'] == null
? null
: SnAttachment.fromJson(json['ref'] as Map<String, dynamic>),
refId: (json['ref_id'] as num?)?.toInt(),
pool: json['pool'] == null
? null
: 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(),
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 {},
);
@ -42,7 +62,7 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'deleted_at': instance.deletedAt?.toIso8601String(),
'rid': instance.rid,
'uuid': instance.uuid,
'size': instance.size,
@ -52,20 +72,72 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
'hash': instance.hash,
'destination': instance.destination,
'ref_count': instance.refCount,
'file_chunks': instance.fileChunks,
'cleaned_at': instance.cleanedAt,
'is_mature': instance.isMature,
'content_rating': instance.contentRating,
'quality_rating': instance.qualityRating,
'cleaned_at': instance.cleanedAt?.toIso8601String(),
'is_analyzed': instance.isAnalyzed,
'is_uploaded': instance.isUploaded,
'is_self_ref': instance.isSelfRef,
'ref': instance.ref,
'is_indexable': instance.isIndexable,
'ref': instance.ref?.toJson(),
'ref_id': instance.refId,
'pool': instance.pool?.toJson(),
'pool_id': instance.poolId,
'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,
};
_$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(
Map<String, dynamic> json) =>
_$SnAttachmentPoolImpl(
@ -95,3 +167,54 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
'config': instance.config,
'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.g.dart';
const List<String> kCheckInResultTierSymbols = ['大凶', '', '中平', '', '大吉'];
@freezed
class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._();
@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord {
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
String get symbol => switch (resultTier) {
0 => '大凶',
1 => '',
2 => '中平',
3 => '',
_ => '大吉',
};
String get symbol => kCheckInResultTierSymbols[resultTier];
}

View File

@ -19,7 +19,7 @@ class SnPost with _$SnPost {
required String? alias,
required String? aliasPrefix,
@Default([]) List<SnPostTag> tags,
@Default([]) List<dynamic> categories,
@Default([]) List<SnPostCategory> categories,
required List<SnPost>? replies,
required int? replyId,
required int? repostId,
@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag {
_$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
class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({

View File

@ -30,7 +30,7 @@ mixin _$SnPost {
String? get alias => throw _privateConstructorUsedError;
String? get aliasPrefix => 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;
int? get replyId => throw _privateConstructorUsedError;
int? get repostId => throw _privateConstructorUsedError;
@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> {
String? alias,
String? aliasPrefix,
List<SnPostTag> tags,
List<dynamic> categories,
List<SnPostCategory> categories,
List<SnPost>? replies,
int? replyId,
int? repostId,
@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
categories: null == categories
? _value.categories
: categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
as List<SnPostCategory>,
replies: freezed == replies
? _value.replies
: replies // ignore: cast_nullable_to_non_nullable
@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String? alias,
String? aliasPrefix,
List<SnPostTag> tags,
List<dynamic> categories,
List<SnPostCategory> categories,
List<SnPost>? replies,
int? replyId,
int? repostId,
@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
categories: null == categories
? _value._categories
: categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
as List<SnPostCategory>,
replies: freezed == replies
? _value._replies
: replies // ignore: cast_nullable_to_non_nullable
@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost {
required this.alias,
required this.aliasPrefix,
final List<SnPostTag> tags = const [],
final List<dynamic> categories = const [],
final List<SnPostCategory> categories = const [],
required final List<SnPost>? replies,
required this.replyId,
required this.repostId,
@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost {
return EqualUnmodifiableListView(_tags);
}
final List<dynamic> _categories;
final List<SnPostCategory> _categories;
@override
@JsonKey()
List<dynamic> get categories {
List<SnPostCategory> get categories {
if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_categories);
@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost {
required final String? alias,
required final String? aliasPrefix,
final List<SnPostTag> tags,
final List<dynamic> categories,
final List<SnPostCategory> categories,
required final List<SnPost>? replies,
required final int? replyId,
required final int? repostId,
@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost {
@override
List<SnPostTag> get tags;
@override
List<dynamic> get categories;
List<SnPostCategory> get categories;
@override
List<SnPost>? get replies;
@override
@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag {
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) {
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>))
.toList() ??
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>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList(),
@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'alias': instance.alias,
'alias_prefix': instance.aliasPrefix,
'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(),
'reply_id': instance.replyId,
'repost_id': instance.repostId,
@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
'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(
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> _selectedUsers = List.empty(growable: true);
int _accountId = 0;
final int _accountId = 0;
Future<void> _revertSelectedUsers() async {
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:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
@ -18,8 +20,11 @@ import 'package:uuid/uuid.dart';
class AttachmentItem extends StatelessWidget {
final SnAttachment? data;
final String? heroTag;
final BoxFit fit;
const AttachmentItem({
super.key,
this.fit = BoxFit.cover,
required this.data,
required this.heroTag,
});
@ -40,7 +45,7 @@ class AttachmentItem extends StatelessWidget {
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'),
fit: BoxFit.cover,
fit: fit,
),
);
case 'video':
@ -60,10 +65,13 @@ class AttachmentItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (data!.isMature) {
return _AttachmentItemSensitiveBlur(
child: _buildContent(context),
);
if (data!.contentRating > 0) {
return LayoutBuilder(builder: (context, constraints) {
return _AttachmentItemSensitiveBlur(
isCompact: constraints.maxHeight < 360,
child: _buildContent(context),
);
});
}
return _buildContent(context);
@ -72,15 +80,15 @@ class AttachmentItem extends StatelessWidget {
class _AttachmentItemSensitiveBlur extends StatefulWidget {
final Widget child;
const _AttachmentItemSensitiveBlur({super.key, required this.child});
final bool isCompact;
const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false});
@override
State<_AttachmentItemSensitiveBlur> createState() =>
_AttachmentItemSensitiveBlurState();
State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState();
}
class _AttachmentItemSensitiveBlurState
extends State<_AttachmentItemSensitiveBlur> {
class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> {
bool _doesShow = false;
@override
@ -104,24 +112,21 @@ class _AttachmentItemSensitiveBlurState
color: Colors.white,
size: 32,
),
const Gap(8),
Text('sensitiveContent', textAlign: TextAlign.center)
.tr()
.fontSize(20)
.textColor(Colors.white)
.bold(),
Text(
'sensitiveContentDescription',
textAlign: TextAlign.center,
)
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
InkWell(
child: Text('sensitiveContentReveal')
if (!widget.isCompact) const Gap(8),
if (!widget.isCompact)
Text('sensitiveContent', textAlign: TextAlign.center)
.tr()
.textColor(Colors.white),
.fontSize(20)
.textColor(Colors.white)
.bold(),
if (!widget.isCompact)
Text(
'sensitiveContentDescription',
textAlign: TextAlign.center,
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
if (!widget.isCompact) const Gap(16),
InkWell(
child: Text('sensitiveContentReveal').tr().textColor(Colors.white),
onTap: () {
setState(() => _doesShow = !_doesShow);
},
@ -131,9 +136,7 @@ class _AttachmentItemSensitiveBlurState
).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)
Positioned(
top: 0,
@ -163,20 +166,19 @@ class _AttachmentItemSensitiveBlurState
class _AttachmentItemContentVideo extends StatefulWidget {
final SnAttachment data;
final bool isAutoload;
const _AttachmentItemContentVideo({
super.key,
required this.data,
this.isAutoload = false,
});
@override
State<_AttachmentItemContentVideo> createState() =>
_AttachmentItemContentVideoState();
State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState();
}
class _AttachmentItemContentVideoState
extends State<_AttachmentItemContentVideo> {
class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> {
bool _showContent = false;
bool _showOriginal = false;
Player? _videoPlayer;
VideoController? _videoController;
@ -185,15 +187,29 @@ class _AttachmentItemContentVideoState
setState(() => _showContent = true);
MediaKit.ensureInitialized();
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();
_videoController = VideoController(_videoPlayer!);
_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
void initState() {
super.initState();
_showOriginal = widget.data.compressedId == null;
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>();
@ -216,9 +232,9 @@ class _AttachmentItemContentVideoState
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
if (widget.data.thumbnail != null)
AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
sn.getAttachmentUrl(widget.data.thumbnail!.rid),
fit: BoxFit.cover,
)
else
@ -266,10 +282,7 @@ class _AttachmentItemContentVideoState
),
Text(
Duration(
milliseconds:
(widget.data.metadata['duration'] ?? 0)
.toInt() *
1000,
milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000,
).toString(),
style: GoogleFonts.robotoMono(
fontSize: 12,
@ -301,9 +314,48 @@ class _AttachmentItemContentVideoState
);
}
return Video(
controller: _videoController!,
aspectRatio: ratio,
return MaterialDesktopVideoControlsTheme(
key: Key('material-desktop-video-controls-theme-$_showOriginal'),
normal: MaterialDesktopVideoControlsThemeData(
buttonBarButtonSize: 24,
buttonBarButtonColor: Colors.white,
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 12, vertical: 2),
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
iconSize: 24,
onPressed: _toggleOriginal,
icon: Icon(
_showOriginal ? Symbols.high_quality : Symbols.sd,
size: 24,
),
),
],
),
fullscreen: const MaterialDesktopVideoControlsThemeData(),
child: MaterialVideoControlsTheme(
key: Key('material-video-controls-theme-$_showOriginal'),
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!,
aspectRatio: ratio,
controls:
!kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls,
),
),
);
}
@ -317,19 +369,17 @@ class _AttachmentItemContentVideoState
class _AttachmentItemContentAudio extends StatefulWidget {
final SnAttachment data;
final bool isAutoload;
const _AttachmentItemContentAudio({
super.key,
required this.data,
this.isAutoload = false,
});
@override
State<_AttachmentItemContentAudio> createState() =>
_AttachmentItemContentAudioState();
State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState();
}
class _AttachmentItemContentAudioState
extends State<_AttachmentItemContentAudio> {
class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> {
bool _showContent = false;
double? _draggingValue;
@ -378,11 +428,11 @@ class _AttachmentItemContentAudioState
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
if (widget.data.thumbnail != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
sn.getAttachmentUrl(widget.data.data['thumbnail']),
fit: BoxFit.cover,
),
)
@ -463,11 +513,11 @@ class _AttachmentItemContentAudioState
return Stack(
children: [
if (widget.data.metadata['thumbnail'] != null)
if (widget.data.data['thumbnail'] != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
sn.getAttachmentUrl(widget.data.data['thumbnail']),
fit: BoxFit.cover,
),
),
@ -499,12 +549,8 @@ class _AttachmentItemContentAudioState
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
secondaryTrackValue: _bufferedPosition
.inMilliseconds
.abs()
.toDouble(),
value: _draggingValue?.abs() ??
_position.inMilliseconds.toDouble().abs(),
secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(),
value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(),
min: 0,
max: math
.max(
@ -544,9 +590,7 @@ class _AttachmentItemContentAudioState
),
const Gap(16),
IconButton.filled(
icon: _isPlaying
? const Icon(Symbols.pause)
: const Icon(Symbols.play_arrow),
icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow),
onPressed: () {
_audioPlayer!.playOrPause();
},

View File

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

View File

@ -0,0 +1,87 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/widgets/dialog.dart';
class PendingAttachmentAltDialog extends StatefulWidget {
final PostWriteMedia media;
const PendingAttachmentAltDialog({super.key, required this.media});
@override
State<PendingAttachmentAltDialog> createState() => _PendingAttachmentAltDialogState();
}
class _PendingAttachmentAltDialogState extends State<PendingAttachmentAltDialog> {
final _contentController = TextEditingController();
@override
void initState() {
super.initState();
_contentController.text = widget.media.attachment!.alt;
}
bool _isBusy = false;
Future<void> _performAction() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final attach = context.read<SnAttachmentProvider>();
final result = await attach.updateOne(
widget.media.attachment!,
alt: _contentController.text,
);
if (!mounted) return;
attach.putCache([result]);
Navigator.pop(context, result);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
setState(() => _isBusy = false);
}
}
@override
void dispose() {
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('attachmentSetAlt').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _contentController,
decoration: InputDecoration(
labelText: 'fieldAttachmentAlt'.tr(),
border: const UnderlineInputBorder(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
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,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

@ -1,4 +1,4 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
@ -130,7 +130,7 @@ class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
Text('callCamera').tr(),
Switch(
value: call.enableVideo,
onChanged: (value) => call.setEnableAudio(value),
onChanged: call.setEnableVideo,
),
],
).padding(bottom: 5),

View File

@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:swipe_to/swipe_to.dart';
@ -52,8 +53,8 @@ class ChatMessage extends StatelessWidget {
iconOnRightSwipe: Symbols.edit,
swipeSensitivity: 20,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null,
child: ContextMenuRegion(
onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
child: ContextMenuArea(
contextMenu: ContextMenu(
entries: [
MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])),
@ -102,18 +103,17 @@ class ChatMessage extends StatelessWidget {
children: [
if (!isMerged)
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isCompact)
AccountImage(
content: user?.avatar,
radius: 12,
).padding(right: 6),
).padding(right: 8),
Text(
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
).bold(),
const Gap(6),
const Gap(8),
Text(
dateFormatter.format(data.createdAt.toLocal()),
).fontSize(13),
@ -142,7 +142,7 @@ class ChatMessage extends StatelessWidget {
onEdit: onEdit,
onDelete: onDelete,
),
)).padding(bottom: 4, top: isMerged ? 4 : 2),
)).padding(bottom: 4, top: 4),
switch (data.type) {
'messages.new' => _ChatMessageText(data: data),
_ => _ChatMessageSystemNotify(data: data),
@ -152,15 +152,17 @@ class ChatMessage extends StatelessWidget {
)
],
).opacity(isPending ? 0.5 : 1),
if (data.body['text'] != null && (data.body['text']?.isNotEmpty ?? false))
if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false))
LinkPreviewWidget(text: data.body['text']!),
if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList(
data: data.preload!.attachments!,
bordered: true,
gridded: true,
noGrow: true,
maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8),
maxHeight: 560,
minWidth: 480,
padding: const EdgeInsets.only(top: 8),
),
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
],
@ -173,7 +175,7 @@ class ChatMessage extends StatelessWidget {
class _ChatMessageText extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageText({super.key, required this.data});
const _ChatMessageText({required this.data});
@override
Widget build(BuildContext context) {
@ -213,7 +215,7 @@ class _ChatMessageText extends StatelessWidget {
class _ChatMessageSystemNotify extends StatelessWidget {
final SnChatMessage data;
const _ChatMessageSystemNotify({super.key, required this.data});
const _ChatMessageSystemNotify({required this.data});
String _formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : '';

View File

@ -1,18 +1,14 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.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:pasteboard/pasteboard.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
@ -80,13 +76,14 @@ class ChatMessageInputState extends State<ChatMessageInput> {
media.name,
'messaging',
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(
media.toFile()!,
place.$1,
place.$2,
analyzeNow: media.type == SnMediaType.image,
onProgress: (progress) {
// Calculate overall progress for attachments
setState(() {
@ -123,40 +120,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
}
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
void dispose() {
@ -198,75 +161,84 @@ class ChatMessageInputState extends State<ChatMessageInput> {
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
padding: _replyingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
child: _replyingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.reply),
backgroundColor: Colors.transparent,
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_replyingMessage?.body['text'] != null)
MarkdownTextContent(
content: _replyingMessage?.body['text'],
),
],
child: _replyingMessage != null
? Container(
padding: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
actions: [
TextButton(
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.reply, size: 20),
const Gap(8),
Expanded(
child: Text(
_replyingMessage?.body['text'] ?? '${_replyingMessage?.sender.nick}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Gap(16),
InkWell(
child: Text('cancel'.tr()),
onPressed: () {
onTap: () {
setState(() => _replyingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
).padding(vertical: 8),
)
: const SizedBox.shrink(),
)
.height(_replyingMessage != null ? 54 + 8 : 0, animate: true)
.height(_replyingMessage != null ? 38 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
padding: _editingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
child: _editingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.edit),
backgroundColor: Colors.transparent,
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_editingMessage?.body['text'] != null)
MarkdownTextContent(
content: _editingMessage?.body['text'],
),
],
child: _editingMessage != null
? Container(
padding: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
actions: [
TextButton(
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.edit, size: 20),
const Gap(8),
Expanded(
child: Text(
_editingMessage?.body['text'] ?? '${_editingMessage?.sender.nick}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Gap(16),
InkWell(
child: Text('cancel'.tr()),
onPressed: () {
onTap: () {
_contentController.clear();
setState(() => _editingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
).padding(vertical: 8),
)
: const SizedBox.shrink(),
)
.height(_editingMessage != null ? 54 + 8 : 0, animate: true)
.height(_editingMessage != null ? 38 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SizedBox(
height: 56,
@ -294,63 +266,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
),
),
const Gap(8),
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.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
AddPostMediaButton(
onAdd: (items) {
setState(() {
_attachments.addAll(items);
});
},
),
IconButton(
onPressed: _isBusy ? null : _sendMessage,

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;
const _LinkPreviewEntry({
super.key,
required this.meta,
});

View File

@ -1,7 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';

View File

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

View File

@ -6,15 +6,28 @@ import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_context_menu/flutter_context_menu.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:pasteboard/pasteboard.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_attachment.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/pending_attachment_alt.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/universal_image.dart';
import '../attachment/pending_attachment_compress.dart';
class PostMediaPendingList extends StatelessWidget {
final PostWriteMedia? thumbnail;
@ -70,6 +83,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 {
final media = idx == -1 ? thumbnail! : attachments[idx];
if (media.attachment == null) return;
@ -87,9 +131,79 @@ 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);
}
Future<void> _setAlt(BuildContext context, int idx) async {
final result = await showDialog<SnAttachment?>(
context: context,
builder: (context) => PendingAttachmentAltDialog(media: attachments[idx]),
);
if (result == null) return;
onUpdate!(idx, PostWriteMedia(result));
}
ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) {
final canCompressVideo = !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
return ContextMenu(
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: 'attachmentSetAlt'.tr(),
icon: Symbols.description,
onSelected: () {
_setAlt(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)
MenuItem(
label: 'attachmentUpload'.tr(),
@ -97,7 +211,7 @@ class PostMediaPendingList extends StatelessWidget {
onSelected: () {
onUpload!(idx);
}),
if (media.attachment != null && onPostSetThumbnail != null && idx != -1)
if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null && idx != -1)
MenuItem(
label: 'attachmentSetAsPostThumbnail'.tr(),
icon: Symbols.gallery_thumbnail,
@ -105,7 +219,7 @@ class PostMediaPendingList extends StatelessWidget {
onPostSetThumbnail!(idx);
},
)
else if (media.attachment != null && onPostSetThumbnail != null)
else if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null)
MenuItem(
label: 'attachmentUnsetAsPostThumbnail'.tr(),
icon: Symbols.cancel,
@ -121,7 +235,7 @@ class PostMediaPendingList extends StatelessWidget {
onInsertLink!(idx);
},
),
if (media.type == PostWriteMediaType.image && media.attachment != null)
if (media.type == SnMediaType.image && media.attachment != null)
MenuItem(
label: 'preview'.tr(),
icon: Symbols.preview,
@ -132,12 +246,20 @@ class PostMediaPendingList extends StatelessWidget {
);
},
),
if (media.type == PostWriteMediaType.image && media.attachment == null)
if (media.type == SnMediaType.image && media.attachment == null)
MenuItem(
label: 'crop'.tr(),
icon: Symbols.crop,
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)
MenuItem(
label: 'delete'.tr(),
@ -166,47 +288,15 @@ class PostMediaPendingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
constraints: const BoxConstraints(maxHeight: 120),
child: Row(
children: [
const Gap(8),
if (thumbnail != null)
ContextMenuRegion(
contextMenu: _buildContextMenu(context, -1, thumbnail!),
child: Container(
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(),
),
},
),
),
),
ContextMenuArea(
contextMenu: _createContextMenu(context, -1, thumbnail!),
child: _PostMediaPendingItem(media: thumbnail!),
),
if (thumbnail != null)
const VerticalDivider(width: 1, thickness: 1).padding(
@ -221,39 +311,9 @@ class PostMediaPendingList extends StatelessWidget {
itemCount: attachments.length,
itemBuilder: (context, idx) {
final media = attachments[idx];
return ContextMenuRegion(
contextMenu: _buildContextMenu(context, idx, media),
child: Container(
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(),
),
},
),
),
),
return ContextMenuArea(
contextMenu: _createContextMenu(context, idx, media),
child: _PostMediaPendingItem(media: media),
);
},
),
@ -263,3 +323,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,155 +83,178 @@ class PostMetaEditor extends StatelessWidget {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Column(
children: [
TextField(
controller: controller.titleController,
decoration: InputDecoration(
labelText: 'fieldPostTitle'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
if (controller.mode == 'articles') const Gap(4),
if (controller.mode == 'articles')
return SingleChildScrollView(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
child: Column(
children: [
TextField(
controller: controller.descriptionController,
maxLines: null,
controller: controller.titleController,
decoration: InputDecoration(
labelText: 'fieldPostDescription'.tr(),
labelText: 'fieldPostTitle'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
const Gap(4),
PostTagsField(
initialTags: controller.tags,
labelText: 'fieldPostTags'.tr(),
onUpdate: (value) {
controller.setTags(value);
},
).padding(horizontal: 24),
const Gap(12),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.visibility),
title: Text('postVisibility').tr(),
subtitle: Text('postVisibilityDescription').tr(),
trailing: SizedBox(
width: 180,
child: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: kPostVisibilityLevel.entries
.map(
(entry) => DropdownMenuItem<int>(
value: entry.key,
child: Text(
entry.value,
style: const TextStyle(fontSize: 14),
).tr(),
),
)
.toList(),
value: controller.visibility,
onChanged: (int? value) {
if (value != null) {
controller.setVisibility(value);
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
if (controller.mode == 'articles') const Gap(4),
if (controller.mode == 'articles')
TextField(
controller: controller.descriptionController,
maxLines: null,
decoration: InputDecoration(
labelText: 'fieldPostDescription'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
const Gap(4),
PostTagsField(
initialTags: controller.tags,
labelText: 'fieldPostTags'.tr(),
onUpdate: (value) {
controller.setTags(value);
},
).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),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.visibility),
title: Text('postVisibility').tr(),
subtitle: Text('postVisibilityDescription').tr(),
trailing: SizedBox(
width: 180,
child: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: kPostVisibilityLevel.entries
.map(
(entry) => DropdownMenuItem<int>(
value: entry.key,
child: Text(
entry.value,
style: const TextStyle(fontSize: 14),
).tr(),
),
)
.toList(),
value: controller.visibility,
onChanged: (int? value) {
if (value != null) {
controller.setVisibility(value);
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
),
menuItemStyleData: const MenuItemStyleData(height: 40),
),
menuItemStyleData: const MenuItemStyleData(height: 40),
),
),
),
),
if (controller.visibility == 2)
if (controller.visibility == 2)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postVisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.visibleUsers.length),
onTap: () {
_selectVisibleUser(context);
},
),
if (controller.visibility == 3)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postInvisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.invisibleUsers.length),
onTap: () {
_selectInvisibleUser(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postVisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.visibleUsers.length),
leading: const Icon(Symbols.event_available),
title: Text('postPublishedAt').tr(),
subtitle: Text(
controller.publishedAt != null
? dateFormatter.format(controller.publishedAt!)
: 'unset'.tr(),
),
trailing: controller.publishedAt != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedAt(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectVisibleUser(context);
_selectDate(
context,
initialDateTime: controller.publishedAt,
).then((value) {
controller.setPublishedAt(value);
});
},
),
if (controller.visibility == 3)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postInvisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.invisibleUsers.length),
leading: const Icon(Symbols.event_busy),
title: Text('postPublishedUntil').tr(),
subtitle: Text(
controller.publishedUntil != null
? dateFormatter.format(controller.publishedUntil!)
: 'unset'.tr(),
),
trailing: controller.publishedUntil != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedUntil(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectInvisibleUser(context);
_selectDate(
context,
initialDateTime: controller.publishedUntil,
).then((value) {
controller.setPublishedUntil(value);
});
},
),
ListTile(
leading: const Icon(Symbols.event_available),
title: Text('postPublishedAt').tr(),
subtitle: Text(
controller.publishedAt != null
? dateFormatter.format(controller.publishedAt!)
: 'unset'.tr(),
),
trailing: controller.publishedAt != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedAt(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectDate(
context,
initialDateTime: controller.publishedAt,
).then((value) {
controller.setPublishedAt(value);
});
},
),
ListTile(
leading: const Icon(Symbols.event_busy),
title: Text('postPublishedUntil').tr(),
subtitle: Text(
controller.publishedUntil != null
? dateFormatter.format(controller.publishedUntil!)
: 'unset'.tr(),
),
trailing: controller.publishedUntil != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedUntil(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectDate(
context,
initialDateTime: controller.publishedUntil,
).then((value) {
controller.setPublishedUntil(value);
});
},
),
],
).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:styled_widget/styled_widget.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/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
@ -16,6 +17,7 @@ import 'package:surface/widgets/loading_indicator.dart';
class PostMiniEditor extends StatefulWidget {
final int? postReplyId;
final Function? onPost;
const PostMiniEditor({super.key, this.postReplyId, this.onPost});
@override
@ -26,6 +28,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController();
bool _isFetching = false;
bool get _isLoading => _isFetching || _writeController.isLoading;
List<SnPublisher>? _publishers;
@ -35,11 +38,14 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
try {
final sn = context.read<SnNetworkProvider>();
final config = context.read<ConfigProvider>();
final resp = await sn.client.get('/cgi/co/publishers/me');
_publishers = List<SnPublisher>.from(
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) {
if (!mounted) return;
context.showErrorDialog(err);
@ -93,17 +99,11 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
Text('@${item.name}')
.textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.textStyle(Theme.of(context).textTheme.bodySmall!)
.fontSize(12),
],
),
@ -120,8 +120,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add),
),
const Gap(8),
@ -130,8 +129,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
],
),
),
@ -142,9 +140,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
value: _writeController.publisher,
onChanged: (SnPublisher? value) {
if (value == null) {
GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
if (value == true) {
_publishers = null;
_fetchPublishers();
@ -152,6 +148,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
});
} else {
_writeController.setPublisher(value);
final config = context.read<ConfigProvider>();
config.prefs.setInt('int_last_publisher_id', value.id);
}
},
buttonStyleData: const ButtonStyleData(
@ -178,8 +176,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
),
border: InputBorder.none,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
@ -188,8 +185,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
@ -206,18 +202,16 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
'postEditor',
pathParameters: {'mode': 'stories'},
queryParameters: {
if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
if (widget.postReplyId != null) 'replying': widget.postReplyId.toString(),
},
);
},
),
TextButton.icon(
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
onPressed: (_writeController.isBusy || _writeController.publisher == null)
? null
: () {
_writeController.post(context).then((_) {
_writeController.sendPost(context).then((_) {
if (!context.mounted) return;
if (widget.onPost != null) widget.onPost!();
context.showSnackbar('postPosted'.tr());

View File

@ -1,9 +1,11 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
class PostTagsField extends StatefulWidget {
final List<String>? initialTags;
@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget {
State<PostTagsField> createState() => _PostTagsFieldState();
}
class _PostTagsFieldState extends State<PostTagsField> {
static const List<String> kTagsDividers = [' ', ','];
const List<String> kTagsDividers = [' ', ','];
class _PostTagsFieldState extends State<PostTagsField> {
late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentTags = List.empty(growable: true);
@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> {
color: Theme.of(context).colorScheme.primary,
),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 4.0),
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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);
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {

Some files were not shown because too many files have changed in this diff Show More