Compare commits
	
		
			168 Commits
		
	
	
		
			2.0.0+2
			...
			80a66136ce
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 80a66136ce | |||
| 1f8d47f6c3 | |||
| b750cc3c67 | |||
| b618fcc6da | |||
| f763c7515a | |||
| c7d5cb48ac | |||
| 39470d7dbf | |||
| 4328de21ef | |||
| a3a0e8c7a2 | |||
| 210c73a831 | |||
| edaeae386e | |||
| be66ea354e | |||
| d7c1ffe3cc | |||
| 240ad7dc7e | |||
| bb5fe9c380 | |||
| 1347aacbc5 | |||
| 8880647360 | |||
| 717bccbf3f | |||
| 018441ea0b | |||
| 336bb88ca4 | |||
| 811fc40d79 | |||
| e05209ba3c | |||
| 623095473e | |||
| f47f1b175a | |||
| 3b1d291037 | |||
| 2abc9808e2 | |||
| 41dd7d0b64 | |||
| 20f4e780bc | |||
| da43c940f2 | |||
| a9ca8d36bc | |||
| 1980843ac0 | |||
| 96f6752bbe | |||
| 04b9427cdf | |||
| eab939928f | |||
| d3148ab89d | |||
| f3b7b02e77 | |||
| 687db37daf | |||
| 415446e3bb | |||
| 0afb6b9c5b | |||
| 9f4185dff6 | |||
| 772a33896d | |||
| afc49a7a2a | |||
| 3c621187a7 | |||
| 3f0a7a2227 | |||
| f1dbea190b | |||
| 893b820e24 | |||
| 830da43193 | |||
| c43cca1aae | |||
| 49d1d607ce | |||
| 67feaacf5a | |||
| 45f61533ee | |||
| add904cc41 | |||
| e6a9185d11 | |||
| 669107a99f | |||
| 4805e68fcd | |||
| a693bfdc94 | |||
| be9b3f76d2 | |||
| ed4fcf9944 | |||
| a688e33e33 | |||
| 62d4806b95 | |||
| ed02ba02a8 | |||
| efddaf50f2 | |||
| d4aaf61091 | |||
| fa346b528e | |||
| 4a9ccc7c7a | |||
| 76cf08830b | |||
| 2cbb7fb29e | |||
| c55db308a1 | |||
| 2a837227d5 | |||
| b583780cfc | |||
| 599dd4827b | |||
| 45f489dcb6 | |||
| f16053c475 | |||
| c603b3fcb0 | |||
| d0a4eeb2b2 | |||
| 5dd2e83389 | |||
| aa44a40e59 | |||
| cae4756747 | |||
| 5fc03e48a1 | |||
| 06f2c9ecc2 | |||
| ac06d35c10 | |||
| c5a40702b9 | |||
| 468b7f2c2e | |||
| 273c66f5d5 | |||
| 6d5b690450 | |||
| a70092c6f4 | |||
| 7a617a4f8c | |||
| 441df4090f | |||
| e8384338f8 | |||
| b0790ea145 | |||
| 9588fc0475 | |||
| 177ff513ee | |||
| cf1c4403c1 | |||
| 23c5a1a23e | |||
| 32739821ba | |||
| 000caf4dd2 | |||
| fc025c6bd3 | |||
| db9f4504db | |||
| bb23a12be3 | |||
| a865c4d34b | |||
| 0c2df45337 | |||
| a2a42f66a2 | |||
| 51c7b03ff8 | |||
| ddfbcc5e58 | |||
| 997562d174 | |||
| df6f2af756 | |||
| 041be961c4 | |||
| 36013a3a57 | |||
| dc1ce94145 | |||
| 2261528580 | |||
| 23301764ee | |||
| aa9724102b | |||
| 9395e081f0 | |||
| bd1d6b7be9 | |||
| dabb44635e | |||
| 420588860a | |||
| 312d68286e | |||
| bedffbfad7 | |||
| 6a3cd0a60d | |||
| 356d3d4d3e | |||
| 41e2b08bcc | |||
| 731ab97209 | |||
| a59de65130 | |||
| 9b6544df46 | |||
| 7221af75eb | |||
| 66f41179ba | |||
| ed32a31819 | |||
| 33be7182d8 | |||
| 3cd08da3b6 | |||
| dfd80021b9 | |||
| d64a24454d | |||
| 0ed8c2373d | |||
| b8a1e5b5c0 | |||
| 5d6a52494e | |||
| 85a1dd3053 | |||
| 63499df99f | |||
| e70041fefa | |||
| 1af90cd9e7 | |||
| b52811d66e | |||
| 7e63611416 | |||
| d41e358c6a | |||
| 9fd30a1994 | |||
| 471d3deec5 | |||
| c7f059b6d7 | |||
| 6af695d74e | |||
| fd272ead37 | |||
| 6c5377d9fa | |||
| ce414d92a2 | |||
| 5032cccf38 | |||
| 9f7a3082cb | |||
| 359cd94532 | |||
| 432705c570 | |||
| 2065350698 | |||
| 285bb42b09 | |||
| e9fbd0c65f | |||
| 835203706d | |||
| 0e208cc320 | |||
| ee2cb0c989 | |||
| 37c61a0406 | |||
| fa73a28324 | |||
| d945b103ca | |||
| 8bc0da5188 | |||
| 2e68d227a0 | |||
| b8245b00b6 | |||
| 462e818078 | |||
| e4582b7d25 | |||
| 00eef6e45a | |||
| 9498d428cd | 
@@ -9,6 +9,13 @@
 | 
				
			|||||||
# packages, and plugins designed to encourage good coding practices.
 | 
					# packages, and plugins designed to encourage good coding practices.
 | 
				
			||||||
include: package:flutter_lints/flutter.yaml
 | 
					include: package:flutter_lints/flutter.yaml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					analyzer:
 | 
				
			||||||
 | 
					  exclude:
 | 
				
			||||||
 | 
					    - "**/*.g.dart"
 | 
				
			||||||
 | 
					    - "**/*.freezed.dart"
 | 
				
			||||||
 | 
					  errors:
 | 
				
			||||||
 | 
					    invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
 | 
				
			||||||
 | 
					
 | 
				
			||||||
linter:
 | 
					linter:
 | 
				
			||||||
  # The lint rules applied to this project can be customized in the
 | 
					  # The lint rules applied to this project can be customized in the
 | 
				
			||||||
  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
 | 
					  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,9 @@
 | 
				
			|||||||
plugins {
 | 
					plugins {
 | 
				
			||||||
    id "com.android.application"
 | 
					    id "com.android.application"
 | 
				
			||||||
 | 
					    // START: FlutterFire Configuration
 | 
				
			||||||
 | 
					    id 'com.google.gms.google-services'
 | 
				
			||||||
 | 
					    id 'com.google.firebase.crashlytics'
 | 
				
			||||||
 | 
					    // END: FlutterFire Configuration
 | 
				
			||||||
    id "kotlin-android"
 | 
					    id "kotlin-android"
 | 
				
			||||||
    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
 | 
					    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
 | 
				
			||||||
    id "dev.flutter.flutter-gradle-plugin"
 | 
					    id "dev.flutter.flutter-gradle-plugin"
 | 
				
			||||||
@@ -8,15 +12,15 @@ plugins {
 | 
				
			|||||||
android {
 | 
					android {
 | 
				
			||||||
    namespace = "dev.solsynth.solian"
 | 
					    namespace = "dev.solsynth.solian"
 | 
				
			||||||
    compileSdk = flutter.compileSdkVersion
 | 
					    compileSdk = flutter.compileSdkVersion
 | 
				
			||||||
    ndkVersion = flutter.ndkVersion
 | 
					    ndkVersion = "27.0.12077973"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    compileOptions {
 | 
					    compileOptions {
 | 
				
			||||||
        sourceCompatibility = JavaVersion.VERSION_1_8
 | 
					      sourceCompatibility JavaVersion.VERSION_17
 | 
				
			||||||
        targetCompatibility = JavaVersion.VERSION_1_8
 | 
					      targetCompatibility JavaVersion.VERSION_17
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    kotlinOptions {
 | 
					    kotlinOptions {
 | 
				
			||||||
        jvmTarget = JavaVersion.VERSION_1_8
 | 
					        jvmTarget = JavaVersion.VERSION_17
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    defaultConfig {
 | 
					    defaultConfig {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										29
									
								
								android/app/google-services.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "project_info": {
 | 
				
			||||||
 | 
					    "project_number": "961776991058",
 | 
				
			||||||
 | 
					    "project_id": "solian-0x001",
 | 
				
			||||||
 | 
					    "storage_bucket": "solian-0x001.firebasestorage.app"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "client": [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "client_info": {
 | 
				
			||||||
 | 
					        "mobilesdk_app_id": "1:961776991058:android:a8d3f7995b0b8e86f4188b",
 | 
				
			||||||
 | 
					        "android_client_info": {
 | 
				
			||||||
 | 
					          "package_name": "dev.solsynth.solian"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "oauth_client": [],
 | 
				
			||||||
 | 
					      "api_key": [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          "current_key": "AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "services": {
 | 
				
			||||||
 | 
					        "appinvite_service": {
 | 
				
			||||||
 | 
					          "other_platform_oauth_client": []
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "configuration_version": "1"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,22 @@
 | 
				
			|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 | 
					<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 | 
				
			||||||
 | 
					    <uses-feature android:name="android.hardware.camera" />
 | 
				
			||||||
 | 
					    <uses-feature android:name="android.hardware.camera.autofocus" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.INTERNET" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.CAMERA" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.RECORD_AUDIO" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <application
 | 
					    <application
 | 
				
			||||||
        android:label="surface"
 | 
					        android:label="Solian"
 | 
				
			||||||
        android:name="${applicationName}"
 | 
					        android:name="${applicationName}"
 | 
				
			||||||
        android:icon="@mipmap/ic_launcher">
 | 
					        android:icon="@mipmap/ic_launcher"
 | 
				
			||||||
 | 
					        android:requestLegacyExternalStorage="true">
 | 
				
			||||||
        <activity
 | 
					        <activity
 | 
				
			||||||
            android:name=".MainActivity"
 | 
					            android:name=".MainActivity"
 | 
				
			||||||
            android:exported="true"
 | 
					            android:exported="true"
 | 
				
			||||||
@@ -17,12 +31,12 @@
 | 
				
			|||||||
                 while the Flutter UI initializes. After that, this theme continues
 | 
					                 while the Flutter UI initializes. After that, this theme continues
 | 
				
			||||||
                 to determine the Window background behind the Flutter UI. -->
 | 
					                 to determine the Window background behind the Flutter UI. -->
 | 
				
			||||||
            <meta-data
 | 
					            <meta-data
 | 
				
			||||||
              android:name="io.flutter.embedding.android.NormalTheme"
 | 
					                android:name="io.flutter.embedding.android.NormalTheme"
 | 
				
			||||||
              android:resource="@style/NormalTheme"
 | 
					                android:resource="@style/NormalTheme"
 | 
				
			||||||
              />
 | 
					            />
 | 
				
			||||||
            <intent-filter>
 | 
					            <intent-filter>
 | 
				
			||||||
                <action android:name="android.intent.action.MAIN"/>
 | 
					                <action android:name="android.intent.action.MAIN" />
 | 
				
			||||||
                <category android:name="android.intent.category.LAUNCHER"/>
 | 
					                <category android:name="android.intent.category.LAUNCHER" />
 | 
				
			||||||
            </intent-filter>
 | 
					            </intent-filter>
 | 
				
			||||||
        </activity>
 | 
					        </activity>
 | 
				
			||||||
        <!-- Don't delete the meta-data below.
 | 
					        <!-- Don't delete the meta-data below.
 | 
				
			||||||
@@ -38,8 +52,8 @@
 | 
				
			|||||||
         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
 | 
					         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
 | 
				
			||||||
    <queries>
 | 
					    <queries>
 | 
				
			||||||
        <intent>
 | 
					        <intent>
 | 
				
			||||||
            <action android:name="android.intent.action.PROCESS_TEXT"/>
 | 
					            <action android:name="android.intent.action.PROCESS_TEXT" />
 | 
				
			||||||
            <data android:mimeType="text/plain"/>
 | 
					            <data android:mimeType="text/plain" />
 | 
				
			||||||
        </intent>
 | 
					        </intent>
 | 
				
			||||||
    </queries>
 | 
					    </queries>
 | 
				
			||||||
</manifest>
 | 
					</manifest>
 | 
				
			||||||
 
 | 
				
			|||||||
| 
		 Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB  | 
@@ -1,6 +1,5 @@
 | 
				
			|||||||
<?xml version="1.0" encoding="utf-8"?>
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
 | 
					<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
 | 
				
			||||||
  <background android:drawable="@color/ic_launcher_background"/>
 | 
					    <background android:drawable="@color/ic_launcher_background"/>
 | 
				
			||||||
  <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
 | 
					    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
 | 
				
			||||||
  <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
 | 
					</adaptive-icon>
 | 
				
			||||||
</adaptive-icon>
 | 
					 | 
				
			||||||
@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
 | 
				
			||||||
 | 
					    <background android:drawable="@color/ic_launcher_background"/>
 | 
				
			||||||
 | 
					    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
 | 
				
			||||||
 | 
					</adaptive-icon>
 | 
				
			||||||
@@ -1,3 +0,0 @@
 | 
				
			|||||||
<?xml version="1.0" encoding="utf-8"?>
 | 
					 | 
				
			||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
 | 
					 | 
				
			||||||
</adaptive-icon>
 | 
					 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 1.5 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 952 B  | 
| 
		 Before Width: | Height: | Size: 3.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 872 B  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 2.1 KiB  | 
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
| 
		 Before Width: | Height: | Size: 1017 B  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 644 B  | 
| 
		 Before Width: | Height: | Size: 2.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 594 B  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.3 KiB  | 
| 
		 Before Width: | Height: | Size: 1.1 KiB  | 
| 
		 Before Width: | Height: | Size: 2.1 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.2 KiB  | 
| 
		 Before Width: | Height: | Size: 4.9 KiB  | 
| 
		 After Width: | Height: | Size: 1.1 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 3.0 KiB  | 
| 
		 Before Width: | Height: | Size: 2.3 KiB  | 
| 
		 Before Width: | Height: | Size: 3.3 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.8 KiB  | 
| 
		 Before Width: | Height: | Size: 7.7 KiB  | 
| 
		 After Width: | Height: | Size: 1.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 4.8 KiB  | 
| 
		 Before Width: | Height: | Size: 3.6 KiB  | 
| 
		 Before Width: | Height: | Size: 4.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 2.5 KiB  | 
| 
		 Before Width: | Height: | Size: 11 KiB  | 
| 
		 After Width: | Height: | Size: 2.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 6.9 KiB  | 
| 
		 Before Width: | Height: | Size: 4.8 KiB  | 
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
 | 
				
			|||||||
distributionPath=wrapper/dists
 | 
					distributionPath=wrapper/dists
 | 
				
			||||||
zipStoreBase=GRADLE_USER_HOME
 | 
					zipStoreBase=GRADLE_USER_HOME
 | 
				
			||||||
zipStorePath=wrapper/dists
 | 
					zipStorePath=wrapper/dists
 | 
				
			||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
 | 
					distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,11 @@ pluginManagement {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
plugins {
 | 
					plugins {
 | 
				
			||||||
    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
 | 
					    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
 | 
				
			||||||
    id "com.android.application" version "8.1.0" apply false
 | 
					    id "com.android.application" version '8.7.2' 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
 | 
				
			||||||
 | 
					    // END: FlutterFire Configuration
 | 
				
			||||||
    id "org.jetbrains.kotlin.android" version "1.8.22" apply false
 | 
					    id "org.jetbrains.kotlin.android" version "1.8.22" apply false
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								assets/icon/icon-w-padding.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 44 KiB  | 
@@ -2,6 +2,7 @@
 | 
				
			|||||||
  "nextVersionAlert": "Heavy Development Alert",
 | 
					  "nextVersionAlert": "Heavy Development Alert",
 | 
				
			||||||
  "nextVersionNotice": "You are using Solian 2.0 Preview, which is the first version of Solian 2.0. The current stable branch (sn.solsynth.dev) is 1.4. This version is still under heavy development, some features may not be stable, and not all features are supported. You can roll back to 1.4.X version via TestFlight, or continue to experience the new version (sn-next.solsynth.dev).",
 | 
					  "nextVersionNotice": "You are using Solian 2.0 Preview, which is the first version of Solian 2.0. The current stable branch (sn.solsynth.dev) is 1.4. This version is still under heavy development, some features may not be stable, and not all features are supported. You can roll back to 1.4.X version via TestFlight, or continue to experience the new version (sn-next.solsynth.dev).",
 | 
				
			||||||
  "screen": "Screen",
 | 
					  "screen": "Screen",
 | 
				
			||||||
 | 
					  "screenAbout": "About",
 | 
				
			||||||
  "screenHome": "Home",
 | 
					  "screenHome": "Home",
 | 
				
			||||||
  "screenExplore": "Explore",
 | 
					  "screenExplore": "Explore",
 | 
				
			||||||
  "screenAccount": "Account",
 | 
					  "screenAccount": "Account",
 | 
				
			||||||
@@ -14,9 +15,18 @@
 | 
				
			|||||||
  "screenAccountPublisherNew": "New Publisher",
 | 
					  "screenAccountPublisherNew": "New Publisher",
 | 
				
			||||||
  "screenAccountPublisherEdit": "Edit Publisher",
 | 
					  "screenAccountPublisherEdit": "Edit Publisher",
 | 
				
			||||||
  "screenAccountProfileEdit": "Edit Profile",
 | 
					  "screenAccountProfileEdit": "Edit Profile",
 | 
				
			||||||
 | 
					  "screenAbuseReport": "Abuse Reports",
 | 
				
			||||||
  "screenSettings": "Settings",
 | 
					  "screenSettings": "Settings",
 | 
				
			||||||
  "screenAlbum": "Album",
 | 
					  "screenAlbum": "Album",
 | 
				
			||||||
  "screenChat": "Chat",
 | 
					  "screenChat": "Chat",
 | 
				
			||||||
 | 
					  "screenChatManage": "Edit Channel",
 | 
				
			||||||
 | 
					  "screenChatNew": "New Channel",
 | 
				
			||||||
 | 
					  "screenRealm": "Realm",
 | 
				
			||||||
 | 
					  "screenRealmManage": "Edit Realm",
 | 
				
			||||||
 | 
					  "screenRealmNew": "New Realm",
 | 
				
			||||||
 | 
					  "screenNotification": "Notification",
 | 
				
			||||||
 | 
					  "screenPostSearch": "Search Posts",
 | 
				
			||||||
 | 
					  "screenFriend": "Friends",
 | 
				
			||||||
  "dialogOkay": "Okay",
 | 
					  "dialogOkay": "Okay",
 | 
				
			||||||
  "dialogCancel": "Cancel",
 | 
					  "dialogCancel": "Cancel",
 | 
				
			||||||
  "dialogConfirm": "Confirm",
 | 
					  "dialogConfirm": "Confirm",
 | 
				
			||||||
@@ -28,10 +38,12 @@
 | 
				
			|||||||
  "errorRequestNotFound": "The resource that you looking for is not found.",
 | 
					  "errorRequestNotFound": "The resource that you looking for is not found.",
 | 
				
			||||||
  "errorRequestConnection": "Network connection error, please check your network or the service status.",
 | 
					  "errorRequestConnection": "Network connection error, please check your network or the service status.",
 | 
				
			||||||
  "errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
 | 
					  "errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
 | 
				
			||||||
 | 
					  "unknown": "Unknown",
 | 
				
			||||||
  "prev": "Previous",
 | 
					  "prev": "Previous",
 | 
				
			||||||
  "next": "Next",
 | 
					  "next": "Next",
 | 
				
			||||||
  "edit": "Edit",
 | 
					  "edit": "Edit",
 | 
				
			||||||
  "apply": "Apply",
 | 
					  "apply": "Apply",
 | 
				
			||||||
 | 
					  "cancel": "Cancel",
 | 
				
			||||||
  "create": "Create",
 | 
					  "create": "Create",
 | 
				
			||||||
  "preview": "Preview",
 | 
					  "preview": "Preview",
 | 
				
			||||||
  "loading": "Loading...",
 | 
					  "loading": "Loading...",
 | 
				
			||||||
@@ -41,18 +53,44 @@
 | 
				
			|||||||
  "compress": "Compress",
 | 
					  "compress": "Compress",
 | 
				
			||||||
  "report": "Report",
 | 
					  "report": "Report",
 | 
				
			||||||
  "repost": "Repost",
 | 
					  "repost": "Repost",
 | 
				
			||||||
 | 
					  "replyPost": "Reply",
 | 
				
			||||||
  "reply": "Reply",
 | 
					  "reply": "Reply",
 | 
				
			||||||
  "unset": "Unset",
 | 
					  "unset": "Unset",
 | 
				
			||||||
  "untitled": "Untitled",
 | 
					  "untitled": "Untitled",
 | 
				
			||||||
  "postDetail": "Post detail",
 | 
					  "postDetail": "Post detail",
 | 
				
			||||||
  "postNoun": "Post",
 | 
					  "postNoun": "Post",
 | 
				
			||||||
 | 
					  "postReadMore": "Read more",
 | 
				
			||||||
 | 
					  "postReadEstimate": "Est read time {}",
 | 
				
			||||||
 | 
					  "postTotalLength": {
 | 
				
			||||||
 | 
					    "zero": "No character",
 | 
				
			||||||
 | 
					    "one": "{} character",
 | 
				
			||||||
 | 
					    "other": "{} characters"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postVisibility": "Visibility",
 | 
				
			||||||
 | 
					  "postVisibilityDescription": "Post visibility determines who can see this post.",
 | 
				
			||||||
 | 
					  "postVisibilityAll": "Everyone",
 | 
				
			||||||
 | 
					  "postVisibilityFriends": "Friends",
 | 
				
			||||||
 | 
					  "postVisibilitySelected": "Selected User",
 | 
				
			||||||
 | 
					  "postVisibilityFiltered": "Unselected User",
 | 
				
			||||||
 | 
					  "postVisibilityNone": "Only Me",
 | 
				
			||||||
 | 
					  "postVisibleUsers": "Visible Users",
 | 
				
			||||||
 | 
					  "postInvisibleUsers": "Invisible Users",
 | 
				
			||||||
 | 
					  "postSelectedUsers": {
 | 
				
			||||||
 | 
					    "zero": "No user",
 | 
				
			||||||
 | 
					    "one": "{} user",
 | 
				
			||||||
 | 
					    "other": "{} users"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "fieldUsername": "Username",
 | 
					  "fieldUsername": "Username",
 | 
				
			||||||
  "fieldNickname": "Nickname",
 | 
					  "fieldNickname": "Nickname",
 | 
				
			||||||
  "fieldEmail": "Email address",
 | 
					  "fieldEmail": "Email address",
 | 
				
			||||||
  "fieldPassword": "Password",
 | 
					  "fieldPassword": "Password",
 | 
				
			||||||
  "fieldDescription": "Description",
 | 
					  "fieldDescription": "Description",
 | 
				
			||||||
 | 
					  "fieldUsernameAlphanumOnly": "Username can only contain alphanumeric characters.",
 | 
				
			||||||
 | 
					  "fieldUsernameLengthLimit": "Username must be between {} and {} characters.",
 | 
				
			||||||
  "fieldUsernameCannotEditHint": "Username cannot be edited after created",
 | 
					  "fieldUsernameCannotEditHint": "Username cannot be edited after created",
 | 
				
			||||||
  "fieldUsernameLookupHint": "You can use username, phone number or email to login",
 | 
					  "fieldUsernameLookupHint": "You can use username, phone number or email to login",
 | 
				
			||||||
 | 
					  "fieldNicknameLengthLimit": "Nickname must be between {} and {} characters.",
 | 
				
			||||||
 | 
					  "fieldEmailAddressMustBeValid": "Email address must be an email address.",
 | 
				
			||||||
  "fieldFirstName": "First name",
 | 
					  "fieldFirstName": "First name",
 | 
				
			||||||
  "fieldLastName": "Last name",
 | 
					  "fieldLastName": "Last name",
 | 
				
			||||||
  "fieldBirthday": "Birthday",
 | 
					  "fieldBirthday": "Birthday",
 | 
				
			||||||
@@ -81,12 +119,26 @@
 | 
				
			|||||||
  "publishersNew": "New Publisher",
 | 
					  "publishersNew": "New Publisher",
 | 
				
			||||||
  "publisherNewSubtitle": "Create a new publisher identity.",
 | 
					  "publisherNewSubtitle": "Create a new publisher identity.",
 | 
				
			||||||
  "publisherSyncWithAccount": "Sync with account",
 | 
					  "publisherSyncWithAccount": "Sync with account",
 | 
				
			||||||
 | 
					  "publisherTotalUpvote": "Upvote",
 | 
				
			||||||
 | 
					  "publisherTotalDownvote": "Downvote",
 | 
				
			||||||
 | 
					  "publisherSocialPoint": "Social Point",
 | 
				
			||||||
 | 
					  "publisherJoinedAt": "Joined at {}",
 | 
				
			||||||
 | 
					  "publisherSocialPointTotal": {
 | 
				
			||||||
 | 
					    "zero": "No social point",
 | 
				
			||||||
 | 
					    "one": "{} social point",
 | 
				
			||||||
 | 
					    "other": "{} social points"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherAffiliatedBy": "Affiliated by {}",
 | 
				
			||||||
 | 
					  "publisherRunBy": "Run by {}",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealm": "Belongs to",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
				
			||||||
  "writePostTypeStory": "Post a story",
 | 
					  "writePostTypeStory": "Post a story",
 | 
				
			||||||
  "writePostTypeArticle": "Write an article",
 | 
					  "writePostTypeArticle": "Write an article",
 | 
				
			||||||
  "fieldPostPublisher": "Post publisher",
 | 
					  "fieldPostPublisher": "Post publisher",
 | 
				
			||||||
  "fieldPostContent": "What happened?!",
 | 
					  "fieldPostContent": "What happened?!",
 | 
				
			||||||
  "fieldPostTitle": "Title",
 | 
					  "fieldPostTitle": "Title",
 | 
				
			||||||
  "fieldPostDescription": "Description",
 | 
					  "fieldPostDescription": "Description",
 | 
				
			||||||
 | 
					  "fieldPostTags": "Tags",
 | 
				
			||||||
  "postPublish": "Publish",
 | 
					  "postPublish": "Publish",
 | 
				
			||||||
  "postPosted": "Post has been posted.",
 | 
					  "postPosted": "Post has been posted.",
 | 
				
			||||||
  "postPublishedAt": "Published At",
 | 
					  "postPublishedAt": "Published At",
 | 
				
			||||||
@@ -96,10 +148,20 @@
 | 
				
			|||||||
  "postRepostingNotice": "You're about to repost a post that posted {}.",
 | 
					  "postRepostingNotice": "You're about to repost a post that posted {}.",
 | 
				
			||||||
  "postReact": "React",
 | 
					  "postReact": "React",
 | 
				
			||||||
  "postReactions": "Reactions of Post",
 | 
					  "postReactions": "Reactions of Post",
 | 
				
			||||||
  "postReactionPoints": {
 | 
					  "postReactionUpvote": {
 | 
				
			||||||
    "zero": "{} pt",
 | 
					    "zero": "0 upvote",
 | 
				
			||||||
    "one": "{} pt",
 | 
					    "one": "{} upvote",
 | 
				
			||||||
    "other": "{} pts"
 | 
					    "other": "{} upvotes"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionDownvote": {
 | 
				
			||||||
 | 
					    "zero": "0 downvote",
 | 
				
			||||||
 | 
					    "one": "{} downvote",
 | 
				
			||||||
 | 
					    "other": "{} downvotes"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionSocialPoint": {
 | 
				
			||||||
 | 
					    "zero": "0 point",
 | 
				
			||||||
 | 
					    "one": "{} point",
 | 
				
			||||||
 | 
					    "other": "{} points"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "postReactCompleted": "Reaction has been added.",
 | 
					  "postReactCompleted": "Reaction has been added.",
 | 
				
			||||||
  "postReactUncompleted": "Reaction has been removed.",
 | 
					  "postReactUncompleted": "Reaction has been removed.",
 | 
				
			||||||
@@ -128,8 +190,260 @@
 | 
				
			|||||||
  "settingsNetworkServerPreset": "Present HyperNet Server",
 | 
					  "settingsNetworkServerPreset": "Present HyperNet Server",
 | 
				
			||||||
  "settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
 | 
					  "settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
 | 
				
			||||||
  "settingsNetworkServerSaved": "Server address saved.",
 | 
					  "settingsNetworkServerSaved": "Server address saved.",
 | 
				
			||||||
 | 
					  "settingsMisc": "Misc",
 | 
				
			||||||
 | 
					  "settingsMiscAbout": "About",
 | 
				
			||||||
 | 
					  "settingsMiscAboutDescription": "View the version information of Solian.",
 | 
				
			||||||
  "sensitiveContent": "Sensitive Content",
 | 
					  "sensitiveContent": "Sensitive Content",
 | 
				
			||||||
  "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
 | 
					  "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
 | 
				
			||||||
  "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
 | 
					  "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
 | 
				
			||||||
  "sensitiveContentReveal": "Reveal"
 | 
					  "sensitiveContentReveal": "Reveal",
 | 
				
			||||||
 | 
					  "serverConnecting": "Connecting to server...",
 | 
				
			||||||
 | 
					  "serverDisconnected": "Lost connection from server",
 | 
				
			||||||
 | 
					  "fieldChatAlias": "Channel Alias",
 | 
				
			||||||
 | 
					  "fieldChatAliasHint": "The unique channel alias within the site, used to represent the channel in URL, leave blank to auto generate. Should be URL-Safe.",
 | 
				
			||||||
 | 
					  "fieldChatName": "Name",
 | 
				
			||||||
 | 
					  "fieldChatDescription": "Description",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealm": "Belongs to",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealmUnset": "Unset Channel Belongs to Realm",
 | 
				
			||||||
 | 
					  "channelEditingNotice": "You are editing channel {}",
 | 
				
			||||||
 | 
					  "channelDeleted": "Chat channel {} has been deleted.",
 | 
				
			||||||
 | 
					  "channelDelete": "Delete channel {}",
 | 
				
			||||||
 | 
					  "channelDeleteDescription": "Are you sure you want to delete this channel? This operation is irreversible, all messages in this channel will be permanently deleted.",
 | 
				
			||||||
 | 
					  "channelDetailPersonalRegion": "Personal",
 | 
				
			||||||
 | 
					  "channelDetailMemberRegion": "Members",
 | 
				
			||||||
 | 
					  "channelMemberManage": "Manage Member",
 | 
				
			||||||
 | 
					  "channelMemberManageDescription": "Manage the existing members of this channel.",
 | 
				
			||||||
 | 
					  "channelMemberAdd": "Add Member",
 | 
				
			||||||
 | 
					  "channelMemberAddDescription": "Add new member to this channel.",
 | 
				
			||||||
 | 
					  "channelMemberAdded": "Channel member has been added.",
 | 
				
			||||||
 | 
					  "fieldMemberRelatedName": "Member name / account ID",
 | 
				
			||||||
 | 
					  "channelDetailAdminRegion": "Administration",
 | 
				
			||||||
 | 
					  "channelEditProfile": "Edit Channel Profile",
 | 
				
			||||||
 | 
					  "channelEdit": "Edit Channel",
 | 
				
			||||||
 | 
					  "channelEditDescription": "Change the basic information of the channel, metadata, etc.",
 | 
				
			||||||
 | 
					  "channelProfileEdit": "Edit Channel Profile",
 | 
				
			||||||
 | 
					  "channelActionDelete": "Delete Channel",
 | 
				
			||||||
 | 
					  "channelActionDeleteDescription": "Delete the entire channel, and also delete messages in the channel.",
 | 
				
			||||||
 | 
					  "channelLeave": "Leave Channel {}",
 | 
				
			||||||
 | 
					  "channelLeaveDescription": "Leave this channel, but the messages in the channel will not be removed.",
 | 
				
			||||||
 | 
					  "channelActionLeave": "Leave Channel",
 | 
				
			||||||
 | 
					  "channelActionLeaveDescription": "Delete your profile in this channel.",
 | 
				
			||||||
 | 
					  "channelNotifyLevel": "Notify Level",
 | 
				
			||||||
 | 
					  "channelNotifyLevelDescription": "Decide to receive how much notifications from this channel.",
 | 
				
			||||||
 | 
					  "channelNotifyLevelAll": "All",
 | 
				
			||||||
 | 
					  "channelNotifyLevelMentioned": "Only Mentioned",
 | 
				
			||||||
 | 
					  "channelNotifyLevelNone": "Muted",
 | 
				
			||||||
 | 
					  "channelNotifyLevelApplie": "Channel notify level has been applied.",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNick": "In-Channel Display Name",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNickHint": "The nickname to display in the channel, leave blank to use the account display name.",
 | 
				
			||||||
 | 
					  "fieldRealmAlias": "Realm Alias",
 | 
				
			||||||
 | 
					  "fieldRealmAliasHint": "The unique realm alias within the site, used to represent the realm in URL, leave blank to auto generate. Should be URL-Safe.",
 | 
				
			||||||
 | 
					  "fieldRealmName": "Name",
 | 
				
			||||||
 | 
					  "fieldRealmDescription": "Description",
 | 
				
			||||||
 | 
					  "realmEditingNotice": "You are editing realm {}",
 | 
				
			||||||
 | 
					  "realmDeleted": "Realm {} has been deleted.",
 | 
				
			||||||
 | 
					  "realmDelete": "Delete realm {}",
 | 
				
			||||||
 | 
					  "realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!",
 | 
				
			||||||
 | 
					  "realmActionDelete": "Delete Realm",
 | 
				
			||||||
 | 
					  "realmActionDeleteDescription": "Delete the realm and all its resources.",
 | 
				
			||||||
 | 
					  "realmEdit": "Edit Realm",
 | 
				
			||||||
 | 
					  "realmEditDescription": "Edit the basic information of the realm, metadata, etc.",
 | 
				
			||||||
 | 
					  "realmMemberAdd": "Add Member",
 | 
				
			||||||
 | 
					  "realmMemberAddDescription": "Add new member to this realm.",
 | 
				
			||||||
 | 
					  "realmMemberAdded": "Realm member has been added.",
 | 
				
			||||||
 | 
					  "fieldChatMessage": "Message in {}",
 | 
				
			||||||
 | 
					  "fieldChatMessageDirect": "Message with {}",
 | 
				
			||||||
 | 
					  "eventResourceTag": "Event {}",
 | 
				
			||||||
 | 
					  "messageDelete": "Delete message {}",
 | 
				
			||||||
 | 
					  "messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.",
 | 
				
			||||||
 | 
					  "messageDeleted": "Message {} has been deleted",
 | 
				
			||||||
 | 
					  "messageEdited": "Message {} has been edited",
 | 
				
			||||||
 | 
					  "messageEditedHint": "Edited",
 | 
				
			||||||
 | 
					  "messageUnsupported": "Unsupported message {}",
 | 
				
			||||||
 | 
					  "messageFileHint": {
 | 
				
			||||||
 | 
					    "zero": "No attachments",
 | 
				
			||||||
 | 
					    "one": "{} attachment",
 | 
				
			||||||
 | 
					    "other": "{} attachments"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "addAttachmentFromAlbum": "Add from album",
 | 
				
			||||||
 | 
					  "addAttachmentFromClipboard": "Paste file",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraPhoto": "Take photo",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraVideo": "Take video",
 | 
				
			||||||
 | 
					  "attachmentPastedImage": "Pasted Image",
 | 
				
			||||||
 | 
					  "attachmentInsertLink": "Insert Link",
 | 
				
			||||||
 | 
					  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
 | 
				
			||||||
 | 
					  "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
 | 
				
			||||||
 | 
					  "attachmentSetThumbnail": "Set thumbnail",
 | 
				
			||||||
 | 
					  "attachmentUpload": "Upload",
 | 
				
			||||||
 | 
					  "notification": "Notification",
 | 
				
			||||||
 | 
					  "notificationUnreadCount": {
 | 
				
			||||||
 | 
					    "zero": "All notifications read",
 | 
				
			||||||
 | 
					    "one": "{} unread notification",
 | 
				
			||||||
 | 
					    "other": "{} unread notifications"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationUnread": "Unread",
 | 
				
			||||||
 | 
					  "notificationRead": "Read",
 | 
				
			||||||
 | 
					  "notificationMarkAllRead": "Mark all notifications as read",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadPrompt": {
 | 
				
			||||||
 | 
					    "zero": "Marked 0 notification as read.",
 | 
				
			||||||
 | 
					    "one": "Marked {} notification as read.",
 | 
				
			||||||
 | 
					    "other": "Marked {} notifications as read."
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationMarkOneReadPrompt": "Marked notification {} as read.",
 | 
				
			||||||
 | 
					  "search": "Search",
 | 
				
			||||||
 | 
					  "postSearchResult": {
 | 
				
			||||||
 | 
					    "zero": "No results",
 | 
				
			||||||
 | 
					    "one": "{} result",
 | 
				
			||||||
 | 
					    "other": "{} results"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postSearchTook": "Took {}",
 | 
				
			||||||
 | 
					  "postDelete": "Delete post {}",
 | 
				
			||||||
 | 
					  "postDeleteDescription": "Are you sure you want to delete this post? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "postDeleted": "Post {} has been deleted.",
 | 
				
			||||||
 | 
					  "call": "Call",
 | 
				
			||||||
 | 
					  "callOngoingNotice": "A call is ongoing",
 | 
				
			||||||
 | 
					  "callJoin": "Join",
 | 
				
			||||||
 | 
					  "callResume": "Resume",
 | 
				
			||||||
 | 
					  "callMicrophone": "Microphone",
 | 
				
			||||||
 | 
					  "callCamera": "Camera",
 | 
				
			||||||
 | 
					  "callMicrophoneDisabled": "Microphone is disabled",
 | 
				
			||||||
 | 
					  "callMicrophoneSelect": "Select a microphone",
 | 
				
			||||||
 | 
					  "callCameraDisabled": "Camera is disabled",
 | 
				
			||||||
 | 
					  "callCameraSelect": "Select a camera",
 | 
				
			||||||
 | 
					  "callDisconnected": "Call has been disconnected",
 | 
				
			||||||
 | 
					  "callEnded": "Call has been ended",
 | 
				
			||||||
 | 
					  "callStatusConnected": "Connected",
 | 
				
			||||||
 | 
					  "callStatusDisconnected": "Disconnected",
 | 
				
			||||||
 | 
					  "callStatusConnecting": "Connecting",
 | 
				
			||||||
 | 
					  "callStatusReconnecting": "Reconnecting",
 | 
				
			||||||
 | 
					  "callDisconnect": "Disconnect",
 | 
				
			||||||
 | 
					  "callDisconnectDescription": "Are you sure you want to disconnect from the call?",
 | 
				
			||||||
 | 
					  "callMicrophoneOff": "Turn off microphone",
 | 
				
			||||||
 | 
					  "callMicrophoneOn": "Turn on microphone",
 | 
				
			||||||
 | 
					  "callCameraOff": "Turn off camera",
 | 
				
			||||||
 | 
					  "callCameraOn": "Turn on camera",
 | 
				
			||||||
 | 
					  "callVideoFlip": "Mirror video",
 | 
				
			||||||
 | 
					  "callSpeakerphoneToggle": "Toggle speakerphone",
 | 
				
			||||||
 | 
					  "callScreenOff": "Turn off screen share",
 | 
				
			||||||
 | 
					  "callScreenOn": "Turn on screen share",
 | 
				
			||||||
 | 
					  "callMessageEnded": "Call lasted {}",
 | 
				
			||||||
 | 
					  "callMessageStarted": "Call started",
 | 
				
			||||||
 | 
					  "dailyCheckIn": "Check In",
 | 
				
			||||||
 | 
					  "dailyCheckInNone": "You haven't checked in today",
 | 
				
			||||||
 | 
					  "dailyCheckAction": "Check in right now!",
 | 
				
			||||||
 | 
					  "dailyCheckDetail": "Can't understand the symbol? Master, help me understand it!",
 | 
				
			||||||
 | 
					  "dailyCheckDetailTitle": "{}'s fortune details",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint": "Good for {}",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint": "Bad for {}",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsPositive": "Everything going to be awesome!",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsNegative": "Everything may be wrong...",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1": "Making friends",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1Description": "Friendship lasts forever",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2": "Drinking",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2Description": "Drinking under the moonlight with an imaginary companion",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3": "Traveling",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3Description": "A journey of a thousand miles begins with a single step",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4": "Exercising",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4Description": "Life lies in movement",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5": "Learning",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5Description": "Knowledge knows no bounds; progress every day",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6": "Planting",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6Description": "Sow hope, reap the future",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1": "Eating",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1Description": "Biting your tongue while eating",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2": "Taking exams",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2Description": "The exam covered what you didn't review",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3": "Catching a bus",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3Description": "Just missed the bus",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4": "Shopping",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4Description": "Bought clothes that don't fit",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5": "Gaming",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6": "Going out",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
 | 
				
			||||||
 | 
					  "happyBirthday": "Happy birthday, {}!",
 | 
				
			||||||
 | 
					  "friendNew": "Add Friend",
 | 
				
			||||||
 | 
					  "friendRequests": "Friend Requests",
 | 
				
			||||||
 | 
					  "friendRequestsDescription": {
 | 
				
			||||||
 | 
					    "zero": "You have no friend request",
 | 
				
			||||||
 | 
					    "one": "You have {} friend request",
 | 
				
			||||||
 | 
					    "other": "You have {} friend requests"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendBlocklist": "Blocklist",
 | 
				
			||||||
 | 
					  "friendBlocklistDescription": {
 | 
				
			||||||
 | 
					    "zero": "You blocked no one",
 | 
				
			||||||
 | 
					    "one": "You blocked {} user",
 | 
				
			||||||
 | 
					    "other": "You blocked {} users"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendStatusPending": "Pending",
 | 
				
			||||||
 | 
					  "friendStatusWaiting": "Waiting",
 | 
				
			||||||
 | 
					  "friendStatusActive": "Friend",
 | 
				
			||||||
 | 
					  "friendStatusBlocked": "Blocked",
 | 
				
			||||||
 | 
					  "friendRequestSent": "Friend request has been sent.",
 | 
				
			||||||
 | 
					  "fieldFriendRelatedName": "Friend name / account ID",
 | 
				
			||||||
 | 
					  "friendBlock": "Block",
 | 
				
			||||||
 | 
					  "friendUnblock": "Unblock",
 | 
				
			||||||
 | 
					  "friendDeleteAction": "Delete",
 | 
				
			||||||
 | 
					  "friendDelete": "Delete relation with {}",
 | 
				
			||||||
 | 
					  "friendDeleteDescription": "Are you sure you want to delete the relation with {}? This operation is irreversible.",
 | 
				
			||||||
 | 
					  "friendRequestAccept": "Accept",
 | 
				
			||||||
 | 
					  "friendRequestDecline": "Decline",
 | 
				
			||||||
 | 
					  "subscribe": "Subscribe",
 | 
				
			||||||
 | 
					  "unsubscribe": "Unsubscribe",
 | 
				
			||||||
 | 
					  "attachmentUploadBy": "Upload by",
 | 
				
			||||||
 | 
					  "attachmentShotOn": "Shot on {}",
 | 
				
			||||||
 | 
					  "accountJoinedAt": "Joined at {}",
 | 
				
			||||||
 | 
					  "accountBirthday": "Born on {}",
 | 
				
			||||||
 | 
					  "accountBadge": "Badge",
 | 
				
			||||||
 | 
					  "badgeCompanyStaff": "Solsynth Staff",
 | 
				
			||||||
 | 
					  "badgeSiteMigration": "Solar Network Native",
 | 
				
			||||||
 | 
					  "accountStatus": "Status",
 | 
				
			||||||
 | 
					  "accountStatusOnline": "Online",
 | 
				
			||||||
 | 
					  "accountStatusOffline": "Offline",
 | 
				
			||||||
 | 
					  "accountStatusLastSeen": "Last seen at {}",
 | 
				
			||||||
 | 
					  "postArticle": "Article on the Solar Network",
 | 
				
			||||||
 | 
					  "postStory": "Story on the Solar Network",
 | 
				
			||||||
 | 
					  "articleWrittenAt": "Written at {}",
 | 
				
			||||||
 | 
					  "articleEditedAt": "Edited at {}",
 | 
				
			||||||
 | 
					  "attachmentSaved": "Saved to album",
 | 
				
			||||||
 | 
					  "attachmentSavedDesktop": "Saved to Downloads folder",
 | 
				
			||||||
 | 
					  "openInAlbum": "Open in album",
 | 
				
			||||||
 | 
					  "postAbuseReport": "Report Post",
 | 
				
			||||||
 | 
					  "postAbuseReportDescription": "Report posts that violate our user agreement and community guidelines to help us improve the content on Solar Network. Please describe how this post violates the relevant rules. Do not include any sensitive information. We will process your report within 24 hours.",
 | 
				
			||||||
 | 
					  "abuseReport": "Abuse Report",
 | 
				
			||||||
 | 
					  "abuseReportDescription": "Report any resources that violate our user agreement and community guidelines to help us improve the content on Solar Network. Please describe the location of the resource (provide resource ID as best as possible) and how this violates the relevant rules. Do not include any sensitive information. We will process your report within 24 hours.",
 | 
				
			||||||
 | 
					  "abuseReportAction": "Submit Abuse Report",
 | 
				
			||||||
 | 
					  "abuseReportActionDescription": "Report abuse usage behavior.",
 | 
				
			||||||
 | 
					  "abuseReportResource": "Resource Location / ID",
 | 
				
			||||||
 | 
					  "abuseReportReason": "Reason",
 | 
				
			||||||
 | 
					  "abuseReportSubmitted": "Report submitted, thank you for your contribution.",
 | 
				
			||||||
 | 
					  "submit": "Submit",
 | 
				
			||||||
 | 
					  "accountDeletion": "Delete Account",
 | 
				
			||||||
 | 
					  "accountDeletionDescription": "Are you sure you want to delete this account? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this account will be permanently deleted. Be careful and think twice!",
 | 
				
			||||||
 | 
					  "accountDeletionActionDescription": "Delete your Solarpass account.",
 | 
				
			||||||
 | 
					  "accountDeletionSubmitted": "Account deletion request has been sent, you can check your inbox and follow the instructions in the email to complete the deletion operation.",
 | 
				
			||||||
 | 
					  "channelNewChannel": "New Channel",
 | 
				
			||||||
 | 
					  "channelNewDirectMessage": "New Direct Message",
 | 
				
			||||||
 | 
					  "channelDirectMessageDescription": "Direct Message with {}",
 | 
				
			||||||
 | 
					  "fieldCannotBeEmpty": "This field cannot be empty.",
 | 
				
			||||||
 | 
					  "termAcceptLink": "View terms",
 | 
				
			||||||
 | 
					  "termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
 | 
				
			||||||
 | 
					  "unauthorized": "Unauthorized",
 | 
				
			||||||
 | 
					  "unauthorizedDescription": "Login to explore the entire Solar Network.",
 | 
				
			||||||
 | 
					  "serviceStatus": "Service Status",
 | 
				
			||||||
 | 
					  "termRelated": "Related Terms",
 | 
				
			||||||
 | 
					  "appDetails": "App Details",
 | 
				
			||||||
 | 
					  "postRecommendation": "Highlight Posts",
 | 
				
			||||||
 | 
					  "publisherBlockHint": "Block {}",
 | 
				
			||||||
 | 
					  "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...",
 | 
				
			||||||
 | 
					  "postImageShareReadMore": "Scan the QR code to read full post",
 | 
				
			||||||
 | 
					  "postImageShareAds": "Explore posts on the Solar Network",
 | 
				
			||||||
 | 
					  "postShare": "Share",
 | 
				
			||||||
 | 
					  "postShareImage": "Share via Image",
 | 
				
			||||||
 | 
					  "appInitializing": "Initializing",
 | 
				
			||||||
 | 
					  "poweredBy": "Powered by {}"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "nextVersionAlert": "高强度开发提示",
 | 
					 | 
				
			||||||
  "nextVersionNotice": "您正在使用的是 Solian 2.0 的抢先体验版本,目前稳定分支(sn.solsynth.dev)版本为 1.4。该版本还在持续的开发中,部分功能可能不稳定,也并非所有功能都支持了。您可以通过 TestFlight 回滚到 1.4.X 或者继续体验新版本(sn-next.solsynth.dev)。",
 | 
					 | 
				
			||||||
  "screen": "页面",
 | 
					  "screen": "页面",
 | 
				
			||||||
 | 
					  "screenAbout": "关于",
 | 
				
			||||||
  "screenHome": "首页",
 | 
					  "screenHome": "首页",
 | 
				
			||||||
  "screenExplore": "探索",
 | 
					  "screenExplore": "探索",
 | 
				
			||||||
  "screenAccount": "您",
 | 
					  "screenAccount": "您",
 | 
				
			||||||
@@ -14,9 +13,18 @@
 | 
				
			|||||||
  "screenAccountPublisherNew": "新建发布者",
 | 
					  "screenAccountPublisherNew": "新建发布者",
 | 
				
			||||||
  "screenAccountPublisherEdit": "编辑发布者",
 | 
					  "screenAccountPublisherEdit": "编辑发布者",
 | 
				
			||||||
  "screenAccountProfileEdit": "编辑资料",
 | 
					  "screenAccountProfileEdit": "编辑资料",
 | 
				
			||||||
 | 
					  "screenAbuseReport": "滥用检举",
 | 
				
			||||||
  "screenSettings": "设置",
 | 
					  "screenSettings": "设置",
 | 
				
			||||||
  "screenAlbum": "相册",
 | 
					  "screenAlbum": "相册",
 | 
				
			||||||
  "screenChat": "聊天",
 | 
					  "screenChat": "聊天",
 | 
				
			||||||
 | 
					  "screenChatManage": "编辑聊天频道",
 | 
				
			||||||
 | 
					  "screenChatNew": "新建聊天频道",
 | 
				
			||||||
 | 
					  "screenRealm": "领域",
 | 
				
			||||||
 | 
					  "screenRealmManage": "编辑领域",
 | 
				
			||||||
 | 
					  "screenRealmNew": "新建领域",
 | 
				
			||||||
 | 
					  "screenNotification": "通知",
 | 
				
			||||||
 | 
					  "screenPostSearch": "搜索帖子",
 | 
				
			||||||
 | 
					  "screenFriend": "好友",
 | 
				
			||||||
  "dialogOkay": "好的",
 | 
					  "dialogOkay": "好的",
 | 
				
			||||||
  "dialogCancel": "取消",
 | 
					  "dialogCancel": "取消",
 | 
				
			||||||
  "dialogConfirm": "确认",
 | 
					  "dialogConfirm": "确认",
 | 
				
			||||||
@@ -27,12 +35,14 @@
 | 
				
			|||||||
  "errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
 | 
					  "errorRequestForbidden": "被禁止的请求,您没有足够的权限去做那件事。",
 | 
				
			||||||
  "errorRequestNotFound": "您正查找的资源无法被找到。",
 | 
					  "errorRequestNotFound": "您正查找的资源无法被找到。",
 | 
				
			||||||
  "errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
 | 
					  "errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
 | 
				
			||||||
  "errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
 | 
					  "errorRequestUnknown": "未知请求错误,您可能想将此对话框截图并发送给我们。",
 | 
				
			||||||
 | 
					  "unknown": "未知",
 | 
				
			||||||
  "loading": "加载中…",
 | 
					  "loading": "加载中…",
 | 
				
			||||||
  "prev": "上一步",
 | 
					  "prev": "上一步",
 | 
				
			||||||
  "next": "下一步",
 | 
					  "next": "下一步",
 | 
				
			||||||
  "edit": "编辑",
 | 
					  "edit": "编辑",
 | 
				
			||||||
  "apply": "应用",
 | 
					  "apply": "应用",
 | 
				
			||||||
 | 
					  "cancel": "取消",
 | 
				
			||||||
  "create": "创建",
 | 
					  "create": "创建",
 | 
				
			||||||
  "preview": "预览",
 | 
					  "preview": "预览",
 | 
				
			||||||
  "delete": "删除",
 | 
					  "delete": "删除",
 | 
				
			||||||
@@ -41,17 +51,29 @@
 | 
				
			|||||||
  "compress": "压缩",
 | 
					  "compress": "压缩",
 | 
				
			||||||
  "report": "检举",
 | 
					  "report": "检举",
 | 
				
			||||||
  "repost": "转帖",
 | 
					  "repost": "转帖",
 | 
				
			||||||
  "reply": "回贴",
 | 
					  "replyPost": "回贴",
 | 
				
			||||||
 | 
					  "reply": "回复",
 | 
				
			||||||
  "unset": "未设置",
 | 
					  "unset": "未设置",
 | 
				
			||||||
  "untitled": "无题",
 | 
					  "untitled": "无题",
 | 
				
			||||||
  "postDetail": "帖子详情",
 | 
					  "postDetail": "帖子详情",
 | 
				
			||||||
  "postNoun": "帖子",
 | 
					  "postNoun": "帖子",
 | 
				
			||||||
 | 
					  "postReadMore": "阅读更多",
 | 
				
			||||||
 | 
					  "postReadEstimate": "预计花费 {} 阅读",
 | 
				
			||||||
 | 
					  "postTotalLength": {
 | 
				
			||||||
 | 
					    "zero": "没有内容",
 | 
				
			||||||
 | 
					    "one": "总计 {} 字",
 | 
				
			||||||
 | 
					    "other": "总计 {} 字"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "fieldUsername": "用户名",
 | 
					  "fieldUsername": "用户名",
 | 
				
			||||||
  "fieldNickname": "显示名",
 | 
					  "fieldNickname": "显示名",
 | 
				
			||||||
  "fieldEmail": "电子邮箱地址",
 | 
					  "fieldEmail": "电子邮箱地址",
 | 
				
			||||||
  "fieldPassword": "密码",
 | 
					  "fieldPassword": "密码",
 | 
				
			||||||
 | 
					  "fieldUsernameAlphanumOnly": "用户名只能包含英文大小写字母和数字。",
 | 
				
			||||||
 | 
					  "fieldUsernameLengthLimit": "用户名必须在 {} 和 {} 之间。",
 | 
				
			||||||
  "fieldUsernameCannotEditHint": "用户名在创建后无法修改",
 | 
					  "fieldUsernameCannotEditHint": "用户名在创建后无法修改",
 | 
				
			||||||
  "fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
 | 
					  "fieldUsernameLookupHint": "支持用户名、电话号码或邮箱地址",
 | 
				
			||||||
 | 
					  "fieldNicknameLengthLimit": "昵称必须在 {} 和 {} 之间。",
 | 
				
			||||||
 | 
					  "fieldEmailAddressMustBeValid": "电子邮箱地址必须是一个电子邮箱地址。",
 | 
				
			||||||
  "fieldFirstName": "名",
 | 
					  "fieldFirstName": "名",
 | 
				
			||||||
  "fieldLastName": "姓",
 | 
					  "fieldLastName": "姓",
 | 
				
			||||||
  "fieldBirthday": "生日",
 | 
					  "fieldBirthday": "生日",
 | 
				
			||||||
@@ -81,25 +103,63 @@
 | 
				
			|||||||
  "publishersNew": "新发布者",
 | 
					  "publishersNew": "新发布者",
 | 
				
			||||||
  "publisherNewSubtitle": "创建一个新的公共身份。",
 | 
					  "publisherNewSubtitle": "创建一个新的公共身份。",
 | 
				
			||||||
  "publisherSyncWithAccount": "同步账户信息",
 | 
					  "publisherSyncWithAccount": "同步账户信息",
 | 
				
			||||||
 | 
					  "publisherTotalUpvote": "总顶数",
 | 
				
			||||||
 | 
					  "publisherTotalDownvote": "总踩数",
 | 
				
			||||||
 | 
					  "publisherSocialPoint": "社会信用点",
 | 
				
			||||||
 | 
					  "publisherJoinedAt": "加入于 {}",
 | 
				
			||||||
 | 
					  "publisherSocialPointTotal": {
 | 
				
			||||||
 | 
					    "zero": "无社会信用点",
 | 
				
			||||||
 | 
					    "one": "{} 点社会信用点",
 | 
				
			||||||
 | 
					    "other": "{} 点社会信用点"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherAffiliatedBy": "隶属于 {}",
 | 
				
			||||||
 | 
					  "publisherRunBy": "由 {} 管理",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealm": "所属领域",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
 | 
				
			||||||
  "writePostTypeStory": "发动态",
 | 
					  "writePostTypeStory": "发动态",
 | 
				
			||||||
  "writePostTypeArticle": "写文章",
 | 
					  "writePostTypeArticle": "写文章",
 | 
				
			||||||
  "fieldPostPublisher": "帖子发布者",
 | 
					  "fieldPostPublisher": "帖子发布者",
 | 
				
			||||||
  "fieldPostContent": "发生什么事了?!",
 | 
					  "fieldPostContent": "发生什么事了?!",
 | 
				
			||||||
  "fieldPostTitle": "标题",
 | 
					  "fieldPostTitle": "标题",
 | 
				
			||||||
  "fieldPostDescription": "描述",
 | 
					  "fieldPostDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldPostTags": "标签",
 | 
				
			||||||
  "postPublish": "发布",
 | 
					  "postPublish": "发布",
 | 
				
			||||||
  "postPublishedAt": "发布于",
 | 
					  "postPublishedAt": "发布于",
 | 
				
			||||||
  "postPublishedUntil": "取消发布于",
 | 
					  "postPublishedUntil": "取消发布于",
 | 
				
			||||||
 | 
					  "postVisibility": "可见性",
 | 
				
			||||||
 | 
					  "postVisibilityDescription": "帖子可见性决定了谁能查看该篇帖子。",
 | 
				
			||||||
 | 
					  "postVisibilityAll": "所有人可见",
 | 
				
			||||||
 | 
					  "postVisibilityFriends": "仅限好友可见",
 | 
				
			||||||
 | 
					  "postVisibilitySelected": "选定的用户可见",
 | 
				
			||||||
 | 
					  "postVisibilityFiltered": "选定用户不可见",
 | 
				
			||||||
 | 
					  "postVisibilityNone": "仅自己可见",
 | 
				
			||||||
 | 
					  "postVisibleUsers": "可见的用户",
 | 
				
			||||||
 | 
					  "postInvisibleUsers": "不可见的用户",
 | 
				
			||||||
 | 
					  "postSelectedUsers": {
 | 
				
			||||||
 | 
					    "zero": "未选择用户",
 | 
				
			||||||
 | 
					    "one": "选择了 {} 个用户",
 | 
				
			||||||
 | 
					    "other": "选择了 {} 个用户"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "postEditingNotice": "你正在修改由 {} 发布的帖子。",
 | 
					  "postEditingNotice": "你正在修改由 {} 发布的帖子。",
 | 
				
			||||||
  "postReplyingNotice": "你正在回复由 {} 发布的帖子。",
 | 
					  "postReplyingNotice": "你正在回复由 {} 发布的帖子。",
 | 
				
			||||||
  "postRepostingNotice": "你正在转发由 {} 发布的帖子。",
 | 
					  "postRepostingNotice": "你正在转发由 {} 发布的帖子。",
 | 
				
			||||||
  "postReact": "反应",
 | 
					  "postReact": "反应",
 | 
				
			||||||
  "postPosted": "帖子已经发表。",
 | 
					  "postPosted": "帖子已经发表。",
 | 
				
			||||||
  "postReactions": "帖子的反应",
 | 
					  "postReactions": "帖子的反应",
 | 
				
			||||||
  "postReactionPoints": {
 | 
					  "postReactionUpvote": {
 | 
				
			||||||
    "zero": "{} 点",
 | 
					    "zero": "0 个顶",
 | 
				
			||||||
    "one": "{} 点",
 | 
					    "one": "{} 个顶",
 | 
				
			||||||
    "other": "{} 点"
 | 
					    "other": "{} 个顶"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionDownvote": {
 | 
				
			||||||
 | 
					    "zero": "0 个踩",
 | 
				
			||||||
 | 
					    "one": "{} 个踩",
 | 
				
			||||||
 | 
					    "other": "{} 个踩"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionSocialPoint": {
 | 
				
			||||||
 | 
					    "zero": "无社会信用点变更",
 | 
				
			||||||
 | 
					    "one": "{} 点社会信用点变更",
 | 
				
			||||||
 | 
					    "other": "{} 点社会信用点变更"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "postReactCompleted": "反应已被添加。",
 | 
					  "postReactCompleted": "反应已被添加。",
 | 
				
			||||||
  "postReactUncompleted": "反应已被移除。",
 | 
					  "postReactUncompleted": "反应已被移除。",
 | 
				
			||||||
@@ -128,8 +188,260 @@
 | 
				
			|||||||
  "settingsNetworkServerPreset": "预设的 HyperNet 服务器",
 | 
					  "settingsNetworkServerPreset": "预设的 HyperNet 服务器",
 | 
				
			||||||
  "settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
 | 
					  "settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
 | 
				
			||||||
  "settingsNetworkServerSaved": "服务器地址已保存。",
 | 
					  "settingsNetworkServerSaved": "服务器地址已保存。",
 | 
				
			||||||
 | 
					  "settingsMisc": "杂项",
 | 
				
			||||||
 | 
					  "settingsMiscAbout": "关于",
 | 
				
			||||||
 | 
					  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
				
			||||||
  "sensitiveContent": "敏感内容",
 | 
					  "sensitiveContent": "敏感内容",
 | 
				
			||||||
  "sensitiveContentCollapsed": "敏感内容已折叠。",
 | 
					  "sensitiveContentCollapsed": "敏感内容已折叠。",
 | 
				
			||||||
  "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
 | 
					  "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
 | 
				
			||||||
  "sensitiveContentReveal": "显示内容"
 | 
					  "sensitiveContentReveal": "显示内容",
 | 
				
			||||||
 | 
					  "serverConnecting": "正在连接服务器…",
 | 
				
			||||||
 | 
					  "serverDisconnected": "已与服务器断开连接",
 | 
				
			||||||
 | 
					  "fieldChatAlias": "频道别名",
 | 
				
			||||||
 | 
					  "fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。",
 | 
				
			||||||
 | 
					  "fieldChatName": "名称",
 | 
				
			||||||
 | 
					  "fieldChatDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealm": "所属领域",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealmUnset": "未设置频道所属领域",
 | 
				
			||||||
 | 
					  "channelEditingNotice": "您正在编辑频道 {}",
 | 
				
			||||||
 | 
					  "channelDeleted": "聊天频道 {} 已被删除",
 | 
				
			||||||
 | 
					  "channelDelete": "删除聊天频道 {}",
 | 
				
			||||||
 | 
					  "channelDeleteDescription": "你确定要删除这个聊天频道吗?该操作不可撤销,其频道内的所有消息将被永久删除。",
 | 
				
			||||||
 | 
					  "channelDetailPersonalRegion": "个人区域",
 | 
				
			||||||
 | 
					  "channelDetailMemberRegion": "成员管理",
 | 
				
			||||||
 | 
					  "channelMemberManage": "管理成员",
 | 
				
			||||||
 | 
					  "channelMemberManageDescription": "管理频道内现有成员。",
 | 
				
			||||||
 | 
					  "channelMemberAdd": "添加成员",
 | 
				
			||||||
 | 
					  "channelMemberAddDescription": "给当前频道添加新成员。",
 | 
				
			||||||
 | 
					  "channelMemberAdded": "频道成员已添加。",
 | 
				
			||||||
 | 
					  "fieldMemberRelatedName": "成员名 / 账户 ID",
 | 
				
			||||||
 | 
					  "channelDetailAdminRegion": "管理区域",
 | 
				
			||||||
 | 
					  "channelEditProfile": "更改频道身份",
 | 
				
			||||||
 | 
					  "channelEdit": "编辑频道",
 | 
				
			||||||
 | 
					  "channelEditDescription": "更改频道基本信息,元数据等。",
 | 
				
			||||||
 | 
					  "channelProfileEdit": "编辑频道身份",
 | 
				
			||||||
 | 
					  "channelActionDelete": "删除频道",
 | 
				
			||||||
 | 
					  "channelActionDeleteDescription": "删除整个频道,并且删除频道里的所有信息。",
 | 
				
			||||||
 | 
					  "channelLeave": "退出频道 {}",
 | 
				
			||||||
 | 
					  "channelLeaveDescription": "退出该频道,但是你频道内的信息不会被移除。",
 | 
				
			||||||
 | 
					  "channelActionLeave": "退出频道",
 | 
				
			||||||
 | 
					  "channelActionLeaveDescription": "删除你在这个频道的身份。",
 | 
				
			||||||
 | 
					  "channelNotifyLevel": "通知级别",
 | 
				
			||||||
 | 
					  "channelNotifyLevelDescription": "有您决定要接受多少来自这个频道的消息。",
 | 
				
			||||||
 | 
					  "channelNotifyLevelAll": "全部通知",
 | 
				
			||||||
 | 
					  "channelNotifyLevelMentioned": "仅提及",
 | 
				
			||||||
 | 
					  "channelNotifyLevelNone": "全部静音",
 | 
				
			||||||
 | 
					  "channelNotifyLevelApplied": "已经保存并应用频道通知级别配置。",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNick": "频道内显示名",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNickHint": "在频道内显示的昵称,留空则使用账号显示名。",
 | 
				
			||||||
 | 
					  "fieldRealmAlias": "领域别名",
 | 
				
			||||||
 | 
					  "fieldRealmAliasHint": "全站范围内唯一的领域别名,用于在 URL 中表示该领域,留空则自动生成。应遵循 URL-Safe 的原则。",
 | 
				
			||||||
 | 
					  "fieldRealmName": "名称",
 | 
				
			||||||
 | 
					  "fieldRealmDescription": "描述",
 | 
				
			||||||
 | 
					  "realmEditingNotice": "您正在编辑领域 {}",
 | 
				
			||||||
 | 
					  "realmDeleted": "领域 {} 已被删除",
 | 
				
			||||||
 | 
					  "realmDelete": "删除领域 {}",
 | 
				
			||||||
 | 
					  "realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
 | 
				
			||||||
 | 
					  "realmActionDelete": "删除领域",
 | 
				
			||||||
 | 
					  "realmActionDeleteDescription": "删除整个领域及其附属的资源。",
 | 
				
			||||||
 | 
					  "realmEdit": "编辑领域",
 | 
				
			||||||
 | 
					  "realmEditDescription": "更改领域基本信息,元数据等。",
 | 
				
			||||||
 | 
					  "realmMemberAdd": "添加成员",
 | 
				
			||||||
 | 
					  "realmMemberAddDescription": "给当前领域添加新成员。",
 | 
				
			||||||
 | 
					  "realmMemberAdded": "领域成员已添加。",
 | 
				
			||||||
 | 
					  "fieldChatMessage": "在 {} 中发消息",
 | 
				
			||||||
 | 
					  "fieldChatMessageDirect": "给 {} 发消息",
 | 
				
			||||||
 | 
					  "eventResourceTag": "消息 {}",
 | 
				
			||||||
 | 
					  "messageDelete": "删除消息 {}",
 | 
				
			||||||
 | 
					  "messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。",
 | 
				
			||||||
 | 
					  "messageDeleted": "消息 {} 已被删除",
 | 
				
			||||||
 | 
					  "messageEdited": "消息 {} 已被编辑",
 | 
				
			||||||
 | 
					  "messageEditedHint": "已编辑",
 | 
				
			||||||
 | 
					  "messageUnsupported": "不支持的消息 {}",
 | 
				
			||||||
 | 
					  "messageFileHint": {
 | 
				
			||||||
 | 
					    "zero": "没有附件",
 | 
				
			||||||
 | 
					    "one": "{} 个附件",
 | 
				
			||||||
 | 
					    "other": "{} 个附件"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "addAttachmentFromAlbum": "从相册中添加附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromClipboard": "粘贴附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraPhoto": "拍摄照片",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraVideo": "拍摄视频",
 | 
				
			||||||
 | 
					  "attachmentPastedImage": "粘贴的图片",
 | 
				
			||||||
 | 
					  "attachmentInsertLink": "插入连接",
 | 
				
			||||||
 | 
					  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
 | 
				
			||||||
 | 
					  "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
 | 
				
			||||||
 | 
					  "attachmentSetThumbnail": "设置缩略图",
 | 
				
			||||||
 | 
					  "attachmentUpload": "上传",
 | 
				
			||||||
 | 
					  "notification": "通知",
 | 
				
			||||||
 | 
					  "notificationUnreadCount": {
 | 
				
			||||||
 | 
					    "zero": "无未读通知",
 | 
				
			||||||
 | 
					    "one": "有 {} 个未读通知",
 | 
				
			||||||
 | 
					    "other": "有 {} 个未读通知"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationUnread": "未读",
 | 
				
			||||||
 | 
					  "notificationRead": "已读",
 | 
				
			||||||
 | 
					  "notificationMarkAllRead": "已读所有通知",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadDescription": "您确定要将所有通知设置为已读吗?该操作不可撤销。",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadPrompt": {
 | 
				
			||||||
 | 
					    "zero": "已将 0 个通知标记为已读。",
 | 
				
			||||||
 | 
					    "one": "已将 {} 个通知标记为已读。",
 | 
				
			||||||
 | 
					    "other": "已将 {} 个通知标记为已读。"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。",
 | 
				
			||||||
 | 
					  "search": "搜索",
 | 
				
			||||||
 | 
					  "postSearchResult": {
 | 
				
			||||||
 | 
					    "zero": "没有搜索到结果",
 | 
				
			||||||
 | 
					    "one": "搜索到 {} 个结果",
 | 
				
			||||||
 | 
					    "other": "搜索到 {} 个结果"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postSearchTook": "耗时 {}",
 | 
				
			||||||
 | 
					  "postDelete": "删除帖子 {}",
 | 
				
			||||||
 | 
					  "postDeleteDescription": "你确定要删除这个帖子吗?该操作不可撤销。",
 | 
				
			||||||
 | 
					  "postDeleted": "帖子 {} 已被删除。",
 | 
				
			||||||
 | 
					  "call": "通话",
 | 
				
			||||||
 | 
					  "callOngoingNotice": "一则通话进行中",
 | 
				
			||||||
 | 
					  "callJoin": "加入",
 | 
				
			||||||
 | 
					  "callResume": "恢复",
 | 
				
			||||||
 | 
					  "callMicrophone": "麦克风",
 | 
				
			||||||
 | 
					  "callCamera": "摄像头",
 | 
				
			||||||
 | 
					  "callMicrophoneDisabled": "麦克风已禁用",
 | 
				
			||||||
 | 
					  "callMicrophoneSelect": "选择麦克风",
 | 
				
			||||||
 | 
					  "callCameraDisabled": "摄像头已禁用",
 | 
				
			||||||
 | 
					  "callCameraSelect": "选择摄像头",
 | 
				
			||||||
 | 
					  "callDisconnected": "通话已断开",
 | 
				
			||||||
 | 
					  "callEnded": "通话已结束",
 | 
				
			||||||
 | 
					  "callStatusConnected": "已连接",
 | 
				
			||||||
 | 
					  "callStatusDisconnected": "未连接",
 | 
				
			||||||
 | 
					  "callStatusConnecting": "正在连接",
 | 
				
			||||||
 | 
					  "callStatusReconnecting": "正在重连",
 | 
				
			||||||
 | 
					  "callDisconnect": "断开连接",
 | 
				
			||||||
 | 
					  "callDisconnectDescription": "您确定要与通话断开连接吗?",
 | 
				
			||||||
 | 
					  "callMicrophoneOff": "关闭麦克风",
 | 
				
			||||||
 | 
					  "callMicrophoneOn": "打开麦克风",
 | 
				
			||||||
 | 
					  "callCameraOff": "关闭摄像头",
 | 
				
			||||||
 | 
					  "callCameraOn": "打开摄像头",
 | 
				
			||||||
 | 
					  "callVideoFlip": "镜像画面",
 | 
				
			||||||
 | 
					  "callSpeakerphoneToggle": "切换扬声器",
 | 
				
			||||||
 | 
					  "callScreenOff": "关闭屏幕共享",
 | 
				
			||||||
 | 
					  "callScreenOn": "开启屏幕共享",
 | 
				
			||||||
 | 
					  "callMessageEnded": "通话持续了 {}",
 | 
				
			||||||
 | 
					  "callMessageStarted": "通话开始了",
 | 
				
			||||||
 | 
					  "dailyCheckIn": "每日签到",
 | 
				
			||||||
 | 
					  "dailyCheckInNone": "今日尚未签到",
 | 
				
			||||||
 | 
					  "dailyCheckAction": "现在签到",
 | 
				
			||||||
 | 
					  "dailyCheckDetail": "看不懂符?大师帮我解惑!",
 | 
				
			||||||
 | 
					  "dailyCheckDetailTitle": "{} 的运势详情",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint": "宜 {}",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint": "忌 {}",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsPositive": "诸事皆宜",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsNegative": "诸事不宜",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1": "交友",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1Description": "友谊地久天长",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2": "饮酒",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2Description": "对影成三人",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3": "旅行",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3Description": "千里之行,始于足下",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4": "运动",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4Description": "生命在于运动",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5": "学习",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5Description": "学无止境,日有所进",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6": "种植",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6Description": "种下希望,收获未来",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1": "吃饭",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1Description": "吃饭咬到舌头",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2": "考试",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2Description": "考的东西刚好没复习",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3": "坐公交",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3Description": "赶车刚好错过一班",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4": "购物",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4Description": "买回来的衣服发现不合适",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5": "打游戏",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5Description": "关键时刻断网",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6": "出门",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
 | 
				
			||||||
 | 
					  "happyBirthday": "生日快乐,{}!",
 | 
				
			||||||
 | 
					  "friendNew": "添加好友",
 | 
				
			||||||
 | 
					  "friendRequests": "好友请求",
 | 
				
			||||||
 | 
					  "friendRequestsDescription": {
 | 
				
			||||||
 | 
					    "zero": "你没有好友请求",
 | 
				
			||||||
 | 
					    "one": "你有 {} 个好友请求",
 | 
				
			||||||
 | 
					    "other": "你有 {} 个好友请求"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendBlocklist": "屏蔽列表",
 | 
				
			||||||
 | 
					  "friendBlocklistDescription": {
 | 
				
			||||||
 | 
					    "zero": "你没有屏蔽任何人",
 | 
				
			||||||
 | 
					    "one": "你屏蔽了 {} 个用户",
 | 
				
			||||||
 | 
					    "other": "你屏蔽了 {} 个用户"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendStatusPending": "待处理",
 | 
				
			||||||
 | 
					  "friendStatusWaiting": "等待中",
 | 
				
			||||||
 | 
					  "friendStatusActive": "正活跃",
 | 
				
			||||||
 | 
					  "friendStatusBlocked": "已屏蔽",
 | 
				
			||||||
 | 
					  "friendRequestSent": "好友请求已发送。",
 | 
				
			||||||
 | 
					  "fieldFriendRelatedName": "好友名 / 账户 ID",
 | 
				
			||||||
 | 
					  "friendBlock": "屏蔽",
 | 
				
			||||||
 | 
					  "friendUnblock": "解除屏蔽",
 | 
				
			||||||
 | 
					  "friendDeleteAction": "遗忘",
 | 
				
			||||||
 | 
					  "friendDelete": "遗忘跟 {} 的关系",
 | 
				
			||||||
 | 
					  "friendDeleteDescription": "你确定要遗忘跟 {} 的关系吗?这个操作无法撤销。",
 | 
				
			||||||
 | 
					  "friendRequestAccept": "接受",
 | 
				
			||||||
 | 
					  "friendRequestDecline": "拒绝",
 | 
				
			||||||
 | 
					  "subscribe": "订阅",
 | 
				
			||||||
 | 
					  "unsubscribe": "取消订阅",
 | 
				
			||||||
 | 
					  "attachmentUploadBy": "上传者",
 | 
				
			||||||
 | 
					  "attachmentShotOn": "由 {} 拍摄",
 | 
				
			||||||
 | 
					  "accountJoinedAt": "加入于 {}",
 | 
				
			||||||
 | 
					  "accountBirthday": "出生于 {}",
 | 
				
			||||||
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
 | 
					  "badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
 | 
				
			||||||
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "accountStatus": "状态",
 | 
				
			||||||
 | 
					  "accountStatusOnline": "在线",
 | 
				
			||||||
 | 
					  "accountStatusOffline": "离线",
 | 
				
			||||||
 | 
					  "accountStatusLastSeen": "最后一次在 {} 上线",
 | 
				
			||||||
 | 
					  "postArticle": "Solar Network 上的文章",
 | 
				
			||||||
 | 
					  "postStory": "Solar Network 上的故事",
 | 
				
			||||||
 | 
					  "articleWrittenAt": "发表于 {}",
 | 
				
			||||||
 | 
					  "articleEditedAt": "编辑于 {}",
 | 
				
			||||||
 | 
					  "attachmentSaved": "已保存到相册",
 | 
				
			||||||
 | 
					  "attachmentSavedDesktop": "已保存到下载目录",
 | 
				
			||||||
 | 
					  "openInAlbum": "在相册中打开",
 | 
				
			||||||
 | 
					  "postAbuseReport": "检举帖子",
 | 
				
			||||||
 | 
					  "postAbuseReportDescription": "检举不符合我们用户协议以及社区准则的帖子,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述该帖子如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。",
 | 
				
			||||||
 | 
					  "abuseReport": "检举",
 | 
				
			||||||
 | 
					  "abuseReportDescription": "检举不符合我们用户协议以及社区准则的任何资源,来帮助我们更好的维护 Solar Network 上的内容。请在下面描述资源的位置(提供资源 ID 为佳)以及如何违反我么的相关规定。请勿填写任何敏感信息。我们将会在 24 小时内处理您的检举。",
 | 
				
			||||||
 | 
					  "abuseReportAction": "提交检举",
 | 
				
			||||||
 | 
					  "abuseReportActionDescription": "检举不合规行为。",
 | 
				
			||||||
 | 
					  "abuseReportResource": "资源位置 / ID",
 | 
				
			||||||
 | 
					  "abuseReportReason": "检举原因",
 | 
				
			||||||
 | 
					  "abuseReportSubmitted": "检举已提交,感谢你的贡献。",
 | 
				
			||||||
 | 
					  "submit": "提交",
 | 
				
			||||||
 | 
					  "accountDeletion": "删除帐户",
 | 
				
			||||||
 | 
					  "accountDeletionDescription": "你确定要删除这个帐户吗?该操作不可撤销,其隶属于该帐户的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
 | 
				
			||||||
 | 
					  "accountDeletionActionDescription": "删除你的 Solarpass 帐户。",
 | 
				
			||||||
 | 
					  "accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。",
 | 
				
			||||||
 | 
					  "channelNewChannel": "新建频道",
 | 
				
			||||||
 | 
					  "channelNewDirectMessage": "发起私信",
 | 
				
			||||||
 | 
					  "channelDirectMessageDescription": "与 {} 的私聊",
 | 
				
			||||||
 | 
					  "fieldCannotBeEmpty": "此字段不能为空。",
 | 
				
			||||||
 | 
					  "termAcceptLink": "浏览条款",
 | 
				
			||||||
 | 
					  "termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
 | 
				
			||||||
 | 
					  "unauthorized": "未登陆",
 | 
				
			||||||
 | 
					  "unauthorizedDescription": "登陆以探索整个 Solar Network。",
 | 
				
			||||||
 | 
					  "serviceStatus": "服务状态",
 | 
				
			||||||
 | 
					  "termRelated": "相关条款",
 | 
				
			||||||
 | 
					  "appDetails": "应用程序详情",
 | 
				
			||||||
 | 
					  "postRecommendation": "推荐帖子",
 | 
				
			||||||
 | 
					  "publisherBlockHint": "屏蔽 {}",
 | 
				
			||||||
 | 
					  "publisherBlockHintDescription": "你正要屏蔽此发布者的运营者,该操作也将屏蔽由同一用户运营的发布者。",
 | 
				
			||||||
 | 
					  "userUnblocked": "已解除屏蔽用户 {}",
 | 
				
			||||||
 | 
					  "userBlocked": "已屏蔽用户 {}",
 | 
				
			||||||
 | 
					  "postSharingViaPicture": "正在生成帖子截图,请稍等片刻……",
 | 
				
			||||||
 | 
					  "postImageShareReadMore": "扫描右侧 QRCode 查看全文",
 | 
				
			||||||
 | 
					  "postImageShareAds": "来 Solar Network 探索更多有趣帖子",
 | 
				
			||||||
 | 
					  "postShare": "分享",
 | 
				
			||||||
 | 
					  "postShareImage": "分享帖图",
 | 
				
			||||||
 | 
					  "appInitializing": "正在初始化",
 | 
				
			||||||
 | 
					  "poweredBy": "由 {} 提供支持"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										445
									
								
								assets/translations/zh-HK.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,445 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "screen": "頁面",
 | 
				
			||||||
 | 
					  "screenAbout": "關於",
 | 
				
			||||||
 | 
					  "screenHome": "首頁",
 | 
				
			||||||
 | 
					  "screenExplore": "探索",
 | 
				
			||||||
 | 
					  "screenAccount": "您",
 | 
				
			||||||
 | 
					  "screenAuthLogin": "登陸",
 | 
				
			||||||
 | 
					  "screenAuthLoginSubtitle": "使用 Solarpass 登陸 Solar Network",
 | 
				
			||||||
 | 
					  "screenAuthLoginGreeting": "歡迎回來",
 | 
				
			||||||
 | 
					  "screenAuthRegister": "創建賬號",
 | 
				
			||||||
 | 
					  "screenAuthRegisterSubtitle": "創建一個 Solarpass 賬號",
 | 
				
			||||||
 | 
					  "screenAccountPublishers": "發佈者",
 | 
				
			||||||
 | 
					  "screenAccountPublisherNew": "新建發佈者",
 | 
				
			||||||
 | 
					  "screenAccountPublisherEdit": "編輯發佈者",
 | 
				
			||||||
 | 
					  "screenAccountProfileEdit": "編輯資料",
 | 
				
			||||||
 | 
					  "screenAbuseReport": "濫用檢舉",
 | 
				
			||||||
 | 
					  "screenSettings": "設置",
 | 
				
			||||||
 | 
					  "screenAlbum": "相冊",
 | 
				
			||||||
 | 
					  "screenChat": "聊天",
 | 
				
			||||||
 | 
					  "screenChatManage": "編輯聊天頻道",
 | 
				
			||||||
 | 
					  "screenChatNew": "新建聊天頻道",
 | 
				
			||||||
 | 
					  "screenRealm": "領域",
 | 
				
			||||||
 | 
					  "screenRealmManage": "編輯領域",
 | 
				
			||||||
 | 
					  "screenRealmNew": "新建領域",
 | 
				
			||||||
 | 
					  "screenNotification": "通知",
 | 
				
			||||||
 | 
					  "screenPostSearch": "搜索帖子",
 | 
				
			||||||
 | 
					  "screenFriend": "好友",
 | 
				
			||||||
 | 
					  "dialogOkay": "好的",
 | 
				
			||||||
 | 
					  "dialogCancel": "取消",
 | 
				
			||||||
 | 
					  "dialogConfirm": "確認",
 | 
				
			||||||
 | 
					  "dialogDismiss": "忽略",
 | 
				
			||||||
 | 
					  "dialogError": "出了點問題",
 | 
				
			||||||
 | 
					  "errorRequestBad": "服務器拒絕了您的請求,請檢查您的輸入。",
 | 
				
			||||||
 | 
					  "errorRequestUnauthorized": "未授權的請求,請登錄或者嘗試重新登陸。",
 | 
				
			||||||
 | 
					  "errorRequestForbidden": "被禁止的請求,您沒有足夠的權限去做那件事。",
 | 
				
			||||||
 | 
					  "errorRequestNotFound": "您正查找的資源無法被找到。",
 | 
				
			||||||
 | 
					  "errorRequestConnection": "網絡連接錯誤,請檢查您的網絡狀態或者檢查我們的服務狀態。",
 | 
				
			||||||
 | 
					  "errorRequestUnknown": "未知請求錯誤,您可能想將此對話框截圖併發送給我們。",
 | 
				
			||||||
 | 
					  "unknown": "未知",
 | 
				
			||||||
 | 
					  "loading": "加載中…",
 | 
				
			||||||
 | 
					  "prev": "上一步",
 | 
				
			||||||
 | 
					  "next": "下一步",
 | 
				
			||||||
 | 
					  "edit": "編輯",
 | 
				
			||||||
 | 
					  "apply": "應用",
 | 
				
			||||||
 | 
					  "cancel": "取消",
 | 
				
			||||||
 | 
					  "create": "創建",
 | 
				
			||||||
 | 
					  "preview": "預覽",
 | 
				
			||||||
 | 
					  "delete": "刪除",
 | 
				
			||||||
 | 
					  "unlink": "解除鏈接",
 | 
				
			||||||
 | 
					  "crop": "裁剪",
 | 
				
			||||||
 | 
					  "compress": "壓縮",
 | 
				
			||||||
 | 
					  "report": "檢舉",
 | 
				
			||||||
 | 
					  "repost": "轉帖",
 | 
				
			||||||
 | 
					  "replyPost": "回貼",
 | 
				
			||||||
 | 
					  "reply": "回覆",
 | 
				
			||||||
 | 
					  "unset": "未設置",
 | 
				
			||||||
 | 
					  "untitled": "無題",
 | 
				
			||||||
 | 
					  "postDetail": "帖子詳情",
 | 
				
			||||||
 | 
					  "postNoun": "帖子",
 | 
				
			||||||
 | 
					  "postReadMore": "閲讀更多",
 | 
				
			||||||
 | 
					  "postReadEstimate": "預計花費 {} 閲讀",
 | 
				
			||||||
 | 
					  "postTotalLength": {
 | 
				
			||||||
 | 
					    "zero": "沒有內容",
 | 
				
			||||||
 | 
					    "one": "總計 {} 字",
 | 
				
			||||||
 | 
					    "other": "總計 {} 字"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "fieldUsername": "用户名",
 | 
				
			||||||
 | 
					  "fieldNickname": "顯示名",
 | 
				
			||||||
 | 
					  "fieldEmail": "電子郵箱地址",
 | 
				
			||||||
 | 
					  "fieldPassword": "密碼",
 | 
				
			||||||
 | 
					  "fieldUsernameAlphanumOnly": "用户名只能包含英文大小寫字母和數字。",
 | 
				
			||||||
 | 
					  "fieldUsernameLengthLimit": "用户名必須在 {} 和 {} 之間。",
 | 
				
			||||||
 | 
					  "fieldUsernameCannotEditHint": "用户名在創建後無法修改",
 | 
				
			||||||
 | 
					  "fieldUsernameLookupHint": "支持用户名、電話號碼或郵箱地址",
 | 
				
			||||||
 | 
					  "fieldNicknameLengthLimit": "暱稱必須在 {} 和 {} 之間。",
 | 
				
			||||||
 | 
					  "fieldEmailAddressMustBeValid": "電子郵箱地址必須是一個電子郵箱地址。",
 | 
				
			||||||
 | 
					  "fieldFirstName": "名",
 | 
				
			||||||
 | 
					  "fieldLastName": "姓",
 | 
				
			||||||
 | 
					  "fieldBirthday": "生日",
 | 
				
			||||||
 | 
					  "fieldImageHint": "你可以點擊這些個人頭像來編輯它們。",
 | 
				
			||||||
 | 
					  "fieldDescription": "簡介",
 | 
				
			||||||
 | 
					  "forgotPassword": "忘記密碼",
 | 
				
			||||||
 | 
					  "loginPickFactor": "選擇方式驗證",
 | 
				
			||||||
 | 
					  "loginMultiFactor": {
 | 
				
			||||||
 | 
					    "one": "{} 步驗證",
 | 
				
			||||||
 | 
					    "other": "{} 步驗證"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "loginEnterPassword": "驗證代碼",
 | 
				
			||||||
 | 
					  "loginSuccess": "登錄為 {}",
 | 
				
			||||||
 | 
					  "authFactorPassword": "密碼",
 | 
				
			||||||
 | 
					  "authFactorEmail": "電郵一次性驗證碼",
 | 
				
			||||||
 | 
					  "accountIntroTitle": "喜歡您來!",
 | 
				
			||||||
 | 
					  "accountIntroSubtitle": "登陸以探索更廣大的世界。",
 | 
				
			||||||
 | 
					  "accountLogout": "退出登錄",
 | 
				
			||||||
 | 
					  "accountLogoutSubtitle": "註銷當前賬户的登陸狀態。",
 | 
				
			||||||
 | 
					  "accountLogoutConfirmTitle": "您確定要退出登錄嗎?",
 | 
				
			||||||
 | 
					  "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
 | 
				
			||||||
 | 
					  "accountPublishers": "你的發佈者",
 | 
				
			||||||
 | 
					  "accountPublishersSubtitle": "管理你的公共形象。",
 | 
				
			||||||
 | 
					  "accountProfileEdit": "編輯資料",
 | 
				
			||||||
 | 
					  "accountProfileEditSubtitle": "使你的 Solarpass 賬户更像你。",
 | 
				
			||||||
 | 
					  "accountProfileEditApplied": "個人資料修改已被應用。",
 | 
				
			||||||
 | 
					  "publishersNew": "新發布者",
 | 
				
			||||||
 | 
					  "publisherNewSubtitle": "創建一個新的公共身份。",
 | 
				
			||||||
 | 
					  "publisherSyncWithAccount": "同步賬户信息",
 | 
				
			||||||
 | 
					  "publisherTotalUpvote": "總頂數",
 | 
				
			||||||
 | 
					  "publisherTotalDownvote": "總踩數",
 | 
				
			||||||
 | 
					  "publisherSocialPoint": "社會信用點",
 | 
				
			||||||
 | 
					  "publisherJoinedAt": "加入於 {}",
 | 
				
			||||||
 | 
					  "publisherSocialPointTotal": {
 | 
				
			||||||
 | 
					    "zero": "無社會信用點",
 | 
				
			||||||
 | 
					    "one": "{} 點社會信用點",
 | 
				
			||||||
 | 
					    "other": "{} 點社會信用點"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherAffiliatedBy": "隸屬於 {}",
 | 
				
			||||||
 | 
					  "publisherRunBy": "由 {} 管理",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealm": "所屬領域",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
				
			||||||
 | 
					  "writePostTypeStory": "發動態",
 | 
				
			||||||
 | 
					  "writePostTypeArticle": "寫文章",
 | 
				
			||||||
 | 
					  "fieldPostPublisher": "帖子發佈者",
 | 
				
			||||||
 | 
					  "fieldPostContent": "發生什麼事了?!",
 | 
				
			||||||
 | 
					  "fieldPostTitle": "標題",
 | 
				
			||||||
 | 
					  "fieldPostDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldPostTags": "標籤",
 | 
				
			||||||
 | 
					  "postPublish": "發佈",
 | 
				
			||||||
 | 
					  "postPublishedAt": "發佈於",
 | 
				
			||||||
 | 
					  "postPublishedUntil": "取消發佈於",
 | 
				
			||||||
 | 
					  "postVisibility": "可見性",
 | 
				
			||||||
 | 
					  "postVisibilityDescription": "帖子可見性決定了誰能查看該篇帖子。",
 | 
				
			||||||
 | 
					  "postVisibilityAll": "所有人可見",
 | 
				
			||||||
 | 
					  "postVisibilityFriends": "僅限好友可見",
 | 
				
			||||||
 | 
					  "postVisibilitySelected": "選定的用户可見",
 | 
				
			||||||
 | 
					  "postVisibilityFiltered": "選定用户不可見",
 | 
				
			||||||
 | 
					  "postVisibilityNone": "僅自己可見",
 | 
				
			||||||
 | 
					  "postVisibleUsers": "可見的用户",
 | 
				
			||||||
 | 
					  "postInvisibleUsers": "不可見的用户",
 | 
				
			||||||
 | 
					  "postSelectedUsers": {
 | 
				
			||||||
 | 
					    "zero": "未選擇用户",
 | 
				
			||||||
 | 
					    "one": "選擇了 {} 個用户",
 | 
				
			||||||
 | 
					    "other": "選擇了 {} 個用户"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postEditingNotice": "你正在修改由 {} 發佈的帖子。",
 | 
				
			||||||
 | 
					  "postReplyingNotice": "你正在回覆由 {} 發佈的帖子。",
 | 
				
			||||||
 | 
					  "postRepostingNotice": "你正在轉發由 {} 發佈的帖子。",
 | 
				
			||||||
 | 
					  "postReact": "反應",
 | 
				
			||||||
 | 
					  "postPosted": "帖子已經發表。",
 | 
				
			||||||
 | 
					  "postReactions": "帖子的反應",
 | 
				
			||||||
 | 
					  "postReactionUpvote": {
 | 
				
			||||||
 | 
					    "zero": "0 個頂",
 | 
				
			||||||
 | 
					    "one": "{} 個頂",
 | 
				
			||||||
 | 
					    "other": "{} 個頂"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionDownvote": {
 | 
				
			||||||
 | 
					    "zero": "0 個踩",
 | 
				
			||||||
 | 
					    "one": "{} 個踩",
 | 
				
			||||||
 | 
					    "other": "{} 個踩"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionSocialPoint": {
 | 
				
			||||||
 | 
					    "zero": "無社會信用點變更",
 | 
				
			||||||
 | 
					    "one": "{} 點社會信用點變更",
 | 
				
			||||||
 | 
					    "other": "{} 點社會信用點變更"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactCompleted": "反應已被添加。",
 | 
				
			||||||
 | 
					  "postReactUncompleted": "反應已被移除。",
 | 
				
			||||||
 | 
					  "postComments": {
 | 
				
			||||||
 | 
					    "zero": "評論",
 | 
				
			||||||
 | 
					    "one": "{} 條評論",
 | 
				
			||||||
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postCommentsDetailed": {
 | 
				
			||||||
 | 
					    "zero": "沒有評論",
 | 
				
			||||||
 | 
					    "one": "{} 條評論",
 | 
				
			||||||
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "settingsAppearance": "外觀",
 | 
				
			||||||
 | 
					  "settingsBackgroundImage": "背景圖片",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageClear": "清除現存背景圖",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
 | 
				
			||||||
 | 
					  "settingsThemeMaterial3": "使用 Material You 設計範式",
 | 
				
			||||||
 | 
					  "settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。",
 | 
				
			||||||
 | 
					  "settingsNetwork": "網絡",
 | 
				
			||||||
 | 
					  "settingsNetworkServer": "HyperNet 服務器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerReset": "重設為官方服務器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerResetDescription": "重設為 Solar Network 的服務器地址。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerPreset": "預設的 HyperNet 服務器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 服務器地址。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerSaved": "服務器地址已保存。",
 | 
				
			||||||
 | 
					  "settingsMisc": "雜項",
 | 
				
			||||||
 | 
					  "settingsMiscAbout": "關於",
 | 
				
			||||||
 | 
					  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
				
			||||||
 | 
					  "sensitiveContent": "敏感內容",
 | 
				
			||||||
 | 
					  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
				
			||||||
 | 
					  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
 | 
				
			||||||
 | 
					  "sensitiveContentReveal": "顯示內容",
 | 
				
			||||||
 | 
					  "serverConnecting": "正在連接服務器…",
 | 
				
			||||||
 | 
					  "serverDisconnected": "已與服務器斷開連接",
 | 
				
			||||||
 | 
					  "fieldChatAlias": "頻道別名",
 | 
				
			||||||
 | 
					  "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
				
			||||||
 | 
					  "fieldChatName": "名稱",
 | 
				
			||||||
 | 
					  "fieldChatDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealm": "所屬領域",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealmUnset": "未設置頻道所屬領域",
 | 
				
			||||||
 | 
					  "channelEditingNotice": "您正在編輯頻道 {}",
 | 
				
			||||||
 | 
					  "channelDeleted": "聊天頻道 {} 已被刪除",
 | 
				
			||||||
 | 
					  "channelDelete": "刪除聊天頻道 {}",
 | 
				
			||||||
 | 
					  "channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有消息將被永久刪除。",
 | 
				
			||||||
 | 
					  "channelDetailPersonalRegion": "個人區域",
 | 
				
			||||||
 | 
					  "channelDetailMemberRegion": "成員管理",
 | 
				
			||||||
 | 
					  "channelMemberManage": "管理成員",
 | 
				
			||||||
 | 
					  "channelMemberManageDescription": "管理頻道內現有成員。",
 | 
				
			||||||
 | 
					  "channelMemberAdd": "添加成員",
 | 
				
			||||||
 | 
					  "channelMemberAddDescription": "給當前頻道添加新成員。",
 | 
				
			||||||
 | 
					  "channelMemberAdded": "頻道成員已添加。",
 | 
				
			||||||
 | 
					  "fieldMemberRelatedName": "成員名 / 賬户 ID",
 | 
				
			||||||
 | 
					  "channelDetailAdminRegion": "管理區域",
 | 
				
			||||||
 | 
					  "channelEditProfile": "更改頻道身份",
 | 
				
			||||||
 | 
					  "channelEdit": "編輯頻道",
 | 
				
			||||||
 | 
					  "channelEditDescription": "更改頻道基本信息,元數據等。",
 | 
				
			||||||
 | 
					  "channelProfileEdit": "編輯頻道身份",
 | 
				
			||||||
 | 
					  "channelActionDelete": "刪除頻道",
 | 
				
			||||||
 | 
					  "channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有信息。",
 | 
				
			||||||
 | 
					  "channelLeave": "退出頻道 {}",
 | 
				
			||||||
 | 
					  "channelLeaveDescription": "退出該頻道,但是你頻道內的信息不會被移除。",
 | 
				
			||||||
 | 
					  "channelActionLeave": "退出頻道",
 | 
				
			||||||
 | 
					  "channelActionLeaveDescription": "刪除你在這個頻道的身份。",
 | 
				
			||||||
 | 
					  "channelNotifyLevel": "通知級別",
 | 
				
			||||||
 | 
					  "channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的消息。",
 | 
				
			||||||
 | 
					  "channelNotifyLevelAll": "全部通知",
 | 
				
			||||||
 | 
					  "channelNotifyLevelMentioned": "僅提及",
 | 
				
			||||||
 | 
					  "channelNotifyLevelNone": "全部靜音",
 | 
				
			||||||
 | 
					  "channelNotifyLevelApplied": "已經保存並應用頻道通知級別配置。",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNick": "頻道內顯示名",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNickHint": "在頻道內顯示的暱稱,留空則使用賬號顯示名。",
 | 
				
			||||||
 | 
					  "fieldRealmAlias": "領域別名",
 | 
				
			||||||
 | 
					  "fieldRealmAliasHint": "全站範圍內唯一的領域別名,用於在 URL 中表示該領域,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
				
			||||||
 | 
					  "fieldRealmName": "名稱",
 | 
				
			||||||
 | 
					  "fieldRealmDescription": "描述",
 | 
				
			||||||
 | 
					  "realmEditingNotice": "您正在編輯領域 {}",
 | 
				
			||||||
 | 
					  "realmDeleted": "領域 {} 已被刪除",
 | 
				
			||||||
 | 
					  "realmDelete": "刪除領域 {}",
 | 
				
			||||||
 | 
					  "realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
 | 
				
			||||||
 | 
					  "realmActionDelete": "刪除領域",
 | 
				
			||||||
 | 
					  "realmActionDeleteDescription": "刪除整個領域及其附屬的資源。",
 | 
				
			||||||
 | 
					  "realmEdit": "編輯領域",
 | 
				
			||||||
 | 
					  "realmEditDescription": "更改領域基本信息,元數據等。",
 | 
				
			||||||
 | 
					  "realmMemberAdd": "添加成員",
 | 
				
			||||||
 | 
					  "realmMemberAddDescription": "給當前領域添加新成員。",
 | 
				
			||||||
 | 
					  "realmMemberAdded": "領域成員已添加。",
 | 
				
			||||||
 | 
					  "fieldChatMessage": "在 {} 中發消息",
 | 
				
			||||||
 | 
					  "fieldChatMessageDirect": "給 {} 發消息",
 | 
				
			||||||
 | 
					  "eventResourceTag": "消息 {}",
 | 
				
			||||||
 | 
					  "messageDelete": "刪除消息 {}",
 | 
				
			||||||
 | 
					  "messageDeleteDescription": "你確定要刪除這個消息嗎?該操作不可撤銷。同時您將留下一條刪除消息的記錄。",
 | 
				
			||||||
 | 
					  "messageDeleted": "消息 {} 已被刪除",
 | 
				
			||||||
 | 
					  "messageEdited": "消息 {} 已被編輯",
 | 
				
			||||||
 | 
					  "messageEditedHint": "已編輯",
 | 
				
			||||||
 | 
					  "messageUnsupported": "不支持的消息 {}",
 | 
				
			||||||
 | 
					  "messageFileHint": {
 | 
				
			||||||
 | 
					    "zero": "沒有附件",
 | 
				
			||||||
 | 
					    "one": "{} 個附件",
 | 
				
			||||||
 | 
					    "other": "{} 個附件"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "addAttachmentFromAlbum": "從相冊中添加附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromClipboard": "粘貼附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
				
			||||||
 | 
					  "attachmentPastedImage": "粘貼的圖片",
 | 
				
			||||||
 | 
					  "attachmentInsertLink": "插入連接",
 | 
				
			||||||
 | 
					  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
				
			||||||
 | 
					  "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
 | 
				
			||||||
 | 
					  "attachmentSetThumbnail": "設置縮略圖",
 | 
				
			||||||
 | 
					  "attachmentUpload": "上傳",
 | 
				
			||||||
 | 
					  "notification": "通知",
 | 
				
			||||||
 | 
					  "notificationUnreadCount": {
 | 
				
			||||||
 | 
					    "zero": "無未讀通知",
 | 
				
			||||||
 | 
					    "one": "有 {} 個未讀通知",
 | 
				
			||||||
 | 
					    "other": "有 {} 個未讀通知"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationUnread": "未讀",
 | 
				
			||||||
 | 
					  "notificationRead": "已讀",
 | 
				
			||||||
 | 
					  "notificationMarkAllRead": "已讀所有通知",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadDescription": "您確定要將所有通知設置為已讀嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadPrompt": {
 | 
				
			||||||
 | 
					    "zero": "已將 0 個通知標記為已讀。",
 | 
				
			||||||
 | 
					    "one": "已將 {} 個通知標記為已讀。",
 | 
				
			||||||
 | 
					    "other": "已將 {} 個通知標記為已讀。"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。",
 | 
				
			||||||
 | 
					  "search": "搜索",
 | 
				
			||||||
 | 
					  "postSearchResult": {
 | 
				
			||||||
 | 
					    "zero": "沒有搜索到結果",
 | 
				
			||||||
 | 
					    "one": "搜索到 {} 個結果",
 | 
				
			||||||
 | 
					    "other": "搜索到 {} 個結果"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postSearchTook": "耗時 {}",
 | 
				
			||||||
 | 
					  "postDelete": "刪除帖子 {}",
 | 
				
			||||||
 | 
					  "postDeleteDescription": "你確定要刪除這個帖子嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "postDeleted": "帖子 {} 已被刪除。",
 | 
				
			||||||
 | 
					  "call": "通話",
 | 
				
			||||||
 | 
					  "callOngoingNotice": "一則通話進行中",
 | 
				
			||||||
 | 
					  "callJoin": "加入",
 | 
				
			||||||
 | 
					  "callResume": "恢復",
 | 
				
			||||||
 | 
					  "callMicrophone": "麥克風",
 | 
				
			||||||
 | 
					  "callCamera": "攝像頭",
 | 
				
			||||||
 | 
					  "callMicrophoneDisabled": "麥克風已禁用",
 | 
				
			||||||
 | 
					  "callMicrophoneSelect": "選擇麥克風",
 | 
				
			||||||
 | 
					  "callCameraDisabled": "攝像頭已禁用",
 | 
				
			||||||
 | 
					  "callCameraSelect": "選擇攝像頭",
 | 
				
			||||||
 | 
					  "callDisconnected": "通話已斷開",
 | 
				
			||||||
 | 
					  "callEnded": "通話已結束",
 | 
				
			||||||
 | 
					  "callStatusConnected": "已連接",
 | 
				
			||||||
 | 
					  "callStatusDisconnected": "未連接",
 | 
				
			||||||
 | 
					  "callStatusConnecting": "正在連接",
 | 
				
			||||||
 | 
					  "callStatusReconnecting": "正在重連",
 | 
				
			||||||
 | 
					  "callDisconnect": "斷開連接",
 | 
				
			||||||
 | 
					  "callDisconnectDescription": "您確定要與通話斷開連接嗎?",
 | 
				
			||||||
 | 
					  "callMicrophoneOff": "關閉麥克風",
 | 
				
			||||||
 | 
					  "callMicrophoneOn": "打開麥克風",
 | 
				
			||||||
 | 
					  "callCameraOff": "關閉攝像頭",
 | 
				
			||||||
 | 
					  "callCameraOn": "打開攝像頭",
 | 
				
			||||||
 | 
					  "callVideoFlip": "鏡像畫面",
 | 
				
			||||||
 | 
					  "callSpeakerphoneToggle": "切換揚聲器",
 | 
				
			||||||
 | 
					  "callScreenOff": "關閉屏幕共享",
 | 
				
			||||||
 | 
					  "callScreenOn": "開啓屏幕共享",
 | 
				
			||||||
 | 
					  "callMessageEnded": "通話持續了 {}",
 | 
				
			||||||
 | 
					  "callMessageStarted": "通話開始了",
 | 
				
			||||||
 | 
					  "dailyCheckIn": "每日簽到",
 | 
				
			||||||
 | 
					  "dailyCheckInNone": "今日尚未簽到",
 | 
				
			||||||
 | 
					  "dailyCheckAction": "現在簽到",
 | 
				
			||||||
 | 
					  "dailyCheckDetail": "看不懂符?大師幫我解惑!",
 | 
				
			||||||
 | 
					  "dailyCheckDetailTitle": "{} 的運勢詳情",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint": "宜 {}",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint": "忌 {}",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsPositive": "諸事皆宜",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsNegative": "諸事不宜",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1": "交友",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1Description": "友誼地久天長",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2": "飲酒",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2Description": "對影成三人",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3": "旅行",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3Description": "千里之行,始於足下",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4": "運動",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4Description": "生命在於運動",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5": "學習",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5Description": "學無止境,日有所進",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6": "種植",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6Description": "種下希望,收穫未來",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1": "吃飯",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1Description": "吃飯咬到舌頭",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2": "考試",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2Description": "考的東西剛好沒複習",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3": "坐公交",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3Description": "趕車剛好錯過一班",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4": "購物",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4Description": "買回來的衣服發現不合適",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5": "打遊戲",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5Description": "關鍵時刻斷網",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6": "出門",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
 | 
				
			||||||
 | 
					  "happyBirthday": "生日快樂,{}!",
 | 
				
			||||||
 | 
					  "friendNew": "添加好友",
 | 
				
			||||||
 | 
					  "friendRequests": "好友請求",
 | 
				
			||||||
 | 
					  "friendRequestsDescription": {
 | 
				
			||||||
 | 
					    "zero": "你沒有好友請求",
 | 
				
			||||||
 | 
					    "one": "你有 {} 個好友請求",
 | 
				
			||||||
 | 
					    "other": "你有 {} 個好友請求"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendBlocklist": "屏蔽列表",
 | 
				
			||||||
 | 
					  "friendBlocklistDescription": {
 | 
				
			||||||
 | 
					    "zero": "你沒有屏蔽任何人",
 | 
				
			||||||
 | 
					    "one": "你屏蔽了 {} 個用户",
 | 
				
			||||||
 | 
					    "other": "你屏蔽了 {} 個用户"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendStatusPending": "待處理",
 | 
				
			||||||
 | 
					  "friendStatusWaiting": "等待中",
 | 
				
			||||||
 | 
					  "friendStatusActive": "正活躍",
 | 
				
			||||||
 | 
					  "friendStatusBlocked": "已屏蔽",
 | 
				
			||||||
 | 
					  "friendRequestSent": "好友請求已發送。",
 | 
				
			||||||
 | 
					  "fieldFriendRelatedName": "好友名 / 賬户 ID",
 | 
				
			||||||
 | 
					  "friendBlock": "屏蔽",
 | 
				
			||||||
 | 
					  "friendUnblock": "解除屏蔽",
 | 
				
			||||||
 | 
					  "friendDeleteAction": "遺忘",
 | 
				
			||||||
 | 
					  "friendDelete": "遺忘跟 {} 的關係",
 | 
				
			||||||
 | 
					  "friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
 | 
				
			||||||
 | 
					  "friendRequestAccept": "接受",
 | 
				
			||||||
 | 
					  "friendRequestDecline": "拒絕",
 | 
				
			||||||
 | 
					  "subscribe": "訂閲",
 | 
				
			||||||
 | 
					  "unsubscribe": "取消訂閲",
 | 
				
			||||||
 | 
					  "attachmentUploadBy": "上傳者",
 | 
				
			||||||
 | 
					  "attachmentShotOn": "由 {} 拍攝",
 | 
				
			||||||
 | 
					  "accountJoinedAt": "加入於 {}",
 | 
				
			||||||
 | 
					  "accountBirthday": "出生於 {}",
 | 
				
			||||||
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
 | 
					  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
				
			||||||
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "accountStatus": "狀態",
 | 
				
			||||||
 | 
					  "accountStatusOnline": "在線",
 | 
				
			||||||
 | 
					  "accountStatusOffline": "離線",
 | 
				
			||||||
 | 
					  "accountStatusLastSeen": "最後一次在 {} 上線",
 | 
				
			||||||
 | 
					  "postArticle": "Solar Network 上的文章",
 | 
				
			||||||
 | 
					  "postStory": "Solar Network 上的故事",
 | 
				
			||||||
 | 
					  "articleWrittenAt": "發表於 {}",
 | 
				
			||||||
 | 
					  "articleEditedAt": "編輯於 {}",
 | 
				
			||||||
 | 
					  "attachmentSaved": "已保存到相冊",
 | 
				
			||||||
 | 
					  "attachmentSavedDesktop": "已保存到下載目錄",
 | 
				
			||||||
 | 
					  "openInAlbum": "在相冊中打開",
 | 
				
			||||||
 | 
					  "postAbuseReport": "檢舉帖子",
 | 
				
			||||||
 | 
					  "postAbuseReportDescription": "檢舉不符合我們用户協議以及社區準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
 | 
				
			||||||
 | 
					  "abuseReport": "檢舉",
 | 
				
			||||||
 | 
					  "abuseReportDescription": "檢舉不符合我們用户協議以及社區準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
 | 
				
			||||||
 | 
					  "abuseReportAction": "提交檢舉",
 | 
				
			||||||
 | 
					  "abuseReportActionDescription": "檢舉不合規行為。",
 | 
				
			||||||
 | 
					  "abuseReportResource": "資源位置 / ID",
 | 
				
			||||||
 | 
					  "abuseReportReason": "檢舉原因",
 | 
				
			||||||
 | 
					  "abuseReportSubmitted": "檢舉已提交,感謝你的貢獻。",
 | 
				
			||||||
 | 
					  "submit": "提交",
 | 
				
			||||||
 | 
					  "accountDeletion": "刪除帳户",
 | 
				
			||||||
 | 
					  "accountDeletionDescription": "你確定要刪除這個帳户嗎?該操作不可撤銷,其隸屬於該帳户的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
 | 
				
			||||||
 | 
					  "accountDeletionActionDescription": "刪除你的 Solarpass 帳户。",
 | 
				
			||||||
 | 
					  "accountDeletionSubmitted": "帳户刪除申請已發出,你可以檢查你的收件箱並根據郵件內的指示完成刪除操作。",
 | 
				
			||||||
 | 
					  "channelNewChannel": "新建頻道",
 | 
				
			||||||
 | 
					  "channelNewDirectMessage": "發起私信",
 | 
				
			||||||
 | 
					  "channelDirectMessageDescription": "與 {} 的私聊",
 | 
				
			||||||
 | 
					  "fieldCannotBeEmpty": "此字段不能為空。",
 | 
				
			||||||
 | 
					  "termAcceptLink": "瀏覽條款",
 | 
				
			||||||
 | 
					  "termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
				
			||||||
 | 
					  "unauthorized": "未登陸",
 | 
				
			||||||
 | 
					  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
				
			||||||
 | 
					  "serviceStatus": "服務狀態",
 | 
				
			||||||
 | 
					  "termRelated": "相關條款",
 | 
				
			||||||
 | 
					  "appDetails": "應用程序詳情",
 | 
				
			||||||
 | 
					  "postRecommendation": "推薦帖子",
 | 
				
			||||||
 | 
					  "publisherBlockHint": "屏蔽 {}",
 | 
				
			||||||
 | 
					  "publisherBlockHintDescription": "你正要屏蔽此發佈者的運營者,該操作也將屏蔽由同一用户運營的發佈者。",
 | 
				
			||||||
 | 
					  "userUnblocked": "已解除屏蔽用户 {}",
 | 
				
			||||||
 | 
					  "userBlocked": "已屏蔽用户 {}",
 | 
				
			||||||
 | 
					  "postSharingViaPicture": "正在生成帖子截圖,請稍等片刻……",
 | 
				
			||||||
 | 
					  "postImageShareReadMore": "掃描右側 QRCode 查看全文",
 | 
				
			||||||
 | 
					  "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
 | 
				
			||||||
 | 
					  "postShare": "分享",
 | 
				
			||||||
 | 
					  "postShareImage": "分享帖圖"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										445
									
								
								assets/translations/zh-TW.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,445 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "screen": "頁面",
 | 
				
			||||||
 | 
					  "screenAbout": "關於",
 | 
				
			||||||
 | 
					  "screenHome": "首頁",
 | 
				
			||||||
 | 
					  "screenExplore": "探索",
 | 
				
			||||||
 | 
					  "screenAccount": "您",
 | 
				
			||||||
 | 
					  "screenAuthLogin": "登陸",
 | 
				
			||||||
 | 
					  "screenAuthLoginSubtitle": "使用 Solarpass 登陸 Solar Network",
 | 
				
			||||||
 | 
					  "screenAuthLoginGreeting": "歡迎回來",
 | 
				
			||||||
 | 
					  "screenAuthRegister": "建立賬號",
 | 
				
			||||||
 | 
					  "screenAuthRegisterSubtitle": "建立一個 Solarpass 賬號",
 | 
				
			||||||
 | 
					  "screenAccountPublishers": "釋出者",
 | 
				
			||||||
 | 
					  "screenAccountPublisherNew": "新建釋出者",
 | 
				
			||||||
 | 
					  "screenAccountPublisherEdit": "編輯釋出者",
 | 
				
			||||||
 | 
					  "screenAccountProfileEdit": "編輯資料",
 | 
				
			||||||
 | 
					  "screenAbuseReport": "濫用檢舉",
 | 
				
			||||||
 | 
					  "screenSettings": "設定",
 | 
				
			||||||
 | 
					  "screenAlbum": "相簿",
 | 
				
			||||||
 | 
					  "screenChat": "聊天",
 | 
				
			||||||
 | 
					  "screenChatManage": "編輯聊天頻道",
 | 
				
			||||||
 | 
					  "screenChatNew": "新建聊天頻道",
 | 
				
			||||||
 | 
					  "screenRealm": "領域",
 | 
				
			||||||
 | 
					  "screenRealmManage": "編輯領域",
 | 
				
			||||||
 | 
					  "screenRealmNew": "新建領域",
 | 
				
			||||||
 | 
					  "screenNotification": "通知",
 | 
				
			||||||
 | 
					  "screenPostSearch": "搜尋帖子",
 | 
				
			||||||
 | 
					  "screenFriend": "好友",
 | 
				
			||||||
 | 
					  "dialogOkay": "好的",
 | 
				
			||||||
 | 
					  "dialogCancel": "取消",
 | 
				
			||||||
 | 
					  "dialogConfirm": "確認",
 | 
				
			||||||
 | 
					  "dialogDismiss": "忽略",
 | 
				
			||||||
 | 
					  "dialogError": "出了點問題",
 | 
				
			||||||
 | 
					  "errorRequestBad": "伺服器拒絕了您的請求,請檢查您的輸入。",
 | 
				
			||||||
 | 
					  "errorRequestUnauthorized": "未授權的請求,請登入或者嘗試重新登陸。",
 | 
				
			||||||
 | 
					  "errorRequestForbidden": "被禁止的請求,您沒有足夠的許可權去做那件事。",
 | 
				
			||||||
 | 
					  "errorRequestNotFound": "您正查詢的資源無法被找到。",
 | 
				
			||||||
 | 
					  "errorRequestConnection": "網路連線錯誤,請檢查您的網路狀態或者檢查我們的服務狀態。",
 | 
				
			||||||
 | 
					  "errorRequestUnknown": "未知請求錯誤,您可能想將此對話方塊截圖併發送給我們。",
 | 
				
			||||||
 | 
					  "unknown": "未知",
 | 
				
			||||||
 | 
					  "loading": "載入中…",
 | 
				
			||||||
 | 
					  "prev": "上一步",
 | 
				
			||||||
 | 
					  "next": "下一步",
 | 
				
			||||||
 | 
					  "edit": "編輯",
 | 
				
			||||||
 | 
					  "apply": "應用",
 | 
				
			||||||
 | 
					  "cancel": "取消",
 | 
				
			||||||
 | 
					  "create": "建立",
 | 
				
			||||||
 | 
					  "preview": "預覽",
 | 
				
			||||||
 | 
					  "delete": "刪除",
 | 
				
			||||||
 | 
					  "unlink": "解除連結",
 | 
				
			||||||
 | 
					  "crop": "裁剪",
 | 
				
			||||||
 | 
					  "compress": "壓縮",
 | 
				
			||||||
 | 
					  "report": "檢舉",
 | 
				
			||||||
 | 
					  "repost": "轉帖",
 | 
				
			||||||
 | 
					  "replyPost": "回貼",
 | 
				
			||||||
 | 
					  "reply": "回覆",
 | 
				
			||||||
 | 
					  "unset": "未設定",
 | 
				
			||||||
 | 
					  "untitled": "無題",
 | 
				
			||||||
 | 
					  "postDetail": "帖子詳情",
 | 
				
			||||||
 | 
					  "postNoun": "帖子",
 | 
				
			||||||
 | 
					  "postReadMore": "閱讀更多",
 | 
				
			||||||
 | 
					  "postReadEstimate": "預計花費 {} 閱讀",
 | 
				
			||||||
 | 
					  "postTotalLength": {
 | 
				
			||||||
 | 
					    "zero": "沒有內容",
 | 
				
			||||||
 | 
					    "one": "總計 {} 字",
 | 
				
			||||||
 | 
					    "other": "總計 {} 字"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "fieldUsername": "使用者名稱",
 | 
				
			||||||
 | 
					  "fieldNickname": "顯示名",
 | 
				
			||||||
 | 
					  "fieldEmail": "電子郵箱地址",
 | 
				
			||||||
 | 
					  "fieldPassword": "密碼",
 | 
				
			||||||
 | 
					  "fieldUsernameAlphanumOnly": "使用者名稱只能包含英文大小寫字母和數字。",
 | 
				
			||||||
 | 
					  "fieldUsernameLengthLimit": "使用者名稱必須在 {} 和 {} 之間。",
 | 
				
			||||||
 | 
					  "fieldUsernameCannotEditHint": "使用者名稱在建立後無法修改",
 | 
				
			||||||
 | 
					  "fieldUsernameLookupHint": "支援使用者名稱、電話號碼或郵箱地址",
 | 
				
			||||||
 | 
					  "fieldNicknameLengthLimit": "暱稱必須在 {} 和 {} 之間。",
 | 
				
			||||||
 | 
					  "fieldEmailAddressMustBeValid": "電子郵箱地址必須是一個電子郵箱地址。",
 | 
				
			||||||
 | 
					  "fieldFirstName": "名",
 | 
				
			||||||
 | 
					  "fieldLastName": "姓",
 | 
				
			||||||
 | 
					  "fieldBirthday": "生日",
 | 
				
			||||||
 | 
					  "fieldImageHint": "你可以點選這些個人頭像來編輯它們。",
 | 
				
			||||||
 | 
					  "fieldDescription": "簡介",
 | 
				
			||||||
 | 
					  "forgotPassword": "忘記密碼",
 | 
				
			||||||
 | 
					  "loginPickFactor": "選擇方式驗證",
 | 
				
			||||||
 | 
					  "loginMultiFactor": {
 | 
				
			||||||
 | 
					    "one": "{} 步驗證",
 | 
				
			||||||
 | 
					    "other": "{} 步驗證"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "loginEnterPassword": "驗證程式碼",
 | 
				
			||||||
 | 
					  "loginSuccess": "登入為 {}",
 | 
				
			||||||
 | 
					  "authFactorPassword": "密碼",
 | 
				
			||||||
 | 
					  "authFactorEmail": "電郵一次性驗證碼",
 | 
				
			||||||
 | 
					  "accountIntroTitle": "喜歡您來!",
 | 
				
			||||||
 | 
					  "accountIntroSubtitle": "登陸以探索更廣大的世界。",
 | 
				
			||||||
 | 
					  "accountLogout": "退出登入",
 | 
				
			||||||
 | 
					  "accountLogoutSubtitle": "登出當前賬戶的登陸狀態。",
 | 
				
			||||||
 | 
					  "accountLogoutConfirmTitle": "您確定要退出登入嗎?",
 | 
				
			||||||
 | 
					  "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
 | 
				
			||||||
 | 
					  "accountPublishers": "你的釋出者",
 | 
				
			||||||
 | 
					  "accountPublishersSubtitle": "管理你的公共形象。",
 | 
				
			||||||
 | 
					  "accountProfileEdit": "編輯資料",
 | 
				
			||||||
 | 
					  "accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
 | 
				
			||||||
 | 
					  "accountProfileEditApplied": "個人資料修改已被應用。",
 | 
				
			||||||
 | 
					  "publishersNew": "新發布者",
 | 
				
			||||||
 | 
					  "publisherNewSubtitle": "建立一個新的公共身份。",
 | 
				
			||||||
 | 
					  "publisherSyncWithAccount": "同步賬戶資訊",
 | 
				
			||||||
 | 
					  "publisherTotalUpvote": "總頂數",
 | 
				
			||||||
 | 
					  "publisherTotalDownvote": "總踩數",
 | 
				
			||||||
 | 
					  "publisherSocialPoint": "社會信用點",
 | 
				
			||||||
 | 
					  "publisherJoinedAt": "加入於 {}",
 | 
				
			||||||
 | 
					  "publisherSocialPointTotal": {
 | 
				
			||||||
 | 
					    "zero": "無社會信用點",
 | 
				
			||||||
 | 
					    "one": "{} 點社會信用點",
 | 
				
			||||||
 | 
					    "other": "{} 點社會信用點"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "publisherAffiliatedBy": "隸屬於 {}",
 | 
				
			||||||
 | 
					  "publisherRunBy": "由 {} 管理",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealm": "所屬領域",
 | 
				
			||||||
 | 
					  "fieldPublisherBelongToRealmUnset": "未設定釋出者所屬領域",
 | 
				
			||||||
 | 
					  "writePostTypeStory": "發動態",
 | 
				
			||||||
 | 
					  "writePostTypeArticle": "寫文章",
 | 
				
			||||||
 | 
					  "fieldPostPublisher": "帖子釋出者",
 | 
				
			||||||
 | 
					  "fieldPostContent": "發生什麼事了?!",
 | 
				
			||||||
 | 
					  "fieldPostTitle": "標題",
 | 
				
			||||||
 | 
					  "fieldPostDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldPostTags": "標籤",
 | 
				
			||||||
 | 
					  "postPublish": "釋出",
 | 
				
			||||||
 | 
					  "postPublishedAt": "釋出於",
 | 
				
			||||||
 | 
					  "postPublishedUntil": "取消釋出於",
 | 
				
			||||||
 | 
					  "postVisibility": "可見性",
 | 
				
			||||||
 | 
					  "postVisibilityDescription": "帖子可見性決定了誰能檢視該篇帖子。",
 | 
				
			||||||
 | 
					  "postVisibilityAll": "所有人可見",
 | 
				
			||||||
 | 
					  "postVisibilityFriends": "僅限好友可見",
 | 
				
			||||||
 | 
					  "postVisibilitySelected": "選定的使用者可見",
 | 
				
			||||||
 | 
					  "postVisibilityFiltered": "選定使用者不可見",
 | 
				
			||||||
 | 
					  "postVisibilityNone": "僅自己可見",
 | 
				
			||||||
 | 
					  "postVisibleUsers": "可見的使用者",
 | 
				
			||||||
 | 
					  "postInvisibleUsers": "不可見的使用者",
 | 
				
			||||||
 | 
					  "postSelectedUsers": {
 | 
				
			||||||
 | 
					    "zero": "未選擇使用者",
 | 
				
			||||||
 | 
					    "one": "選擇了 {} 個使用者",
 | 
				
			||||||
 | 
					    "other": "選擇了 {} 個使用者"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postEditingNotice": "你正在修改由 {} 釋出的帖子。",
 | 
				
			||||||
 | 
					  "postReplyingNotice": "你正在回覆由 {} 釋出的帖子。",
 | 
				
			||||||
 | 
					  "postRepostingNotice": "你正在轉發由 {} 釋出的帖子。",
 | 
				
			||||||
 | 
					  "postReact": "反應",
 | 
				
			||||||
 | 
					  "postPosted": "帖子已經發表。",
 | 
				
			||||||
 | 
					  "postReactions": "帖子的反應",
 | 
				
			||||||
 | 
					  "postReactionUpvote": {
 | 
				
			||||||
 | 
					    "zero": "0 個頂",
 | 
				
			||||||
 | 
					    "one": "{} 個頂",
 | 
				
			||||||
 | 
					    "other": "{} 個頂"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionDownvote": {
 | 
				
			||||||
 | 
					    "zero": "0 個踩",
 | 
				
			||||||
 | 
					    "one": "{} 個踩",
 | 
				
			||||||
 | 
					    "other": "{} 個踩"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactionSocialPoint": {
 | 
				
			||||||
 | 
					    "zero": "無社會信用點變更",
 | 
				
			||||||
 | 
					    "one": "{} 點社會信用點變更",
 | 
				
			||||||
 | 
					    "other": "{} 點社會信用點變更"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postReactCompleted": "反應已被新增。",
 | 
				
			||||||
 | 
					  "postReactUncompleted": "反應已被移除。",
 | 
				
			||||||
 | 
					  "postComments": {
 | 
				
			||||||
 | 
					    "zero": "評論",
 | 
				
			||||||
 | 
					    "one": "{} 條評論",
 | 
				
			||||||
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postCommentsDetailed": {
 | 
				
			||||||
 | 
					    "zero": "沒有評論",
 | 
				
			||||||
 | 
					    "one": "{} 條評論",
 | 
				
			||||||
 | 
					    "other": "{} 條評論"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "settingsAppearance": "外觀",
 | 
				
			||||||
 | 
					  "settingsBackgroundImage": "背景圖片",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageDescription": "設定應用全域性生效的的背景圖片。",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageClear": "清除現存背景圖",
 | 
				
			||||||
 | 
					  "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
 | 
				
			||||||
 | 
					  "settingsThemeMaterial3": "使用 Material You 設計正規化",
 | 
				
			||||||
 | 
					  "settingsThemeMaterial3Description": "將應用主題設定為 Material 3 設計正規化的主題。",
 | 
				
			||||||
 | 
					  "settingsNetwork": "網路",
 | 
				
			||||||
 | 
					  "settingsNetworkServer": "HyperNet 伺服器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerDescription": "設定 HyperNet 伺服器地址,選擇我們提供的,或者自己搭建。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerReset": "重設為官方伺服器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerResetDescription": "重設為 Solar Network 的伺服器地址。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerPreset": "預設的 HyperNet 伺服器",
 | 
				
			||||||
 | 
					  "settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 伺服器地址。",
 | 
				
			||||||
 | 
					  "settingsNetworkServerSaved": "伺服器地址已儲存。",
 | 
				
			||||||
 | 
					  "settingsMisc": "雜項",
 | 
				
			||||||
 | 
					  "settingsMiscAbout": "關於",
 | 
				
			||||||
 | 
					  "settingsMiscAboutDescription": "檢視 Solian 的版本資訊。",
 | 
				
			||||||
 | 
					  "sensitiveContent": "敏感內容",
 | 
				
			||||||
 | 
					  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
				
			||||||
 | 
					  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人檢視。",
 | 
				
			||||||
 | 
					  "sensitiveContentReveal": "顯示內容",
 | 
				
			||||||
 | 
					  "serverConnecting": "正在連線伺服器…",
 | 
				
			||||||
 | 
					  "serverDisconnected": "已與伺服器斷開連線",
 | 
				
			||||||
 | 
					  "fieldChatAlias": "頻道別名",
 | 
				
			||||||
 | 
					  "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
				
			||||||
 | 
					  "fieldChatName": "名稱",
 | 
				
			||||||
 | 
					  "fieldChatDescription": "描述",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealm": "所屬領域",
 | 
				
			||||||
 | 
					  "fieldChatBelongToRealmUnset": "未設定頻道所屬領域",
 | 
				
			||||||
 | 
					  "channelEditingNotice": "您正在編輯頻道 {}",
 | 
				
			||||||
 | 
					  "channelDeleted": "聊天頻道 {} 已被刪除",
 | 
				
			||||||
 | 
					  "channelDelete": "刪除聊天頻道 {}",
 | 
				
			||||||
 | 
					  "channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有訊息將被永久刪除。",
 | 
				
			||||||
 | 
					  "channelDetailPersonalRegion": "個人區域",
 | 
				
			||||||
 | 
					  "channelDetailMemberRegion": "成員管理",
 | 
				
			||||||
 | 
					  "channelMemberManage": "管理成員",
 | 
				
			||||||
 | 
					  "channelMemberManageDescription": "管理頻道內現有成員。",
 | 
				
			||||||
 | 
					  "channelMemberAdd": "新增成員",
 | 
				
			||||||
 | 
					  "channelMemberAddDescription": "給當前頻道新增新成員。",
 | 
				
			||||||
 | 
					  "channelMemberAdded": "頻道成員已新增。",
 | 
				
			||||||
 | 
					  "fieldMemberRelatedName": "成員名 / 賬戶 ID",
 | 
				
			||||||
 | 
					  "channelDetailAdminRegion": "管理區域",
 | 
				
			||||||
 | 
					  "channelEditProfile": "更改頻道身份",
 | 
				
			||||||
 | 
					  "channelEdit": "編輯頻道",
 | 
				
			||||||
 | 
					  "channelEditDescription": "更改頻道基本資訊,元資料等。",
 | 
				
			||||||
 | 
					  "channelProfileEdit": "編輯頻道身份",
 | 
				
			||||||
 | 
					  "channelActionDelete": "刪除頻道",
 | 
				
			||||||
 | 
					  "channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有資訊。",
 | 
				
			||||||
 | 
					  "channelLeave": "退出頻道 {}",
 | 
				
			||||||
 | 
					  "channelLeaveDescription": "退出該頻道,但是你頻道內的資訊不會被移除。",
 | 
				
			||||||
 | 
					  "channelActionLeave": "退出頻道",
 | 
				
			||||||
 | 
					  "channelActionLeaveDescription": "刪除你在這個頻道的身份。",
 | 
				
			||||||
 | 
					  "channelNotifyLevel": "通知級別",
 | 
				
			||||||
 | 
					  "channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的訊息。",
 | 
				
			||||||
 | 
					  "channelNotifyLevelAll": "全部通知",
 | 
				
			||||||
 | 
					  "channelNotifyLevelMentioned": "僅提及",
 | 
				
			||||||
 | 
					  "channelNotifyLevelNone": "全部靜音",
 | 
				
			||||||
 | 
					  "channelNotifyLevelApplied": "已經儲存並應用頻道通知級別配置。",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNick": "頻道內顯示名",
 | 
				
			||||||
 | 
					  "fieldChannelProfileNickHint": "在頻道內顯示的暱稱,留空則使用賬號顯示名。",
 | 
				
			||||||
 | 
					  "fieldRealmAlias": "領域別名",
 | 
				
			||||||
 | 
					  "fieldRealmAliasHint": "全站範圍內唯一的領域別名,用於在 URL 中表示該領域,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
				
			||||||
 | 
					  "fieldRealmName": "名稱",
 | 
				
			||||||
 | 
					  "fieldRealmDescription": "描述",
 | 
				
			||||||
 | 
					  "realmEditingNotice": "您正在編輯領域 {}",
 | 
				
			||||||
 | 
					  "realmDeleted": "領域 {} 已被刪除",
 | 
				
			||||||
 | 
					  "realmDelete": "刪除領域 {}",
 | 
				
			||||||
 | 
					  "realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!",
 | 
				
			||||||
 | 
					  "realmActionDelete": "刪除領域",
 | 
				
			||||||
 | 
					  "realmActionDeleteDescription": "刪除整個領域及其附屬的資源。",
 | 
				
			||||||
 | 
					  "realmEdit": "編輯領域",
 | 
				
			||||||
 | 
					  "realmEditDescription": "更改領域基本資訊,元資料等。",
 | 
				
			||||||
 | 
					  "realmMemberAdd": "新增成員",
 | 
				
			||||||
 | 
					  "realmMemberAddDescription": "給當前領域新增新成員。",
 | 
				
			||||||
 | 
					  "realmMemberAdded": "領域成員已新增。",
 | 
				
			||||||
 | 
					  "fieldChatMessage": "在 {} 中發訊息",
 | 
				
			||||||
 | 
					  "fieldChatMessageDirect": "給 {} 發訊息",
 | 
				
			||||||
 | 
					  "eventResourceTag": "訊息 {}",
 | 
				
			||||||
 | 
					  "messageDelete": "刪除訊息 {}",
 | 
				
			||||||
 | 
					  "messageDeleteDescription": "你確定要刪除這個訊息嗎?該操作不可撤銷。同時您將留下一條刪除訊息的記錄。",
 | 
				
			||||||
 | 
					  "messageDeleted": "訊息 {} 已被刪除",
 | 
				
			||||||
 | 
					  "messageEdited": "訊息 {} 已被編輯",
 | 
				
			||||||
 | 
					  "messageEditedHint": "已編輯",
 | 
				
			||||||
 | 
					  "messageUnsupported": "不支援的訊息 {}",
 | 
				
			||||||
 | 
					  "messageFileHint": {
 | 
				
			||||||
 | 
					    "zero": "沒有附件",
 | 
				
			||||||
 | 
					    "one": "{} 個附件",
 | 
				
			||||||
 | 
					    "other": "{} 個附件"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "addAttachmentFromAlbum": "從相簿中新增附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromClipboard": "貼上附件",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
				
			||||||
 | 
					  "addAttachmentFromCameraVideo": "拍攝影片",
 | 
				
			||||||
 | 
					  "attachmentPastedImage": "貼上的圖片",
 | 
				
			||||||
 | 
					  "attachmentInsertLink": "插入連線",
 | 
				
			||||||
 | 
					  "attachmentSetAsPostThumbnail": "設定為帖子縮圖",
 | 
				
			||||||
 | 
					  "attachmentUnsetAsPostThumbnail": "取消設定為帖子縮圖",
 | 
				
			||||||
 | 
					  "attachmentSetThumbnail": "設定縮圖",
 | 
				
			||||||
 | 
					  "attachmentUpload": "上傳",
 | 
				
			||||||
 | 
					  "notification": "通知",
 | 
				
			||||||
 | 
					  "notificationUnreadCount": {
 | 
				
			||||||
 | 
					    "zero": "無未讀通知",
 | 
				
			||||||
 | 
					    "one": "有 {} 個未讀通知",
 | 
				
			||||||
 | 
					    "other": "有 {} 個未讀通知"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationUnread": "未讀",
 | 
				
			||||||
 | 
					  "notificationRead": "已讀",
 | 
				
			||||||
 | 
					  "notificationMarkAllRead": "已讀所有通知",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadDescription": "您確定要將所有通知設定為已讀嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "notificationMarkAllReadPrompt": {
 | 
				
			||||||
 | 
					    "zero": "已將 0 個通知標記為已讀。",
 | 
				
			||||||
 | 
					    "one": "已將 {} 個通知標記為已讀。",
 | 
				
			||||||
 | 
					    "other": "已將 {} 個通知標記為已讀。"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。",
 | 
				
			||||||
 | 
					  "search": "搜尋",
 | 
				
			||||||
 | 
					  "postSearchResult": {
 | 
				
			||||||
 | 
					    "zero": "沒有搜尋到結果",
 | 
				
			||||||
 | 
					    "one": "搜尋到 {} 個結果",
 | 
				
			||||||
 | 
					    "other": "搜尋到 {} 個結果"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "postSearchTook": "耗時 {}",
 | 
				
			||||||
 | 
					  "postDelete": "刪除帖子 {}",
 | 
				
			||||||
 | 
					  "postDeleteDescription": "你確定要刪除這個帖子嗎?該操作不可撤銷。",
 | 
				
			||||||
 | 
					  "postDeleted": "帖子 {} 已被刪除。",
 | 
				
			||||||
 | 
					  "call": "通話",
 | 
				
			||||||
 | 
					  "callOngoingNotice": "一則通話進行中",
 | 
				
			||||||
 | 
					  "callJoin": "加入",
 | 
				
			||||||
 | 
					  "callResume": "恢復",
 | 
				
			||||||
 | 
					  "callMicrophone": "麥克風",
 | 
				
			||||||
 | 
					  "callCamera": "攝像頭",
 | 
				
			||||||
 | 
					  "callMicrophoneDisabled": "麥克風已停用",
 | 
				
			||||||
 | 
					  "callMicrophoneSelect": "選擇麥克風",
 | 
				
			||||||
 | 
					  "callCameraDisabled": "攝像頭已停用",
 | 
				
			||||||
 | 
					  "callCameraSelect": "選擇攝像頭",
 | 
				
			||||||
 | 
					  "callDisconnected": "通話已斷開",
 | 
				
			||||||
 | 
					  "callEnded": "通話已結束",
 | 
				
			||||||
 | 
					  "callStatusConnected": "已連線",
 | 
				
			||||||
 | 
					  "callStatusDisconnected": "未連線",
 | 
				
			||||||
 | 
					  "callStatusConnecting": "正在連線",
 | 
				
			||||||
 | 
					  "callStatusReconnecting": "正在重連",
 | 
				
			||||||
 | 
					  "callDisconnect": "斷開連線",
 | 
				
			||||||
 | 
					  "callDisconnectDescription": "您確定要與通話斷開連線嗎?",
 | 
				
			||||||
 | 
					  "callMicrophoneOff": "關閉麥克風",
 | 
				
			||||||
 | 
					  "callMicrophoneOn": "開啟麥克風",
 | 
				
			||||||
 | 
					  "callCameraOff": "關閉攝像頭",
 | 
				
			||||||
 | 
					  "callCameraOn": "開啟攝像頭",
 | 
				
			||||||
 | 
					  "callVideoFlip": "映象畫面",
 | 
				
			||||||
 | 
					  "callSpeakerphoneToggle": "切換揚聲器",
 | 
				
			||||||
 | 
					  "callScreenOff": "關閉螢幕共享",
 | 
				
			||||||
 | 
					  "callScreenOn": "開啟螢幕共享",
 | 
				
			||||||
 | 
					  "callMessageEnded": "通話持續了 {}",
 | 
				
			||||||
 | 
					  "callMessageStarted": "通話開始了",
 | 
				
			||||||
 | 
					  "dailyCheckIn": "每日簽到",
 | 
				
			||||||
 | 
					  "dailyCheckInNone": "今日尚未簽到",
 | 
				
			||||||
 | 
					  "dailyCheckAction": "現在簽到",
 | 
				
			||||||
 | 
					  "dailyCheckDetail": "看不懂符?大師幫我解惑!",
 | 
				
			||||||
 | 
					  "dailyCheckDetailTitle": "{} 的運勢詳情",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint": "宜 {}",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint": "忌 {}",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsPositive": "諸事皆宜",
 | 
				
			||||||
 | 
					  "dailyCheckEverythingIsNegative": "諸事不宜",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1": "交友",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint1Description": "友誼地久天長",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2": "飲酒",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint2Description": "對影成三人",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3": "旅行",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint3Description": "千里之行,始於足下",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4": "運動",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint4Description": "生命在於運動",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5": "學習",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint5Description": "學無止境,日有所進",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6": "種植",
 | 
				
			||||||
 | 
					  "dailyCheckPositiveHint6Description": "種下希望,收穫未來",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1": "吃飯",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint1Description": "吃飯咬到舌頭",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2": "考試",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint2Description": "考的東西剛好沒複習",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3": "坐公交",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint3Description": "趕車剛好錯過一班",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4": "購物",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint4Description": "買回來的衣服發現不合適",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5": "打遊戲",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint5Description": "關鍵時刻斷網",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6": "出門",
 | 
				
			||||||
 | 
					  "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
 | 
				
			||||||
 | 
					  "happyBirthday": "生日快樂,{}!",
 | 
				
			||||||
 | 
					  "friendNew": "新增好友",
 | 
				
			||||||
 | 
					  "friendRequests": "好友請求",
 | 
				
			||||||
 | 
					  "friendRequestsDescription": {
 | 
				
			||||||
 | 
					    "zero": "你沒有好友請求",
 | 
				
			||||||
 | 
					    "one": "你有 {} 個好友請求",
 | 
				
			||||||
 | 
					    "other": "你有 {} 個好友請求"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendBlocklist": "遮蔽列表",
 | 
				
			||||||
 | 
					  "friendBlocklistDescription": {
 | 
				
			||||||
 | 
					    "zero": "你沒有遮蔽任何人",
 | 
				
			||||||
 | 
					    "one": "你遮蔽了 {} 個使用者",
 | 
				
			||||||
 | 
					    "other": "你遮蔽了 {} 個使用者"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "friendStatusPending": "待處理",
 | 
				
			||||||
 | 
					  "friendStatusWaiting": "等待中",
 | 
				
			||||||
 | 
					  "friendStatusActive": "正活躍",
 | 
				
			||||||
 | 
					  "friendStatusBlocked": "已遮蔽",
 | 
				
			||||||
 | 
					  "friendRequestSent": "好友請求已傳送。",
 | 
				
			||||||
 | 
					  "fieldFriendRelatedName": "好友名 / 賬戶 ID",
 | 
				
			||||||
 | 
					  "friendBlock": "遮蔽",
 | 
				
			||||||
 | 
					  "friendUnblock": "解除遮蔽",
 | 
				
			||||||
 | 
					  "friendDeleteAction": "遺忘",
 | 
				
			||||||
 | 
					  "friendDelete": "遺忘跟 {} 的關係",
 | 
				
			||||||
 | 
					  "friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
 | 
				
			||||||
 | 
					  "friendRequestAccept": "接受",
 | 
				
			||||||
 | 
					  "friendRequestDecline": "拒絕",
 | 
				
			||||||
 | 
					  "subscribe": "訂閱",
 | 
				
			||||||
 | 
					  "unsubscribe": "取消訂閱",
 | 
				
			||||||
 | 
					  "attachmentUploadBy": "上傳者",
 | 
				
			||||||
 | 
					  "attachmentShotOn": "由 {} 拍攝",
 | 
				
			||||||
 | 
					  "accountJoinedAt": "加入於 {}",
 | 
				
			||||||
 | 
					  "accountBirthday": "出生於 {}",
 | 
				
			||||||
 | 
					  "accountBadge": "徽章",
 | 
				
			||||||
 | 
					  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
 | 
				
			||||||
 | 
					  "badgeSiteMigration": "Solar Network 原住民",
 | 
				
			||||||
 | 
					  "accountStatus": "狀態",
 | 
				
			||||||
 | 
					  "accountStatusOnline": "線上",
 | 
				
			||||||
 | 
					  "accountStatusOffline": "離線",
 | 
				
			||||||
 | 
					  "accountStatusLastSeen": "最後一次在 {} 上線",
 | 
				
			||||||
 | 
					  "postArticle": "Solar Network 上的文章",
 | 
				
			||||||
 | 
					  "postStory": "Solar Network 上的故事",
 | 
				
			||||||
 | 
					  "articleWrittenAt": "發表於 {}",
 | 
				
			||||||
 | 
					  "articleEditedAt": "編輯於 {}",
 | 
				
			||||||
 | 
					  "attachmentSaved": "已儲存到相簿",
 | 
				
			||||||
 | 
					  "attachmentSavedDesktop": "已儲存到下載目錄",
 | 
				
			||||||
 | 
					  "openInAlbum": "在相簿中開啟",
 | 
				
			||||||
 | 
					  "postAbuseReport": "檢舉帖子",
 | 
				
			||||||
 | 
					  "postAbuseReportDescription": "檢舉不符合我們使用者協議以及社群準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。",
 | 
				
			||||||
 | 
					  "abuseReport": "檢舉",
 | 
				
			||||||
 | 
					  "abuseReportDescription": "檢舉不符合我們使用者協議以及社群準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。",
 | 
				
			||||||
 | 
					  "abuseReportAction": "提交檢舉",
 | 
				
			||||||
 | 
					  "abuseReportActionDescription": "檢舉不合規行為。",
 | 
				
			||||||
 | 
					  "abuseReportResource": "資源位置 / ID",
 | 
				
			||||||
 | 
					  "abuseReportReason": "檢舉原因",
 | 
				
			||||||
 | 
					  "abuseReportSubmitted": "檢舉已提交,感謝你的貢獻。",
 | 
				
			||||||
 | 
					  "submit": "提交",
 | 
				
			||||||
 | 
					  "accountDeletion": "刪除帳戶",
 | 
				
			||||||
 | 
					  "accountDeletionDescription": "你確定要刪除這個帳戶嗎?該操作不可撤銷,其隸屬於該帳戶的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!",
 | 
				
			||||||
 | 
					  "accountDeletionActionDescription": "刪除你的 Solarpass 帳戶。",
 | 
				
			||||||
 | 
					  "accountDeletionSubmitted": "帳戶刪除申請已發出,你可以檢查你的收件箱並根據郵件內的指示完成刪除操作。",
 | 
				
			||||||
 | 
					  "channelNewChannel": "新建頻道",
 | 
				
			||||||
 | 
					  "channelNewDirectMessage": "發起私信",
 | 
				
			||||||
 | 
					  "channelDirectMessageDescription": "與 {} 的私聊",
 | 
				
			||||||
 | 
					  "fieldCannotBeEmpty": "此欄位不能為空。",
 | 
				
			||||||
 | 
					  "termAcceptLink": "瀏覽條款",
 | 
				
			||||||
 | 
					  "termAcceptNextWithAgree": "點選 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
 | 
				
			||||||
 | 
					  "unauthorized": "未登陸",
 | 
				
			||||||
 | 
					  "unauthorizedDescription": "登陸以探索整個 Solar Network。",
 | 
				
			||||||
 | 
					  "serviceStatus": "服務狀態",
 | 
				
			||||||
 | 
					  "termRelated": "相關條款",
 | 
				
			||||||
 | 
					  "appDetails": "應用程式詳情",
 | 
				
			||||||
 | 
					  "postRecommendation": "推薦帖子",
 | 
				
			||||||
 | 
					  "publisherBlockHint": "遮蔽 {}",
 | 
				
			||||||
 | 
					  "publisherBlockHintDescription": "你正要遮蔽此釋出者的運營者,該操作也將遮蔽由同一使用者運營的釋出者。",
 | 
				
			||||||
 | 
					  "userUnblocked": "已解除遮蔽使用者 {}",
 | 
				
			||||||
 | 
					  "userBlocked": "已遮蔽使用者 {}",
 | 
				
			||||||
 | 
					  "postSharingViaPicture": "正在生成帖子截圖,請稍等片刻……",
 | 
				
			||||||
 | 
					  "postImageShareReadMore": "掃描右側 QRCode 檢視全文",
 | 
				
			||||||
 | 
					  "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
 | 
				
			||||||
 | 
					  "postShare": "分享",
 | 
				
			||||||
 | 
					  "postShareImage": "分享帖圖"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1
									
								
								firebase.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					{"flutter":{"platforms":{"android":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:android:a8d3f7995b0b8e86f4188b","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"solian-0x001","configurations":{"android":"1:961776991058:android:a8d3f7995b0b8e86f4188b","ios":"1:961776991058:ios:727229d368cc47e1f4188b","macos":"1:961776991058:ios:727229d368cc47e1f4188b","web":"1:961776991058:web:b91d12f2892a5609f4188b","windows":"1:961776991058:web:f152fd119699e13ef4188b"}}}}}}
 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
# Uncomment this line to define a global platform for your project
 | 
					# Uncomment this line to define a global platform for your project
 | 
				
			||||||
# platform :ios, '12.0'
 | 
					platform :ios, '13.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 | 
					# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 | 
				
			||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
 | 
					ENV['COCOAPODS_DISABLE_STATS'] = 'true'
 | 
				
			||||||
@@ -40,5 +40,9 @@ end
 | 
				
			|||||||
post_install do |installer|
 | 
					post_install do |installer|
 | 
				
			||||||
  installer.pods_project.targets.each do |target|
 | 
					  installer.pods_project.targets.each do |target|
 | 
				
			||||||
    flutter_additional_ios_build_settings(target)
 | 
					    flutter_additional_ios_build_settings(target)
 | 
				
			||||||
 | 
					    target.build_configurations.each do |config|
 | 
				
			||||||
 | 
					      # Workaround for https://github.com/flutter/flutter/issues/64502
 | 
				
			||||||
 | 
					      config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES'
 | 
				
			||||||
 | 
					     end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										282
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						@@ -4,7 +4,7 @@ PODS:
 | 
				
			|||||||
    - FlutterMacOS
 | 
					    - FlutterMacOS
 | 
				
			||||||
  - croppy (0.0.1):
 | 
					  - croppy (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - cupertino_http (0.0.1):
 | 
					  - device_info_plus (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - DKImagePickerController/Core (4.3.9):
 | 
					  - DKImagePickerController/Core (4.3.9):
 | 
				
			||||||
    - DKImagePickerController/ImageDataManager
 | 
					    - DKImagePickerController/ImageDataManager
 | 
				
			||||||
@@ -40,19 +40,164 @@ PODS:
 | 
				
			|||||||
  - file_picker (0.0.1):
 | 
					  - file_picker (0.0.1):
 | 
				
			||||||
    - DKImagePickerController/PhotoGallery
 | 
					    - DKImagePickerController/PhotoGallery
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - file_saver (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - Firebase/Analytics (11.4.0):
 | 
				
			||||||
 | 
					    - Firebase/Core
 | 
				
			||||||
 | 
					  - Firebase/Core (11.4.0):
 | 
				
			||||||
 | 
					    - Firebase/CoreOnly
 | 
				
			||||||
 | 
					    - FirebaseAnalytics (~> 11.4.0)
 | 
				
			||||||
 | 
					  - Firebase/CoreOnly (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseCore (= 11.4.0)
 | 
				
			||||||
 | 
					  - Firebase/Messaging (11.4.0):
 | 
				
			||||||
 | 
					    - Firebase/CoreOnly
 | 
				
			||||||
 | 
					    - FirebaseMessaging (~> 11.4.0)
 | 
				
			||||||
 | 
					  - firebase_analytics (11.3.6):
 | 
				
			||||||
 | 
					    - Firebase/Analytics (= 11.4.0)
 | 
				
			||||||
 | 
					    - firebase_core
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - firebase_core (3.8.1):
 | 
				
			||||||
 | 
					    - Firebase/CoreOnly (= 11.4.0)
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - firebase_messaging (15.1.6):
 | 
				
			||||||
 | 
					    - Firebase/Messaging (= 11.4.0)
 | 
				
			||||||
 | 
					    - firebase_core
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - FirebaseAnalytics (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseAnalytics/AdIdSupport (= 11.4.0)
 | 
				
			||||||
 | 
					    - FirebaseCore (~> 11.0)
 | 
				
			||||||
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					  - FirebaseAnalytics/AdIdSupport (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseCore (~> 11.0)
 | 
				
			||||||
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
 | 
					    - GoogleAppMeasurement (= 11.4.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					  - FirebaseCore (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseCoreInternal (~> 11.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Environment (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger (~> 8.0)
 | 
				
			||||||
 | 
					  - FirebaseCoreInternal (11.6.0):
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					  - FirebaseInstallations (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseCore (~> 11.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Environment (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
				
			||||||
 | 
					    - PromisesObjC (~> 2.4)
 | 
				
			||||||
 | 
					  - FirebaseMessaging (11.4.0):
 | 
				
			||||||
 | 
					    - FirebaseCore (~> 11.0)
 | 
				
			||||||
 | 
					    - FirebaseInstallations (~> 11.0)
 | 
				
			||||||
 | 
					    - GoogleDataTransport (~> 10.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Environment (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Reachability (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
  - Flutter (1.0.0)
 | 
					  - Flutter (1.0.0)
 | 
				
			||||||
  - flutter_native_splash (0.0.1):
 | 
					  - flutter_native_splash (2.4.3):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
  - flutter_secure_storage (3.3.1):
 | 
					  - flutter_udid (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					    - SAMKeychain
 | 
				
			||||||
 | 
					  - flutter_webrtc (0.12.2):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					    - WebRTC-SDK (= 125.6422.06)
 | 
				
			||||||
 | 
					  - gal (1.0.0):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					    - FlutterMacOS
 | 
				
			||||||
 | 
					  - GoogleAppMeasurement (11.4.0):
 | 
				
			||||||
 | 
					    - GoogleAppMeasurement/AdIdSupport (= 11.4.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					  - GoogleAppMeasurement/AdIdSupport (11.4.0):
 | 
				
			||||||
 | 
					    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.4.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					  - GoogleAppMeasurement/WithoutAdIdSupport (11.4.0):
 | 
				
			||||||
 | 
					    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network (~> 8.0)
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					  - GoogleDataTransport (10.1.0):
 | 
				
			||||||
 | 
					    - nanopb (~> 3.30910.0)
 | 
				
			||||||
 | 
					    - PromisesObjC (~> 2.4)
 | 
				
			||||||
 | 
					  - GoogleUtilities/AppDelegateSwizzler (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Environment
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger
 | 
				
			||||||
 | 
					    - GoogleUtilities/Network
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/Environment (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/Logger (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Environment
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/MethodSwizzler (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/Network (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger
 | 
				
			||||||
 | 
					    - "GoogleUtilities/NSData+zlib"
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					    - GoogleUtilities/Reachability
 | 
				
			||||||
 | 
					  - "GoogleUtilities/NSData+zlib (8.0.2)":
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/Privacy (8.0.2)
 | 
				
			||||||
 | 
					  - GoogleUtilities/Reachability (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
 | 
					  - GoogleUtilities/UserDefaults (8.0.2):
 | 
				
			||||||
 | 
					    - GoogleUtilities/Logger
 | 
				
			||||||
 | 
					    - GoogleUtilities/Privacy
 | 
				
			||||||
  - image_picker_ios (0.0.1):
 | 
					  - image_picker_ios (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - livekit_client (2.3.2):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					    - flutter_webrtc
 | 
				
			||||||
 | 
					    - WebRTC-SDK (= 125.6422.06)
 | 
				
			||||||
 | 
					  - media_kit_libs_ios_video (1.0.4):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - media_kit_native_event_loop (1.0.0):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - media_kit_video (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - nanopb (3.30910.0):
 | 
				
			||||||
 | 
					    - nanopb/decode (= 3.30910.0)
 | 
				
			||||||
 | 
					    - nanopb/encode (= 3.30910.0)
 | 
				
			||||||
 | 
					  - nanopb/decode (3.30910.0)
 | 
				
			||||||
 | 
					  - nanopb/encode (3.30910.0)
 | 
				
			||||||
 | 
					  - package_info_plus (0.4.5):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - pasteboard (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
  - path_provider_foundation (0.0.1):
 | 
					  - path_provider_foundation (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - FlutterMacOS
 | 
					    - FlutterMacOS
 | 
				
			||||||
  - SDWebImage (5.19.7):
 | 
					  - permission_handler_apple (9.3.0):
 | 
				
			||||||
    - SDWebImage/Core (= 5.19.7)
 | 
					    - Flutter
 | 
				
			||||||
  - SDWebImage/Core (5.19.7)
 | 
					  - PromisesObjC (2.4.0)
 | 
				
			||||||
 | 
					  - SAMKeychain (1.5.3)
 | 
				
			||||||
 | 
					  - screen_brightness_ios (0.1.0):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - SDWebImage (5.20.0):
 | 
				
			||||||
 | 
					    - SDWebImage/Core (= 5.20.0)
 | 
				
			||||||
 | 
					  - SDWebImage/Core (5.20.0)
 | 
				
			||||||
 | 
					  - share_plus (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
  - shared_preferences_foundation (0.0.1):
 | 
					  - shared_preferences_foundation (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - FlutterMacOS
 | 
					    - FlutterMacOS
 | 
				
			||||||
@@ -62,72 +207,171 @@ PODS:
 | 
				
			|||||||
  - SwiftyGif (5.4.5)
 | 
					  - SwiftyGif (5.4.5)
 | 
				
			||||||
  - url_launcher_ios (0.0.1):
 | 
					  - url_launcher_ios (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - volume_controller (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - wakelock_plus (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
 | 
					  - WebRTC-SDK (125.6422.06)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
DEPENDENCIES:
 | 
					DEPENDENCIES:
 | 
				
			||||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
 | 
					  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
 | 
				
			||||||
  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
					  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
				
			||||||
  - cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
 | 
					  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
				
			||||||
  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
					  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
				
			||||||
 | 
					  - file_saver (from `.symlinks/plugins/file_saver/ios`)
 | 
				
			||||||
 | 
					  - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
 | 
				
			||||||
 | 
					  - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
 | 
				
			||||||
 | 
					  - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
 | 
				
			||||||
  - Flutter (from `Flutter`)
 | 
					  - Flutter (from `Flutter`)
 | 
				
			||||||
  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
					  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
				
			||||||
  - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
 | 
					  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
				
			||||||
 | 
					  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
				
			||||||
 | 
					  - gal (from `.symlinks/plugins/gal/darwin`)
 | 
				
			||||||
  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
					  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
				
			||||||
 | 
					  - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
 | 
				
			||||||
 | 
					  - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
 | 
				
			||||||
 | 
					  - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
 | 
				
			||||||
 | 
					  - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
 | 
				
			||||||
 | 
					  - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
 | 
				
			||||||
 | 
					  - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
 | 
				
			||||||
  - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
 | 
					  - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
 | 
				
			||||||
 | 
					  - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
 | 
				
			||||||
 | 
					  - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
 | 
				
			||||||
 | 
					  - share_plus (from `.symlinks/plugins/share_plus/ios`)
 | 
				
			||||||
  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
					  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
				
			||||||
  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
					  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
				
			||||||
  - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
 | 
					  - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
 | 
				
			||||||
 | 
					  - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
 | 
				
			||||||
 | 
					  - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SPEC REPOS:
 | 
					SPEC REPOS:
 | 
				
			||||||
  trunk:
 | 
					  trunk:
 | 
				
			||||||
    - DKImagePickerController
 | 
					    - DKImagePickerController
 | 
				
			||||||
    - DKPhotoGallery
 | 
					    - DKPhotoGallery
 | 
				
			||||||
 | 
					    - Firebase
 | 
				
			||||||
 | 
					    - FirebaseAnalytics
 | 
				
			||||||
 | 
					    - FirebaseCore
 | 
				
			||||||
 | 
					    - FirebaseCoreInternal
 | 
				
			||||||
 | 
					    - FirebaseInstallations
 | 
				
			||||||
 | 
					    - FirebaseMessaging
 | 
				
			||||||
 | 
					    - GoogleAppMeasurement
 | 
				
			||||||
 | 
					    - GoogleDataTransport
 | 
				
			||||||
 | 
					    - GoogleUtilities
 | 
				
			||||||
 | 
					    - nanopb
 | 
				
			||||||
 | 
					    - PromisesObjC
 | 
				
			||||||
 | 
					    - SAMKeychain
 | 
				
			||||||
    - SDWebImage
 | 
					    - SDWebImage
 | 
				
			||||||
    - SwiftyGif
 | 
					    - SwiftyGif
 | 
				
			||||||
 | 
					    - WebRTC-SDK
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXTERNAL SOURCES:
 | 
					EXTERNAL SOURCES:
 | 
				
			||||||
  connectivity_plus:
 | 
					  connectivity_plus:
 | 
				
			||||||
    :path: ".symlinks/plugins/connectivity_plus/darwin"
 | 
					    :path: ".symlinks/plugins/connectivity_plus/darwin"
 | 
				
			||||||
  croppy:
 | 
					  croppy:
 | 
				
			||||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
					    :path: ".symlinks/plugins/croppy/ios"
 | 
				
			||||||
  cupertino_http:
 | 
					  device_info_plus:
 | 
				
			||||||
    :path: ".symlinks/plugins/cupertino_http/ios"
 | 
					    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
				
			||||||
  file_picker:
 | 
					  file_picker:
 | 
				
			||||||
    :path: ".symlinks/plugins/file_picker/ios"
 | 
					    :path: ".symlinks/plugins/file_picker/ios"
 | 
				
			||||||
 | 
					  file_saver:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/file_saver/ios"
 | 
				
			||||||
 | 
					  firebase_analytics:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/firebase_analytics/ios"
 | 
				
			||||||
 | 
					  firebase_core:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/firebase_core/ios"
 | 
				
			||||||
 | 
					  firebase_messaging:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/firebase_messaging/ios"
 | 
				
			||||||
  Flutter:
 | 
					  Flutter:
 | 
				
			||||||
    :path: Flutter
 | 
					    :path: Flutter
 | 
				
			||||||
  flutter_native_splash:
 | 
					  flutter_native_splash:
 | 
				
			||||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
					    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
				
			||||||
  flutter_secure_storage:
 | 
					  flutter_udid:
 | 
				
			||||||
    :path: ".symlinks/plugins/flutter_secure_storage/ios"
 | 
					    :path: ".symlinks/plugins/flutter_udid/ios"
 | 
				
			||||||
 | 
					  flutter_webrtc:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/flutter_webrtc/ios"
 | 
				
			||||||
 | 
					  gal:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/gal/darwin"
 | 
				
			||||||
  image_picker_ios:
 | 
					  image_picker_ios:
 | 
				
			||||||
    :path: ".symlinks/plugins/image_picker_ios/ios"
 | 
					    :path: ".symlinks/plugins/image_picker_ios/ios"
 | 
				
			||||||
 | 
					  livekit_client:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/livekit_client/ios"
 | 
				
			||||||
 | 
					  media_kit_libs_ios_video:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
 | 
				
			||||||
 | 
					  media_kit_native_event_loop:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/media_kit_native_event_loop/ios"
 | 
				
			||||||
 | 
					  media_kit_video:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/media_kit_video/ios"
 | 
				
			||||||
 | 
					  package_info_plus:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/package_info_plus/ios"
 | 
				
			||||||
 | 
					  pasteboard:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/pasteboard/ios"
 | 
				
			||||||
  path_provider_foundation:
 | 
					  path_provider_foundation:
 | 
				
			||||||
    :path: ".symlinks/plugins/path_provider_foundation/darwin"
 | 
					    :path: ".symlinks/plugins/path_provider_foundation/darwin"
 | 
				
			||||||
 | 
					  permission_handler_apple:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/permission_handler_apple/ios"
 | 
				
			||||||
 | 
					  screen_brightness_ios:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/screen_brightness_ios/ios"
 | 
				
			||||||
 | 
					  share_plus:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/share_plus/ios"
 | 
				
			||||||
  shared_preferences_foundation:
 | 
					  shared_preferences_foundation:
 | 
				
			||||||
    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
					    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
				
			||||||
  sqflite_darwin:
 | 
					  sqflite_darwin:
 | 
				
			||||||
    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
					    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
				
			||||||
  url_launcher_ios:
 | 
					  url_launcher_ios:
 | 
				
			||||||
    :path: ".symlinks/plugins/url_launcher_ios/ios"
 | 
					    :path: ".symlinks/plugins/url_launcher_ios/ios"
 | 
				
			||||||
 | 
					  volume_controller:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/volume_controller/ios"
 | 
				
			||||||
 | 
					  wakelock_plus:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/wakelock_plus/ios"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SPEC CHECKSUMS:
 | 
					SPEC CHECKSUMS:
 | 
				
			||||||
  connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
 | 
					  connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
 | 
				
			||||||
  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
					  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
				
			||||||
  cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
 | 
					  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
				
			||||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
					  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
				
			||||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
					  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
				
			||||||
  file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
 | 
					  file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
 | 
				
			||||||
 | 
					  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
				
			||||||
 | 
					  Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
 | 
				
			||||||
 | 
					  firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75
 | 
				
			||||||
 | 
					  firebase_core: 418aed674e9a0b8b6088aec16cde82a811f6261f
 | 
				
			||||||
 | 
					  firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9
 | 
				
			||||||
 | 
					  FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
 | 
				
			||||||
 | 
					  FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
 | 
				
			||||||
 | 
					  FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
 | 
				
			||||||
 | 
					  FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
 | 
				
			||||||
 | 
					  FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
 | 
				
			||||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
					  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
				
			||||||
  flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
 | 
					  flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
 | 
				
			||||||
  flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
 | 
					  flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
 | 
				
			||||||
 | 
					  flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
 | 
				
			||||||
 | 
					  gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
 | 
				
			||||||
 | 
					  GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
 | 
				
			||||||
 | 
					  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
				
			||||||
 | 
					  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
				
			||||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
					  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
				
			||||||
 | 
					  livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd
 | 
				
			||||||
 | 
					  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
				
			||||||
 | 
					  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
				
			||||||
 | 
					  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
				
			||||||
 | 
					  nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
 | 
				
			||||||
 | 
					  package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
 | 
				
			||||||
 | 
					  pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
 | 
				
			||||||
  path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
 | 
					  path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
 | 
				
			||||||
  SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
 | 
					  permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
 | 
				
			||||||
 | 
					  PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
 | 
				
			||||||
 | 
					  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
				
			||||||
 | 
					  screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
 | 
				
			||||||
 | 
					  SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
 | 
				
			||||||
 | 
					  share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
 | 
				
			||||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
					  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
				
			||||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
					  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
				
			||||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
					  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
				
			||||||
  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
					  url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
 | 
				
			||||||
 | 
					  volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
 | 
				
			||||||
 | 
					  wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
 | 
				
			||||||
 | 
					  WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
 | 
					PODFILE CHECKSUM: d2bdaa1cc7915e14cf47235c34a21fcb07b00390
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COCOAPODS: 1.15.2
 | 
					COCOAPODS: 1.16.2
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,9 @@
 | 
				
			|||||||
		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
 | 
							1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
 | 
				
			||||||
		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
 | 
							331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
 | 
				
			||||||
		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
 | 
							3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
 | 
				
			||||||
 | 
							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 */; };
 | 
							74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
 | 
				
			||||||
 | 
							8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */; };
 | 
				
			||||||
		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 | 
							97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 | 
				
			||||||
		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 | 
							97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 | 
				
			||||||
		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
 | 
							97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
 | 
				
			||||||
@@ -26,9 +28,27 @@
 | 
				
			|||||||
			remoteGlobalIDString = 97C146ED1CF9000F007C117D;
 | 
								remoteGlobalIDString = 97C146ED1CF9000F007C117D;
 | 
				
			||||||
			remoteInfo = Runner;
 | 
								remoteInfo = Runner;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA89FF2D05C7620024A03E /* PBXContainerItemProxy */ = {
 | 
				
			||||||
 | 
								isa = PBXContainerItemProxy;
 | 
				
			||||||
 | 
								containerPortal = 97C146E61CF9000F007C117D /* Project object */;
 | 
				
			||||||
 | 
								proxyType = 1;
 | 
				
			||||||
 | 
								remoteGlobalIDString = 73DA89F92D05C7620024A03E;
 | 
				
			||||||
 | 
								remoteInfo = SolarNotifyService;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
/* End PBXContainerItemProxy section */
 | 
					/* End PBXContainerItemProxy section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Begin PBXCopyFilesBuildPhase section */
 | 
					/* Begin PBXCopyFilesBuildPhase section */
 | 
				
			||||||
 | 
							73DA8A022D05C7620024A03E /* Embed Foundation Extensions */ = {
 | 
				
			||||||
 | 
								isa = PBXCopyFilesBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								dstPath = "";
 | 
				
			||||||
 | 
								dstSubfolderSpec = 13;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
									73DA8A012D05C7620024A03E /* SolarNotifyService.appex in Embed Foundation Extensions */,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								name = "Embed Foundation Extensions";
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		9705A1C41CF9048500538489 /* Embed Frameworks */ = {
 | 
							9705A1C41CF9048500538489 /* Embed Frameworks */ = {
 | 
				
			||||||
			isa = PBXCopyFilesBuildPhase;
 | 
								isa = PBXCopyFilesBuildPhase;
 | 
				
			||||||
			buildActionMask = 2147483647;
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
@@ -53,6 +73,8 @@
 | 
				
			|||||||
		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>"; };
 | 
							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>"; };
 | 
				
			||||||
		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>"; };
 | 
							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>"; };
 | 
				
			||||||
		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>"; };
 | 
							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>"; };
 | 
				
			||||||
 | 
							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>"; };
 | 
							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>"; };
 | 
							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>"; };
 | 
							7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
 | 
				
			||||||
@@ -64,10 +86,32 @@
 | 
				
			|||||||
		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 | 
							97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 | 
				
			||||||
		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 | 
							97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 | 
				
			||||||
		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 | 
							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>"; };
 | 
				
			||||||
		EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
							EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
				
			||||||
/* End PBXFileReference section */
 | 
					/* End PBXFileReference section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
 | 
				
			||||||
 | 
							73DA8A062D05C7620024A03E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
 | 
				
			||||||
 | 
								isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
 | 
				
			||||||
 | 
								membershipExceptions = (
 | 
				
			||||||
 | 
									Info.plist,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								target = 73DA89F92D05C7620024A03E /* SolarNotifyService */;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
					/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Begin PBXFileSystemSynchronizedRootGroup section */
 | 
				
			||||||
 | 
							73DA89FB2D05C7620024A03E /* SolarNotifyService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (73DA8A062D05C7620024A03E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = SolarNotifyService; sourceTree = "<group>"; };
 | 
				
			||||||
 | 
					/* End PBXFileSystemSynchronizedRootGroup section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Begin PBXFrameworksBuildPhase section */
 | 
					/* Begin PBXFrameworksBuildPhase section */
 | 
				
			||||||
 | 
							73DA89F72D05C7620024A03E /* Frameworks */ = {
 | 
				
			||||||
 | 
								isa = PBXFrameworksBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C146EB1CF9000F007C117D /* Frameworks */ = {
 | 
							97C146EB1CF9000F007C117D /* Frameworks */ = {
 | 
				
			||||||
			isa = PBXFrameworksBuildPhase;
 | 
								isa = PBXFrameworksBuildPhase;
 | 
				
			||||||
			buildActionMask = 2147483647;
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
@@ -120,10 +164,12 @@
 | 
				
			|||||||
			children = (
 | 
								children = (
 | 
				
			||||||
				9740EEB11CF90186004384FC /* Flutter */,
 | 
									9740EEB11CF90186004384FC /* Flutter */,
 | 
				
			||||||
				97C146F01CF9000F007C117D /* Runner */,
 | 
									97C146F01CF9000F007C117D /* Runner */,
 | 
				
			||||||
 | 
									73DA89FB2D05C7620024A03E /* SolarNotifyService */,
 | 
				
			||||||
				97C146EF1CF9000F007C117D /* Products */,
 | 
									97C146EF1CF9000F007C117D /* Products */,
 | 
				
			||||||
				331C8082294A63A400263BE5 /* RunnerTests */,
 | 
									331C8082294A63A400263BE5 /* RunnerTests */,
 | 
				
			||||||
				F5165E3BD1F2519F85CD4BE2 /* Pods */,
 | 
									F5165E3BD1F2519F85CD4BE2 /* Pods */,
 | 
				
			||||||
				09229EB4EB35A0678AB9738D /* Frameworks */,
 | 
									09229EB4EB35A0678AB9738D /* Frameworks */,
 | 
				
			||||||
 | 
									A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			sourceTree = "<group>";
 | 
								sourceTree = "<group>";
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
@@ -132,6 +178,7 @@
 | 
				
			|||||||
			children = (
 | 
								children = (
 | 
				
			||||||
				97C146EE1CF9000F007C117D /* Runner.app */,
 | 
									97C146EE1CF9000F007C117D /* Runner.app */,
 | 
				
			||||||
				331C8081294A63A400263BE5 /* RunnerTests.xctest */,
 | 
									331C8081294A63A400263BE5 /* RunnerTests.xctest */,
 | 
				
			||||||
 | 
									73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			name = Products;
 | 
								name = Products;
 | 
				
			||||||
			sourceTree = "<group>";
 | 
								sourceTree = "<group>";
 | 
				
			||||||
@@ -139,6 +186,7 @@
 | 
				
			|||||||
		97C146F01CF9000F007C117D /* Runner */ = {
 | 
							97C146F01CF9000F007C117D /* Runner */ = {
 | 
				
			||||||
			isa = PBXGroup;
 | 
								isa = PBXGroup;
 | 
				
			||||||
			children = (
 | 
								children = (
 | 
				
			||||||
 | 
									73111C212CEE3D5E004CF4B3 /* Runner.entitlements */,
 | 
				
			||||||
				97C146FA1CF9000F007C117D /* Main.storyboard */,
 | 
									97C146FA1CF9000F007C117D /* Main.storyboard */,
 | 
				
			||||||
				97C146FD1CF9000F007C117D /* Assets.xcassets */,
 | 
									97C146FD1CF9000F007C117D /* Assets.xcassets */,
 | 
				
			||||||
				97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
 | 
									97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
 | 
				
			||||||
@@ -186,6 +234,28 @@
 | 
				
			|||||||
			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
 | 
								productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
 | 
				
			||||||
			productType = "com.apple.product-type.bundle.unit-test";
 | 
								productType = "com.apple.product-type.bundle.unit-test";
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA89F92D05C7620024A03E /* SolarNotifyService */ = {
 | 
				
			||||||
 | 
								isa = PBXNativeTarget;
 | 
				
			||||||
 | 
								buildConfigurationList = 73DA8A072D05C7620024A03E /* Build configuration list for PBXNativeTarget "SolarNotifyService" */;
 | 
				
			||||||
 | 
								buildPhases = (
 | 
				
			||||||
 | 
									73DA89F62D05C7620024A03E /* Sources */,
 | 
				
			||||||
 | 
									73DA89F72D05C7620024A03E /* Frameworks */,
 | 
				
			||||||
 | 
									73DA89F82D05C7620024A03E /* Resources */,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								buildRules = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								dependencies = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								fileSystemSynchronizedGroups = (
 | 
				
			||||||
 | 
									73DA89FB2D05C7620024A03E /* SolarNotifyService */,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								name = SolarNotifyService;
 | 
				
			||||||
 | 
								packageProductDependencies = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								productName = SolarNotifyService;
 | 
				
			||||||
 | 
								productReference = 73DA89FA2D05C7620024A03E /* SolarNotifyService.appex */;
 | 
				
			||||||
 | 
								productType = "com.apple.product-type.app-extension";
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C146ED1CF9000F007C117D /* Runner */ = {
 | 
							97C146ED1CF9000F007C117D /* Runner */ = {
 | 
				
			||||||
			isa = PBXNativeTarget;
 | 
								isa = PBXNativeTarget;
 | 
				
			||||||
			buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
 | 
								buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
 | 
				
			||||||
@@ -195,13 +265,17 @@
 | 
				
			|||||||
				97C146EA1CF9000F007C117D /* Sources */,
 | 
									97C146EA1CF9000F007C117D /* Sources */,
 | 
				
			||||||
				97C146EB1CF9000F007C117D /* Frameworks */,
 | 
									97C146EB1CF9000F007C117D /* Frameworks */,
 | 
				
			||||||
				97C146EC1CF9000F007C117D /* Resources */,
 | 
									97C146EC1CF9000F007C117D /* Resources */,
 | 
				
			||||||
 | 
									73DA8A022D05C7620024A03E /* Embed Foundation Extensions */,
 | 
				
			||||||
				9705A1C41CF9048500538489 /* Embed Frameworks */,
 | 
									9705A1C41CF9048500538489 /* Embed Frameworks */,
 | 
				
			||||||
				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
 | 
									3B06AD1E1E4923F5004D2608 /* Thin Binary */,
 | 
				
			||||||
				FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */,
 | 
									FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */,
 | 
				
			||||||
 | 
									244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
 | 
				
			||||||
 | 
									43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			buildRules = (
 | 
								buildRules = (
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			dependencies = (
 | 
								dependencies = (
 | 
				
			||||||
 | 
									73DA8A002D05C7620024A03E /* PBXTargetDependency */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			name = Runner;
 | 
								name = Runner;
 | 
				
			||||||
			productName = Runner;
 | 
								productName = Runner;
 | 
				
			||||||
@@ -215,6 +289,7 @@
 | 
				
			|||||||
			isa = PBXProject;
 | 
								isa = PBXProject;
 | 
				
			||||||
			attributes = {
 | 
								attributes = {
 | 
				
			||||||
				BuildIndependentTargetsInParallel = YES;
 | 
									BuildIndependentTargetsInParallel = YES;
 | 
				
			||||||
 | 
									LastSwiftUpdateCheck = 1610;
 | 
				
			||||||
				LastUpgradeCheck = 1510;
 | 
									LastUpgradeCheck = 1510;
 | 
				
			||||||
				ORGANIZATIONNAME = "";
 | 
									ORGANIZATIONNAME = "";
 | 
				
			||||||
				TargetAttributes = {
 | 
									TargetAttributes = {
 | 
				
			||||||
@@ -222,6 +297,9 @@
 | 
				
			|||||||
						CreatedOnToolsVersion = 14.0;
 | 
											CreatedOnToolsVersion = 14.0;
 | 
				
			||||||
						TestTargetID = 97C146ED1CF9000F007C117D;
 | 
											TestTargetID = 97C146ED1CF9000F007C117D;
 | 
				
			||||||
					};
 | 
										};
 | 
				
			||||||
 | 
										73DA89F92D05C7620024A03E = {
 | 
				
			||||||
 | 
											CreatedOnToolsVersion = 16.1;
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
					97C146ED1CF9000F007C117D = {
 | 
										97C146ED1CF9000F007C117D = {
 | 
				
			||||||
						CreatedOnToolsVersion = 7.3.1;
 | 
											CreatedOnToolsVersion = 7.3.1;
 | 
				
			||||||
						LastSwiftMigration = 1100;
 | 
											LastSwiftMigration = 1100;
 | 
				
			||||||
@@ -243,6 +321,7 @@
 | 
				
			|||||||
			targets = (
 | 
								targets = (
 | 
				
			||||||
				97C146ED1CF9000F007C117D /* Runner */,
 | 
									97C146ED1CF9000F007C117D /* Runner */,
 | 
				
			||||||
				331C8080294A63A400263BE5 /* RunnerTests */,
 | 
									331C8080294A63A400263BE5 /* RunnerTests */,
 | 
				
			||||||
 | 
									73DA89F92D05C7620024A03E /* SolarNotifyService */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
/* End PBXProject section */
 | 
					/* End PBXProject section */
 | 
				
			||||||
@@ -255,6 +334,13 @@
 | 
				
			|||||||
			);
 | 
								);
 | 
				
			||||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA89F82D05C7620024A03E /* Resources */ = {
 | 
				
			||||||
 | 
								isa = PBXResourcesBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C146EC1CF9000F007C117D /* Resources */ = {
 | 
							97C146EC1CF9000F007C117D /* Resources */ = {
 | 
				
			||||||
			isa = PBXResourcesBuildPhase;
 | 
								isa = PBXResourcesBuildPhase;
 | 
				
			||||||
			buildActionMask = 2147483647;
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
@@ -263,12 +349,31 @@
 | 
				
			|||||||
				3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
 | 
									3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
 | 
				
			||||||
				97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
 | 
									97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
 | 
				
			||||||
				97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
 | 
									97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
 | 
				
			||||||
 | 
									8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */,
 | 
				
			||||||
			);
 | 
								);
 | 
				
			||||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
/* End PBXResourcesBuildPhase section */
 | 
					/* End PBXResourcesBuildPhase section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Begin PBXShellScriptBuildPhase section */
 | 
					/* Begin PBXShellScriptBuildPhase section */
 | 
				
			||||||
 | 
							244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
 | 
				
			||||||
 | 
								isa = PBXShellScriptBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								inputFileListPaths = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								inputPaths = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\"";
 | 
				
			||||||
 | 
								outputFileListPaths = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								outputPaths = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
								shellPath = /bin/sh;
 | 
				
			||||||
 | 
								shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n";
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
 | 
							3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
 | 
				
			||||||
			isa = PBXShellScriptBuildPhase;
 | 
								isa = PBXShellScriptBuildPhase;
 | 
				
			||||||
			alwaysOutOfDate = 1;
 | 
								alwaysOutOfDate = 1;
 | 
				
			||||||
@@ -285,6 +390,23 @@
 | 
				
			|||||||
			shellPath = /bin/sh;
 | 
								shellPath = /bin/sh;
 | 
				
			||||||
			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
 | 
								shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */ = {
 | 
				
			||||||
 | 
								isa = PBXShellScriptBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								inputFileListPaths = (
 | 
				
			||||||
 | 
									"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								name = "[CP] Copy Pods Resources";
 | 
				
			||||||
 | 
								outputFileListPaths = (
 | 
				
			||||||
 | 
									"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
								shellPath = /bin/sh;
 | 
				
			||||||
 | 
								shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
 | 
				
			||||||
 | 
								showEnvVarsInLog = 0;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		9740EEB61CF901F6004384FC /* Run Script */ = {
 | 
							9740EEB61CF901F6004384FC /* Run Script */ = {
 | 
				
			||||||
			isa = PBXShellScriptBuildPhase;
 | 
								isa = PBXShellScriptBuildPhase;
 | 
				
			||||||
			alwaysOutOfDate = 1;
 | 
								alwaysOutOfDate = 1;
 | 
				
			||||||
@@ -372,6 +494,13 @@
 | 
				
			|||||||
			);
 | 
								);
 | 
				
			||||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA89F62D05C7620024A03E /* Sources */ = {
 | 
				
			||||||
 | 
								isa = PBXSourcesBuildPhase;
 | 
				
			||||||
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
 | 
								files = (
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								runOnlyForDeploymentPostprocessing = 0;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C146EA1CF9000F007C117D /* Sources */ = {
 | 
							97C146EA1CF9000F007C117D /* Sources */ = {
 | 
				
			||||||
			isa = PBXSourcesBuildPhase;
 | 
								isa = PBXSourcesBuildPhase;
 | 
				
			||||||
			buildActionMask = 2147483647;
 | 
								buildActionMask = 2147483647;
 | 
				
			||||||
@@ -389,6 +518,11 @@
 | 
				
			|||||||
			target = 97C146ED1CF9000F007C117D /* Runner */;
 | 
								target = 97C146ED1CF9000F007C117D /* Runner */;
 | 
				
			||||||
			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
 | 
								targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA8A002D05C7620024A03E /* PBXTargetDependency */ = {
 | 
				
			||||||
 | 
								isa = PBXTargetDependency;
 | 
				
			||||||
 | 
								target = 73DA89F92D05C7620024A03E /* SolarNotifyService */;
 | 
				
			||||||
 | 
								targetProxy = 73DA89FF2D05C7620024A03E /* PBXContainerItemProxy */;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
/* End PBXTargetDependency section */
 | 
					/* End PBXTargetDependency section */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Begin PBXVariantGroup section */
 | 
					/* Begin PBXVariantGroup section */
 | 
				
			||||||
@@ -469,11 +603,13 @@
 | 
				
			|||||||
			buildSettings = {
 | 
								buildSettings = {
 | 
				
			||||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
									ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
				
			||||||
				CLANG_ENABLE_MODULES = YES;
 | 
									CLANG_ENABLE_MODULES = YES;
 | 
				
			||||||
 | 
									CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
				
			||||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
									CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
				
			||||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
				ENABLE_BITCODE = NO;
 | 
									ENABLE_BITCODE = NO;
 | 
				
			||||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
									INFOPLIST_FILE = Runner/Info.plist;
 | 
				
			||||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
									INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
				
			||||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
					"$(inherited)",
 | 
										"$(inherited)",
 | 
				
			||||||
					"@executable_path/Frameworks",
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
@@ -536,6 +672,120 @@
 | 
				
			|||||||
			};
 | 
								};
 | 
				
			||||||
			name = Profile;
 | 
								name = Profile;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA8A032D05C7620024A03E /* Debug */ = {
 | 
				
			||||||
 | 
								isa = XCBuildConfiguration;
 | 
				
			||||||
 | 
								buildSettings = {
 | 
				
			||||||
 | 
									ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
				
			||||||
 | 
									CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
				
			||||||
 | 
									CLANG_ENABLE_OBJC_WEAK = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CODE_SIGN_STYLE = Automatic;
 | 
				
			||||||
 | 
									CURRENT_PROJECT_VERSION = 1;
 | 
				
			||||||
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
 | 
									ENABLE_USER_SCRIPT_SANDBOXING = YES;
 | 
				
			||||||
 | 
									GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
				
			||||||
 | 
									GENERATE_INFOPLIST_FILE = YES;
 | 
				
			||||||
 | 
									INFOPLIST_FILE = SolarNotifyService/Info.plist;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_NSHumanReadableCopyright = "";
 | 
				
			||||||
 | 
									IPHONEOS_DEPLOYMENT_TARGET = 18.1;
 | 
				
			||||||
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
 | 
										"$(inherited)",
 | 
				
			||||||
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
 | 
										"@executable_path/../../Frameworks",
 | 
				
			||||||
 | 
									);
 | 
				
			||||||
 | 
									LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
				
			||||||
 | 
									MARKETING_VERSION = 1.0;
 | 
				
			||||||
 | 
									MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 | 
				
			||||||
 | 
									MTL_FAST_MATH = YES;
 | 
				
			||||||
 | 
									PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
 | 
				
			||||||
 | 
									PRODUCT_NAME = "$(TARGET_NAME)";
 | 
				
			||||||
 | 
									SKIP_INSTALL = YES;
 | 
				
			||||||
 | 
									SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
 | 
				
			||||||
 | 
									SWIFT_EMIT_LOC_STRINGS = YES;
 | 
				
			||||||
 | 
									SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 | 
				
			||||||
 | 
									SWIFT_VERSION = 5.0;
 | 
				
			||||||
 | 
									TARGETED_DEVICE_FAMILY = "1,2";
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								name = Debug;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							73DA8A042D05C7620024A03E /* Release */ = {
 | 
				
			||||||
 | 
								isa = XCBuildConfiguration;
 | 
				
			||||||
 | 
								buildSettings = {
 | 
				
			||||||
 | 
									ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
				
			||||||
 | 
									CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
				
			||||||
 | 
									CLANG_ENABLE_OBJC_WEAK = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CODE_SIGN_STYLE = Automatic;
 | 
				
			||||||
 | 
									CURRENT_PROJECT_VERSION = 1;
 | 
				
			||||||
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
 | 
									ENABLE_USER_SCRIPT_SANDBOXING = YES;
 | 
				
			||||||
 | 
									GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
				
			||||||
 | 
									GENERATE_INFOPLIST_FILE = YES;
 | 
				
			||||||
 | 
									INFOPLIST_FILE = SolarNotifyService/Info.plist;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_NSHumanReadableCopyright = "";
 | 
				
			||||||
 | 
									IPHONEOS_DEPLOYMENT_TARGET = 18.1;
 | 
				
			||||||
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
 | 
										"$(inherited)",
 | 
				
			||||||
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
 | 
										"@executable_path/../../Frameworks",
 | 
				
			||||||
 | 
									);
 | 
				
			||||||
 | 
									LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
				
			||||||
 | 
									MARKETING_VERSION = 1.0;
 | 
				
			||||||
 | 
									MTL_FAST_MATH = YES;
 | 
				
			||||||
 | 
									PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
 | 
				
			||||||
 | 
									PRODUCT_NAME = "$(TARGET_NAME)";
 | 
				
			||||||
 | 
									SKIP_INSTALL = YES;
 | 
				
			||||||
 | 
									SWIFT_EMIT_LOC_STRINGS = YES;
 | 
				
			||||||
 | 
									SWIFT_VERSION = 5.0;
 | 
				
			||||||
 | 
									TARGETED_DEVICE_FAMILY = "1,2";
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								name = Release;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
							73DA8A052D05C7620024A03E /* Profile */ = {
 | 
				
			||||||
 | 
								isa = XCBuildConfiguration;
 | 
				
			||||||
 | 
								buildSettings = {
 | 
				
			||||||
 | 
									ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
				
			||||||
 | 
									CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
				
			||||||
 | 
									CLANG_ENABLE_OBJC_WEAK = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
				
			||||||
 | 
									CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
				
			||||||
 | 
									CODE_SIGN_STYLE = Automatic;
 | 
				
			||||||
 | 
									CURRENT_PROJECT_VERSION = 1;
 | 
				
			||||||
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
 | 
									ENABLE_USER_SCRIPT_SANDBOXING = YES;
 | 
				
			||||||
 | 
									GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
				
			||||||
 | 
									GENERATE_INFOPLIST_FILE = YES;
 | 
				
			||||||
 | 
									INFOPLIST_FILE = SolarNotifyService/Info.plist;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_CFBundleDisplayName = SolarNotifyService;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_NSHumanReadableCopyright = "";
 | 
				
			||||||
 | 
									IPHONEOS_DEPLOYMENT_TARGET = 18.1;
 | 
				
			||||||
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
 | 
										"$(inherited)",
 | 
				
			||||||
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
 | 
										"@executable_path/../../Frameworks",
 | 
				
			||||||
 | 
									);
 | 
				
			||||||
 | 
									LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
				
			||||||
 | 
									MARKETING_VERSION = 1.0;
 | 
				
			||||||
 | 
									MTL_FAST_MATH = YES;
 | 
				
			||||||
 | 
									PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolarNotifyService;
 | 
				
			||||||
 | 
									PRODUCT_NAME = "$(TARGET_NAME)";
 | 
				
			||||||
 | 
									SKIP_INSTALL = YES;
 | 
				
			||||||
 | 
									SWIFT_EMIT_LOC_STRINGS = YES;
 | 
				
			||||||
 | 
									SWIFT_VERSION = 5.0;
 | 
				
			||||||
 | 
									TARGETED_DEVICE_FAMILY = "1,2";
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								name = Profile;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C147031CF9000F007C117D /* Debug */ = {
 | 
							97C147031CF9000F007C117D /* Debug */ = {
 | 
				
			||||||
			isa = XCBuildConfiguration;
 | 
								isa = XCBuildConfiguration;
 | 
				
			||||||
			buildSettings = {
 | 
								buildSettings = {
 | 
				
			||||||
@@ -653,11 +903,13 @@
 | 
				
			|||||||
			buildSettings = {
 | 
								buildSettings = {
 | 
				
			||||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
									ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
				
			||||||
				CLANG_ENABLE_MODULES = YES;
 | 
									CLANG_ENABLE_MODULES = YES;
 | 
				
			||||||
 | 
									CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
				
			||||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
									CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
				
			||||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
				ENABLE_BITCODE = NO;
 | 
									ENABLE_BITCODE = NO;
 | 
				
			||||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
									INFOPLIST_FILE = Runner/Info.plist;
 | 
				
			||||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
									INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
				
			||||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
					"$(inherited)",
 | 
										"$(inherited)",
 | 
				
			||||||
					"@executable_path/Frameworks",
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
@@ -677,11 +929,13 @@
 | 
				
			|||||||
			buildSettings = {
 | 
								buildSettings = {
 | 
				
			||||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
									ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
				
			||||||
				CLANG_ENABLE_MODULES = YES;
 | 
									CLANG_ENABLE_MODULES = YES;
 | 
				
			||||||
 | 
									CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
				
			||||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
									CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
				
			||||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
									DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
				
			||||||
				ENABLE_BITCODE = NO;
 | 
									ENABLE_BITCODE = NO;
 | 
				
			||||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
									INFOPLIST_FILE = Runner/Info.plist;
 | 
				
			||||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
									INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
				
			||||||
 | 
									INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
				
			||||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
									LD_RUNPATH_SEARCH_PATHS = (
 | 
				
			||||||
					"$(inherited)",
 | 
										"$(inherited)",
 | 
				
			||||||
					"@executable_path/Frameworks",
 | 
										"@executable_path/Frameworks",
 | 
				
			||||||
@@ -707,6 +961,16 @@
 | 
				
			|||||||
			defaultConfigurationIsVisible = 0;
 | 
								defaultConfigurationIsVisible = 0;
 | 
				
			||||||
			defaultConfigurationName = Release;
 | 
								defaultConfigurationName = Release;
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
							73DA8A072D05C7620024A03E /* Build configuration list for PBXNativeTarget "SolarNotifyService" */ = {
 | 
				
			||||||
 | 
								isa = XCConfigurationList;
 | 
				
			||||||
 | 
								buildConfigurations = (
 | 
				
			||||||
 | 
									73DA8A032D05C7620024A03E /* Debug */,
 | 
				
			||||||
 | 
									73DA8A042D05C7620024A03E /* Release */,
 | 
				
			||||||
 | 
									73DA8A052D05C7620024A03E /* Profile */,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
								defaultConfigurationIsVisible = 0;
 | 
				
			||||||
 | 
								defaultConfigurationName = Release;
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
		97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
 | 
							97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
 | 
				
			||||||
			isa = XCConfigurationList;
 | 
								isa = XCConfigurationList;
 | 
				
			||||||
			buildConfigurations = (
 | 
								buildConfigurations = (
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										30
									
								
								ios/Runner/GoogleService-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="UTF-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>API_KEY</key>
 | 
				
			||||||
 | 
						<string>AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8</string>
 | 
				
			||||||
 | 
						<key>GCM_SENDER_ID</key>
 | 
				
			||||||
 | 
						<string>961776991058</string>
 | 
				
			||||||
 | 
						<key>PLIST_VERSION</key>
 | 
				
			||||||
 | 
						<string>1</string>
 | 
				
			||||||
 | 
						<key>BUNDLE_ID</key>
 | 
				
			||||||
 | 
						<string>dev.solsynth.solian</string>
 | 
				
			||||||
 | 
						<key>PROJECT_ID</key>
 | 
				
			||||||
 | 
						<string>solian-0x001</string>
 | 
				
			||||||
 | 
						<key>STORAGE_BUCKET</key>
 | 
				
			||||||
 | 
						<string>solian-0x001.firebasestorage.app</string>
 | 
				
			||||||
 | 
						<key>IS_ADS_ENABLED</key>
 | 
				
			||||||
 | 
						<false></false>
 | 
				
			||||||
 | 
						<key>IS_ANALYTICS_ENABLED</key>
 | 
				
			||||||
 | 
						<false></false>
 | 
				
			||||||
 | 
						<key>IS_APPINVITE_ENABLED</key>
 | 
				
			||||||
 | 
						<true></true>
 | 
				
			||||||
 | 
						<key>IS_GCM_ENABLED</key>
 | 
				
			||||||
 | 
						<true></true>
 | 
				
			||||||
 | 
						<key>IS_SIGNIN_ENABLED</key>
 | 
				
			||||||
 | 
						<true></true>
 | 
				
			||||||
 | 
						<key>GOOGLE_APP_ID</key>
 | 
				
			||||||
 | 
						<string>1:961776991058:ios:727229d368cc47e1f4188b</string>
 | 
				
			||||||
 | 
					</dict>
 | 
				
			||||||
 | 
					</plist>
 | 
				
			||||||
@@ -2,6 +2,8 @@
 | 
				
			|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
					<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
				
			||||||
<plist version="1.0">
 | 
					<plist version="1.0">
 | 
				
			||||||
<dict>
 | 
					<dict>
 | 
				
			||||||
 | 
						<key>CADisableMinimumFrameDurationOnPhone</key>
 | 
				
			||||||
 | 
						<true/>
 | 
				
			||||||
	<key>CFBundleDevelopmentRegion</key>
 | 
						<key>CFBundleDevelopmentRegion</key>
 | 
				
			||||||
	<string>$(DEVELOPMENT_LANGUAGE)</string>
 | 
						<string>$(DEVELOPMENT_LANGUAGE)</string>
 | 
				
			||||||
	<key>CFBundleDisplayName</key>
 | 
						<key>CFBundleDisplayName</key>
 | 
				
			||||||
@@ -12,6 +14,11 @@
 | 
				
			|||||||
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
 | 
						<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
 | 
				
			||||||
	<key>CFBundleInfoDictionaryVersion</key>
 | 
						<key>CFBundleInfoDictionaryVersion</key>
 | 
				
			||||||
	<string>6.0</string>
 | 
						<string>6.0</string>
 | 
				
			||||||
 | 
						<key>CFBundleLocalizations</key>
 | 
				
			||||||
 | 
						<array>
 | 
				
			||||||
 | 
							<string>en</string>
 | 
				
			||||||
 | 
							<string>zh_CN</string>
 | 
				
			||||||
 | 
						</array>
 | 
				
			||||||
	<key>CFBundleName</key>
 | 
						<key>CFBundleName</key>
 | 
				
			||||||
	<string>Solian</string>
 | 
						<string>Solian</string>
 | 
				
			||||||
	<key>CFBundlePackageType</key>
 | 
						<key>CFBundlePackageType</key>
 | 
				
			||||||
@@ -22,12 +29,37 @@
 | 
				
			|||||||
	<string>????</string>
 | 
						<string>????</string>
 | 
				
			||||||
	<key>CFBundleVersion</key>
 | 
						<key>CFBundleVersion</key>
 | 
				
			||||||
	<string>$(FLUTTER_BUILD_NUMBER)</string>
 | 
						<string>$(FLUTTER_BUILD_NUMBER)</string>
 | 
				
			||||||
 | 
						<key>ITSAppUsesNonExemptEncryption</key>
 | 
				
			||||||
 | 
						<false/>
 | 
				
			||||||
	<key>LSRequiresIPhoneOS</key>
 | 
						<key>LSRequiresIPhoneOS</key>
 | 
				
			||||||
	<true/>
 | 
						<true/>
 | 
				
			||||||
 | 
						<key>NSCameraUsageDescription</key>
 | 
				
			||||||
 | 
						<string>Grant access to Photo Library 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>
 | 
				
			||||||
 | 
						<key>NSPhotoLibraryAddUsageDescription</key>
 | 
				
			||||||
 | 
						<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
 | 
				
			||||||
 | 
						<key>NSPhotoLibraryUsageDescription</key>
 | 
				
			||||||
 | 
						<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
 | 
				
			||||||
 | 
						<key>NSUserActivityTypes</key>
 | 
				
			||||||
 | 
						<array>
 | 
				
			||||||
 | 
							<string>INSendMessageIntent</string>
 | 
				
			||||||
 | 
						</array>
 | 
				
			||||||
 | 
						<key>UIApplicationSupportsIndirectInputEvents</key>
 | 
				
			||||||
 | 
						<true/>
 | 
				
			||||||
 | 
						<key>UIBackgroundModes</key>
 | 
				
			||||||
 | 
						<array>
 | 
				
			||||||
 | 
							<string>fetch</string>
 | 
				
			||||||
 | 
							<string>remote-notification</string>
 | 
				
			||||||
 | 
							<string>audio</string>
 | 
				
			||||||
 | 
							<string>voip</string>
 | 
				
			||||||
 | 
						</array>
 | 
				
			||||||
	<key>UILaunchStoryboardName</key>
 | 
						<key>UILaunchStoryboardName</key>
 | 
				
			||||||
	<string>LaunchScreen</string>
 | 
						<string>LaunchScreen</string>
 | 
				
			||||||
	<key>UIMainStoryboardFile</key>
 | 
						<key>UIMainStoryboardFile</key>
 | 
				
			||||||
	<string>Main</string>
 | 
						<string>Main</string>
 | 
				
			||||||
 | 
						<key>UIStatusBarHidden</key>
 | 
				
			||||||
 | 
						<false/>
 | 
				
			||||||
	<key>UISupportedInterfaceOrientations</key>
 | 
						<key>UISupportedInterfaceOrientations</key>
 | 
				
			||||||
	<array>
 | 
						<array>
 | 
				
			||||||
		<string>UIInterfaceOrientationPortrait</string>
 | 
							<string>UIInterfaceOrientationPortrait</string>
 | 
				
			||||||
@@ -41,24 +73,5 @@
 | 
				
			|||||||
		<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
							<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
				
			||||||
		<string>UIInterfaceOrientationLandscapeRight</string>
 | 
							<string>UIInterfaceOrientationLandscapeRight</string>
 | 
				
			||||||
	</array>
 | 
						</array>
 | 
				
			||||||
	<key>CADisableMinimumFrameDurationOnPhone</key>
 | 
					 | 
				
			||||||
	<true/>
 | 
					 | 
				
			||||||
	<key>UIApplicationSupportsIndirectInputEvents</key>
 | 
					 | 
				
			||||||
	<true/>
 | 
					 | 
				
			||||||
	<key>CFBundleLocalizations</key>
 | 
					 | 
				
			||||||
	<array>
 | 
					 | 
				
			||||||
		<string>en</string>
 | 
					 | 
				
			||||||
		<string>zh_CN</string>
 | 
					 | 
				
			||||||
	</array>
 | 
					 | 
				
			||||||
	<key>NSPhotoLibraryUsageDescription</key>
 | 
					 | 
				
			||||||
	<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
 | 
					 | 
				
			||||||
	<key>NSCameraUsageDescription</key>
 | 
					 | 
				
			||||||
	<string>Grant access to Photo Library 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>
 | 
					 | 
				
			||||||
	<key>ITSAppUsesNonExemptEncryption</key>
 | 
					 | 
				
			||||||
	<false/>
 | 
					 | 
				
			||||||
	<key>UIStatusBarHidden</key>
 | 
					 | 
				
			||||||
	<false/>
 | 
					 | 
				
			||||||
</dict>
 | 
					</dict>
 | 
				
			||||||
</plist>
 | 
					</plist>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								ios/Runner/Runner.entitlements
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="UTF-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>aps-environment</key>
 | 
				
			||||||
 | 
						<string>development</string>
 | 
				
			||||||
 | 
						<key>com.apple.developer.usernotifications.communication</key>
 | 
				
			||||||
 | 
						<true/>
 | 
				
			||||||
 | 
					</dict>
 | 
				
			||||||
 | 
					</plist>
 | 
				
			||||||
							
								
								
									
										18
									
								
								ios/SolarNotifyService/Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="UTF-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>NSUserActivityTypes</key>
 | 
				
			||||||
 | 
						<array>
 | 
				
			||||||
 | 
							<string>INStartCallIntent</string>
 | 
				
			||||||
 | 
							<string>INSendMessageIntent</string>
 | 
				
			||||||
 | 
						</array>
 | 
				
			||||||
 | 
						<key>NSExtension</key>
 | 
				
			||||||
 | 
						<dict>
 | 
				
			||||||
 | 
							<key>NSExtensionPointIdentifier</key>
 | 
				
			||||||
 | 
							<string>com.apple.usernotifications.service</string>
 | 
				
			||||||
 | 
							<key>NSExtensionPrincipalClass</key>
 | 
				
			||||||
 | 
							<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
 | 
				
			||||||
 | 
						</dict>
 | 
				
			||||||
 | 
					</dict>
 | 
				
			||||||
 | 
					</plist>
 | 
				
			||||||
							
								
								
									
										245
									
								
								ios/SolarNotifyService/NotificationService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,245 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  NotificationService.swift
 | 
				
			||||||
 | 
					//  SolarNotifyService
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by LittleSheep on 2024/12/8.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import UserNotifications
 | 
				
			||||||
 | 
					import Intents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum ParseNotificationPayloadError: Error {
 | 
				
			||||||
 | 
					    case missingMetadata(String)
 | 
				
			||||||
 | 
					    case missingAvatarUrl(String)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotificationService: UNNotificationServiceExtension {
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private var contentHandler: ((UNNotificationContent) -> Void)?
 | 
				
			||||||
 | 
					    private var bestAttemptContent: UNMutableNotificationContent?
 | 
				
			||||||
 | 
					    private let serverBaseUrl = "https://api.sn.solsynth.dev"
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func getAttachmentUrl(for identifier: String) -> String {
 | 
				
			||||||
 | 
					        identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/cgi/uc/attachments/\(identifier)"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        self.contentHandler = contentHandler
 | 
				
			||||||
 | 
					        guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
 | 
				
			||||||
 | 
					            contentHandler(request.content)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        self.bestAttemptContent = bestAttemptContent
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        do {
 | 
				
			||||||
 | 
					            try processNotification(request: request, content: bestAttemptContent)
 | 
				
			||||||
 | 
					        } catch {
 | 
				
			||||||
 | 
					            contentHandler(bestAttemptContent)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    override func serviceExtensionTimeWillExpire() {
 | 
				
			||||||
 | 
					        if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
 | 
				
			||||||
 | 
					            contentHandler(bestAttemptContent)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
 | 
				
			||||||
 | 
					        switch content.categoryIdentifier {
 | 
				
			||||||
 | 
					        case "messaging.message", "messaging.callStart":
 | 
				
			||||||
 | 
					            try handleMessagingNotification(request: request, content: content)
 | 
				
			||||||
 | 
					        default:
 | 
				
			||||||
 | 
					            try handleDefaultNotification(content: content)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func handleMessagingNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
 | 
				
			||||||
 | 
					        guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
 | 
				
			||||||
 | 
					            throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        guard let avatarIdentifier = metadata["avatar"] as? String else {
 | 
				
			||||||
 | 
					            throw ParseNotificationPayloadError.missingAvatarUrl("The notification has no avatar.")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
 | 
				
			||||||
 | 
					        fetchAvatarImage(from: avatarUrl) { [weak self] inImage in
 | 
				
			||||||
 | 
					            guard let self = self else { return }
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            let handle = INPersonHandle(value: "\(metadata["user_id"] ?? "")", type: .unknown)
 | 
				
			||||||
 | 
					            let sender = INPerson(
 | 
				
			||||||
 | 
					                personHandle: handle,
 | 
				
			||||||
 | 
					                nameComponents: nil,
 | 
				
			||||||
 | 
					                displayName: content.title,
 | 
				
			||||||
 | 
					                image: inImage,
 | 
				
			||||||
 | 
					                contactIdentifier: nil,
 | 
				
			||||||
 | 
					                customIdentifier: nil
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            if content.categoryIdentifier == "messaging.callStart" {
 | 
				
			||||||
 | 
					                let intent = self.createCallIntent(with: sender)
 | 
				
			||||||
 | 
					                self.donateInteraction(for: intent)
 | 
				
			||||||
 | 
					                let updatedContent = try? request.content.updating(from: intent)
 | 
				
			||||||
 | 
					                self.contentHandler?(updatedContent ?? content)
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                let intent = self.createMessageIntent(with: sender, metadata: metadata, 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 {
 | 
				
			||||||
 | 
					        guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
 | 
				
			||||||
 | 
					            throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if let imageIdentifier = metadata["image"] as? String {
 | 
				
			||||||
 | 
					            attachMedia(to: content, withIdentifier: imageIdentifier)
 | 
				
			||||||
 | 
					        } else if let avatarIdentifier = metadata["avatar"] as? String {
 | 
				
			||||||
 | 
					            attachMedia(to: content, withIdentifier: avatarIdentifier)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        contentHandler?(content)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String) {
 | 
				
			||||||
 | 
					        let attachmentUrl = getAttachmentUrl(for: identifier)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        guard let remoteUrl = URL(string: attachmentUrl) else {
 | 
				
			||||||
 | 
					            print("Invalid URL for attachment: \(attachmentUrl)")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Define a cache location based on the identifier
 | 
				
			||||||
 | 
					        let tempDirectory = FileManager.default.temporaryDirectory
 | 
				
			||||||
 | 
					        let cachedFileUrl = tempDirectory.appendingPathComponent(identifier)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                do {
 | 
				
			||||||
 | 
					                    // Move the downloaded file to the cache
 | 
				
			||||||
 | 
					                    try FileManager.default.moveItem(at: localUrl, to: cachedFileUrl)
 | 
				
			||||||
 | 
					                    self.attachLocalMedia(to: content, from: cachedFileUrl, withIdentifier: identifier)
 | 
				
			||||||
 | 
					                } catch {
 | 
				
			||||||
 | 
					                    print("Failed to cache media file: \(error.localizedDescription)")
 | 
				
			||||||
 | 
					                    self.contentHandler?(content)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }.resume()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func attachLocalMedia(to content: UNMutableNotificationContent, from localUrl: URL, withIdentifier identifier: String) {
 | 
				
			||||||
 | 
					        if let attachment = try? UNNotificationAttachment(identifier: identifier, url: localUrl) {
 | 
				
			||||||
 | 
					            content.attachments = [attachment]
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            print("Failed to create attachment from cached file: \(localUrl.path)")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        self.contentHandler?(content)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func createCallIntent(with sender: INPerson) -> INStartCallIntent {
 | 
				
			||||||
 | 
					        INStartCallIntent(
 | 
				
			||||||
 | 
					            callRecordFilter: nil,
 | 
				
			||||||
 | 
					            callRecordToCallBack: nil,
 | 
				
			||||||
 | 
					            audioRoute: .unknown,
 | 
				
			||||||
 | 
					            destinationType: .normal,
 | 
				
			||||||
 | 
					            contacts: [sender],
 | 
				
			||||||
 | 
					            callCapability: .unknown
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func createMessageIntent(with sender: INPerson, metadata: [AnyHashable: Any], body: String) -> INSendMessageIntent {
 | 
				
			||||||
 | 
					        INSendMessageIntent(
 | 
				
			||||||
 | 
					            recipients: nil,
 | 
				
			||||||
 | 
					            outgoingMessageType: .outgoingMessageText,
 | 
				
			||||||
 | 
					            content: body,
 | 
				
			||||||
 | 
					            speakableGroupName: nil,
 | 
				
			||||||
 | 
					            conversationIdentifier: "\(metadata["channel_id"] ?? "")",
 | 
				
			||||||
 | 
					            serviceName: nil,
 | 
				
			||||||
 | 
					            sender: sender,
 | 
				
			||||||
 | 
					            attachments: nil
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private func donateInteraction(for intent: INIntent) {
 | 
				
			||||||
 | 
					        let interaction = INInteraction(intent: intent, response: nil)
 | 
				
			||||||
 | 
					        interaction.direction = .incoming
 | 
				
			||||||
 | 
					        interaction.donate(completion: nil)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										432
									
								
								lib/controllers/chat_message_controller.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,432 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:collection/collection.dart';
 | 
				
			||||||
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:hive/hive.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatMessageController extends ChangeNotifier {
 | 
				
			||||||
 | 
					  static const kChatMessageBoxPrefix = 'nex_chat_messages_';
 | 
				
			||||||
 | 
					  static const kSingleBatchLoadLimit = 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
 | 
					  late final WebSocketProvider _ws;
 | 
				
			||||||
 | 
					  late final SnAttachmentProvider _attach;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  StreamSubscription? _wsSubscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ChatMessageController(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    _ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					    _attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool isPending = true;
 | 
				
			||||||
 | 
					  bool isLoading = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int? messageTotal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool get isAllLoaded =>
 | 
				
			||||||
 | 
					      messageTotal != null && messages.length >= messageTotal!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String? _boxKey;
 | 
				
			||||||
 | 
					  SnChannel? channel;
 | 
				
			||||||
 | 
					  SnChannelMember? profile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Messages are the all the messages that in the channel
 | 
				
			||||||
 | 
					  final List<SnChatMessage> messages = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Unconfirmed messages are the messages that sent by client but did not receive the reply from websocket server.
 | 
				
			||||||
 | 
					  /// Stored as a list of nonce to provide the loading state
 | 
				
			||||||
 | 
					  final List<String> unconfirmedMessages = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Box<SnChatMessage>? get _box =>
 | 
				
			||||||
 | 
					      (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> initialize(SnChannel chan) async {
 | 
				
			||||||
 | 
					    channel = chan;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Initialize local data
 | 
				
			||||||
 | 
					    _boxKey = '$kChatMessageBoxPrefix${chan.id}';
 | 
				
			||||||
 | 
					    await Hive.openBox<SnChatMessage>(_boxKey!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Fetch channel profile
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					      '/cgi/im/channels/${chan.keyPath}/me',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    profile = SnChannelMember.fromJson(
 | 
				
			||||||
 | 
					      resp.data as Map<String, dynamic>,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _wsSubscription = _ws.stream.stream.listen((event) {
 | 
				
			||||||
 | 
					      switch (event.method) {
 | 
				
			||||||
 | 
					        case 'events.new':
 | 
				
			||||||
 | 
					          final payload = SnChatMessage.fromJson(event.payload!);
 | 
				
			||||||
 | 
					          _addMessage(payload);
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					        case 'status.typing':
 | 
				
			||||||
 | 
					          if (event.payload?['channel_id'] != channel?.id) break;
 | 
				
			||||||
 | 
					          final member = SnChannelMember.fromJson(event.payload!['member']);
 | 
				
			||||||
 | 
					          if (member.id == profile?.id) break;
 | 
				
			||||||
 | 
					        // TODO impl typing users
 | 
				
			||||||
 | 
					        // if (!_typingUsers.any((x) => x.id == member.id)) {
 | 
				
			||||||
 | 
					        //   setState(() {
 | 
				
			||||||
 | 
					        //     _typingUsers.add(member);
 | 
				
			||||||
 | 
					        //   });
 | 
				
			||||||
 | 
					        // }
 | 
				
			||||||
 | 
					        // _typingInactiveTimer[member.id]?.cancel();
 | 
				
			||||||
 | 
					        // _typingInactiveTimer[member.id] = Timer(
 | 
				
			||||||
 | 
					        //   const Duration(seconds: 3),
 | 
				
			||||||
 | 
					        //   () {
 | 
				
			||||||
 | 
					        //     setState(() {
 | 
				
			||||||
 | 
					        //       _typingUsers.removeWhere((x) => x.id == member.id);
 | 
				
			||||||
 | 
					        //       _typingInactiveTimer.remove(member.id);
 | 
				
			||||||
 | 
					        //     });
 | 
				
			||||||
 | 
					        //   },
 | 
				
			||||||
 | 
					        // );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isPending = false;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
 | 
				
			||||||
 | 
					    if (_box == null) return;
 | 
				
			||||||
 | 
					    await _box!.putAll({
 | 
				
			||||||
 | 
					      for (final message in messages) message.id: message,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
 | 
				
			||||||
 | 
					    SnChatMessage? quoteEvent;
 | 
				
			||||||
 | 
					    if (message.quoteEventId != null) {
 | 
				
			||||||
 | 
					      quoteEvent = await getMessage(message.quoteEventId as int);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final attachmentRid = List<String>.from(
 | 
				
			||||||
 | 
					      message.body['attachments']?.cast<String>() ?? [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final attachments = await _attach.getMultiple(attachmentRid);
 | 
				
			||||||
 | 
					    message = message.copyWith(
 | 
				
			||||||
 | 
					      preload: SnChatMessagePreload(
 | 
				
			||||||
 | 
					        quoteEvent: quoteEvent,
 | 
				
			||||||
 | 
					        attachments: attachments,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    messages.insert(0, message);
 | 
				
			||||||
 | 
					    unconfirmedMessages.add(message.uuid);
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _addMessage(SnChatMessage message) async {
 | 
				
			||||||
 | 
					    SnChatMessage? quoteEvent;
 | 
				
			||||||
 | 
					    if (message.quoteEventId != null) {
 | 
				
			||||||
 | 
					      quoteEvent = await getMessage(message.quoteEventId as int);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final attachmentRid = List<String>.from(
 | 
				
			||||||
 | 
					      message.body['attachments']?.cast<String>() ?? [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final attachments = await _attach.getMultiple(attachmentRid);
 | 
				
			||||||
 | 
					    message = message.copyWith(
 | 
				
			||||||
 | 
					      preload: SnChatMessagePreload(
 | 
				
			||||||
 | 
					        quoteEvent: quoteEvent,
 | 
				
			||||||
 | 
					        attachments: attachments,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final idx = messages.indexWhere((e) => e.uuid == message.uuid);
 | 
				
			||||||
 | 
					    if (idx != -1) {
 | 
				
			||||||
 | 
					      unconfirmedMessages.remove(message.uuid);
 | 
				
			||||||
 | 
					      messages[idx] = message;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      messages.insert(0, message);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    await _applyMessage(message);
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_box == null) return;
 | 
				
			||||||
 | 
					    await _box!.put(message.id, message);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _applyMessage(SnChatMessage message) async {
 | 
				
			||||||
 | 
					    if (message.channelId != channel?.id) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (message.type) {
 | 
				
			||||||
 | 
					      case 'messages.edit':
 | 
				
			||||||
 | 
					        if (message.relatedEventId != null) {
 | 
				
			||||||
 | 
					          final idx =
 | 
				
			||||||
 | 
					              messages.indexWhere((x) => x.id == message.relatedEventId);
 | 
				
			||||||
 | 
					          if (idx != -1) {
 | 
				
			||||||
 | 
					            final newBody = message.body;
 | 
				
			||||||
 | 
					            newBody.remove('related_event');
 | 
				
			||||||
 | 
					            messages[idx] = messages[idx].copyWith(
 | 
				
			||||||
 | 
					              body: newBody,
 | 
				
			||||||
 | 
					              updatedAt: message.updatedAt,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            if (_box!.containsKey(message.relatedEventId)) {
 | 
				
			||||||
 | 
					              await _box!.put(message.relatedEventId, messages[idx]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      case 'messages.delete':
 | 
				
			||||||
 | 
					        if (message.relatedEventId != null) {
 | 
				
			||||||
 | 
					          messages.removeWhere((x) => x.id == message.relatedEventId);
 | 
				
			||||||
 | 
					          if (_box!.containsKey(message.relatedEventId)) {
 | 
				
			||||||
 | 
					            await _box!.delete(message.relatedEventId);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> sendMessage(
 | 
				
			||||||
 | 
					    String type,
 | 
				
			||||||
 | 
					    String content, {
 | 
				
			||||||
 | 
					    int? quoteId,
 | 
				
			||||||
 | 
					    int? relatedId,
 | 
				
			||||||
 | 
					    List<String>? attachments,
 | 
				
			||||||
 | 
					    SnChatMessage? editingMessage,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
 | 
					    if (channel == null) return;
 | 
				
			||||||
 | 
					    const uuid = Uuid();
 | 
				
			||||||
 | 
					    final nonce = uuid.v4();
 | 
				
			||||||
 | 
					    final body = {
 | 
				
			||||||
 | 
					      'text': content,
 | 
				
			||||||
 | 
					      'algorithm': 'plain',
 | 
				
			||||||
 | 
					      if (quoteId != null) 'quote_event': quoteId,
 | 
				
			||||||
 | 
					      if (relatedId != null) 'related_event': relatedId,
 | 
				
			||||||
 | 
					      if (attachments != null && attachments.isNotEmpty)
 | 
				
			||||||
 | 
					        'attachments': attachments,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Mock the message locally
 | 
				
			||||||
 | 
					    final createdAt = DateTime.now();
 | 
				
			||||||
 | 
					    final message = SnChatMessage(
 | 
				
			||||||
 | 
					      id: 0,
 | 
				
			||||||
 | 
					      createdAt: createdAt,
 | 
				
			||||||
 | 
					      updatedAt: createdAt,
 | 
				
			||||||
 | 
					      deletedAt: null,
 | 
				
			||||||
 | 
					      uuid: nonce,
 | 
				
			||||||
 | 
					      body: body,
 | 
				
			||||||
 | 
					      type: type,
 | 
				
			||||||
 | 
					      channel: channel!,
 | 
				
			||||||
 | 
					      channelId: channel!.id,
 | 
				
			||||||
 | 
					      sender: profile!,
 | 
				
			||||||
 | 
					      senderId: profile!.id,
 | 
				
			||||||
 | 
					      quoteEventId: quoteId,
 | 
				
			||||||
 | 
					      relatedEventId: relatedId,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    _addUnconfirmedMessage(message);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Send to server
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await _sn.client.request(
 | 
				
			||||||
 | 
					        editingMessage != null
 | 
				
			||||||
 | 
					            ? '/cgi/im/channels/${channel!.keyPath}/messages/${editingMessage.id}'
 | 
				
			||||||
 | 
					            : '/cgi/im/channels/${channel!.keyPath}/messages',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'type': type,
 | 
				
			||||||
 | 
					          'uuid': nonce,
 | 
				
			||||||
 | 
					          'body': body,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        options: Options(
 | 
				
			||||||
 | 
					          method: editingMessage != null ? 'PUT' : 'POST',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      // ignore
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> deleteMessage(SnChatMessage message) async {
 | 
				
			||||||
 | 
					    if (message.channelId != channel?.id) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await _sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${channel!.keyPath}/messages/${message.id}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      // ignore
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Check the local storage is up to date with the server.
 | 
				
			||||||
 | 
					  /// If the local storage is not up to date, it will be updated.
 | 
				
			||||||
 | 
					  Future<void> checkUpdate() async {
 | 
				
			||||||
 | 
					    if (_box == null) return;
 | 
				
			||||||
 | 
					    if (_box!.isEmpty) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isLoading = true;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${channel!.keyPath}/events/update',
 | 
				
			||||||
 | 
					        queryParameters: {
 | 
				
			||||||
 | 
					          'pivot': _box!.values.last.id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (resp.data['up_to_date'] == true) return;
 | 
				
			||||||
 | 
					      // Only preload the first 100 messages to prevent first time check update cause load to server and waste local storage.
 | 
				
			||||||
 | 
					      // FIXME If the local is missing more than 100 messages, it won't be fetched, this is a problem, we need to fix it.
 | 
				
			||||||
 | 
					      final countToFetch = math.min(resp.data['count'] as int, 100);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
 | 
				
			||||||
 | 
					        await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      await loadMessages();
 | 
				
			||||||
 | 
					      isLoading = false;
 | 
				
			||||||
 | 
					      notifyListeners();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Get a single event from the current channel
 | 
				
			||||||
 | 
					  /// If it was not found in local storage we will look it up in remote
 | 
				
			||||||
 | 
					  Future<SnChatMessage?> getMessage(int id) async {
 | 
				
			||||||
 | 
					    SnChatMessage? out;
 | 
				
			||||||
 | 
					    if (_box != null && _box!.containsKey(id)) {
 | 
				
			||||||
 | 
					      out = _box!.get(id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (out == null) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        final resp = await _sn.client
 | 
				
			||||||
 | 
					            .get('/cgi/im/channels/${channel!.keyPath}/events/$id');
 | 
				
			||||||
 | 
					        out = SnChatMessage.fromJson(resp.data);
 | 
				
			||||||
 | 
					        _saveMessageToLocal([out]);
 | 
				
			||||||
 | 
					      } catch (_) {
 | 
				
			||||||
 | 
					        // ignore, maybe not found
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Preload some related things if found
 | 
				
			||||||
 | 
					    if (out != null) {
 | 
				
			||||||
 | 
					      await _ud.listAccount([out.sender.accountId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final attachments = await _attach.getMultiple(
 | 
				
			||||||
 | 
					        out.body['attachments']?.cast<String>() ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      out = out.copyWith(
 | 
				
			||||||
 | 
					        preload: SnChatMessagePreload(
 | 
				
			||||||
 | 
					          attachments: attachments,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Get message from local storage first, then from the server.
 | 
				
			||||||
 | 
					  /// Will not check local storage is up to date with the server.
 | 
				
			||||||
 | 
					  /// If you need to do the sync, do the `checkUpdate` instead.
 | 
				
			||||||
 | 
					  Future<List<SnChatMessage>> getMessages(
 | 
				
			||||||
 | 
					    int take,
 | 
				
			||||||
 | 
					    int offset, {
 | 
				
			||||||
 | 
					    bool forceLocal = false,
 | 
				
			||||||
 | 
					    bool forceRemote = false,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
 | 
					    late List<SnChatMessage> out;
 | 
				
			||||||
 | 
					    if (_box != null &&
 | 
				
			||||||
 | 
					        (_box!.length >= take + offset || forceLocal) &&
 | 
				
			||||||
 | 
					        !forceRemote) {
 | 
				
			||||||
 | 
					      out = _box!.keys
 | 
				
			||||||
 | 
					          .toList()
 | 
				
			||||||
 | 
					          .cast<int>()
 | 
				
			||||||
 | 
					          .sorted((a, b) => b.compareTo(a))
 | 
				
			||||||
 | 
					          .skip(offset)
 | 
				
			||||||
 | 
					          .take(take)
 | 
				
			||||||
 | 
					          .map((key) => _box!.get(key)!)
 | 
				
			||||||
 | 
					          .toList();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${channel!.keyPath}/events',
 | 
				
			||||||
 | 
					        queryParameters: {
 | 
				
			||||||
 | 
					          'take': take,
 | 
				
			||||||
 | 
					          'offset': offset,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      messageTotal = resp.data['count'] as int?;
 | 
				
			||||||
 | 
					      out = List<SnChatMessage>.from(
 | 
				
			||||||
 | 
					        resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _saveMessageToLocal(out);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Preload attachments
 | 
				
			||||||
 | 
					    final attachmentRid = List<String>.from(
 | 
				
			||||||
 | 
					      out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final attachments = await _attach.getMultiple(attachmentRid);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Putting preload back to data
 | 
				
			||||||
 | 
					    for (var i = 0; i < out.length; i++) {
 | 
				
			||||||
 | 
					      // Preload related events (quoted)
 | 
				
			||||||
 | 
					      SnChatMessage? quoteEvent;
 | 
				
			||||||
 | 
					      if (out[i].quoteEventId != null) {
 | 
				
			||||||
 | 
					        quoteEvent = await getMessage(out[i].quoteEventId as int);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      out[i] = out[i].copyWith(
 | 
				
			||||||
 | 
					        preload: SnChatMessagePreload(
 | 
				
			||||||
 | 
					          quoteEvent: quoteEvent,
 | 
				
			||||||
 | 
					          attachments: attachments
 | 
				
			||||||
 | 
					              .where(
 | 
				
			||||||
 | 
					                (ele) =>
 | 
				
			||||||
 | 
					                    out[i].body['attachments']?.contains(ele?.rid) ?? false,
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					              .toList(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Preload sender accounts
 | 
				
			||||||
 | 
					    final accountId = out
 | 
				
			||||||
 | 
					        .where((ele) => ele.sender.accountId >= 0)
 | 
				
			||||||
 | 
					        .map((ele) => ele.sender.accountId)
 | 
				
			||||||
 | 
					        .toSet();
 | 
				
			||||||
 | 
					    await _ud.listAccount(accountId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// The load messages method work as same as the `getMessages` method.
 | 
				
			||||||
 | 
					  /// But it won't return the messages instead append them to the value that controller has.
 | 
				
			||||||
 | 
					  /// At the same time, this method provide the `isLoading` state.
 | 
				
			||||||
 | 
					  /// The `skip` parameter is no longer required since it will skip the messages count that already loaded.
 | 
				
			||||||
 | 
					  Future<void> loadMessages({int take = 20}) async {
 | 
				
			||||||
 | 
					    isLoading = true;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final out = await getMessages(take, messages.length);
 | 
				
			||||||
 | 
					      messages.addAll(out);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      isLoading = false;
 | 
				
			||||||
 | 
					      notifyListeners();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _box?.close();
 | 
				
			||||||
 | 
					    _wsSubscription?.cancel();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
 | 
				
			|||||||
import 'package:image_picker/image_picker.dart';
 | 
					import 'package:image_picker/image_picker.dart';
 | 
				
			||||||
import 'package:mime/mime.dart';
 | 
					import 'package:mime/mime.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/attachment.dart';
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
@@ -27,6 +28,8 @@ class PostWriteMedia {
 | 
				
			|||||||
  final XFile? file;
 | 
					  final XFile? file;
 | 
				
			||||||
  final Uint8List? raw;
 | 
					  final Uint8List? raw;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  PostWriteMedia? thumbnail;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PostWriteMedia(this.attachment, {this.file, this.raw}) {
 | 
					  PostWriteMedia(this.attachment, {this.file, this.raw}) {
 | 
				
			||||||
    name = attachment!.name;
 | 
					    name = attachment!.name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -66,8 +69,7 @@ class PostWriteMedia {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PostWriteMedia.fromBytes(this.raw, this.name, this.type,
 | 
					  PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
 | 
				
			||||||
      {this.attachment, this.file});
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get isEmpty => attachment == null && file == null && raw == null;
 | 
					  bool get isEmpty => attachment == null && file == null && raw == null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -86,7 +88,10 @@ class PostWriteMedia {
 | 
				
			|||||||
    if (file != null) {
 | 
					    if (file != null) {
 | 
				
			||||||
      return file!;
 | 
					      return file!;
 | 
				
			||||||
    } else if (raw != null) {
 | 
					    } else if (raw != null) {
 | 
				
			||||||
      return XFile.fromData(raw!, name: name);
 | 
					      return XFile.fromData(
 | 
				
			||||||
 | 
					        raw!,
 | 
				
			||||||
 | 
					        name: name,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -98,8 +103,7 @@ class PostWriteMedia {
 | 
				
			|||||||
  }) {
 | 
					  }) {
 | 
				
			||||||
    if (attachment != null) {
 | 
					    if (attachment != null) {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final ImageProvider provider =
 | 
					      final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
				
			||||||
          UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
					 | 
				
			||||||
      if (width != null && height != null) {
 | 
					      if (width != null && height != null) {
 | 
				
			||||||
        return ResizeImage(
 | 
					        return ResizeImage(
 | 
				
			||||||
          provider,
 | 
					          provider,
 | 
				
			||||||
@@ -110,8 +114,7 @@ class PostWriteMedia {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
      return provider;
 | 
					      return provider;
 | 
				
			||||||
    } else if (file != null) {
 | 
					    } else if (file != null) {
 | 
				
			||||||
      final ImageProvider provider =
 | 
					      final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
				
			||||||
          kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
					 | 
				
			||||||
      if (width != null && height != null) {
 | 
					      if (width != null && height != null) {
 | 
				
			||||||
        return ResizeImage(
 | 
					        return ResizeImage(
 | 
				
			||||||
          provider,
 | 
					          provider,
 | 
				
			||||||
@@ -158,9 +161,10 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  String mode = kTitleMap.keys.first;
 | 
					  String mode = kTitleMap.keys.first;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String get title => titleController.text;
 | 
					  String get title => titleController.text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String get description => descriptionController.text;
 | 
					  String get description => descriptionController.text;
 | 
				
			||||||
  bool get isRelatedNull =>
 | 
					
 | 
				
			||||||
      ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
 | 
					  bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool isLoading = false, isBusy = false;
 | 
					  bool isLoading = false, isBusy = false;
 | 
				
			||||||
  double? progress;
 | 
					  double? progress;
 | 
				
			||||||
@@ -168,6 +172,11 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  SnPublisher? publisher;
 | 
					  SnPublisher? publisher;
 | 
				
			||||||
  SnPost? editingPost, repostingPost, replyingPost;
 | 
					  SnPost? editingPost, repostingPost, replyingPost;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int visibility = 0;
 | 
				
			||||||
 | 
					  List<int> visibleUsers = List.empty();
 | 
				
			||||||
 | 
					  List<int> invisibleUsers = List.empty();
 | 
				
			||||||
 | 
					  List<String> tags = List.empty();
 | 
				
			||||||
 | 
					  PostWriteMedia? thumbnail;
 | 
				
			||||||
  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
					  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
				
			||||||
  DateTime? publishedAt, publishedUntil;
 | 
					  DateTime? publishedAt, publishedUntil;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -177,53 +186,41 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    int? reposting,
 | 
					    int? reposting,
 | 
				
			||||||
    int? replying,
 | 
					    int? replying,
 | 
				
			||||||
  }) async {
 | 
					  }) async {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
    final attach = context.read<SnAttachmentProvider>();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    isLoading = true;
 | 
					    isLoading = true;
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      if (editing != null) {
 | 
					      if (editing != null) {
 | 
				
			||||||
        final resp = await sn.client.get('/cgi/co/posts/$editing');
 | 
					        final post = await pt.getPost(editing);
 | 
				
			||||||
        final post = SnPost.fromJson(resp.data);
 | 
					 | 
				
			||||||
        final alts = await attach
 | 
					 | 
				
			||||||
            .getMultiple(post.body['attachments']?.cast<String>() ?? []);
 | 
					 | 
				
			||||||
        publisher = post.publisher;
 | 
					        publisher = post.publisher;
 | 
				
			||||||
        titleController.text = post.body['title'] ?? '';
 | 
					        titleController.text = post.body['title'] ?? '';
 | 
				
			||||||
        descriptionController.text = post.body['description'] ?? '';
 | 
					        descriptionController.text = post.body['description'] ?? '';
 | 
				
			||||||
        contentController.text = post.body['content'] ?? '';
 | 
					        contentController.text = post.body['content'] ?? '';
 | 
				
			||||||
        publishedAt = post.publishedAt;
 | 
					        publishedAt = post.publishedAt;
 | 
				
			||||||
        publishedUntil = post.publishedUntil;
 | 
					        publishedUntil = post.publishedUntil;
 | 
				
			||||||
        attachments.addAll(alts.map((ele) => PostWriteMedia(ele)));
 | 
					        visibleUsers = List.from(post.visibleUsersList ?? []);
 | 
				
			||||||
 | 
					        invisibleUsers = List.from(post.invisibleUsersList ?? []);
 | 
				
			||||||
 | 
					        visibility = post.visibility;
 | 
				
			||||||
 | 
					        tags = List.from(post.tags.map((ele) => ele.alias));
 | 
				
			||||||
 | 
					        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        editingPost = post.copyWith(
 | 
					        if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
				
			||||||
          preload: SnPostPreload(
 | 
					          thumbnail = PostWriteMedia(post.preload!.thumbnail);
 | 
				
			||||||
            attachments: alts,
 | 
					        }
 | 
				
			||||||
          ),
 | 
					
 | 
				
			||||||
        );
 | 
					        editingPost = post;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (replying != null) {
 | 
					      if (replying != null) {
 | 
				
			||||||
        final resp = await sn.client.get('/cgi/co/posts/$replying');
 | 
					        final post = await pt.getPost(replying);
 | 
				
			||||||
        final post = SnPost.fromJson(resp.data);
 | 
					        replyingPost = post;
 | 
				
			||||||
        replyingPost = post.copyWith(
 | 
					 | 
				
			||||||
          preload: SnPostPreload(
 | 
					 | 
				
			||||||
            attachments: await attach
 | 
					 | 
				
			||||||
                .getMultiple(post.body['attachments']?.cast<String>() ?? []),
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (reposting != null) {
 | 
					      if (reposting != null) {
 | 
				
			||||||
        final resp = await sn.client.get('/cgi/co/posts/$reposting');
 | 
					        final post = await pt.getPost(reposting);
 | 
				
			||||||
        final post = SnPost.fromJson(resp.data);
 | 
					        repostingPost = post;
 | 
				
			||||||
        repostingPost = post.copyWith(
 | 
					 | 
				
			||||||
          preload: SnPostPreload(
 | 
					 | 
				
			||||||
            attachments: await attach
 | 
					 | 
				
			||||||
                .getMultiple(post.body['attachments']?.cast<String>() ?? []),
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!context.mounted) return;
 | 
					      if (!context.mounted) return;
 | 
				
			||||||
@@ -234,6 +231,44 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async {
 | 
				
			||||||
 | 
					    final attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final place = await attach.chunkedUploadInitialize(
 | 
				
			||||||
 | 
					      (await media.length())!,
 | 
				
			||||||
 | 
					      media.name,
 | 
				
			||||||
 | 
					      'interactive',
 | 
				
			||||||
 | 
					      null,
 | 
				
			||||||
 | 
					      mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final item = await attach.chunkedUploadParts(
 | 
				
			||||||
 | 
					      media.toFile()!,
 | 
				
			||||||
 | 
					      place.$1,
 | 
				
			||||||
 | 
					      place.$2,
 | 
				
			||||||
 | 
					      onProgress: (progress) {
 | 
				
			||||||
 | 
					        progress = progress;
 | 
				
			||||||
 | 
					        notifyListeners();
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return item;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
 | 
				
			||||||
 | 
					    if (isBusy) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final media = idx == -1 ? thumbnail! : attachments[idx];
 | 
				
			||||||
 | 
					    isBusy = true;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final item = await _uploadAttachment(context, media);
 | 
				
			||||||
 | 
					    attachments[idx] = PostWriteMedia(item);
 | 
				
			||||||
 | 
					    isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> post(BuildContext context) async {
 | 
					  Future<void> post(BuildContext context) async {
 | 
				
			||||||
    if (isBusy || publisher == null) return;
 | 
					    if (isBusy || publisher == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -246,6 +281,11 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // Uploading attachments
 | 
					    // Uploading attachments
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
					      if (thumbnail != null && thumbnail!.attachment == null) {
 | 
				
			||||||
 | 
					        final thumb = await _uploadAttachment(context, thumbnail!);
 | 
				
			||||||
 | 
					        thumbnail = PostWriteMedia(thumb);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      for (int i = 0; i < attachments.length; i++) {
 | 
					      for (int i = 0; i < attachments.length; i++) {
 | 
				
			||||||
        final media = attachments[i];
 | 
					        final media = attachments[i];
 | 
				
			||||||
        if (media.attachment != null) continue; // Already uploaded, skip
 | 
					        if (media.attachment != null) continue; // Already uploaded, skip
 | 
				
			||||||
@@ -256,6 +296,7 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
          media.name,
 | 
					          media.name,
 | 
				
			||||||
          'interactive',
 | 
					          'interactive',
 | 
				
			||||||
          null,
 | 
					          null,
 | 
				
			||||||
 | 
					          mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        final item = await attach.chunkedUploadParts(
 | 
					        final item = await attach.chunkedUploadParts(
 | 
				
			||||||
@@ -264,8 +305,7 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
          place.$2,
 | 
					          place.$2,
 | 
				
			||||||
          onProgress: (progress) {
 | 
					          onProgress: (progress) {
 | 
				
			||||||
            // Calculate overall progress for attachments
 | 
					            // Calculate overall progress for attachments
 | 
				
			||||||
            progress = ((i + progress) / attachments.length) *
 | 
					            progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
 | 
				
			||||||
                kAttachmentProgressWeight;
 | 
					 | 
				
			||||||
            notifyListeners();
 | 
					            notifyListeners();
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
@@ -295,28 +335,24 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
          'publisher': publisher!.id,
 | 
					          'publisher': publisher!.id,
 | 
				
			||||||
          'content': contentController.text,
 | 
					          'content': contentController.text,
 | 
				
			||||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
					          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
				
			||||||
          if (descriptionController.text.isNotEmpty)
 | 
					          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
				
			||||||
            'description': descriptionController.text,
 | 
					          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
 | 
				
			||||||
          'attachments': attachments
 | 
					          'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
 | 
				
			||||||
              .where((e) => e.attachment != null)
 | 
					          'tags': tags.map((ele) => {'alias': ele}).toList(),
 | 
				
			||||||
              .map((e) => e.attachment!.rid)
 | 
					          'visibility': visibility,
 | 
				
			||||||
              .toList(),
 | 
					          'visible_users_list': visibleUsers,
 | 
				
			||||||
          if (publishedAt != null)
 | 
					          'invisible_users_list': invisibleUsers,
 | 
				
			||||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
					          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
				
			||||||
          if (publishedUntil != null)
 | 
					          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
				
			||||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
					 | 
				
			||||||
          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
					          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
				
			||||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
					          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        onSendProgress: (count, total) {
 | 
					        onSendProgress: (count, total) {
 | 
				
			||||||
          progress =
 | 
					          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
				
			||||||
              baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
					 | 
				
			||||||
          notifyListeners();
 | 
					          notifyListeners();
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        onReceiveProgress: (count, total) {
 | 
					        onReceiveProgress: (count, total) {
 | 
				
			||||||
          progress = baseProgressVal +
 | 
					          progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
 | 
				
			||||||
              (kPostingProgressWeight / 2) +
 | 
					 | 
				
			||||||
              (count / total) * (kPostingProgressWeight / 2);
 | 
					 | 
				
			||||||
          notifyListeners();
 | 
					          notifyListeners();
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        options: Options(
 | 
					        options: Options(
 | 
				
			||||||
@@ -338,12 +374,34 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void setAttachmentAt(int idx, PostWriteMedia item) {
 | 
					  void setAttachmentAt(int idx, PostWriteMedia item) {
 | 
				
			||||||
    attachments[idx] = item;
 | 
					    if (idx == -1) {
 | 
				
			||||||
 | 
					      thumbnail = item;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      attachments[idx] = item;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void removeAttachmentAt(int idx) {
 | 
					  void removeAttachmentAt(int idx) {
 | 
				
			||||||
    attachments.removeAt(idx);
 | 
					    if (idx == -1) {
 | 
				
			||||||
 | 
					      thumbnail = null;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      attachments.removeAt(idx);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setThumbnail(int? idx) {
 | 
				
			||||||
 | 
					    if (idx == null) {
 | 
				
			||||||
 | 
					      attachments.add(thumbnail!);
 | 
				
			||||||
 | 
					      thumbnail = null;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (thumbnail != null) {
 | 
				
			||||||
 | 
					        attachments.add(thumbnail!);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      thumbnail = attachments[idx];
 | 
				
			||||||
 | 
					      attachments.removeAt(idx);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -362,11 +420,41 @@ class PostWriteController extends ChangeNotifier {
 | 
				
			|||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setTags(List<String> value) {
 | 
				
			||||||
 | 
					    tags = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setVisibility(int value) {
 | 
				
			||||||
 | 
					    visibility = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setVisibleUsers(List<int> value) {
 | 
				
			||||||
 | 
					    visibleUsers = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setInvisibleUsers(List<int> value) {
 | 
				
			||||||
 | 
					    invisibleUsers = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setProgress(double? value) {
 | 
				
			||||||
 | 
					    progress = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void setIsBusy(bool value) {
 | 
					  void setIsBusy(bool value) {
 | 
				
			||||||
    isBusy = value;
 | 
					    isBusy = value;
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setMode(String value) {
 | 
				
			||||||
 | 
					    mode = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void reset() {
 | 
					  void reset() {
 | 
				
			||||||
    publishedAt = null;
 | 
					    publishedAt = null;
 | 
				
			||||||
    publishedUntil = null;
 | 
					    publishedUntil = null;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										89
									
								
								lib/firebase_options.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,89 @@
 | 
				
			|||||||
 | 
					// File generated by FlutterFire CLI.
 | 
				
			||||||
 | 
					// ignore_for_file: type=lint
 | 
				
			||||||
 | 
					import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
 | 
				
			||||||
 | 
					import 'package:flutter/foundation.dart'
 | 
				
			||||||
 | 
					    show defaultTargetPlatform, kIsWeb, TargetPlatform;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Default [FirebaseOptions] for use with your Firebase apps.
 | 
				
			||||||
 | 
					///
 | 
				
			||||||
 | 
					/// Example:
 | 
				
			||||||
 | 
					/// ```dart
 | 
				
			||||||
 | 
					/// import 'firebase_options.dart';
 | 
				
			||||||
 | 
					/// // ...
 | 
				
			||||||
 | 
					/// await Firebase.initializeApp(
 | 
				
			||||||
 | 
					///   options: DefaultFirebaseOptions.currentPlatform,
 | 
				
			||||||
 | 
					/// );
 | 
				
			||||||
 | 
					/// ```
 | 
				
			||||||
 | 
					class DefaultFirebaseOptions {
 | 
				
			||||||
 | 
					  static FirebaseOptions get currentPlatform {
 | 
				
			||||||
 | 
					    if (kIsWeb) {
 | 
				
			||||||
 | 
					      return web;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    switch (defaultTargetPlatform) {
 | 
				
			||||||
 | 
					      case TargetPlatform.android:
 | 
				
			||||||
 | 
					        return android;
 | 
				
			||||||
 | 
					      case TargetPlatform.iOS:
 | 
				
			||||||
 | 
					        return ios;
 | 
				
			||||||
 | 
					      case TargetPlatform.macOS:
 | 
				
			||||||
 | 
					        return macos;
 | 
				
			||||||
 | 
					      case TargetPlatform.windows:
 | 
				
			||||||
 | 
					        return windows;
 | 
				
			||||||
 | 
					      case TargetPlatform.linux:
 | 
				
			||||||
 | 
					        throw UnsupportedError(
 | 
				
			||||||
 | 
					          'DefaultFirebaseOptions have not been configured for linux - '
 | 
				
			||||||
 | 
					          'you can reconfigure this by running the FlutterFire CLI again.',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        throw UnsupportedError(
 | 
				
			||||||
 | 
					          'DefaultFirebaseOptions are not supported for this platform.',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const FirebaseOptions web = FirebaseOptions(
 | 
				
			||||||
 | 
					    apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
 | 
				
			||||||
 | 
					    appId: '1:961776991058:web:b91d12f2892a5609f4188b',
 | 
				
			||||||
 | 
					    messagingSenderId: '961776991058',
 | 
				
			||||||
 | 
					    projectId: 'solian-0x001',
 | 
				
			||||||
 | 
					    authDomain: 'solian-0x001.firebaseapp.com',
 | 
				
			||||||
 | 
					    storageBucket: 'solian-0x001.firebasestorage.app',
 | 
				
			||||||
 | 
					    measurementId: 'G-XY3HHKG0PE',
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const FirebaseOptions android = FirebaseOptions(
 | 
				
			||||||
 | 
					    apiKey: 'AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk',
 | 
				
			||||||
 | 
					    appId: '1:961776991058:android:a8d3f7995b0b8e86f4188b',
 | 
				
			||||||
 | 
					    messagingSenderId: '961776991058',
 | 
				
			||||||
 | 
					    projectId: 'solian-0x001',
 | 
				
			||||||
 | 
					    storageBucket: 'solian-0x001.firebasestorage.app',
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const FirebaseOptions ios = FirebaseOptions(
 | 
				
			||||||
 | 
					    apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
 | 
				
			||||||
 | 
					    appId: '1:961776991058:ios:727229d368cc47e1f4188b',
 | 
				
			||||||
 | 
					    messagingSenderId: '961776991058',
 | 
				
			||||||
 | 
					    projectId: 'solian-0x001',
 | 
				
			||||||
 | 
					    storageBucket: 'solian-0x001.firebasestorage.app',
 | 
				
			||||||
 | 
					    iosBundleId: 'dev.solsynth.solian',
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const FirebaseOptions macos = FirebaseOptions(
 | 
				
			||||||
 | 
					    apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8',
 | 
				
			||||||
 | 
					    appId: '1:961776991058:ios:727229d368cc47e1f4188b',
 | 
				
			||||||
 | 
					    messagingSenderId: '961776991058',
 | 
				
			||||||
 | 
					    projectId: 'solian-0x001',
 | 
				
			||||||
 | 
					    storageBucket: 'solian-0x001.firebasestorage.app',
 | 
				
			||||||
 | 
					    iosBundleId: 'dev.solsynth.solian',
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const FirebaseOptions windows = FirebaseOptions(
 | 
				
			||||||
 | 
					    apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE',
 | 
				
			||||||
 | 
					    appId: '1:961776991058:web:f152fd119699e13ef4188b',
 | 
				
			||||||
 | 
					    messagingSenderId: '961776991058',
 | 
				
			||||||
 | 
					    projectId: 'solian-0x001',
 | 
				
			||||||
 | 
					    authDomain: 'solian-0x001.firebaseapp.com',
 | 
				
			||||||
 | 
					    storageBucket: 'solian-0x001.firebasestorage.app',
 | 
				
			||||||
 | 
					    measurementId: 'G-19FCN0CD9X',
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										154
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						@@ -1,24 +1,64 @@
 | 
				
			|||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
				
			||||||
import 'package:croppy/croppy.dart';
 | 
					import 'package:croppy/croppy.dart';
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:easy_localization_loader/easy_localization_loader.dart';
 | 
					import 'package:easy_localization_loader/easy_localization_loader.dart';
 | 
				
			||||||
 | 
					import 'package:firebase_core/firebase_core.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					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:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:relative_time/relative_time.dart';
 | 
					import 'package:relative_time/relative_time.dart';
 | 
				
			||||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
					import 'package:responsive_framework/responsive_framework.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/link_preview.dart';
 | 
				
			||||||
import 'package:surface/providers/navigation.dart';
 | 
					import 'package:surface/providers/navigation.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/notification.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/relationship.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/theme.dart';
 | 
					import 'package:surface/providers/theme.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
import 'package:surface/router.dart';
 | 
					import 'package:surface/router.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
void main() async {
 | 
					void main() async {
 | 
				
			||||||
  WidgetsFlutterBinding.ensureInitialized();
 | 
					  WidgetsFlutterBinding.ensureInitialized();
 | 
				
			||||||
  await EasyLocalization.ensureInitialized();
 | 
					  await EasyLocalization.ensureInitialized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!kReleaseMode) {
 | 
					  await Hive.initFlutter();
 | 
				
			||||||
    debugInvertOversizedImages = true;
 | 
					  Hive.registerAdapter(SnChannelImplAdapter());
 | 
				
			||||||
 | 
					  Hive.registerAdapter(SnRealmImplAdapter());
 | 
				
			||||||
 | 
					  Hive.registerAdapter(SnChannelMemberImplAdapter());
 | 
				
			||||||
 | 
					  Hive.registerAdapter(SnChatMessageImplAdapter());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await Firebase.initializeApp(
 | 
				
			||||||
 | 
					    options: DefaultFirebaseOptions.currentPlatform,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  GoRouter.optionURLReflectsImperativeAPIs = true;
 | 
				
			||||||
 | 
					  usePathUrlStrategy();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
 | 
				
			||||||
 | 
					    doWhenWindowReady(() {
 | 
				
			||||||
 | 
					      appWindow.minSize = Size(480, 640);
 | 
				
			||||||
 | 
					      appWindow.size = Size(1280, 720);
 | 
				
			||||||
 | 
					      appWindow.alignment = Alignment.center;
 | 
				
			||||||
 | 
					      appWindow.show();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  runApp(const SolianApp());
 | 
					  runApp(const SolianApp());
 | 
				
			||||||
@@ -32,19 +72,35 @@ class SolianApp extends StatelessWidget {
 | 
				
			|||||||
    return ResponsiveBreakpoints.builder(
 | 
					    return ResponsiveBreakpoints.builder(
 | 
				
			||||||
      child: EasyLocalization(
 | 
					      child: EasyLocalization(
 | 
				
			||||||
        path: 'assets/translations',
 | 
					        path: 'assets/translations',
 | 
				
			||||||
        supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')],
 | 
					        supportedLocales: [
 | 
				
			||||||
 | 
					          Locale('en', 'US'),
 | 
				
			||||||
 | 
					          Locale('zh', 'CN'),
 | 
				
			||||||
 | 
					          Locale('zh', 'TW'),
 | 
				
			||||||
 | 
					          Locale('zh', 'HK'),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
        fallbackLocale: Locale('en', 'US'),
 | 
					        fallbackLocale: Locale('en', 'US'),
 | 
				
			||||||
        useFallbackTranslations: true,
 | 
					        useFallbackTranslations: true,
 | 
				
			||||||
        assetLoader: JsonAssetLoader(),
 | 
					        assetLoader: JsonAssetLoader(),
 | 
				
			||||||
        child: MultiProvider(
 | 
					        child: MultiProvider(
 | 
				
			||||||
          providers: [
 | 
					          providers: [
 | 
				
			||||||
            Provider(create: (_) => SnNetworkProvider()),
 | 
					            // Display layer
 | 
				
			||||||
            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
					 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
 | 
					 | 
				
			||||||
            ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
 | 
					 | 
				
			||||||
            ChangeNotifierProvider(create: (_) => ThemeProvider()),
 | 
					            ChangeNotifierProvider(create: (_) => ThemeProvider()),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Data layer
 | 
				
			||||||
 | 
					            Provider(create: (_) => SnNetworkProvider()),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => UserDirectoryProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => SnAttachmentProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => SnPostContentProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => SnRelationshipProvider(ctx)),
 | 
				
			||||||
 | 
					            Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
 | 
				
			||||||
 | 
					            ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
          child: AppMainContent(),
 | 
					          child: _AppDelegate(),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      breakpoints: [
 | 
					      breakpoints: [
 | 
				
			||||||
@@ -56,13 +112,15 @@ class SolianApp extends StatelessWidget {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AppMainContent extends StatelessWidget {
 | 
					class _AppDelegate extends StatelessWidget {
 | 
				
			||||||
  const AppMainContent({super.key});
 | 
					  const _AppDelegate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    context.read<NavigationProvider>();
 | 
					    context.read<NavigationProvider>();
 | 
				
			||||||
    context.read<UserProvider>();
 | 
					    context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					    context.read<ChatChannelProvider>();
 | 
				
			||||||
 | 
					    context.read<NotificationProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final th = context.watch<ThemeProvider>();
 | 
					    final th = context.watch<ThemeProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -77,6 +135,80 @@ class AppMainContent extends StatelessWidget {
 | 
				
			|||||||
        ...context.localizationDelegates,
 | 
					        ...context.localizationDelegates,
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
      routerConfig: appRouter,
 | 
					      routerConfig: appRouter,
 | 
				
			||||||
 | 
					      builder: (context, child) {
 | 
				
			||||||
 | 
					        return _AppSplashScreen(
 | 
				
			||||||
 | 
					          key: const Key('global-splash-screen'),
 | 
				
			||||||
 | 
					          child: child!,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AppSplashScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final Widget child;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _AppSplashScreen({super.key, required this.child});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_AppSplashScreen> createState() => _AppSplashScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
				
			||||||
 | 
					  bool _isReady = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _initialize() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.initializeUserAgent();
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					      await ua.initialize();
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					      await ws.tryConnect();
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final notify = context.read<NotificationProvider>();
 | 
				
			||||||
 | 
					      await notify.registerPushNotifications();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      await context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isReady = true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _initialize();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    if (!_isReady) {
 | 
				
			||||||
 | 
					      return Scaffold(
 | 
				
			||||||
 | 
					        backgroundColor: Theme.of(context).colorScheme.surface,
 | 
				
			||||||
 | 
					        body: Container(
 | 
				
			||||||
 | 
					          constraints: const BoxConstraints(maxWidth: 180),
 | 
				
			||||||
 | 
					          child: Column(
 | 
				
			||||||
 | 
					            mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					            mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Image.asset("assets/icon/icon.png", width: 64, height: 64),
 | 
				
			||||||
 | 
					              const Gap(6),
 | 
				
			||||||
 | 
					              LinearProgressIndicator(
 | 
				
			||||||
 | 
					                backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              const Gap(20),
 | 
				
			||||||
 | 
					              Text('appInitializing'.tr(), textAlign: TextAlign.center),
 | 
				
			||||||
 | 
					              AppVersionLabel(),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ).center(),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return widget.child;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +0,0 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					 | 
				
			||||||
import 'package:native_dio_adapter/native_dio_adapter.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Dio addClientAdapter(Dio client) {
 | 
					 | 
				
			||||||
  if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
 | 
					 | 
				
			||||||
    // Switch to native implementation if possible
 | 
					 | 
				
			||||||
    client.httpClientAdapter = NativeAdapter();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return client;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,2 +0,0 @@
 | 
				
			|||||||
export 'package:surface/providers/adapters/sn_network_web.dart'
 | 
					 | 
				
			||||||
    if (dart.library.io) 'package:surface/providers/adapters/sn_network_native.dart';
 | 
					 | 
				
			||||||
@@ -1,5 +0,0 @@
 | 
				
			|||||||
import 'package:dio/dio.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Dio addClientAdapter(Dio client) {
 | 
					 | 
				
			||||||
  return client;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										144
									
								
								lib/providers/channel.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,144 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:hive_flutter/hive_flutter.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/controllers/chat_message_controller.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatChannelProvider extends ChangeNotifier {
 | 
				
			||||||
 | 
					  static const kChatChannelBoxName = 'nex_chat_channels';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ChatChannelProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    _initializeLocalData();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _initializeLocalData() async {
 | 
				
			||||||
 | 
					    await Hive.openBox<SnChannel>(kChatChannelBoxName);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
 | 
				
			||||||
 | 
					    if (_channelBox == null) return;
 | 
				
			||||||
 | 
					    await _channelBox!.putAll({
 | 
				
			||||||
 | 
					      for (final channel in channels) channel.key: channel,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnChannel>> _fetchChannelsFromServer({
 | 
				
			||||||
 | 
					    String scope = 'global',
 | 
				
			||||||
 | 
					    bool direct = false,
 | 
				
			||||||
 | 
					    bool doNotSave = false,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					      '/cgi/im/channels/$scope/me/available',
 | 
				
			||||||
 | 
					      queryParameters: {
 | 
				
			||||||
 | 
					        'direct': direct,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final out = List<SnChannel>.from(
 | 
				
			||||||
 | 
					      resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!doNotSave) _saveChannelToLocal(out);
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// The get channel method will return the channel with the given alias.
 | 
				
			||||||
 | 
					  /// It will use the local storage as much as possible.
 | 
				
			||||||
 | 
					  /// The alias should include the scope, formatted as `scope:alias`.
 | 
				
			||||||
 | 
					  Future<SnChannel> getChannel(String key) async {
 | 
				
			||||||
 | 
					    if (_channelBox != null) {
 | 
				
			||||||
 | 
					      final local = _channelBox!.get(key);
 | 
				
			||||||
 | 
					      if (local != null) return local;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var resp = await _sn.client.get('/cgi/im/channels/$key');
 | 
				
			||||||
 | 
					    var out = SnChannel.fromJson(resp.data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Preload realm of the channel
 | 
				
			||||||
 | 
					    if (out.realmId != null) {
 | 
				
			||||||
 | 
					      resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
 | 
				
			||||||
 | 
					      out = out.copyWith(realm: SnRealm.fromJson(resp.data));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _saveChannelToLocal([out]);
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// The fetch channel method return a stream, which will emit twice.
 | 
				
			||||||
 | 
					  /// The first time is when the data was fetched from the local storage.
 | 
				
			||||||
 | 
					  /// And the second time is when the data was fetched from the server.
 | 
				
			||||||
 | 
					  /// But there is some exception that will only cause one of them to be emitted.
 | 
				
			||||||
 | 
					  /// Like the local storage is broken or the server is down.
 | 
				
			||||||
 | 
					  Stream<List<SnChannel>> fetchChannels() async* {
 | 
				
			||||||
 | 
					    if (_channelBox != null) yield _channelBox!.values.toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var resp = await _sn.client.get('/cgi/id/realms/me/available');
 | 
				
			||||||
 | 
					    final realms = List<SnRealm>.from(
 | 
				
			||||||
 | 
					      resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final realmMap = {
 | 
				
			||||||
 | 
					      for (final realm in realms) realm.alias: realm,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final List<SnChannel> result = List.empty(growable: true);
 | 
				
			||||||
 | 
					    final directMessages = await _fetchChannelsFromServer(
 | 
				
			||||||
 | 
					      scope: scopeToFetch.first,
 | 
				
			||||||
 | 
					      direct: true,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    result.addAll(directMessages);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final nonBelongsChannels = await _fetchChannelsFromServer(
 | 
				
			||||||
 | 
					      scope: scopeToFetch.first,
 | 
				
			||||||
 | 
					      direct: false,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    result.addAll(nonBelongsChannels);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (final scope in scopeToFetch.skip(1)) {
 | 
				
			||||||
 | 
					      final channel = await _fetchChannelsFromServer(
 | 
				
			||||||
 | 
					        scope: scope,
 | 
				
			||||||
 | 
					        direct: false,
 | 
				
			||||||
 | 
					        doNotSave: true,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
 | 
				
			||||||
 | 
					      _saveChannelToLocal(out);
 | 
				
			||||||
 | 
					      result.addAll(out);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    yield result;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnChatMessage>> getLastMessages(
 | 
				
			||||||
 | 
					    Iterable<SnChannel> channels,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    final result = List<SnChatMessage>.empty(growable: true);
 | 
				
			||||||
 | 
					    for (final channel in channels) {
 | 
				
			||||||
 | 
					      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;
 | 
				
			||||||
 | 
					      if (lastMessage != null) result.add(lastMessage);
 | 
				
			||||||
 | 
					      channelBox.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet());
 | 
				
			||||||
 | 
					    return result;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _channelBox?.close();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										459
									
								
								lib/providers/chat_call.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,459 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:livekit_client/livekit_client.dart';
 | 
				
			||||||
 | 
					import 'package:permission_handler/permission_handler.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:wakelock_plus/wakelock_plus.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatCallProvider extends ChangeNotifier {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ChatCallProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnChatCall? _current;
 | 
				
			||||||
 | 
					  SnChannel? _channel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isReady = false;
 | 
				
			||||||
 | 
					  bool _isMounted = false;
 | 
				
			||||||
 | 
					  bool _isInitialized = false;
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String _lastDuration = '00:00:00';
 | 
				
			||||||
 | 
					  Timer? _lastDurationUpdateTimer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String? token;
 | 
				
			||||||
 | 
					  String? endpoint;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  StreamSubscription? hwSubscription;
 | 
				
			||||||
 | 
					  List<MediaDevice> _audioInputs = [];
 | 
				
			||||||
 | 
					  List<MediaDevice> _videoInputs = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _enableAudio = true;
 | 
				
			||||||
 | 
					  bool _enableVideo = false;
 | 
				
			||||||
 | 
					  LocalAudioTrack? _audioTrack;
 | 
				
			||||||
 | 
					  LocalVideoTrack? _videoTrack;
 | 
				
			||||||
 | 
					  MediaDevice? _videoDevice;
 | 
				
			||||||
 | 
					  MediaDevice? _audioDevice;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late Room _room;
 | 
				
			||||||
 | 
					  late EventsListener<RoomEvent> _listener;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<ParticipantTrack> _participantTracks = [];
 | 
				
			||||||
 | 
					  ParticipantTrack? _focusTrack;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Getters for private fields
 | 
				
			||||||
 | 
					  SnChatCall? get current => _current;
 | 
				
			||||||
 | 
					  SnChannel? get channel => _channel;
 | 
				
			||||||
 | 
					  bool get isReady => _isReady;
 | 
				
			||||||
 | 
					  bool get isMounted => _isMounted;
 | 
				
			||||||
 | 
					  bool get isInitialized => _isInitialized;
 | 
				
			||||||
 | 
					  bool get isBusy => _isBusy;
 | 
				
			||||||
 | 
					  String get lastDuration => _lastDuration;
 | 
				
			||||||
 | 
					  List<MediaDevice> get audioInputs => _audioInputs;
 | 
				
			||||||
 | 
					  List<MediaDevice> get videoInputs => _videoInputs;
 | 
				
			||||||
 | 
					  bool get enableAudio => _enableAudio;
 | 
				
			||||||
 | 
					  bool get enableVideo => _enableVideo;
 | 
				
			||||||
 | 
					  LocalAudioTrack? get audioTrack => _audioTrack;
 | 
				
			||||||
 | 
					  LocalVideoTrack? get videoTrack => _videoTrack;
 | 
				
			||||||
 | 
					  MediaDevice? get videoDevice => _videoDevice;
 | 
				
			||||||
 | 
					  MediaDevice? get audioDevice => _audioDevice;
 | 
				
			||||||
 | 
					  List<ParticipantTrack> get participantTracks => _participantTracks;
 | 
				
			||||||
 | 
					  ParticipantTrack? get focusTrack => _focusTrack;
 | 
				
			||||||
 | 
					  Room get room => _room;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _updateDuration() {
 | 
				
			||||||
 | 
					    if (_current == null) {
 | 
				
			||||||
 | 
					      _lastDuration = '00:00:00';
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      Duration duration = DateTime.now().difference(_current!.createdAt);
 | 
				
			||||||
 | 
					      String twoDigits(int n) => n.toString().padLeft(2, '0');
 | 
				
			||||||
 | 
					      _lastDuration = '${twoDigits(duration.inHours)}:'
 | 
				
			||||||
 | 
					          '${twoDigits(duration.inMinutes.remainder(60))}:'
 | 
				
			||||||
 | 
					          '${twoDigits(duration.inSeconds.remainder(60))}';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void enableDurationUpdater() {
 | 
				
			||||||
 | 
					    _updateDuration();
 | 
				
			||||||
 | 
					    _lastDurationUpdateTimer = Timer.periodic(
 | 
				
			||||||
 | 
					      const Duration(seconds: 1),
 | 
				
			||||||
 | 
					      (_) => _updateDuration(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void disableDurationUpdater() {
 | 
				
			||||||
 | 
					    _lastDurationUpdateTimer?.cancel();
 | 
				
			||||||
 | 
					    _lastDurationUpdateTimer = null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> checkPermissions() async {
 | 
				
			||||||
 | 
					    if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await Permission.camera.request();
 | 
				
			||||||
 | 
					    await Permission.microphone.request();
 | 
				
			||||||
 | 
					    await Permission.bluetooth.request();
 | 
				
			||||||
 | 
					    await Permission.bluetoothConnect.request();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setCall(SnChatCall call, SnChannel related) {
 | 
				
			||||||
 | 
					    _current = call;
 | 
				
			||||||
 | 
					    _channel = related;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<(String, String)> getRoomToken() async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.post(
 | 
				
			||||||
 | 
					      '/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    token = resp.data['token'];
 | 
				
			||||||
 | 
					    endpoint = 'wss://${resp.data['endpoint']}';
 | 
				
			||||||
 | 
					    return (token!, endpoint!);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void initHardware() {
 | 
				
			||||||
 | 
					    if (_isReady) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _isReady = true;
 | 
				
			||||||
 | 
					    hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
 | 
				
			||||||
 | 
					      _revertDevices,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    Hardware.instance.enumerateDevices().then(_revertDevices);
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void initRoom() {
 | 
				
			||||||
 | 
					    initHardware();
 | 
				
			||||||
 | 
					    _room = Room(
 | 
				
			||||||
 | 
					      roomOptions: const RoomOptions(
 | 
				
			||||||
 | 
					        dynacast: true,
 | 
				
			||||||
 | 
					        adaptiveStream: true,
 | 
				
			||||||
 | 
					        defaultAudioPublishOptions: AudioPublishOptions(
 | 
				
			||||||
 | 
					          name: 'call_voice',
 | 
				
			||||||
 | 
					          stream: 'call_stream',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        defaultVideoPublishOptions: VideoPublishOptions(
 | 
				
			||||||
 | 
					          name: 'call_video',
 | 
				
			||||||
 | 
					          stream: 'call_stream',
 | 
				
			||||||
 | 
					          simulcast: true,
 | 
				
			||||||
 | 
					          backupVideoCodec: BackupVideoCodec(enabled: true),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
 | 
				
			||||||
 | 
					          useiOSBroadcastExtension: true,
 | 
				
			||||||
 | 
					          params: VideoParametersPresets.screenShareH1080FPS30,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        defaultCameraCaptureOptions: CameraCaptureOptions(
 | 
				
			||||||
 | 
					          maxFrameRate: 30,
 | 
				
			||||||
 | 
					          params: VideoParametersPresets.h1080_169,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    _listener = _room.createListener();
 | 
				
			||||||
 | 
					    WakelockPlus.enable();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> joinRoom(String url, String token) async {
 | 
				
			||||||
 | 
					    if (_isMounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await _room.connect(
 | 
				
			||||||
 | 
					        url,
 | 
				
			||||||
 | 
					        token,
 | 
				
			||||||
 | 
					        fastConnectOptions: FastConnectOptions(
 | 
				
			||||||
 | 
					          microphone: TrackOption(track: _audioTrack),
 | 
				
			||||||
 | 
					          camera: TrackOption(track: _videoTrack),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      _isMounted = true;
 | 
				
			||||||
 | 
					      notifyListeners();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setupRoom() {
 | 
				
			||||||
 | 
					    if (isInitialized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    sortParticipants();
 | 
				
			||||||
 | 
					    _room.addListener(_onRoomDidUpdate);
 | 
				
			||||||
 | 
					    WidgetsBindingCompatible.instance?.addPostFrameCallback(
 | 
				
			||||||
 | 
					      (_) => autoPublish(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (lkPlatformIsMobile()) {
 | 
				
			||||||
 | 
					      Hardware.instance.setSpeakerphoneOn(true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _isBusy = false;
 | 
				
			||||||
 | 
					    _isInitialized = true;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void autoPublish() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (enableVideo) {
 | 
				
			||||||
 | 
					        await _room.localParticipant?.setCameraEnabled(true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (enableAudio) {
 | 
				
			||||||
 | 
					        await _room.localParticipant?.setMicrophoneEnabled(true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> setEnableAudio(bool value) async {
 | 
				
			||||||
 | 
					    _enableAudio = value;
 | 
				
			||||||
 | 
					    if (!_enableAudio) {
 | 
				
			||||||
 | 
					      await _audioTrack?.stop();
 | 
				
			||||||
 | 
					      _audioTrack = null;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      await _changeLocalAudioTrack();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> setEnableVideo(bool value) async {
 | 
				
			||||||
 | 
					    _enableVideo = value;
 | 
				
			||||||
 | 
					    if (!_enableVideo) {
 | 
				
			||||||
 | 
					      await _videoTrack?.stop();
 | 
				
			||||||
 | 
					      _videoTrack = null;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      await _changeLocalVideoTrack();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setupRoomListeners({
 | 
				
			||||||
 | 
					    required Function(DisconnectReason?) onDisconnected,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    _listener
 | 
				
			||||||
 | 
					      ..on<RoomDisconnectedEvent>((event) async {
 | 
				
			||||||
 | 
					        onDisconnected(event.reason);
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      ..on<ParticipantEvent>((event) => sortParticipants())
 | 
				
			||||||
 | 
					      ..on<LocalTrackPublishedEvent>((_) => sortParticipants())
 | 
				
			||||||
 | 
					      ..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
 | 
				
			||||||
 | 
					      ..on<TrackSubscribedEvent>((_) => sortParticipants())
 | 
				
			||||||
 | 
					      ..on<TrackUnsubscribedEvent>((_) => sortParticipants())
 | 
				
			||||||
 | 
					      ..on<ParticipantNameUpdatedEvent>((event) {
 | 
				
			||||||
 | 
					        sortParticipants();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void sortParticipants() {
 | 
				
			||||||
 | 
					    Map<String, ParticipantTrack> mediaTracks = {};
 | 
				
			||||||
 | 
					    for (var participant in _room.remoteParticipants.values) {
 | 
				
			||||||
 | 
					      mediaTracks[participant.sid] = ParticipantTrack(
 | 
				
			||||||
 | 
					        participant: participant,
 | 
				
			||||||
 | 
					        videoTrack: null,
 | 
				
			||||||
 | 
					        isScreenShare: false,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (var t in participant.videoTrackPublications) {
 | 
				
			||||||
 | 
					        mediaTracks[participant.sid]?.videoTrack = t.track;
 | 
				
			||||||
 | 
					        mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final newTracks = List<ParticipantTrack>.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final mediaTrackList = mediaTracks.values.toList();
 | 
				
			||||||
 | 
					    mediaTrackList.sort((a, b) {
 | 
				
			||||||
 | 
					      // Loudest people first
 | 
				
			||||||
 | 
					      if (a.participant.isSpeaking && b.participant.isSpeaking) {
 | 
				
			||||||
 | 
					        if (a.participant.audioLevel > b.participant.audioLevel) {
 | 
				
			||||||
 | 
					          return -1;
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          return 1;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Last spoke first
 | 
				
			||||||
 | 
					      final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
 | 
				
			||||||
 | 
					      final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (aSpokeAt != bSpokeAt) {
 | 
				
			||||||
 | 
					        return aSpokeAt > bSpokeAt ? -1 : 1;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Has video first
 | 
				
			||||||
 | 
					      if (a.participant.hasVideo != b.participant.hasVideo) {
 | 
				
			||||||
 | 
					        return a.participant.hasVideo ? -1 : 1;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // First joined people first
 | 
				
			||||||
 | 
					      return a.participant.joinedAt.millisecondsSinceEpoch -
 | 
				
			||||||
 | 
					          b.participant.joinedAt.millisecondsSinceEpoch;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    newTracks.addAll(mediaTrackList);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_room.localParticipant != null) {
 | 
				
			||||||
 | 
					      ParticipantTrack localTrack = ParticipantTrack(
 | 
				
			||||||
 | 
					        participant: _room.localParticipant!,
 | 
				
			||||||
 | 
					        videoTrack: null,
 | 
				
			||||||
 | 
					        isScreenShare: false,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final localParticipantTracks =
 | 
				
			||||||
 | 
					          _room.localParticipant?.videoTrackPublications;
 | 
				
			||||||
 | 
					      if (localParticipantTracks != null) {
 | 
				
			||||||
 | 
					        for (var t in localParticipantTracks) {
 | 
				
			||||||
 | 
					          localTrack.videoTrack = t.track;
 | 
				
			||||||
 | 
					          localTrack.isScreenShare = t.isScreenShare;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      newTracks.add(localTrack);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _participantTracks = newTracks;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (focusTrack != null) {
 | 
				
			||||||
 | 
					      final idx = participantTracks
 | 
				
			||||||
 | 
					          .indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
 | 
				
			||||||
 | 
					      if (idx == -1) {
 | 
				
			||||||
 | 
					        _focusTrack = null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (focusTrack == null) {
 | 
				
			||||||
 | 
					      _focusTrack = participantTracks.firstOrNull;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      final idx = participantTracks.indexWhere(
 | 
				
			||||||
 | 
					        (x) => _focusTrack!.participant.sid == x.participant.sid,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (idx > -1) {
 | 
				
			||||||
 | 
					        _focusTrack = participantTracks[idx];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _changeLocalAudioTrack() async {
 | 
				
			||||||
 | 
					    if (_audioTrack != null) {
 | 
				
			||||||
 | 
					      await _audioTrack!.stop();
 | 
				
			||||||
 | 
					      _audioTrack = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_audioDevice != null) {
 | 
				
			||||||
 | 
					      _audioTrack = await LocalAudioTrack.create(
 | 
				
			||||||
 | 
					        AudioCaptureOptions(deviceId: _audioDevice!.deviceId),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      await _audioTrack!.start();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _changeLocalVideoTrack() async {
 | 
				
			||||||
 | 
					    if (_videoTrack != null) {
 | 
				
			||||||
 | 
					      await _videoTrack!.stop();
 | 
				
			||||||
 | 
					      _videoTrack = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_videoDevice != null) {
 | 
				
			||||||
 | 
					      _videoTrack = await LocalVideoTrack.createCameraTrack(
 | 
				
			||||||
 | 
					        CameraCaptureOptions(
 | 
				
			||||||
 | 
					          deviceId: _videoDevice!.deviceId,
 | 
				
			||||||
 | 
					          params: VideoParametersPresets.h1080_169,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      await _videoTrack!.start();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _revertDevices(List<MediaDevice> devices) {
 | 
				
			||||||
 | 
					    _audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
 | 
				
			||||||
 | 
					    _videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onRoomDidUpdate() => sortParticipants();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> changeLocalAudioTrack() async {
 | 
				
			||||||
 | 
					    if (audioTrack != null) {
 | 
				
			||||||
 | 
					      await audioTrack!.stop();
 | 
				
			||||||
 | 
					      _audioTrack = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (audioDevice != null) {
 | 
				
			||||||
 | 
					      _audioTrack = await LocalAudioTrack.create(
 | 
				
			||||||
 | 
					        AudioCaptureOptions(
 | 
				
			||||||
 | 
					          deviceId: audioDevice!.deviceId,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      await audioTrack!.start();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> changeLocalVideoTrack() async {
 | 
				
			||||||
 | 
					    if (videoTrack != null) {
 | 
				
			||||||
 | 
					      await _videoTrack!.stop();
 | 
				
			||||||
 | 
					      _videoTrack = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (videoDevice != null) {
 | 
				
			||||||
 | 
					      _videoTrack = await LocalVideoTrack.createCameraTrack(
 | 
				
			||||||
 | 
					        CameraCaptureOptions(
 | 
				
			||||||
 | 
					          deviceId: videoDevice!.deviceId,
 | 
				
			||||||
 | 
					          params: VideoParametersPresets.h1080_169,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      await videoTrack!.start();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void deactivateHardware() {
 | 
				
			||||||
 | 
					    hwSubscription?.cancel();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void disposeRoom() {
 | 
				
			||||||
 | 
					    _isBusy = false;
 | 
				
			||||||
 | 
					    _isMounted = false;
 | 
				
			||||||
 | 
					    _isInitialized = false;
 | 
				
			||||||
 | 
					    _current = null;
 | 
				
			||||||
 | 
					    _channel = null;
 | 
				
			||||||
 | 
					    _room.removeListener(_onRoomDidUpdate);
 | 
				
			||||||
 | 
					    _room.disconnect();
 | 
				
			||||||
 | 
					    _room.dispose();
 | 
				
			||||||
 | 
					    _listener.dispose();
 | 
				
			||||||
 | 
					    WakelockPlus.disable();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void disposeHardware() {
 | 
				
			||||||
 | 
					    _isReady = false;
 | 
				
			||||||
 | 
					    _audioTrack?.stop();
 | 
				
			||||||
 | 
					    _audioTrack = null;
 | 
				
			||||||
 | 
					    _videoTrack?.stop();
 | 
				
			||||||
 | 
					    _videoTrack = null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setVideoDevice(MediaDevice? value) {
 | 
				
			||||||
 | 
					    _videoDevice = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setAudioDevice(MediaDevice? value) {
 | 
				
			||||||
 | 
					    _audioDevice = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setFocusTrack(ParticipantTrack? value) {
 | 
				
			||||||
 | 
					    _focusTrack = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void setIsBusy(bool value) {
 | 
				
			||||||
 | 
					    _isBusy = value;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								lib/providers/link_preview.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/link.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnLinkPreviewProvider {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final Map<String, SnLinkMeta> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnLinkPreviewProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnLinkMeta?> getLinkMeta(String url) async {
 | 
				
			||||||
 | 
					    final b64 = utf8.fuse(base64Url);
 | 
				
			||||||
 | 
					    final target = b64.encode(url);
 | 
				
			||||||
 | 
					    if (_cache.containsKey(target)) return _cache[target];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    log('[LinkPreview] Fetching $url ($target)');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp = await _sn.client.get('/cgi/re/link/$target');
 | 
				
			||||||
 | 
					      final meta = SnLinkMeta.fromJson(resp.data);
 | 
				
			||||||
 | 
					      _cache[url] = meta;
 | 
				
			||||||
 | 
					      return meta;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      log('[LinkPreview] Failed to fetch $url ($target)...');
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -24,6 +24,14 @@ class NavigationProvider extends ChangeNotifier {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  int? get currentIndex => _currentIndex;
 | 
					  int? get currentIndex => _currentIndex;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const List<String> kShowBottomNavScreen = [
 | 
				
			||||||
 | 
					    'home',
 | 
				
			||||||
 | 
					    'explore',
 | 
				
			||||||
 | 
					    'account',
 | 
				
			||||||
 | 
					    'album',
 | 
				
			||||||
 | 
					    'chat',
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static const List<AppNavDestination> kAllDestination = [
 | 
					  static const List<AppNavDestination> kAllDestination = [
 | 
				
			||||||
    AppNavDestination(
 | 
					    AppNavDestination(
 | 
				
			||||||
      icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
 | 
					      icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
 | 
				
			||||||
@@ -35,26 +43,42 @@ class NavigationProvider extends ChangeNotifier {
 | 
				
			|||||||
      screen: 'explore',
 | 
					      screen: 'explore',
 | 
				
			||||||
      label: 'screenExplore',
 | 
					      label: 'screenExplore',
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    AppNavDestination(
 | 
				
			||||||
 | 
					      icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
 | 
				
			||||||
 | 
					      screen: 'chat',
 | 
				
			||||||
 | 
					      label: 'screenChat',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    AppNavDestination(
 | 
					    AppNavDestination(
 | 
				
			||||||
      icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
 | 
					      icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
 | 
				
			||||||
      screen: 'account',
 | 
					      screen: 'account',
 | 
				
			||||||
      label: 'screenAccount',
 | 
					      label: 'screenAccount',
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    AppNavDestination(
 | 
					    AppNavDestination(
 | 
				
			||||||
      icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
 | 
					      icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
 | 
				
			||||||
 | 
					      screen: 'realm',
 | 
				
			||||||
 | 
					      label: 'screenRealm',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    AppNavDestination(
 | 
				
			||||||
 | 
					      icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
 | 
				
			||||||
      screen: 'album',
 | 
					      screen: 'album',
 | 
				
			||||||
      label: 'screenAlbum',
 | 
					      label: 'screenAlbum',
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    AppNavDestination(
 | 
					    AppNavDestination(
 | 
				
			||||||
      icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
 | 
					      icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
 | 
				
			||||||
      screen: 'chat',
 | 
					      screen: 'friend',
 | 
				
			||||||
      label: 'screenChat',
 | 
					      label: 'screenFriend',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    AppNavDestination(
 | 
				
			||||||
 | 
					      icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
 | 
				
			||||||
 | 
					      screen: 'notification',
 | 
				
			||||||
 | 
					      label: 'screenNotification',
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
  static const List<String> kDefaultPinnedDestination = [
 | 
					  static const List<String> kDefaultPinnedDestination = [
 | 
				
			||||||
    'home',
 | 
					    'home',
 | 
				
			||||||
    'explore',
 | 
					    'explore',
 | 
				
			||||||
    'account'
 | 
					    'chat',
 | 
				
			||||||
 | 
					    'account',
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  List<AppNavDestination> destinations = [];
 | 
					  List<AppNavDestination> destinations = [];
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										65
									
								
								lib/providers/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:firebase_messaging/firebase_messaging.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_udid/flutter_udid.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotificationProvider extends ChangeNotifier {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final UserProvider _ua;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  NotificationProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> registerPushNotifications() async {
 | 
				
			||||||
 | 
					    if (kIsWeb || Platform.isWindows || Platform.isLinux) return;
 | 
				
			||||||
 | 
					    if (!_ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await FirebaseMessaging.instance.requestPermission(
 | 
				
			||||||
 | 
					      alert: true,
 | 
				
			||||||
 | 
					      announcement: true,
 | 
				
			||||||
 | 
					      badge: true,
 | 
				
			||||||
 | 
					      carPlay: false,
 | 
				
			||||||
 | 
					      criticalAlert: false,
 | 
				
			||||||
 | 
					      provisional: false,
 | 
				
			||||||
 | 
					      sound: true,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    late final String? token;
 | 
				
			||||||
 | 
					    late final String provider;
 | 
				
			||||||
 | 
					    var deviceUuid = await FlutterUdid.consistentUdid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (deviceUuid.isEmpty) {
 | 
				
			||||||
 | 
					      log("Unable to active push notifications, couldn't get device uuid");
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      log('Device UUID is $deviceUuid');
 | 
				
			||||||
 | 
					      log('Registering device push notifications...');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (Platform.isIOS || Platform.isMacOS) {
 | 
				
			||||||
 | 
					      provider = 'apns';
 | 
				
			||||||
 | 
					      token = await FirebaseMessaging.instance.getAPNSToken();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      provider = 'fcm';
 | 
				
			||||||
 | 
					      token = await FirebaseMessaging.instance.getToken();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    log('Device Push Token is $token');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await _sn.client.post(
 | 
				
			||||||
 | 
					      '/cgi/id/notifications/subscription',
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        'provider': provider,
 | 
				
			||||||
 | 
					        'device_token': token,
 | 
				
			||||||
 | 
					        'device_id': deviceUuid,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										142
									
								
								lib/providers/post.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnPostContentProvider {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final UserDirectoryProvider _ud;
 | 
				
			||||||
 | 
					  late final SnAttachmentProvider _attach;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnPostContentProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    _attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
 | 
				
			||||||
 | 
					    Set<String> rids = {};
 | 
				
			||||||
 | 
					    for (var i = 0; i < out.length; i++) {
 | 
				
			||||||
 | 
					      rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
 | 
				
			||||||
 | 
					      if (out[i].body['thumbnail'] != null) {
 | 
				
			||||||
 | 
					        rids.add(out[i].body['thumbnail']);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (out[i].repostTo != null) {
 | 
				
			||||||
 | 
					        out[i] = out[i].copyWith(
 | 
				
			||||||
 | 
					          repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final attachments = await _attach.getMultiple(rids.toList());
 | 
				
			||||||
 | 
					    for (var i = 0; i < out.length; i++) {
 | 
				
			||||||
 | 
					      out[i] = out[i].copyWith(
 | 
				
			||||||
 | 
					        preload: SnPostPreload(
 | 
				
			||||||
 | 
					          thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
 | 
				
			||||||
 | 
					          attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await _ud.listAccount(
 | 
				
			||||||
 | 
					      attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
 | 
				
			||||||
 | 
					    Set<String> rids = {};
 | 
				
			||||||
 | 
					    rids.addAll(out.body['attachments']?.cast<String>() ?? []);
 | 
				
			||||||
 | 
					    if (out.body['thumbnail'] != null) {
 | 
				
			||||||
 | 
					      rids.add(out.body['thumbnail']);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (out.repostTo != null) {
 | 
				
			||||||
 | 
					      out = out.copyWith(
 | 
				
			||||||
 | 
					        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final attachments = await _attach.getMultiple(rids.toList());
 | 
				
			||||||
 | 
					    out = out.copyWith(
 | 
				
			||||||
 | 
					      preload: SnPostPreload(
 | 
				
			||||||
 | 
					        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
				
			||||||
 | 
					        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnPost>> listRecommendations() async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/co/recommendations');
 | 
				
			||||||
 | 
					    final out = _preloadRelatedDataInBatch(
 | 
				
			||||||
 | 
					      List.from(resp.data.map((ele) => SnPost.fromJson(ele))),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<(List<SnPost>, int)> listPosts({
 | 
				
			||||||
 | 
					    int take = 10,
 | 
				
			||||||
 | 
					    int offset = 0,
 | 
				
			||||||
 | 
					    String? type,
 | 
				
			||||||
 | 
					    String? author,
 | 
				
			||||||
 | 
					  }) 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,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
				
			||||||
 | 
					      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (out, resp.data['count'] as int);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<(List<SnPost>, int)> listPostReplies(
 | 
				
			||||||
 | 
					    dynamic parentId, {
 | 
				
			||||||
 | 
					    int take = 10,
 | 
				
			||||||
 | 
					    int offset = 0,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: {
 | 
				
			||||||
 | 
					      'take': take,
 | 
				
			||||||
 | 
					      'offset': offset,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
				
			||||||
 | 
					      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (out, resp.data['count'] as int);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<(List<SnPost>, int)> searchPosts(
 | 
				
			||||||
 | 
					    String searchTerm, {
 | 
				
			||||||
 | 
					    int take = 10,
 | 
				
			||||||
 | 
					    int offset = 0,
 | 
				
			||||||
 | 
					    Iterable<String>? tags,
 | 
				
			||||||
 | 
					  }) 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(','),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    final List<SnPost> out = await _preloadRelatedDataInBatch(
 | 
				
			||||||
 | 
					      List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (out, resp.data['count'] as int);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnPost> getPost(dynamic id) async {
 | 
				
			||||||
 | 
					    final resp = await _sn.client.get('/cgi/co/posts/$id');
 | 
				
			||||||
 | 
					    final out = _preloadRelatedDataSingle(
 | 
				
			||||||
 | 
					      SnPost.fromJson(resp.data),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										44
									
								
								lib/providers/relationship.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SnRelationshipProvider {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnRelationshipProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnRelationship?> getRelationship(int relatedId) async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp = await _sn.client.get('/cgi/id/users/me/relations/$relatedId');
 | 
				
			||||||
 | 
					      return SnRelationship.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> updateRelationship(
 | 
				
			||||||
 | 
					    int relatedId,
 | 
				
			||||||
 | 
					    int status,
 | 
				
			||||||
 | 
					    Map<String, dynamic> permNodes,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    await _sn.client.put('/cgi/id/users/me/relations/$relatedId', data: {
 | 
				
			||||||
 | 
					      'status': status,
 | 
				
			||||||
 | 
					      'perm_nodes': permNodes,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> deleteRelationship(int relatedId) async {
 | 
				
			||||||
 | 
					    await _sn.client.delete('/cgi/id/users/me/relations/$relatedId');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> acceptFriendRequest(int relatedId) async {
 | 
				
			||||||
 | 
					    await _sn.client.post('/cgi/id/users/me/relations/$relatedId/accept');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> declineFriendRequest(int relatedId) async {
 | 
				
			||||||
 | 
					    await _sn.client.post('/cgi/id/users/me/relations/$relatedId/decline');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -19,6 +19,14 @@ class SnAttachmentProvider {
 | 
				
			|||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
 | 
				
			||||||
 | 
					    for (final item in items) {
 | 
				
			||||||
 | 
					      if ((item.isAnalyzed && item.isUploaded) || noCheck) {
 | 
				
			||||||
 | 
					        _cache[item.rid] = item;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<SnAttachment> getOne(String rid, {noCache = false}) async {
 | 
					  Future<SnAttachment> getOne(String rid, {noCache = false}) async {
 | 
				
			||||||
    if (!noCache && _cache.containsKey(rid)) {
 | 
					    if (!noCache && _cache.containsKey(rid)) {
 | 
				
			||||||
      return _cache[rid]!;
 | 
					      return _cache[rid]!;
 | 
				
			||||||
@@ -26,37 +34,49 @@ class SnAttachmentProvider {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
 | 
					    final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
 | 
				
			||||||
    final out = SnAttachment.fromJson(resp.data);
 | 
					    final out = SnAttachment.fromJson(resp.data);
 | 
				
			||||||
    _cache[rid] = out;
 | 
					    if (out.isAnalyzed && out.isUploaded) {
 | 
				
			||||||
 | 
					      _cache[rid] = out;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return out;
 | 
					    return out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<List<SnAttachment>> getMultiple(List<String> rids,
 | 
					  Future<List<SnAttachment?>> getMultiple(List<String> rids,
 | 
				
			||||||
      {noCache = false}) async {
 | 
					      {noCache = false}) async {
 | 
				
			||||||
    final pendingFetch =
 | 
					    final result = List<SnAttachment?>.filled(rids.length, null);
 | 
				
			||||||
        noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList();
 | 
					    final Map<String, int> randomMapping = {};
 | 
				
			||||||
 | 
					    for (int i = 0; i < rids.length; i++) {
 | 
				
			||||||
 | 
					      final rid = rids[i];
 | 
				
			||||||
 | 
					      if (noCache || !_cache.containsKey(rid)) {
 | 
				
			||||||
 | 
					        randomMapping[rid] = i;
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        result[i] = _cache[rid]!;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    final pendingFetch = randomMapping.keys;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (pendingFetch.isEmpty) {
 | 
					    if (pendingFetch.isNotEmpty) {
 | 
				
			||||||
      return rids.map((rid) => _cache[rid]!).toList();
 | 
					      final resp = await _sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/uc/attachments',
 | 
				
			||||||
 | 
					        queryParameters: {
 | 
				
			||||||
 | 
					          'take': pendingFetch.length,
 | 
				
			||||||
 | 
					          'id': pendingFetch.join(','),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final out = resp.data['data']
 | 
				
			||||||
 | 
					          .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
 | 
				
			||||||
 | 
					          .toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (final item in out) {
 | 
				
			||||||
 | 
					        if (item == null) continue;
 | 
				
			||||||
 | 
					        if (item.isAnalyzed && item.isUploaded) {
 | 
				
			||||||
 | 
					          _cache[item.rid] = item;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        result[randomMapping[item.rid]!] = item;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: {
 | 
					    return result;
 | 
				
			||||||
      'take': pendingFetch.length,
 | 
					 | 
				
			||||||
      'id': pendingFetch.join(','),
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    final out = resp.data['data']
 | 
					 | 
				
			||||||
        .where((e) => e['id'] != 0)
 | 
					 | 
				
			||||||
        .map((e) => SnAttachment.fromJson(e))
 | 
					 | 
				
			||||||
        .toList();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (final item in out) {
 | 
					 | 
				
			||||||
      _cache[item.rid] = item;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return rids
 | 
					 | 
				
			||||||
        .where((rid) => _cache.containsKey(rid))
 | 
					 | 
				
			||||||
        .map((rid) => _cache[rid]!)
 | 
					 | 
				
			||||||
        .toList();
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Map<String, String> mimetypeOverrides = {
 | 
					  static Map<String, String> mimetypeOverrides = {
 | 
				
			||||||
@@ -110,8 +130,9 @@ class SnAttachmentProvider {
 | 
				
			|||||||
    int size,
 | 
					    int size,
 | 
				
			||||||
    String filename,
 | 
					    String filename,
 | 
				
			||||||
    String pool,
 | 
					    String pool,
 | 
				
			||||||
    Map<String, dynamic>? metadata,
 | 
					    Map<String, dynamic>? metadata, {
 | 
				
			||||||
  ) async {
 | 
					    String? mimetype,
 | 
				
			||||||
 | 
					  }) async {
 | 
				
			||||||
    final fileAlt = filename.contains('.')
 | 
					    final fileAlt = filename.contains('.')
 | 
				
			||||||
        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
					        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
				
			||||||
        : filename;
 | 
					        : filename;
 | 
				
			||||||
@@ -119,8 +140,10 @@ class SnAttachmentProvider {
 | 
				
			|||||||
        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
					        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    String? mimetypeOverride;
 | 
					    String? mimetypeOverride;
 | 
				
			||||||
    if (mimetypeOverrides.keys.contains(fileExt)) {
 | 
					    if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
 | 
				
			||||||
      mimetypeOverride = mimetypeOverrides[fileExt];
 | 
					      mimetypeOverride = mimetypeOverrides[fileExt];
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      mimetypeOverride = mimetype;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {
 | 
					    final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,29 +1,33 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:convert';
 | 
					import 'dart:convert';
 | 
				
			||||||
import 'dart:developer';
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:dio/dio.dart';
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
 | 
					import 'package:dio_smart_retry/dio_smart_retry.dart';
 | 
				
			||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
 | 
					import 'package:package_info_plus/package_info_plus.dart';
 | 
				
			||||||
 | 
					import 'package:device_info_plus/device_info_plus.dart';
 | 
				
			||||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
import 'package:surface/providers/adapters/sn_network_universal.dart';
 | 
					import 'package:synchronized/synchronized.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const kAtkStoreKey = 'nex_user_atk';
 | 
					const kAtkStoreKey = 'nex_user_atk';
 | 
				
			||||||
const kRtkStoreKey = 'nex_user_rtk';
 | 
					const kRtkStoreKey = 'nex_user_rtk';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const kNetworkServerDefault = 'https://api.sn-next.solsynth.dev';
 | 
					const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
 | 
				
			||||||
const kNetworkServerStoreKey = 'app_server_url';
 | 
					const kNetworkServerStoreKey = 'app_server_url';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const kNetworkServerDirectory = [
 | 
					const kNetworkServerDirectory = [
 | 
				
			||||||
  ('SN Preview', 'https://api.sn-next.solsynth.dev'),
 | 
					  ('Solar Network', 'https://api.sn.solsynth.dev'),
 | 
				
			||||||
  ('SN Stable', 'https://api.sn.solsynth.dev'),
 | 
					 | 
				
			||||||
  ('Local', 'http://localhost:8001'),
 | 
					  ('Local', 'http://localhost:8001'),
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SnNetworkProvider {
 | 
					class SnNetworkProvider {
 | 
				
			||||||
  late Dio client;
 | 
					  late final Dio client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  late final SharedPreferences _prefs;
 | 
					  late final SharedPreferences _prefs;
 | 
				
			||||||
  late final FlutterSecureStorage _storage = FlutterSecureStorage();
 | 
					
 | 
				
			||||||
 | 
					  String? _userAgent;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SnNetworkProvider() {
 | 
					  SnNetworkProvider() {
 | 
				
			||||||
    client = Dio();
 | 
					    client = Dio();
 | 
				
			||||||
@@ -44,82 +48,129 @@ class SnNetworkProvider {
 | 
				
			|||||||
          RequestOptions options,
 | 
					          RequestOptions options,
 | 
				
			||||||
          RequestInterceptorHandler handler,
 | 
					          RequestInterceptorHandler handler,
 | 
				
			||||||
        ) async {
 | 
					        ) async {
 | 
				
			||||||
          try {
 | 
					          final atk = await getFreshAtk();
 | 
				
			||||||
            var atk = await _storage.read(key: kAtkStoreKey);
 | 
					          if (atk != null) {
 | 
				
			||||||
            if (atk != null) {
 | 
					            options.headers['Authorization'] = 'Bearer $atk';
 | 
				
			||||||
              final atkParts = atk.split('.');
 | 
					 | 
				
			||||||
              if (atkParts.length != 3) {
 | 
					 | 
				
			||||||
                throw Exception('invalid format of access token');
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              var rawPayload =
 | 
					 | 
				
			||||||
                  atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
 | 
					 | 
				
			||||||
              switch (rawPayload.length % 4) {
 | 
					 | 
				
			||||||
                case 0:
 | 
					 | 
				
			||||||
                  break;
 | 
					 | 
				
			||||||
                case 2:
 | 
					 | 
				
			||||||
                  rawPayload += '==';
 | 
					 | 
				
			||||||
                  break;
 | 
					 | 
				
			||||||
                case 3:
 | 
					 | 
				
			||||||
                  rawPayload += '=';
 | 
					 | 
				
			||||||
                  break;
 | 
					 | 
				
			||||||
                default:
 | 
					 | 
				
			||||||
                  throw Exception('illegal format of access token payload');
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              final b64 = utf8.fuse(base64Url);
 | 
					 | 
				
			||||||
              final payload = b64.decode(rawPayload);
 | 
					 | 
				
			||||||
              final exp = jsonDecode(payload)['exp'];
 | 
					 | 
				
			||||||
              if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
 | 
					 | 
				
			||||||
                log('Access token need refresh, doing it at ${DateTime.now()}');
 | 
					 | 
				
			||||||
                atk = await refreshToken();
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              if (atk != null) {
 | 
					 | 
				
			||||||
                options.headers['Authorization'] = 'Bearer $atk';
 | 
					 | 
				
			||||||
              } else {
 | 
					 | 
				
			||||||
                log('Access token refresh failed...');
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          } catch (err) {
 | 
					 | 
				
			||||||
            log('Failed to authenticate user: $err');
 | 
					 | 
				
			||||||
          } finally {
 | 
					 | 
				
			||||||
            handler.next(options);
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					          if (_userAgent != null) {
 | 
				
			||||||
 | 
					            options.headers['User-Agent'] = _userAgent!;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          return handler.next(options);
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    client = addClientAdapter(client);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    SharedPreferences.getInstance().then((prefs) {
 | 
					    SharedPreferences.getInstance().then((prefs) {
 | 
				
			||||||
      _prefs = prefs;
 | 
					      _prefs = prefs;
 | 
				
			||||||
      client.options.baseUrl =
 | 
					      client.options.baseUrl = _prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
				
			||||||
          _prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> initializeUserAgent() async {
 | 
				
			||||||
 | 
					    final String platformInfo;
 | 
				
			||||||
 | 
					    if (kIsWeb) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'Web; ${deviceInfo.vendor}';
 | 
				
			||||||
 | 
					    } else if (Platform.isAndroid) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().androidInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
 | 
				
			||||||
 | 
					    } else if (Platform.isIOS) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().iosInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
 | 
				
			||||||
 | 
					    } else if (Platform.isMacOS) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().macOsInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
 | 
				
			||||||
 | 
					    } else if (Platform.isWindows) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().windowsInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
				
			||||||
 | 
					    } else if (Platform.isLinux) {
 | 
				
			||||||
 | 
					      final deviceInfo = await DeviceInfoPlugin().linuxInfo;
 | 
				
			||||||
 | 
					      platformInfo = 'Linux; ${deviceInfo.prettyName}';
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      platformInfo = 'Unknown';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final packageInfo = await PackageInfo.fromPlatform();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _userAgent = 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final tkLock = Lock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Completer<String?>? _refreshCompleter;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<String?> getFreshAtk() async {
 | 
				
			||||||
 | 
					    if (_refreshCompleter != null) {
 | 
				
			||||||
 | 
					      return await _refreshCompleter!.future;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      _refreshCompleter = Completer<String?>();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      var atk = _prefs.getString(kAtkStoreKey);
 | 
				
			||||||
 | 
					      if (atk != null) {
 | 
				
			||||||
 | 
					        final atkParts = atk.split('.');
 | 
				
			||||||
 | 
					        if (atkParts.length != 3) {
 | 
				
			||||||
 | 
					          throw Exception('invalid format of access token');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
 | 
				
			||||||
 | 
					        switch (rawPayload.length % 4) {
 | 
				
			||||||
 | 
					          case 0:
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          case 2:
 | 
				
			||||||
 | 
					            rawPayload += '==';
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          case 3:
 | 
				
			||||||
 | 
					            rawPayload += '=';
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          default:
 | 
				
			||||||
 | 
					            throw Exception('illegal format of access token payload');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final b64 = utf8.fuse(base64Url);
 | 
				
			||||||
 | 
					        final payload = b64.decode(rawPayload);
 | 
				
			||||||
 | 
					        final exp = jsonDecode(payload)['exp'];
 | 
				
			||||||
 | 
					        if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
 | 
				
			||||||
 | 
					          log('Access token need refresh, doing it at ${DateTime.now()}');
 | 
				
			||||||
 | 
					          atk = await refreshToken();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (atk != null) {
 | 
				
			||||||
 | 
					          _refreshCompleter!.complete(atk);
 | 
				
			||||||
 | 
					          return atk;
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          log('Access token refresh failed...');
 | 
				
			||||||
 | 
					          _refreshCompleter!.complete(null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      log('Failed to authenticate user: $err');
 | 
				
			||||||
 | 
					      _refreshCompleter!.completeError(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      _refreshCompleter = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String getAttachmentUrl(String ky) {
 | 
					  String getAttachmentUrl(String ky) {
 | 
				
			||||||
    if (ky.startsWith("http")) return ky;
 | 
					    if (ky.startsWith("http")) return ky;
 | 
				
			||||||
    return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
 | 
					    return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> setTokenPair(String atk, String rtk) async {
 | 
					  void setTokenPair(String atk, String rtk) {
 | 
				
			||||||
    await Future.wait([
 | 
					    _prefs.setString(kAtkStoreKey, atk);
 | 
				
			||||||
      _storage.write(key: kAtkStoreKey, value: atk),
 | 
					    _prefs.setString(kRtkStoreKey, rtk);
 | 
				
			||||||
      _storage.write(key: kRtkStoreKey, value: rtk),
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<void> clearTokenPair() async {
 | 
					  void clearTokenPair() {
 | 
				
			||||||
    await Future.wait([
 | 
					    _prefs.remove(kAtkStoreKey);
 | 
				
			||||||
      _storage.delete(key: kAtkStoreKey),
 | 
					    _prefs.remove(kRtkStoreKey);
 | 
				
			||||||
      _storage.delete(key: kRtkStoreKey),
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Future<String?> refreshToken() async {
 | 
					  Future<String?> refreshToken() async {
 | 
				
			||||||
    final rtk = await _storage.read(key: kRtkStoreKey);
 | 
					    final rtk = _prefs.getString(kRtkStoreKey);
 | 
				
			||||||
    if (rtk == null) return null;
 | 
					    if (rtk == null) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final dio = Dio();
 | 
					    final dio = Dio();
 | 
				
			||||||
@@ -132,7 +183,7 @@ class SnNetworkProvider {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    final atk = resp.data['access_token'];
 | 
					    final atk = resp.data['access_token'];
 | 
				
			||||||
    final nRtk = resp.data['refresh_token'];
 | 
					    final nRtk = resp.data['refresh_token'];
 | 
				
			||||||
    await setTokenPair(atk, nRtk);
 | 
					    setTokenPair(atk, nRtk);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return atk;
 | 
					    return atk;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										50
									
								
								lib/providers/user_directory.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserDirectoryProvider {
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  UserDirectoryProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final Map<String, int> _idCache = {};
 | 
				
			||||||
 | 
					  final Map<int, SnAccount> _cache = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
 | 
				
			||||||
 | 
					    final out = await Future.wait(
 | 
				
			||||||
 | 
					      id.map((e) => getAccount(e)),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    return out;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<SnAccount?> getAccount(dynamic id) async {
 | 
				
			||||||
 | 
					    if (id is String && _idCache.containsKey(id)) {
 | 
				
			||||||
 | 
					      id = _idCache[id];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (_cache.containsKey(id)) {
 | 
				
			||||||
 | 
					      return _cache[id];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp = await _sn.client.get('/cgi/id/users/$id');
 | 
				
			||||||
 | 
					      final account = SnAccount.fromJson(
 | 
				
			||||||
 | 
					        resp.data as Map<String, dynamic>,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _cache[account.id] = account;
 | 
				
			||||||
 | 
					      if (id is String) _idCache[id] = account.id;
 | 
				
			||||||
 | 
					      return account;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnAccount? getAccountFromCache(dynamic id) {
 | 
				
			||||||
 | 
					    if (id is String && _idCache.containsKey(id)) {
 | 
				
			||||||
 | 
					      id = _idCache[id];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return _cache[id];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
import 'dart:developer';
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | 
					 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:shared_preferences/shared_preferences.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/account.dart';
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -11,19 +11,25 @@ class UserProvider extends ChangeNotifier {
 | 
				
			|||||||
  SnAccount? user;
 | 
					  SnAccount? user;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  late final SnNetworkProvider _sn;
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
  late final FlutterSecureStorage _storage = FlutterSecureStorage();
 | 
					
 | 
				
			||||||
 | 
					  Future<String?> get atk async {
 | 
				
			||||||
 | 
					    final prefs = await SharedPreferences.getInstance();
 | 
				
			||||||
 | 
					    return prefs.getString(kAtkStoreKey);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  UserProvider(BuildContext context) {
 | 
					  UserProvider(BuildContext context) {
 | 
				
			||||||
    _sn = context.read<SnNetworkProvider>();
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _storage.read(key: kAtkStoreKey).then((value) {
 | 
					  Future<void> initialize() async {
 | 
				
			||||||
      isAuthorized = value != null;
 | 
					    final prefs = await SharedPreferences.getInstance();
 | 
				
			||||||
      notifyListeners();
 | 
					    final value = prefs.getString(kAtkStoreKey);
 | 
				
			||||||
      refreshUser().then((value) {
 | 
					    isAuthorized = value != null;
 | 
				
			||||||
        if (value != null) {
 | 
					    notifyListeners();
 | 
				
			||||||
          log('Logged in as @${value.name}');
 | 
					    refreshUser().then((value) {
 | 
				
			||||||
        }
 | 
					      if (value != null) {
 | 
				
			||||||
      });
 | 
					        log('Logged in as @${value.name}');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,7 +45,7 @@ class UserProvider extends ChangeNotifier {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void logoutUser() async {
 | 
					  void logoutUser() async {
 | 
				
			||||||
    await _sn.clearTokenPair();
 | 
					    _sn.clearTokenPair();
 | 
				
			||||||
    isAuthorized = false;
 | 
					    isAuthorized = false;
 | 
				
			||||||
    user = null;
 | 
					    user = null;
 | 
				
			||||||
    notifyListeners();
 | 
					    notifyListeners();
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										104
									
								
								lib/providers/websocket.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,104 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:web_socket_channel/web_socket_channel.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class WebSocketProvider extends ChangeNotifier {
 | 
				
			||||||
 | 
					  bool isBusy = false;
 | 
				
			||||||
 | 
					  bool isConnected = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  WebSocketChannel? conn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final SnNetworkProvider _sn;
 | 
				
			||||||
 | 
					  late final UserProvider _ua;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  StreamController<WebSocketPackage> stream = StreamController.broadcast();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  WebSocketProvider(BuildContext context) {
 | 
				
			||||||
 | 
					    _sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    _ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> tryConnect() async {
 | 
				
			||||||
 | 
					    if (isConnected) return;
 | 
				
			||||||
 | 
					    if (!_ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    log('[WebSocket] Connecting to the server...');
 | 
				
			||||||
 | 
					    await connect();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> connect({noRetry = false}) async {
 | 
				
			||||||
 | 
					    if (!_ua.isAuthorized) return;
 | 
				
			||||||
 | 
					    if (isConnected) {
 | 
				
			||||||
 | 
					      disconnect();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final atk = await _sn.getFreshAtk();
 | 
				
			||||||
 | 
					    final uri = Uri.parse(
 | 
				
			||||||
 | 
					      '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isBusy = true;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      conn = WebSocketChannel.connect(uri);
 | 
				
			||||||
 | 
					      await conn!.ready;
 | 
				
			||||||
 | 
					      listen();
 | 
				
			||||||
 | 
					      log('[WebSocket] Connected to server!');
 | 
				
			||||||
 | 
					      isConnected = true;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (err is WebSocketChannelException) {
 | 
				
			||||||
 | 
					        log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        log('Failed to connect to websocket: $err');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!noRetry) {
 | 
				
			||||||
 | 
					        log('Retry connecting to websocket in 3 seconds...');
 | 
				
			||||||
 | 
					        return Future.delayed(
 | 
				
			||||||
 | 
					          const Duration(seconds: 3),
 | 
				
			||||||
 | 
					          () => connect(noRetry: true),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      isBusy = false;
 | 
				
			||||||
 | 
					      notifyListeners();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void disconnect() {
 | 
				
			||||||
 | 
					    if (conn != null) {
 | 
				
			||||||
 | 
					      conn!.sink.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    conn = null;
 | 
				
			||||||
 | 
					    isConnected = false;
 | 
				
			||||||
 | 
					    notifyListeners();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void listen() {
 | 
				
			||||||
 | 
					    conn?.stream.listen(
 | 
				
			||||||
 | 
					      (event) {
 | 
				
			||||||
 | 
					        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
				
			||||||
 | 
					        log('Websocket incoming message: ${packet.method} ${packet.message}');
 | 
				
			||||||
 | 
					        stream.sink.add(packet);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      onDone: () {
 | 
				
			||||||
 | 
					        isConnected = false;
 | 
				
			||||||
 | 
					        notifyListeners();
 | 
				
			||||||
 | 
					        Future.delayed(const Duration(seconds: 1), () => connect());
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      onError: (err) {
 | 
				
			||||||
 | 
					        isConnected = false;
 | 
				
			||||||
 | 
					        notifyListeners();
 | 
				
			||||||
 | 
					        Future.delayed(const Duration(seconds: 11), () => connect());
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										414
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,9 @@
 | 
				
			|||||||
 | 
					import 'package:animations/animations.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/abuse_report.dart';
 | 
				
			||||||
import 'package:surface/screens/account.dart';
 | 
					import 'package:surface/screens/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/account/pfp.dart';
 | 
				
			||||||
import 'package:surface/screens/account/profile_edit.dart';
 | 
					import 'package:surface/screens/account/profile_edit.dart';
 | 
				
			||||||
import 'package:surface/screens/account/publishers/publisher_edit.dart';
 | 
					import 'package:surface/screens/account/publishers/publisher_edit.dart';
 | 
				
			||||||
import 'package:surface/screens/account/publishers/publisher_new.dart';
 | 
					import 'package:surface/screens/account/publishers/publisher_new.dart';
 | 
				
			||||||
@@ -8,134 +12,310 @@ import 'package:surface/screens/album.dart';
 | 
				
			|||||||
import 'package:surface/screens/auth/login.dart';
 | 
					import 'package:surface/screens/auth/login.dart';
 | 
				
			||||||
import 'package:surface/screens/auth/register.dart';
 | 
					import 'package:surface/screens/auth/register.dart';
 | 
				
			||||||
import 'package:surface/screens/chat.dart';
 | 
					import 'package:surface/screens/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/chat/call_room.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/chat/channel_detail.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/chat/manage.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/chat/room.dart';
 | 
				
			||||||
import 'package:surface/screens/explore.dart';
 | 
					import 'package:surface/screens/explore.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/friend.dart';
 | 
				
			||||||
import 'package:surface/screens/home.dart';
 | 
					import 'package:surface/screens/home.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/notification.dart';
 | 
				
			||||||
import 'package:surface/screens/post/post_detail.dart';
 | 
					import 'package:surface/screens/post/post_detail.dart';
 | 
				
			||||||
import 'package:surface/screens/post/post_editor.dart';
 | 
					import 'package:surface/screens/post/post_editor.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/post/publisher_page.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/post/post_search.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/realm/manage.dart';
 | 
				
			||||||
 | 
					import 'package:surface/screens/realm/realm_detail.dart';
 | 
				
			||||||
import 'package:surface/screens/settings.dart';
 | 
					import 'package:surface/screens/settings.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/about.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/navigation/app_background.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final _appRoutes = [
 | 
				
			||||||
 | 
					  ShellRoute(
 | 
				
			||||||
 | 
					    builder: (context, state, child) => AppPageScaffold(
 | 
				
			||||||
 | 
					      body: child,
 | 
				
			||||||
 | 
					      showAppBar: false,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    routes: [
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/',
 | 
				
			||||||
 | 
					        name: 'home',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const HomeScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/posts',
 | 
				
			||||||
 | 
					        name: 'explore',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const ExploreScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        routes: [
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/write/:mode',
 | 
				
			||||||
 | 
					            name: 'postEditor',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: PostEditorScreen(
 | 
				
			||||||
 | 
					                mode: state.pathParameters['mode']!,
 | 
				
			||||||
 | 
					                postEditId: int.tryParse(
 | 
				
			||||||
 | 
					                  state.uri.queryParameters['editing'] ?? '',
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                postReplyId: int.tryParse(
 | 
				
			||||||
 | 
					                  state.uri.queryParameters['replying'] ?? '',
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                postRepostId: int.tryParse(
 | 
				
			||||||
 | 
					                  state.uri.queryParameters['reposting'] ?? '',
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/search',
 | 
				
			||||||
 | 
					            name: 'postSearch',
 | 
				
			||||||
 | 
					            builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					              child: PostSearchScreen(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/publishers/:name',
 | 
				
			||||||
 | 
					            name: 'postPublisher',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: PostPublisherScreen(name: state.pathParameters['name']!),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/:slug',
 | 
				
			||||||
 | 
					            name: 'postDetail',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: PostDetailScreen(
 | 
				
			||||||
 | 
					                slug: state.pathParameters['slug']!,
 | 
				
			||||||
 | 
					                preload: state.extra as SnPost?,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/account',
 | 
				
			||||||
 | 
					        name: 'account',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const AccountScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        routes: [],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/chat',
 | 
				
			||||||
 | 
					        name: 'chat',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const ChatScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        routes: [
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/:scope/:alias',
 | 
				
			||||||
 | 
					            name: 'chatRoom',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: ChatRoomScreen(
 | 
				
			||||||
 | 
					                scope: state.pathParameters['scope']!,
 | 
				
			||||||
 | 
					                alias: state.pathParameters['alias']!,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/:scope/:alias/call',
 | 
				
			||||||
 | 
					            name: 'chatCallRoom',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: CallRoomScreen(
 | 
				
			||||||
 | 
					                scope: state.pathParameters['scope']!,
 | 
				
			||||||
 | 
					                alias: state.pathParameters['alias']!,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/:scope/:alias/detail',
 | 
				
			||||||
 | 
					            name: 'channelDetail',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: ChannelDetailScreen(
 | 
				
			||||||
 | 
					                scope: state.pathParameters['scope']!,
 | 
				
			||||||
 | 
					                alias: state.pathParameters['alias']!,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/manage',
 | 
				
			||||||
 | 
					            name: 'chatManage',
 | 
				
			||||||
 | 
					            pageBuilder: (context, state) => CustomTransitionPage(
 | 
				
			||||||
 | 
					              child: ChatManageScreen(
 | 
				
			||||||
 | 
					                editingChannelAlias: state.uri.queryParameters['editing'],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              transitionsBuilder: (context, animation, secondaryAnimation, child) {
 | 
				
			||||||
 | 
					                return FadeThroughTransition(
 | 
				
			||||||
 | 
					                  animation: animation,
 | 
				
			||||||
 | 
					                  secondaryAnimation: secondaryAnimation,
 | 
				
			||||||
 | 
					                  fillColor: Colors.transparent,
 | 
				
			||||||
 | 
					                  child: AppBackground(
 | 
				
			||||||
 | 
					                    child: child,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/:alias',
 | 
				
			||||||
 | 
					            name: 'realmDetail',
 | 
				
			||||||
 | 
					            builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					              child: RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/realm',
 | 
				
			||||||
 | 
					        name: 'realm',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const RealmScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        routes: [
 | 
				
			||||||
 | 
					          GoRoute(
 | 
				
			||||||
 | 
					            path: '/manage',
 | 
				
			||||||
 | 
					            name: 'realmManage',
 | 
				
			||||||
 | 
					            pageBuilder: (context, state) => CustomTransitionPage(
 | 
				
			||||||
 | 
					              child: RealmManageScreen(
 | 
				
			||||||
 | 
					                editingRealmAlias: state.uri.queryParameters['editing'],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              transitionsBuilder: (context, animation, secondaryAnimation, child) {
 | 
				
			||||||
 | 
					                return FadeThroughTransition(
 | 
				
			||||||
 | 
					                  animation: animation,
 | 
				
			||||||
 | 
					                  secondaryAnimation: secondaryAnimation,
 | 
				
			||||||
 | 
					                  fillColor: Colors.transparent,
 | 
				
			||||||
 | 
					                  child: AppBackground(
 | 
				
			||||||
 | 
					                    child: child,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/album',
 | 
				
			||||||
 | 
					        name: 'album',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const AlbumScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/friend',
 | 
				
			||||||
 | 
					        name: 'friend',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const FriendScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/notification',
 | 
				
			||||||
 | 
					        name: 'notification',
 | 
				
			||||||
 | 
					        pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					          child: const NotificationScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  ShellRoute(
 | 
				
			||||||
 | 
					    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
				
			||||||
 | 
					    routes: [
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/auth/login',
 | 
				
			||||||
 | 
					        name: 'authLogin',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: LoginScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/auth/register',
 | 
				
			||||||
 | 
					        name: 'authRegister',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: RegisterScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/reports',
 | 
				
			||||||
 | 
					        name: 'abuseReport',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: AbuseReportScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/account/profile/edit',
 | 
				
			||||||
 | 
					        name: 'accountProfileEdit',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: ProfileEditScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/account/publishers',
 | 
				
			||||||
 | 
					        name: 'accountPublishers',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: PublisherScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/account/publishers/new',
 | 
				
			||||||
 | 
					        name: 'accountPublisherNew',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: AccountPublisherNewScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/account/publishers/edit/:name',
 | 
				
			||||||
 | 
					        name: 'accountPublisherEdit',
 | 
				
			||||||
 | 
					        builder: (context, state) => AppBackground(
 | 
				
			||||||
 | 
					          child: AccountPublisherEditScreen(
 | 
				
			||||||
 | 
					            name: state.pathParameters['name']!,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  GoRoute(
 | 
				
			||||||
 | 
					    path: '/account/:name',
 | 
				
			||||||
 | 
					    name: 'accountProfilePage',
 | 
				
			||||||
 | 
					    pageBuilder: (context, state) => NoTransitionPage(
 | 
				
			||||||
 | 
					      child: UserScreen(name: state.pathParameters['name']!),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  ShellRoute(
 | 
				
			||||||
 | 
					    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
				
			||||||
 | 
					    routes: [
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/settings',
 | 
				
			||||||
 | 
					        name: 'settings',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: SettingsScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  ShellRoute(
 | 
				
			||||||
 | 
					    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
				
			||||||
 | 
					    routes: [
 | 
				
			||||||
 | 
					      GoRoute(
 | 
				
			||||||
 | 
					        path: '/about',
 | 
				
			||||||
 | 
					        name: 'about',
 | 
				
			||||||
 | 
					        builder: (context, state) => const AppBackground(
 | 
				
			||||||
 | 
					          child: AboutScreen(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
final appRouter = GoRouter(
 | 
					final appRouter = GoRouter(
 | 
				
			||||||
  routes: [
 | 
					  routes: [
 | 
				
			||||||
    ShellRoute(
 | 
					    ShellRoute(
 | 
				
			||||||
      builder: (context, state, child) => AppScaffold(
 | 
					      routes: _appRoutes,
 | 
				
			||||||
        body: child,
 | 
					      builder: (context, state, child) => AppRootScaffold(body: child),
 | 
				
			||||||
        showBottomNavigation: true,
 | 
					 | 
				
			||||||
        showDrawer: true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      routes: [
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/',
 | 
					 | 
				
			||||||
          name: 'home',
 | 
					 | 
				
			||||||
          builder: (context, state) => const HomeScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/posts',
 | 
					 | 
				
			||||||
          name: 'explore',
 | 
					 | 
				
			||||||
          builder: (context, state) => const ExploreScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/account',
 | 
					 | 
				
			||||||
          name: 'account',
 | 
					 | 
				
			||||||
          builder: (context, state) => const AccountScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/chat',
 | 
					 | 
				
			||||||
          name: 'chat',
 | 
					 | 
				
			||||||
          builder: (context, state) => const ChatScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/album',
 | 
					 | 
				
			||||||
          name: 'album',
 | 
					 | 
				
			||||||
          builder: (context, state) => const AlbumScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    ShellRoute(
 | 
					 | 
				
			||||||
      builder: (context, state, child) => AppScaffold(
 | 
					 | 
				
			||||||
        body: child,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      routes: [
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/post/write/:mode',
 | 
					 | 
				
			||||||
          name: 'postEditor',
 | 
					 | 
				
			||||||
          builder: (context, state) => PostEditorScreen(
 | 
					 | 
				
			||||||
            mode: state.pathParameters['mode']!,
 | 
					 | 
				
			||||||
            postEditId: int.tryParse(
 | 
					 | 
				
			||||||
              state.uri.queryParameters['editing'] ?? '',
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            postReplyId: int.tryParse(
 | 
					 | 
				
			||||||
              state.uri.queryParameters['replying'] ?? '',
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            postRepostId: int.tryParse(
 | 
					 | 
				
			||||||
              state.uri.queryParameters['reposting'] ?? '',
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/post/:slug',
 | 
					 | 
				
			||||||
          name: 'postDetail',
 | 
					 | 
				
			||||||
          builder: (context, state) => PostDetailScreen(
 | 
					 | 
				
			||||||
            slug: state.pathParameters['slug']!,
 | 
					 | 
				
			||||||
            preload: state.extra as SnPost?,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    ShellRoute(
 | 
					 | 
				
			||||||
      builder: (context, state, child) => AppScaffold(
 | 
					 | 
				
			||||||
        body: child,
 | 
					 | 
				
			||||||
        autoImplyAppBar: true,
 | 
					 | 
				
			||||||
        showDrawer: true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      routes: [
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/auth/login',
 | 
					 | 
				
			||||||
          name: 'authLogin',
 | 
					 | 
				
			||||||
          builder: (context, state) => const LoginScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/auth/register',
 | 
					 | 
				
			||||||
          name: 'authRegister',
 | 
					 | 
				
			||||||
          builder: (context, state) => const RegisterScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/account/profile/edit',
 | 
					 | 
				
			||||||
          name: 'accountProfileEdit',
 | 
					 | 
				
			||||||
          builder: (context, state) => const ProfileEditScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/account/publishers',
 | 
					 | 
				
			||||||
          name: 'accountPublishers',
 | 
					 | 
				
			||||||
          builder: (context, state) => const PublisherScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/account/publishers/new',
 | 
					 | 
				
			||||||
          name: 'accountPublisherNew',
 | 
					 | 
				
			||||||
          builder: (context, state) => const AccountPublisherNewScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/account/publishers/edit/:name',
 | 
					 | 
				
			||||||
          name: 'accountPublisherEdit',
 | 
					 | 
				
			||||||
          builder: (context, state) => AccountPublisherEditScreen(
 | 
					 | 
				
			||||||
            name: state.pathParameters['name']!,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
    ShellRoute(
 | 
					 | 
				
			||||||
      builder: (context, state, child) => AppScaffold(
 | 
					 | 
				
			||||||
        body: child,
 | 
					 | 
				
			||||||
        autoImplyAppBar: true,
 | 
					 | 
				
			||||||
      ),
 | 
					 | 
				
			||||||
      routes: [
 | 
					 | 
				
			||||||
        GoRoute(
 | 
					 | 
				
			||||||
          path: '/settings',
 | 
					 | 
				
			||||||
          name: 'settings',
 | 
					 | 
				
			||||||
          builder: (context, state) => const SettingsScreen(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										189
									
								
								lib/screens/abuse_report.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,189 @@
 | 
				
			|||||||
 | 
					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:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../types/account.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AbuseReportScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const AbuseReportScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<AbuseReportScreen> createState() => _AbuseReportScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AbuseReportScreenState extends State<AbuseReportScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnAbuseReport> _reports = List.empty();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchReports() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/reports/abuse');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _reports = resp.data.map((e) => SnAbuseReport.fromJson(e)).cast<SnAbuseReport>().toList();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showAbuseReportDialog() {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => AbuseReportDialog(),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value == true && mounted) {
 | 
				
			||||||
 | 
					        _fetchReports();
 | 
				
			||||||
 | 
					        context.showSnackbar('abuseReportSubmitted'.tr());
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchReports();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          ListTile(
 | 
				
			||||||
 | 
					            title: Text('abuseReportAction').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('abuseReportActionDescription').tr(),
 | 
				
			||||||
 | 
					            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					            leading: const Icon(Icons.report),
 | 
				
			||||||
 | 
					            trailing: const Icon(Icons.chevron_right),
 | 
				
			||||||
 | 
					            onTap: _showAbuseReportDialog,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Divider(height: 1),
 | 
				
			||||||
 | 
					          if (_isBusy)
 | 
				
			||||||
 | 
					            const CircularProgressIndicator().padding(all: 24).center()
 | 
				
			||||||
 | 
					          else
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: ListView.builder(
 | 
				
			||||||
 | 
					                itemCount: _reports.length,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  return ListTile(
 | 
				
			||||||
 | 
					                    isThreeLine: true,
 | 
				
			||||||
 | 
					                    title: Text(_reports[idx].resource, style: GoogleFonts.robotoMono(fontSize: 13)),
 | 
				
			||||||
 | 
					                    subtitle: Text(_reports[idx].reason),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                    leading: const Icon(Icons.flag),
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AbuseReportDialog extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String? resourceLocation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const AbuseReportDialog({super.key, this.resourceLocation});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<AbuseReportDialog> createState() => _AbuseReportDialogState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AbuseReportDialogState extends State<AbuseReportDialog> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final _resourceController = TextEditingController();
 | 
				
			||||||
 | 
					  final _reasonController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    if (widget.resourceLocation != null) {
 | 
				
			||||||
 | 
					      _resourceController.text = widget.resourceLocation!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  dispose() {
 | 
				
			||||||
 | 
					    _resourceController.dispose();
 | 
				
			||||||
 | 
					    _reasonController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _performAction() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/id/reports/abuse',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'resource': _resourceController.text,
 | 
				
			||||||
 | 
					          'reason': _reasonController.text,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AlertDialog(
 | 
				
			||||||
 | 
					      title: Text('abuseReport'.tr()),
 | 
				
			||||||
 | 
					      content: Column(
 | 
				
			||||||
 | 
					        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Text('abuseReportDescription'.tr()),
 | 
				
			||||||
 | 
					          const Gap(12),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _resourceController,
 | 
				
			||||||
 | 
					            readOnly: widget.resourceLocation != null,
 | 
				
			||||||
 | 
					            maxLength: null,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'abuseReportResource'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(4),
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _reasonController,
 | 
				
			||||||
 | 
					            maxLength: null,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					              labelText: 'abuseReportReason'.tr(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      actions: [
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : () => Navigator.pop(context),
 | 
				
			||||||
 | 
					          child: Text('dialogDismiss').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : _performAction,
 | 
				
			||||||
 | 
					          child: Text('submit').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,13 +2,16 @@ import 'package:easy_localization/easy_localization.dart';
 | 
				
			|||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:hive_flutter/hive_flutter.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountScreen extends StatelessWidget {
 | 
					class AccountScreen extends StatelessWidget {
 | 
				
			||||||
  const AccountScreen({super.key});
 | 
					  const AccountScreen({super.key});
 | 
				
			||||||
@@ -17,8 +20,9 @@ class AccountScreen extends StatelessWidget {
 | 
				
			|||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final ua = context.watch<UserProvider>();
 | 
					    final ua = context.watch<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
        title: Text("screenAccount").tr(),
 | 
					        title: Text("screenAccount").tr(),
 | 
				
			||||||
        actions: [
 | 
					        actions: [
 | 
				
			||||||
          IconButton(
 | 
					          IconButton(
 | 
				
			||||||
@@ -27,12 +31,11 @@ class AccountScreen extends StatelessWidget {
 | 
				
			|||||||
              GoRouter.of(context).pushNamed('settings');
 | 
					              GoRouter.of(context).pushNamed('settings');
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: ua.isAuthorized
 | 
					        child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(),
 | 
				
			||||||
            ? _AuthorizedAccountScreen()
 | 
					 | 
				
			||||||
            : _UnauthorizedAccountScreen(),
 | 
					 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -68,15 +71,12 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
					                    crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
				
			||||||
                    textBaseline: TextBaseline.alphabetic,
 | 
					                    textBaseline: TextBaseline.alphabetic,
 | 
				
			||||||
                    children: [
 | 
					                    children: [
 | 
				
			||||||
                      Text(ua.user!.nick)
 | 
					                      Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
                          .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					 | 
				
			||||||
                      const Gap(4),
 | 
					                      const Gap(4),
 | 
				
			||||||
                      Text('@${ua.user!.name}')
 | 
					                      Text('@${ua.user!.name}').textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
                          .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  Text(ua.user!.description)
 | 
					                  Text(ua.user!.description).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
				
			||||||
                      .textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
					 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
@@ -102,20 +102,61 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
            GoRouter.of(context).pushNamed('accountPublishers');
 | 
					            GoRouter.of(context).pushNamed('accountPublishers');
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          title: Text('abuseReport').tr(),
 | 
				
			||||||
 | 
					          subtitle: Text('abuseReportActionDescription').tr(),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.flag),
 | 
				
			||||||
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            GoRouter.of(context).pushNamed('abuseReport');
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
        ListTile(
 | 
					        ListTile(
 | 
				
			||||||
          title: Text('accountLogout').tr(),
 | 
					          title: Text('accountLogout').tr(),
 | 
				
			||||||
          subtitle: Text('accountLogoutSubtitle').tr(),
 | 
					          subtitle: Text('accountLogoutSubtitle').tr(),
 | 
				
			||||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
          leading: const Icon(Symbols.logout),
 | 
					          leading: const Icon(Symbols.logout),
 | 
				
			||||||
          trailing: const Icon(Symbols.chevron_right),
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					          onTap: () async {
 | 
				
			||||||
 | 
					            final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					              'accountLogoutConfirmTitle'.tr(),
 | 
				
			||||||
 | 
					              'accountLogoutConfirm'.tr(),
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (!confirm) return;
 | 
				
			||||||
 | 
					            if (!context.mounted) return;
 | 
				
			||||||
 | 
					            ua.logoutUser();
 | 
				
			||||||
 | 
					            final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					            ws.disconnect();
 | 
				
			||||||
 | 
					            await Hive.deleteFromDisk();
 | 
				
			||||||
 | 
					            await Hive.initFlutter();
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          title: Text('accountDeletion'.tr()),
 | 
				
			||||||
 | 
					          subtitle: Text('accountDeletionActionDescription'.tr()),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.person_cancel),
 | 
				
			||||||
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
          onTap: () {
 | 
					          onTap: () {
 | 
				
			||||||
            context
 | 
					            context
 | 
				
			||||||
                .showConfirmDialog(
 | 
					                .showConfirmDialog(
 | 
				
			||||||
              'accountLogoutConfirmTitle'.tr(),
 | 
					              'accountDeletion'.tr(),
 | 
				
			||||||
              'accountLogoutConfirm'.tr(),
 | 
					              'accountDeletionDescription'.tr(),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
                .then((value) {
 | 
					                .then((value) {
 | 
				
			||||||
              if (value) ua.logoutUser();
 | 
					              if (!value || !context.mounted) return;
 | 
				
			||||||
 | 
					              final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					              sn.client.post('/cgi/id/users/me/deletion').then((value) {
 | 
				
			||||||
 | 
					                if (context.mounted) {
 | 
				
			||||||
 | 
					                  context.showSnackbar('accountDeletionSubmitted'.tr());
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              }).catchError((err) {
 | 
				
			||||||
 | 
					                if (context.mounted) {
 | 
				
			||||||
 | 
					                  context.showErrorDialog(err);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
@@ -142,9 +183,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
                  child: Icon(Symbols.waving_hand, size: 28),
 | 
					                  child: Icon(Symbols.waving_hand, size: 28),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                const Gap(8),
 | 
					                const Gap(8),
 | 
				
			||||||
                Text('accountIntroTitle')
 | 
					                Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
                    .tr()
 | 
					 | 
				
			||||||
                    .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					 | 
				
			||||||
                Text('accountIntroSubtitle').tr(),
 | 
					                Text('accountIntroSubtitle').tr(),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ).padding(all: 20),
 | 
					            ).padding(all: 20),
 | 
				
			||||||
@@ -157,7 +196,14 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
				
			|||||||
          leading: const Icon(Symbols.login),
 | 
					          leading: const Icon(Symbols.login),
 | 
				
			||||||
          trailing: const Icon(Symbols.chevron_right),
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
          onTap: () {
 | 
					          onTap: () {
 | 
				
			||||||
            GoRouter.of(context).pushNamed('authLogin');
 | 
					            GoRouter.of(context).pushNamed('authLogin').then((value) {
 | 
				
			||||||
 | 
					              if (value == true && context.mounted) {
 | 
				
			||||||
 | 
					                final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					                context.showSnackbar('loginSuccess'.tr(args: [
 | 
				
			||||||
 | 
					                  '@${ua.user?.name} (${ua.user?.nick})',
 | 
				
			||||||
 | 
					                ]));
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        ListTile(
 | 
					        ListTile(
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										536
									
								
								lib/screens/account/pfp.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,536 @@
 | 
				
			|||||||
 | 
					import 'dart:ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.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:styled_widget/styled_widget.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/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Map<String, (String, IconData, Color)> kBadgesMeta = {
 | 
				
			||||||
 | 
					  'company.staff': (
 | 
				
			||||||
 | 
					    'badgeCompanyStaff',
 | 
				
			||||||
 | 
					    Symbols.tools_wrench,
 | 
				
			||||||
 | 
					    Colors.teal,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					  'site.migration': (
 | 
				
			||||||
 | 
					    'badgeSiteMigration',
 | 
				
			||||||
 | 
					    Symbols.flag,
 | 
				
			||||||
 | 
					    Colors.orange,
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const UserScreen({super.key, required this.name});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<UserScreen> createState() => _UserScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
 | 
				
			||||||
 | 
					  late final ScrollController _scrollController = ScrollController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnAccount? _account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchAccount() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/users/${widget.name}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _account = SnAccount.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err).then((_) {
 | 
				
			||||||
 | 
					        if (mounted) Navigator.pop(context);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnAccountStatusInfo? _status;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchStatus() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/users/${widget.name}/status');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _status = SnAccountStatusInfo.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnPublisher>? _publishers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPublishers() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}');
 | 
				
			||||||
 | 
					      _publishers = List<SnPublisher>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  SnRelationship? _accountRelationship;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _addFriend() async {
 | 
				
			||||||
 | 
					    if (_isBusy) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/id/users/me/relations/friend', data: {
 | 
				
			||||||
 | 
					        'related': _account!.name,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('friendRequestSent'.tr());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _blockAccount() async {
 | 
				
			||||||
 | 
					    if (_isBusy) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/id/users/me/relations/block', data: {
 | 
				
			||||||
 | 
					        'related': _account!.name,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _unblockAccount() async {
 | 
				
			||||||
 | 
					    if (_isBusy) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showAbuseReportDialog() {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => AbuseReportDialog(
 | 
				
			||||||
 | 
					        resourceLocation: 'user:${_account?.name}',
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value == true && mounted) {
 | 
				
			||||||
 | 
					        _fetchAccount();
 | 
				
			||||||
 | 
					        context.showSnackbar('abuseReportSubmitted'.tr());
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  double _appBarBlur = 0.0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
				
			||||||
 | 
					  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _updateAppBarBlur() {
 | 
				
			||||||
 | 
					    if (_scrollController.offset > _appBarHeight) return;
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchAccount().then((_) async {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      _fetchStatus();
 | 
				
			||||||
 | 
					      _fetchPublishers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					        _accountRelationship = await rel.getRelationship(_account!.id);
 | 
				
			||||||
 | 
					        if (mounted) setState(() {});
 | 
				
			||||||
 | 
					      } catch (_) {
 | 
				
			||||||
 | 
					        // ignore
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    _scrollController.addListener(_updateAppBarBlur);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _scrollController.removeListener(_updateAppBarBlur);
 | 
				
			||||||
 | 
					    _scrollController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const kBannerAspectRatio = 7 / 16;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final imageHeight = _appBarHeight + kToolbarHeight + 8;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const labelShadows = <Shadow>[
 | 
				
			||||||
 | 
					      Shadow(
 | 
				
			||||||
 | 
					        offset: Offset(1, 1),
 | 
				
			||||||
 | 
					        blurRadius: 5.0,
 | 
				
			||||||
 | 
					        color: Color.fromARGB(255, 0, 0, 0),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      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,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ]),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					            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,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          if (_account != null)
 | 
				
			||||||
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
 | 
					              child: Column(
 | 
				
			||||||
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  Row(
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      AccountImage(
 | 
				
			||||||
 | 
					                        content: _account!.avatar,
 | 
				
			||||||
 | 
					                        radius: 28,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(16),
 | 
				
			||||||
 | 
					                      Expanded(
 | 
				
			||||||
 | 
					                        child: Column(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            Text(
 | 
				
			||||||
 | 
					                              _account!.nick,
 | 
				
			||||||
 | 
					                              style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					                            ).bold(),
 | 
				
			||||||
 | 
					                            Text('@${_account!.name}').fontSize(13),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      PopupMenuButton(
 | 
				
			||||||
 | 
					                        padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                        style: ButtonStyle(
 | 
				
			||||||
 | 
					                          visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        itemBuilder: (context) => [
 | 
				
			||||||
 | 
					                          PopupMenuItem(
 | 
				
			||||||
 | 
					                            onTap: _showAbuseReportDialog,
 | 
				
			||||||
 | 
					                            child: Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                const Icon(Symbols.flag),
 | 
				
			||||||
 | 
					                                const Gap(16),
 | 
				
			||||||
 | 
					                                Text('report').tr(),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          if (_accountRelationship == null)
 | 
				
			||||||
 | 
					                            PopupMenuItem(
 | 
				
			||||||
 | 
					                              onTap: _addFriend,
 | 
				
			||||||
 | 
					                              child: Row(
 | 
				
			||||||
 | 
					                                children: [
 | 
				
			||||||
 | 
					                                  const Icon(Symbols.person_add),
 | 
				
			||||||
 | 
					                                  const Gap(16),
 | 
				
			||||||
 | 
					                                  Text('friendNew').tr(),
 | 
				
			||||||
 | 
					                                ],
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          if (_accountRelationship?.status != 2)
 | 
				
			||||||
 | 
					                            PopupMenuItem(
 | 
				
			||||||
 | 
					                              onTap: _blockAccount,
 | 
				
			||||||
 | 
					                              child: Row(
 | 
				
			||||||
 | 
					                                children: [
 | 
				
			||||||
 | 
					                                  const Icon(Symbols.block),
 | 
				
			||||||
 | 
					                                  const Gap(16),
 | 
				
			||||||
 | 
					                                  Text('friendBlock').tr(),
 | 
				
			||||||
 | 
					                                ],
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            )
 | 
				
			||||||
 | 
					                          else
 | 
				
			||||||
 | 
					                            PopupMenuItem(
 | 
				
			||||||
 | 
					                              onTap: _unblockAccount,
 | 
				
			||||||
 | 
					                              child: Row(
 | 
				
			||||||
 | 
					                                children: [
 | 
				
			||||||
 | 
					                                  const Icon(Symbols.block),
 | 
				
			||||||
 | 
					                                  const Gap(16),
 | 
				
			||||||
 | 
					                                  Text('friendUnblock').tr(),
 | 
				
			||||||
 | 
					                                ],
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ).padding(right: 8),
 | 
				
			||||||
 | 
					                  const Gap(12),
 | 
				
			||||||
 | 
					                  Text(_account!.description).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                  const Gap(4),
 | 
				
			||||||
 | 
					                  Card(
 | 
				
			||||||
 | 
					                    child: Row(
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Icon(
 | 
				
			||||||
 | 
					                          Symbols.circle,
 | 
				
			||||||
 | 
					                          fill: 1,
 | 
				
			||||||
 | 
					                          size: 16,
 | 
				
			||||||
 | 
					                          color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
 | 
				
			||||||
 | 
					                        ).padding(all: 4),
 | 
				
			||||||
 | 
					                        const Gap(8),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          _status != null
 | 
				
			||||||
 | 
					                              ? _status!.isOnline
 | 
				
			||||||
 | 
					                                  ? 'accountStatusOnline'.tr()
 | 
				
			||||||
 | 
					                                  : 'accountStatusOffline'.tr()
 | 
				
			||||||
 | 
					                              : 'loading'.tr(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
 | 
				
			||||||
 | 
					                          Text(
 | 
				
			||||||
 | 
					                            'accountStatusLastSeen'.tr(args: [
 | 
				
			||||||
 | 
					                              _status!.lastSeenAt != null
 | 
				
			||||||
 | 
					                                  ? RelativeTime(context).format(
 | 
				
			||||||
 | 
					                                      _status!.lastSeenAt!.toLocal(),
 | 
				
			||||||
 | 
					                                    )
 | 
				
			||||||
 | 
					                                  : 'unknown',
 | 
				
			||||||
 | 
					                            ]),
 | 
				
			||||||
 | 
					                          ).padding(left: 6).opacity(0.75),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ).padding(vertical: 8, horizontal: 12),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  const Gap(8),
 | 
				
			||||||
 | 
					                  Wrap(
 | 
				
			||||||
 | 
					                    children: _account!.badges
 | 
				
			||||||
 | 
					                        .map(
 | 
				
			||||||
 | 
					                          (ele) => Tooltip(
 | 
				
			||||||
 | 
					                            richMessage: TextSpan(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
 | 
				
			||||||
 | 
					                                if (ele.metadata['title'] != null)
 | 
				
			||||||
 | 
					                                  TextSpan(
 | 
				
			||||||
 | 
					                                    text: '\n${ele.metadata['title']}',
 | 
				
			||||||
 | 
					                                    style: const TextStyle(fontWeight: FontWeight.bold),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                TextSpan(text: '\n'),
 | 
				
			||||||
 | 
					                                TextSpan(
 | 
				
			||||||
 | 
					                                  text: DateFormat.yMEd().format(ele.createdAt),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            child: Icon(
 | 
				
			||||||
 | 
					                              kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
 | 
				
			||||||
 | 
					                              color: kBadgesMeta[ele.type]?.$3,
 | 
				
			||||||
 | 
					                              fill: 1,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                        .toList(),
 | 
				
			||||||
 | 
					                  ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                  const Gap(8),
 | 
				
			||||||
 | 
					                  Column(
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      Row(
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          const Icon(Symbols.calendar_add_on),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      Row(
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          const Icon(Symbols.cake),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Text('accountBirthday').tr(args: [
 | 
				
			||||||
 | 
					                            _account!.profile?.birthday == null
 | 
				
			||||||
 | 
					                                ? 'unknown'.tr()
 | 
				
			||||||
 | 
					                                : DateFormat('M/d').format(
 | 
				
			||||||
 | 
					                                    _account!.profile!.birthday!.toLocal(),
 | 
				
			||||||
 | 
					                                  )
 | 
				
			||||||
 | 
					                          ]),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      Row(
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          const Icon(Symbols.identity_platform),
 | 
				
			||||||
 | 
					                          const Gap(8),
 | 
				
			||||||
 | 
					                          Text(
 | 
				
			||||||
 | 
					                            '#${_account!.id.toString().padLeft(8, '0')}',
 | 
				
			||||||
 | 
					                            style: GoogleFonts.robotoMono(),
 | 
				
			||||||
 | 
					                          ).opacity(0.8),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ).padding(all: 16),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
 | 
					          const SliverGap(12),
 | 
				
			||||||
 | 
					          SliverToBoxAdapter(
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                SizedBox(
 | 
				
			||||||
 | 
					                  height: 80,
 | 
				
			||||||
 | 
					                  width: double.infinity,
 | 
				
			||||||
 | 
					                  child: ListView(
 | 
				
			||||||
 | 
					                    padding: EdgeInsets.symmetric(horizontal: 8),
 | 
				
			||||||
 | 
					                    scrollDirection: Axis.horizontal,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      for (final badge in _account?.badges ?? [])
 | 
				
			||||||
 | 
					                        SizedBox(
 | 
				
			||||||
 | 
					                          width: 280,
 | 
				
			||||||
 | 
					                          child: Card(
 | 
				
			||||||
 | 
					                            child: ListTile(
 | 
				
			||||||
 | 
					                              leading: Icon(
 | 
				
			||||||
 | 
					                                kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
 | 
				
			||||||
 | 
					                                color: kBadgesMeta[badge.type]?.$3,
 | 
				
			||||||
 | 
					                                fill: 1,
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                              title: Text(
 | 
				
			||||||
 | 
					                                kBadgesMeta[badge.type]?.$1 ?? 'unknown',
 | 
				
			||||||
 | 
					                              ).tr(),
 | 
				
			||||||
 | 
					                              subtitle: badge.metadata['title'] != null
 | 
				
			||||||
 | 
					                                  ? Text(badge.metadata['title'])
 | 
				
			||||||
 | 
					                                  : Text(
 | 
				
			||||||
 | 
					                                      DateFormat('y/M/d').format(badge.createdAt),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const SliverGap(8),
 | 
				
			||||||
 | 
					          SliverToBoxAdapter(child: const Divider()),
 | 
				
			||||||
 | 
					          SliverList.builder(
 | 
				
			||||||
 | 
					            itemCount: _publishers?.length ?? 0,
 | 
				
			||||||
 | 
					            itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					              final ele = _publishers![idx];
 | 
				
			||||||
 | 
					              return ListTile(
 | 
				
			||||||
 | 
					                contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                leading: AccountImage(
 | 
				
			||||||
 | 
					                  content: ele.avatar,
 | 
				
			||||||
 | 
					                  fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                title: Text(ele.nick),
 | 
				
			||||||
 | 
					                subtitle: Text('@${ele.name}'),
 | 
				
			||||||
 | 
					                trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                    'postPublisher',
 | 
				
			||||||
 | 
					                    pathParameters: {'name': ele.name},
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -18,7 +18,6 @@ import 'package:surface/types/post.dart';
 | 
				
			|||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
import 'package:surface/widgets/universal_image.dart';
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountPublisherEditScreen extends StatefulWidget {
 | 
					class AccountPublisherEditScreen extends StatefulWidget {
 | 
				
			||||||
@@ -149,20 +148,14 @@ class _AccountPublisherEditScreenState
 | 
				
			|||||||
        mimetype: 'image/png',
 | 
					        mimetype: 'image/png',
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      switch (place) {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					        case 'avatar':
 | 
				
			||||||
      await sn.client.put(
 | 
					          _avatar = attachment.rid;
 | 
				
			||||||
        '/cgi/id/users/me/$place',
 | 
					          break;
 | 
				
			||||||
        data: {'attachment': attachment.rid},
 | 
					        case 'banner':
 | 
				
			||||||
      );
 | 
					          _banner = attachment.rid;
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
      if (!mounted) return;
 | 
					      }
 | 
				
			||||||
      final ua = context.read<UserProvider>();
 | 
					 | 
				
			||||||
      await ua.refreshUser();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!mounted) return;
 | 
					 | 
				
			||||||
      context.showSnackbar('accountProfileEditApplied'.tr());
 | 
					 | 
				
			||||||
      _syncWidget();
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -189,7 +182,7 @@ class _AccountPublisherEditScreenState
 | 
				
			|||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: Column(
 | 
					        child: Column(
 | 
				
			||||||
          children: [
 | 
					          children: [
 | 
				
			||||||
@@ -274,11 +267,14 @@ class _AccountPublisherEditScreenState
 | 
				
			|||||||
            Row(
 | 
					            Row(
 | 
				
			||||||
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
					              mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                TextButton.icon(
 | 
					                if (_publisher?.type == 0)
 | 
				
			||||||
                  onPressed: _syncWithAccount,
 | 
					                  TextButton.icon(
 | 
				
			||||||
                  label: Text('publisherSyncWithAccount').tr(),
 | 
					                    onPressed: _syncWithAccount,
 | 
				
			||||||
                  icon: const Icon(Symbols.sync),
 | 
					                    label: Text('publisherSyncWithAccount').tr(),
 | 
				
			||||||
                ),
 | 
					                    icon: const Icon(Symbols.sync),
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                else
 | 
				
			||||||
 | 
					                  const SizedBox(),
 | 
				
			||||||
                ElevatedButton.icon(
 | 
					                ElevatedButton.icon(
 | 
				
			||||||
                  onPressed: _isBusy ? null : _performAction,
 | 
					                  onPressed: _isBusy ? null : _performAction,
 | 
				
			||||||
                  label: Text('apply').tr(),
 | 
					                  label: Text('apply').tr(),
 | 
				
			||||||
@@ -287,7 +283,7 @@ class _AccountPublisherEditScreenState
 | 
				
			|||||||
              ],
 | 
					              ],
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ).padding(horizontal: 16, vertical: 12),
 | 
					        ).padding(horizontal: 24, vertical: 12),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
@@ -6,9 +7,9 @@ import 'package:provider/provider.dart';
 | 
				
			|||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/providers/userinfo.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountPublisherNewScreen extends StatefulWidget {
 | 
					class AccountPublisherNewScreen extends StatefulWidget {
 | 
				
			||||||
  const AccountPublisherNewScreen({super.key});
 | 
					  const AccountPublisherNewScreen({super.key});
 | 
				
			||||||
@@ -23,7 +24,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: Column(
 | 
					        child: Column(
 | 
				
			||||||
          children: [
 | 
					          children: [
 | 
				
			||||||
@@ -48,6 +49,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
 | 
				
			|||||||
            ),
 | 
					            ),
 | 
				
			||||||
            switch (mode) {
 | 
					            switch (mode) {
 | 
				
			||||||
              'personal' => const _PublisherNewPersonal(),
 | 
					              'personal' => const _PublisherNewPersonal(),
 | 
				
			||||||
 | 
					              'organization' => const _PublisherNewOrganization(),
 | 
				
			||||||
              _ => const Placeholder(),
 | 
					              _ => const Placeholder(),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
@@ -67,6 +69,10 @@ class _PublisherNewPersonal extends StatefulWidget {
 | 
				
			|||||||
class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
					class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
				
			||||||
  bool _isBusy = false;
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _nickController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _performAction() async {
 | 
					  void _performAction() async {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
    final ua = context.read<UserProvider>();
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
@@ -75,15 +81,48 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await sn.client.post('/cgi/co/publishers/personal');
 | 
					      await sn.client.post('/cgi/co/publishers/personal', data: {
 | 
				
			||||||
 | 
					        'name': _nameController.text,
 | 
				
			||||||
 | 
					        'nick': _nickController.text,
 | 
				
			||||||
 | 
					        'description': _descriptionController.text,
 | 
				
			||||||
 | 
					        'avatar': ua.user!.avatar,
 | 
				
			||||||
 | 
					        'banner': ua.user!.banner,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
      Navigator.pop(context, true);
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setState(() => _isBusy = false);
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _syncState() {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (ua.user == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _nameController.text = ua.user!.name;
 | 
				
			||||||
 | 
					    _nickController.text = ua.user!.nick;
 | 
				
			||||||
 | 
					    _descriptionController.text = ua.user!.description;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _syncState();
 | 
				
			||||||
 | 
					    _nameController.addListener(() => setState(() => {}));
 | 
				
			||||||
 | 
					    _nickController.addListener(() => setState(() => {}));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _nickController.dispose();
 | 
				
			||||||
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final ua = context.watch<UserProvider>();
 | 
					    final ua = context.watch<UserProvider>();
 | 
				
			||||||
@@ -91,10 +130,41 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
				
			|||||||
    return Column(
 | 
					    return Column(
 | 
				
			||||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
      children: [
 | 
					      children: [
 | 
				
			||||||
        Text('preview')
 | 
					        Column(
 | 
				
			||||||
            .tr()
 | 
					          children: [
 | 
				
			||||||
            .textStyle(Theme.of(context).textTheme.titleMedium!)
 | 
					            TextField(
 | 
				
			||||||
            .padding(horizontal: 16, vertical: 4),
 | 
					              controller: _nameController,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldUsername'.tr(),
 | 
				
			||||||
 | 
					                helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
				
			||||||
 | 
					                helperMaxLines: 2,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            const Gap(4),
 | 
				
			||||||
 | 
					            TextField(
 | 
				
			||||||
 | 
					              controller: _nickController,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldNickname'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            const Gap(4),
 | 
				
			||||||
 | 
					            TextField(
 | 
				
			||||||
 | 
					              controller: _descriptionController,
 | 
				
			||||||
 | 
					              minLines: 3,
 | 
				
			||||||
 | 
					              maxLines: null,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldDescription'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					        const Gap(16),
 | 
				
			||||||
        Card(
 | 
					        Card(
 | 
				
			||||||
          child: SizedBox(
 | 
					          child: SizedBox(
 | 
				
			||||||
            width: double.infinity,
 | 
					            width: double.infinity,
 | 
				
			||||||
@@ -106,10 +176,254 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
				
			|||||||
                  crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
					                  crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
				
			||||||
                  textBaseline: TextBaseline.alphabetic,
 | 
					                  textBaseline: TextBaseline.alphabetic,
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    Text(ua.user!.nick)
 | 
					                    Text(_nickController.text)
 | 
				
			||||||
                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
                    const Gap(4),
 | 
					                    const Gap(4),
 | 
				
			||||||
                    Text('@${ua.user!.name}')
 | 
					                    Text('@${_nameController.text}')
 | 
				
			||||||
 | 
					                        .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ).padding(all: 16),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        SizedBox(
 | 
				
			||||||
 | 
					          width: double.infinity,
 | 
				
			||||||
 | 
					          child: ElevatedButton.icon(
 | 
				
			||||||
 | 
					            onPressed: _isBusy ? null : _performAction,
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					            label: Text('create').tr(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ).padding(horizontal: 2),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PublisherNewOrganization extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _PublisherNewOrganization({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_PublisherNewOrganization> createState() =>
 | 
				
			||||||
 | 
					      _PublisherNewOrganizationState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PublisherNewOrganizationState extends State<_PublisherNewOrganization> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _nickController = TextEditingController();
 | 
				
			||||||
 | 
					  final TextEditingController _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _performAction() async {
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					    if (_belongToRealm == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/co/publishers/organization', data: {
 | 
				
			||||||
 | 
					        'realm': _belongToRealm!.alias,
 | 
				
			||||||
 | 
					        'name': _nameController.text,
 | 
				
			||||||
 | 
					        'nick': _nickController.text,
 | 
				
			||||||
 | 
					        'description': _descriptionController.text,
 | 
				
			||||||
 | 
					        'avatar': _belongToRealm!.avatar,
 | 
				
			||||||
 | 
					        'banner': _belongToRealm!.banner,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnRealm>? _realms;
 | 
				
			||||||
 | 
					  SnRealm? _belongToRealm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/realms/me/available');
 | 
				
			||||||
 | 
					      _realms = List<SnRealm>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _syncState() {
 | 
				
			||||||
 | 
					    if (_belongToRealm == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _nameController.text = _belongToRealm!.alias;
 | 
				
			||||||
 | 
					    _nickController.text = _belongToRealm!.name;
 | 
				
			||||||
 | 
					    _descriptionController.text = _belongToRealm!.description;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchRealms();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _nickController.dispose();
 | 
				
			||||||
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        DropdownButtonHideUnderline(
 | 
				
			||||||
 | 
					          child: DropdownButton2<SnRealm>(
 | 
				
			||||||
 | 
					            isExpanded: true,
 | 
				
			||||||
 | 
					            hint: Text(
 | 
				
			||||||
 | 
					              'fieldPublisherBelongToRealm'.tr(),
 | 
				
			||||||
 | 
					              style: TextStyle(
 | 
				
			||||||
 | 
					                color: Theme.of(context).hintColor,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            items: [
 | 
				
			||||||
 | 
					              ...(_realms?.map(
 | 
				
			||||||
 | 
					                    (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
				
			||||||
 | 
					                      value: item,
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          AccountImage(
 | 
				
			||||||
 | 
					                            content: item.avatar,
 | 
				
			||||||
 | 
					                            radius: 16,
 | 
				
			||||||
 | 
					                            fallbackWidget: const Icon(
 | 
				
			||||||
 | 
					                              Symbols.group,
 | 
				
			||||||
 | 
					                              size: 16,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          const Gap(12),
 | 
				
			||||||
 | 
					                          Expanded(
 | 
				
			||||||
 | 
					                            child: Column(
 | 
				
			||||||
 | 
					                              mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                Text(item.name).textStyle(
 | 
				
			||||||
 | 
					                                    Theme.of(context).textTheme.bodyMedium!),
 | 
				
			||||||
 | 
					                                Text(
 | 
				
			||||||
 | 
					                                  item.description,
 | 
				
			||||||
 | 
					                                  maxLines: 1,
 | 
				
			||||||
 | 
					                                  overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                                ).textStyle(
 | 
				
			||||||
 | 
					                                    Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ) ??
 | 
				
			||||||
 | 
					                  []),
 | 
				
			||||||
 | 
					              DropdownMenuItem<SnRealm>(
 | 
				
			||||||
 | 
					                value: null,
 | 
				
			||||||
 | 
					                child: Row(
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    CircleAvatar(
 | 
				
			||||||
 | 
					                      radius: 16,
 | 
				
			||||||
 | 
					                      backgroundColor: Colors.transparent,
 | 
				
			||||||
 | 
					                      foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
 | 
					                      child: const Icon(Symbols.clear),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    const Gap(12),
 | 
				
			||||||
 | 
					                    Expanded(
 | 
				
			||||||
 | 
					                      child: Column(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Text('fieldPublisherBelongToRealmUnset')
 | 
				
			||||||
 | 
					                              .tr()
 | 
				
			||||||
 | 
					                              .textStyle(
 | 
				
			||||||
 | 
					                                Theme.of(context).textTheme.bodyMedium!,
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            value: _belongToRealm,
 | 
				
			||||||
 | 
					            onChanged: (SnRealm? value) {
 | 
				
			||||||
 | 
					              _belongToRealm = value;
 | 
				
			||||||
 | 
					              _syncState();
 | 
				
			||||||
 | 
					              setState(() {});
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            buttonStyleData: const ButtonStyleData(
 | 
				
			||||||
 | 
					              padding: EdgeInsets.only(right: 16),
 | 
				
			||||||
 | 
					              height: 60,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            menuItemStyleData: const MenuItemStyleData(
 | 
				
			||||||
 | 
					              height: 60,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        Column(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            TextField(
 | 
				
			||||||
 | 
					              controller: _nameController,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldUsername'.tr(),
 | 
				
			||||||
 | 
					                helperText: 'fieldUsernameCannotEditHint'.tr(),
 | 
				
			||||||
 | 
					                helperMaxLines: 2,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            const Gap(4),
 | 
				
			||||||
 | 
					            TextField(
 | 
				
			||||||
 | 
					              controller: _nickController,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldNickname'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            const Gap(4),
 | 
				
			||||||
 | 
					            TextField(
 | 
				
			||||||
 | 
					              controller: _descriptionController,
 | 
				
			||||||
 | 
					              minLines: 3,
 | 
				
			||||||
 | 
					              maxLines: null,
 | 
				
			||||||
 | 
					              decoration: InputDecoration(
 | 
				
			||||||
 | 
					                labelText: 'fieldDescription'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					        const Gap(16),
 | 
				
			||||||
 | 
					        Card(
 | 
				
			||||||
 | 
					          child: SizedBox(
 | 
				
			||||||
 | 
					            width: double.infinity,
 | 
				
			||||||
 | 
					            child: Row(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                AccountImage(content: _belongToRealm?.avatar, radius: 24),
 | 
				
			||||||
 | 
					                const Gap(16),
 | 
				
			||||||
 | 
					                Column(
 | 
				
			||||||
 | 
					                  crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
				
			||||||
 | 
					                  textBaseline: TextBaseline.alphabetic,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    Text(_nickController.text)
 | 
				
			||||||
 | 
					                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
 | 
					                    const Gap(4),
 | 
				
			||||||
 | 
					                    Text('@${_nameController.text}')
 | 
				
			||||||
                        .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
					                        .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
                  ],
 | 
					                  ],
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,4 @@
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/cupertino.dart';
 | 
					 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
@@ -11,7 +10,6 @@ import 'package:surface/types/post.dart';
 | 
				
			|||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PublisherScreen extends StatefulWidget {
 | 
					class PublisherScreen extends StatefulWidget {
 | 
				
			||||||
  const PublisherScreen({super.key});
 | 
					  const PublisherScreen({super.key});
 | 
				
			||||||
@@ -33,7 +31,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/publishers');
 | 
					      final resp = await sn.client.get('/cgi/co/publishers/me');
 | 
				
			||||||
      final List<SnPublisher> out = List<SnPublisher>.from(
 | 
					      final List<SnPublisher> out = List<SnPublisher>.from(
 | 
				
			||||||
          resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
					          resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,7 +53,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      body: Column(
 | 
					      body: Column(
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
          ListTile(
 | 
					          ListTile(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,132 @@
 | 
				
			|||||||
 | 
					import 'package:dismissible_page/dismissible_page.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/attachment/attachment_zoom.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AlbumScreen extends StatelessWidget {
 | 
					class AlbumScreen extends StatefulWidget {
 | 
				
			||||||
  const AlbumScreen({super.key});
 | 
					  const AlbumScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<AlbumScreen> createState() => _AlbumScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _AlbumScreenState extends State<AlbumScreen> {
 | 
				
			||||||
 | 
					  final ScrollController _scrollController = ScrollController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final List<SnAttachment> _attachments = List.empty(growable: true);
 | 
				
			||||||
 | 
					  final List<String> _heroTags = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchAttachments() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const uuid = Uuid();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/uc/attachments', queryParameters: {
 | 
				
			||||||
 | 
					        'take': 10,
 | 
				
			||||||
 | 
					        'offset': _attachments.length,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      final attachments = List<SnAttachment>.from(
 | 
				
			||||||
 | 
					        resp.data['data']?.map((e) => SnAttachment.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      ).where((e) => e.mimetype.startsWith('image')).toList();
 | 
				
			||||||
 | 
					      _attachments.addAll(attachments);
 | 
				
			||||||
 | 
					      _heroTags.addAll(_attachments.map((_) => uuid.v4()));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await ud.listAccount(attachments.map((e) => e.accountId).toSet());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      _totalCount = resp.data['count'] as int?;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchAttachments();
 | 
				
			||||||
 | 
					    _scrollController.addListener(() {
 | 
				
			||||||
 | 
					      if (_scrollController.position.atEdge) {
 | 
				
			||||||
 | 
					        bool isTop = _scrollController.position.pixels == 0;
 | 
				
			||||||
 | 
					        if (!isTop && !_isBusy) {
 | 
				
			||||||
 | 
					          if (_totalCount == null || _attachments.length < _totalCount!) {
 | 
				
			||||||
 | 
					            _fetchAttachments();
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _scrollController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return const Placeholder();
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      body: CustomScrollView(
 | 
				
			||||||
 | 
					        controller: _scrollController,
 | 
				
			||||||
 | 
					        slivers: [
 | 
				
			||||||
 | 
					          SliverAppBar(
 | 
				
			||||||
 | 
					            leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					            title: Text('screenAlbum').tr(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          SliverMasonryGrid.extent(
 | 
				
			||||||
 | 
					            childCount: _attachments.length,
 | 
				
			||||||
 | 
					            maxCrossAxisExtent: 320,
 | 
				
			||||||
 | 
					            mainAxisSpacing: 4,
 | 
				
			||||||
 | 
					            crossAxisSpacing: 4,
 | 
				
			||||||
 | 
					            itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					              final attachment = _attachments[idx];
 | 
				
			||||||
 | 
					              return GestureDetector(
 | 
				
			||||||
 | 
					                child: ClipRRect(
 | 
				
			||||||
 | 
					                  child: AspectRatio(
 | 
				
			||||||
 | 
					                    aspectRatio: attachment.metadata['ratio']?.toDouble() ?? 1,
 | 
				
			||||||
 | 
					                    child: AttachmentItem(
 | 
				
			||||||
 | 
					                      data: attachment,
 | 
				
			||||||
 | 
					                      heroTag: _heroTags[idx],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  context.pushTransparentRoute(
 | 
				
			||||||
 | 
					                    AttachmentZoomView(
 | 
				
			||||||
 | 
					                      data: [attachment],
 | 
				
			||||||
 | 
					                      heroTags: [_heroTags[idx]],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    backgroundColor: Colors.black.withOpacity(0.7),
 | 
				
			||||||
 | 
					                    rootNavigator: true,
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          if (_isBusy)
 | 
				
			||||||
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
 | 
					              child:
 | 
				
			||||||
 | 
					                  const CircularProgressIndicator().padding(all: 24).center(),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,8 @@ import 'package:surface/types/auth.dart';
 | 
				
			|||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
					import 'package:url_launcher/url_launcher_string.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../../providers/websocket.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
 | 
					final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
 | 
				
			||||||
  0: ('authFactorPassword'.tr(), Symbols.password, false),
 | 
					  0: ('authFactorPassword'.tr(), Symbols.password, false),
 | 
				
			||||||
  1: ('authFactorEmail'.tr(), Symbols.email, true),
 | 
					  1: ('authFactorEmail'.tr(), Symbols.email, true),
 | 
				
			||||||
@@ -33,67 +35,67 @@ class _LoginScreenState extends State<LoginScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return Container(
 | 
					    return Theme(
 | 
				
			||||||
      constraints: const BoxConstraints(maxWidth: 280),
 | 
					      data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
 | 
				
			||||||
      child: Theme(
 | 
					      child: SingleChildScrollView(
 | 
				
			||||||
        data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
 | 
					        child: PageTransitionSwitcher(
 | 
				
			||||||
        child: SingleChildScrollView(
 | 
					          transitionBuilder: (
 | 
				
			||||||
          child: PageTransitionSwitcher(
 | 
					            Widget child,
 | 
				
			||||||
            transitionBuilder: (
 | 
					            Animation<double> primaryAnimation,
 | 
				
			||||||
              Widget child,
 | 
					            Animation<double> secondaryAnimation,
 | 
				
			||||||
              Animation<double> primaryAnimation,
 | 
					          ) {
 | 
				
			||||||
              Animation<double> secondaryAnimation,
 | 
					            return SharedAxisTransition(
 | 
				
			||||||
            ) {
 | 
					              animation: primaryAnimation,
 | 
				
			||||||
              return SharedAxisTransition(
 | 
					              secondaryAnimation: secondaryAnimation,
 | 
				
			||||||
                animation: primaryAnimation,
 | 
					              transitionType: SharedAxisTransitionType.horizontal,
 | 
				
			||||||
                secondaryAnimation: secondaryAnimation,
 | 
					              child: Container(
 | 
				
			||||||
                transitionType: SharedAxisTransitionType.horizontal,
 | 
					                constraints: BoxConstraints(maxWidth: 380),
 | 
				
			||||||
                child: child,
 | 
					                child: child,
 | 
				
			||||||
              );
 | 
					              ),
 | 
				
			||||||
            },
 | 
					            );
 | 
				
			||||||
            child: switch (_period % 3) {
 | 
					          },
 | 
				
			||||||
              1 => _LoginPickerScreen(
 | 
					          child: switch (_period % 3) {
 | 
				
			||||||
                  key: const ValueKey(1),
 | 
					            1 => _LoginPickerScreen(
 | 
				
			||||||
                  ticket: _currentTicket,
 | 
					                key: const ValueKey(1),
 | 
				
			||||||
                  factors: _factors,
 | 
					                ticket: _currentTicket,
 | 
				
			||||||
                  onTicket: (p0) => setState(() {
 | 
					                factors: _factors,
 | 
				
			||||||
                    _currentTicket = p0;
 | 
					                onTicket: (p0) => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _currentTicket = p0;
 | 
				
			||||||
                  onPickFactor: (p0) => setState(() {
 | 
					                }),
 | 
				
			||||||
                    _factorPicked = p0;
 | 
					                onPickFactor: (p0) => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _factorPicked = p0;
 | 
				
			||||||
                  onNext: () => setState(() {
 | 
					                }),
 | 
				
			||||||
                    _period++;
 | 
					                onNext: () => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _period++;
 | 
				
			||||||
                ),
 | 
					                }),
 | 
				
			||||||
              2 => _LoginCheckScreen(
 | 
					              ),
 | 
				
			||||||
                  key: const ValueKey(2),
 | 
					            2 => _LoginCheckScreen(
 | 
				
			||||||
                  ticket: _currentTicket,
 | 
					                key: const ValueKey(2),
 | 
				
			||||||
                  factor: _factorPicked,
 | 
					                ticket: _currentTicket,
 | 
				
			||||||
                  onTicket: (p0) => setState(() {
 | 
					                factor: _factorPicked,
 | 
				
			||||||
                    _currentTicket = p0;
 | 
					                onTicket: (p0) => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _currentTicket = p0;
 | 
				
			||||||
                  onNext: () => setState(() {
 | 
					                }),
 | 
				
			||||||
                    _period = 1;
 | 
					                onNext: () => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _period = 1;
 | 
				
			||||||
                ),
 | 
					                }),
 | 
				
			||||||
              _ => _LoginLookupScreen(
 | 
					              ),
 | 
				
			||||||
                  key: const ValueKey(0),
 | 
					            _ => _LoginLookupScreen(
 | 
				
			||||||
                  ticket: _currentTicket,
 | 
					                key: const ValueKey(0),
 | 
				
			||||||
                  onTicket: (p0) => setState(() {
 | 
					                ticket: _currentTicket,
 | 
				
			||||||
                    _currentTicket = p0;
 | 
					                onTicket: (p0) => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _currentTicket = p0;
 | 
				
			||||||
                  onFactor: (p0) => setState(() {
 | 
					                }),
 | 
				
			||||||
                    _factors = p0;
 | 
					                onFactor: (p0) => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _factors = p0;
 | 
				
			||||||
                  onNext: () => setState(() {
 | 
					                }),
 | 
				
			||||||
                    _period++;
 | 
					                onNext: () => setState(() {
 | 
				
			||||||
                  }),
 | 
					                  _period++;
 | 
				
			||||||
                ),
 | 
					                }),
 | 
				
			||||||
            },
 | 
					              ),
 | 
				
			||||||
          ).padding(all: 24),
 | 
					          },
 | 
				
			||||||
        ).center(),
 | 
					        ).padding(all: 24),
 | 
				
			||||||
      ),
 | 
					      ).center(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -151,16 +153,15 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
      final atk = tokenResp.data['access_token'];
 | 
					      final atk = tokenResp.data['access_token'];
 | 
				
			||||||
      final rtk = tokenResp.data['refresh_token'];
 | 
					      final rtk = tokenResp.data['refresh_token'];
 | 
				
			||||||
      await sn.setTokenPair(atk, rtk);
 | 
					      sn.setTokenPair(atk, rtk);
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final user = context.read<UserProvider>();
 | 
					      final user = context.read<UserProvider>();
 | 
				
			||||||
      final userinfo = await user.refreshUser();
 | 
					      await user.refreshUser();
 | 
				
			||||||
      context.showSnackbar('loginSuccess'.tr(args: [
 | 
					      if (!mounted) return;
 | 
				
			||||||
        '@${userinfo!.name} (${userinfo.nick})',
 | 
					      final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
      ]));
 | 
					      await ws.connect();
 | 
				
			||||||
      await Future.delayed(const Duration(milliseconds: 1850), () {
 | 
					      if (!mounted) return;
 | 
				
			||||||
        Navigator.pop(context);
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:email_validator/email_validator.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
@@ -7,6 +8,7 @@ import 'package:provider/provider.dart';
 | 
				
			|||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:url_launcher/url_launcher_string.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RegisterScreen extends StatefulWidget {
 | 
					class RegisterScreen extends StatefulWidget {
 | 
				
			||||||
  const RegisterScreen({super.key});
 | 
					  const RegisterScreen({super.key});
 | 
				
			||||||
@@ -16,20 +18,21 @@ class RegisterScreen extends StatefulWidget {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _RegisterScreenState extends State<RegisterScreen> {
 | 
					class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			||||||
 | 
					  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  final _emailController = TextEditingController();
 | 
					  final _emailController = TextEditingController();
 | 
				
			||||||
  final _usernameController = TextEditingController();
 | 
					  final _usernameController = TextEditingController();
 | 
				
			||||||
  final _nicknameController = TextEditingController();
 | 
					  final _nicknameController = TextEditingController();
 | 
				
			||||||
  final _passwordController = TextEditingController();
 | 
					  final _passwordController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void _performAction(BuildContext context) async {
 | 
					  void _performAction(BuildContext context) async {
 | 
				
			||||||
 | 
					    if (!_formKey.currentState!.validate()) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final email = _emailController.value.text;
 | 
					    final email = _emailController.value.text;
 | 
				
			||||||
    final username = _usernameController.value.text;
 | 
					    final username = _usernameController.value.text;
 | 
				
			||||||
    final nickname = _nicknameController.value.text;
 | 
					    final nickname = _nicknameController.value.text;
 | 
				
			||||||
    final password = _passwordController.value.text;
 | 
					    final password = _passwordController.value.text;
 | 
				
			||||||
    if (email.isEmpty ||
 | 
					    if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) {
 | 
				
			||||||
        username.isEmpty ||
 | 
					 | 
				
			||||||
        nickname.isEmpty ||
 | 
					 | 
				
			||||||
        password.isEmpty) {
 | 
					 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -42,8 +45,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
        'password': password,
 | 
					        'password': password,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!context.mounted) return;
 | 
				
			||||||
 | 
					 | 
				
			||||||
      GoRouter.of(context).replaceNamed("authLogin");
 | 
					      GoRouter.of(context).replaceNamed("authLogin");
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
@@ -52,33 +54,44 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return Container(
 | 
					    return StyledWidget(Container(
 | 
				
			||||||
      constraints: const BoxConstraints(maxWidth: 280),
 | 
					      constraints: const BoxConstraints(maxWidth: 380),
 | 
				
			||||||
      child: StyledWidget(
 | 
					      child: SingleChildScrollView(
 | 
				
			||||||
        SingleChildScrollView(
 | 
					        child: Column(
 | 
				
			||||||
          child: Column(
 | 
					          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
					          children: [
 | 
				
			||||||
            children: [
 | 
					            Align(
 | 
				
			||||||
              Align(
 | 
					              alignment: Alignment.centerLeft,
 | 
				
			||||||
                alignment: Alignment.centerLeft,
 | 
					              child: CircleAvatar(
 | 
				
			||||||
                child: CircleAvatar(
 | 
					                radius: 26,
 | 
				
			||||||
                  radius: 26,
 | 
					                child: const Icon(
 | 
				
			||||||
                  child: const Icon(
 | 
					                  Symbols.person_add,
 | 
				
			||||||
                    Symbols.person_add,
 | 
					                  size: 28,
 | 
				
			||||||
                    size: 28,
 | 
					 | 
				
			||||||
                  ),
 | 
					 | 
				
			||||||
                ).padding(bottom: 8),
 | 
					 | 
				
			||||||
              ),
 | 
					 | 
				
			||||||
              Text(
 | 
					 | 
				
			||||||
                'screenAuthRegister',
 | 
					 | 
				
			||||||
                style: const TextStyle(
 | 
					 | 
				
			||||||
                  fontSize: 28,
 | 
					 | 
				
			||||||
                  fontWeight: FontWeight.w900,
 | 
					 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ).tr().padding(left: 4, bottom: 16),
 | 
					              ).padding(bottom: 8),
 | 
				
			||||||
              Column(
 | 
					            ),
 | 
				
			||||||
 | 
					            Text(
 | 
				
			||||||
 | 
					              'screenAuthRegister',
 | 
				
			||||||
 | 
					              style: const TextStyle(
 | 
				
			||||||
 | 
					                fontSize: 28,
 | 
				
			||||||
 | 
					                fontWeight: FontWeight.w900,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ).tr().padding(left: 4, bottom: 16),
 | 
				
			||||||
 | 
					            Form(
 | 
				
			||||||
 | 
					              key: _formKey,
 | 
				
			||||||
 | 
					              autovalidateMode: AutovalidateMode.onUserInteraction,
 | 
				
			||||||
 | 
					              child: Column(
 | 
				
			||||||
                children: [
 | 
					                children: [
 | 
				
			||||||
                  TextField(
 | 
					                  TextFormField(
 | 
				
			||||||
 | 
					                    validator: (value) {
 | 
				
			||||||
 | 
					                      if (value == null || value.length < 4 || value.length > 32) {
 | 
				
			||||||
 | 
					                        return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
 | 
				
			||||||
 | 
					                        return 'fieldUsernameAlphanumOnly'.tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return null;
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                    autocorrect: false,
 | 
					                    autocorrect: false,
 | 
				
			||||||
                    enableSuggestions: false,
 | 
					                    enableSuggestions: false,
 | 
				
			||||||
                    controller: _usernameController,
 | 
					                    controller: _usernameController,
 | 
				
			||||||
@@ -88,11 +101,16 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
                      border: const UnderlineInputBorder(),
 | 
					                      border: const UnderlineInputBorder(),
 | 
				
			||||||
                      labelText: 'fieldUsername'.tr(),
 | 
					                      labelText: 'fieldUsername'.tr(),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    onTapOutside: (_) =>
 | 
					                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  const Gap(12),
 | 
					                  const Gap(12),
 | 
				
			||||||
                  TextField(
 | 
					                  TextFormField(
 | 
				
			||||||
 | 
					                    validator: (value) {
 | 
				
			||||||
 | 
					                      if (value == null || value.length < 4 || value.length > 32) {
 | 
				
			||||||
 | 
					                        return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return null;
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                    autocorrect: false,
 | 
					                    autocorrect: false,
 | 
				
			||||||
                    enableSuggestions: false,
 | 
					                    enableSuggestions: false,
 | 
				
			||||||
                    controller: _nicknameController,
 | 
					                    controller: _nicknameController,
 | 
				
			||||||
@@ -102,11 +120,19 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
                      border: const UnderlineInputBorder(),
 | 
					                      border: const UnderlineInputBorder(),
 | 
				
			||||||
                      labelText: 'fieldNickname'.tr(),
 | 
					                      labelText: 'fieldNickname'.tr(),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    onTapOutside: (_) =>
 | 
					                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  const Gap(12),
 | 
					                  const Gap(12),
 | 
				
			||||||
                  TextField(
 | 
					                  TextFormField(
 | 
				
			||||||
 | 
					                    validator: (value) {
 | 
				
			||||||
 | 
					                      if (value == null || value.isEmpty) {
 | 
				
			||||||
 | 
					                        return 'fieldCannotBeEmpty'.tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      if (!EmailValidator.validate(value)) {
 | 
				
			||||||
 | 
					                        return 'fieldEmailAddressMustBeValid'.tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return null;
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                    autocorrect: false,
 | 
					                    autocorrect: false,
 | 
				
			||||||
                    enableSuggestions: false,
 | 
					                    enableSuggestions: false,
 | 
				
			||||||
                    controller: _emailController,
 | 
					                    controller: _emailController,
 | 
				
			||||||
@@ -116,11 +142,16 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
                      border: const UnderlineInputBorder(),
 | 
					                      border: const UnderlineInputBorder(),
 | 
				
			||||||
                      labelText: 'fieldEmail'.tr(),
 | 
					                      labelText: 'fieldEmail'.tr(),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    onTapOutside: (_) =>
 | 
					                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  const Gap(12),
 | 
					                  const Gap(12),
 | 
				
			||||||
                  TextField(
 | 
					                  TextFormField(
 | 
				
			||||||
 | 
					                    validator: (value) {
 | 
				
			||||||
 | 
					                      if (value == null || value.isEmpty) {
 | 
				
			||||||
 | 
					                        return 'fieldCannotBeEmpty'.tr();
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      return null;
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                    obscureText: true,
 | 
					                    obscureText: true,
 | 
				
			||||||
                    autocorrect: false,
 | 
					                    autocorrect: false,
 | 
				
			||||||
                    enableSuggestions: false,
 | 
					                    enableSuggestions: false,
 | 
				
			||||||
@@ -131,30 +162,67 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
				
			|||||||
                      border: const UnderlineInputBorder(),
 | 
					                      border: const UnderlineInputBorder(),
 | 
				
			||||||
                      labelText: 'fieldPassword'.tr(),
 | 
					                      labelText: 'fieldPassword'.tr(),
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    onTapOutside: (_) =>
 | 
					                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                    onSubmitted: (_) => _performAction(context),
 | 
					 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
              ).padding(horizontal: 7),
 | 
					              ).padding(horizontal: 7),
 | 
				
			||||||
              const Gap(16),
 | 
					            ),
 | 
				
			||||||
              Align(
 | 
					            const Gap(16),
 | 
				
			||||||
                alignment: Alignment.centerRight,
 | 
					            Align(
 | 
				
			||||||
                child: TextButton(
 | 
					              alignment: Alignment.centerRight,
 | 
				
			||||||
                  onPressed: () => _performAction(context),
 | 
					              child: StyledWidget(
 | 
				
			||||||
                  child: Row(
 | 
					                Container(
 | 
				
			||||||
                    mainAxisSize: MainAxisSize.min,
 | 
					                  constraints: const BoxConstraints(maxWidth: 290),
 | 
				
			||||||
 | 
					                  child: Column(
 | 
				
			||||||
 | 
					                    crossAxisAlignment: CrossAxisAlignment.end,
 | 
				
			||||||
                    children: [
 | 
					                    children: [
 | 
				
			||||||
                      Text('next').tr(),
 | 
					                      Text(
 | 
				
			||||||
                      const Icon(Symbols.chevron_right),
 | 
					                        'termAcceptNextWithAgree'.tr(),
 | 
				
			||||||
 | 
					                        textAlign: TextAlign.end,
 | 
				
			||||||
 | 
					                        style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
				
			||||||
 | 
					                          color: Theme.of(context)
 | 
				
			||||||
 | 
					                              .colorScheme
 | 
				
			||||||
 | 
					                              .onSurface
 | 
				
			||||||
 | 
					                              .withAlpha((255 * 0.75).round()),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      Material(
 | 
				
			||||||
 | 
					                        color: Colors.transparent,
 | 
				
			||||||
 | 
					                        child: InkWell(
 | 
				
			||||||
 | 
					                          child: Row(
 | 
				
			||||||
 | 
					                            mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              Text('termAcceptLink'.tr()),
 | 
				
			||||||
 | 
					                              const Gap(4),
 | 
				
			||||||
 | 
					                              const Icon(Symbols.launch, size: 14),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          onTap: () {
 | 
				
			||||||
 | 
					                            launchUrlString('https://solsynth.dev/terms');
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
                    ],
 | 
					                    ],
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              )
 | 
					              ).padding(horizontal: 16),
 | 
				
			||||||
            ],
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					            Align(
 | 
				
			||||||
 | 
					              alignment: Alignment.centerRight,
 | 
				
			||||||
 | 
					              child: TextButton(
 | 
				
			||||||
 | 
					                onPressed: () => _performAction(context),
 | 
				
			||||||
 | 
					                child: Row(
 | 
				
			||||||
 | 
					                  mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    Text('next').tr(),
 | 
				
			||||||
 | 
					                    const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ).padding(all: 24).center(),
 | 
					      ),
 | 
				
			||||||
    );
 | 
					    )).padding(all: 24).center();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,283 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 | 
				
			||||||
 | 
					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:surface/providers/channel.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_select.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/unauthorized_hint.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChatScreen extends StatelessWidget {
 | 
					import '../providers/sn_network.dart';
 | 
				
			||||||
 | 
					import '../providers/userinfo.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatScreen extends StatefulWidget {
 | 
				
			||||||
  const ChatScreen({super.key});
 | 
					  const ChatScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<ChatScreen> createState() => _ChatScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChatScreenState extends State<ChatScreen> {
 | 
				
			||||||
 | 
					  final _fabKey = GlobalKey<ExpandableFabState>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnChannel>? _channels;
 | 
				
			||||||
 | 
					  Map<int, SnChatMessage>? _lastMessages;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _refreshChannels() {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final chan = context.read<ChatChannelProvider>();
 | 
				
			||||||
 | 
					    chan.fetchChannels().listen((channels) async {
 | 
				
			||||||
 | 
					      final lastMessages = await chan.getLastMessages(channels);
 | 
				
			||||||
 | 
					      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
				
			||||||
 | 
					      channels.sort((a, b) {
 | 
				
			||||||
 | 
					        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
				
			||||||
 | 
					          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
				
			||||||
 | 
					        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
				
			||||||
 | 
					        return 0;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      for (final channel in channels) {
 | 
				
			||||||
 | 
					        if (channel.type == 1) {
 | 
				
			||||||
 | 
					          await ud.listAccount(
 | 
				
			||||||
 | 
					            channel.members
 | 
				
			||||||
 | 
					                    ?.cast<SnChannelMember?>()
 | 
				
			||||||
 | 
					                    .map((ele) => ele?.accountId)
 | 
				
			||||||
 | 
					                    .where((ele) => ele != null)
 | 
				
			||||||
 | 
					                    .toSet() ??
 | 
				
			||||||
 | 
					                {},
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (mounted) setState(() => _channels = channels);
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					      ..onError((err) {
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        context.showErrorDialog(err);
 | 
				
			||||||
 | 
					        setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      ..onDone(() {
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _newDirectMessage() async {
 | 
				
			||||||
 | 
					    final user = await showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (user == null) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const uuid = Uuid();
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/im/channels/global/dm', data: {
 | 
				
			||||||
 | 
					        'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
				
			||||||
 | 
					        'name': 'DM',
 | 
				
			||||||
 | 
					        'description': 'A direct message channel between @${ua.user?.name} and @${user.name}',
 | 
				
			||||||
 | 
					        'related_user': user.id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      _fabKey.currentState!.toggle();
 | 
				
			||||||
 | 
					      _refreshChannels();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _refreshChannels();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return const Placeholder();
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      return Scaffold(
 | 
				
			||||||
 | 
					        appBar: AppBar(
 | 
				
			||||||
 | 
					          leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					          title: Text('screenChat').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        body: Center(
 | 
				
			||||||
 | 
					          child: UnauthorizedHint(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					        title: Text('screenChat').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      floatingActionButtonLocation: ExpandableFab.location,
 | 
				
			||||||
 | 
					      floatingActionButton: ExpandableFab(
 | 
				
			||||||
 | 
					        key: _fabKey,
 | 
				
			||||||
 | 
					        distance: 75,
 | 
				
			||||||
 | 
					        type: ExpandableFabType.up,
 | 
				
			||||||
 | 
					        childrenAnimation: ExpandableFabAnimation.none,
 | 
				
			||||||
 | 
					        overlayStyle: ExpandableFabOverlayStyle(
 | 
				
			||||||
 | 
					          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,
 | 
				
			||||||
 | 
					          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,
 | 
				
			||||||
 | 
					          shape: const CircleBorder(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Row(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text('channelNewChannel').tr(),
 | 
				
			||||||
 | 
					              const Gap(20),
 | 
				
			||||||
 | 
					              FloatingActionButton(
 | 
				
			||||||
 | 
					                heroTag: null,
 | 
				
			||||||
 | 
					                tooltip: 'channelNewChannel'.tr(),
 | 
				
			||||||
 | 
					                onPressed: () {
 | 
				
			||||||
 | 
					                  _fabKey.currentState!.toggle();
 | 
				
			||||||
 | 
					                  GoRouter.of(context).pushNamed('chatManage').then((value) {
 | 
				
			||||||
 | 
					                    if (value != null && context.mounted) _refreshChannels();
 | 
				
			||||||
 | 
					                  });
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                child: const Icon(Symbols.chat_add_on),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          Row(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text('channelNewDirectMessage').tr(),
 | 
				
			||||||
 | 
					              const Gap(20),
 | 
				
			||||||
 | 
					              FloatingActionButton(
 | 
				
			||||||
 | 
					                heroTag: null,
 | 
				
			||||||
 | 
					                tooltip: 'channelNewDirectMessage'.tr(),
 | 
				
			||||||
 | 
					                onPressed: _newDirectMessage,
 | 
				
			||||||
 | 
					                child: const Icon(Symbols.communication),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: RefreshIndicator(
 | 
				
			||||||
 | 
					              onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
				
			||||||
 | 
					              child: ListView.builder(
 | 
				
			||||||
 | 
					                itemCount: _channels?.length ?? 0,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  final channel = _channels![idx];
 | 
				
			||||||
 | 
					                  final lastMessage = _lastMessages?[channel.id];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  if (channel.type == 1) {
 | 
				
			||||||
 | 
					                    final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
				
			||||||
 | 
					                          (ele) => ele?.accountId != ua.user?.id,
 | 
				
			||||||
 | 
					                          orElse: () => null,
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return ListTile(
 | 
				
			||||||
 | 
					                      title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
 | 
				
			||||||
 | 
					                      subtitle: lastMessage != null
 | 
				
			||||||
 | 
					                          ? Text(
 | 
				
			||||||
 | 
					                              '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                              overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                            )
 | 
				
			||||||
 | 
					                          : Text(
 | 
				
			||||||
 | 
					                              'channelDirectMessageDescription'.tr(args: [
 | 
				
			||||||
 | 
					                                '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
 | 
				
			||||||
 | 
					                              ]),
 | 
				
			||||||
 | 
					                              maxLines: 1,
 | 
				
			||||||
 | 
					                              overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					                      leading: AccountImage(
 | 
				
			||||||
 | 
					                        content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      onTap: () {
 | 
				
			||||||
 | 
					                        GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                          'chatRoom',
 | 
				
			||||||
 | 
					                          pathParameters: {
 | 
				
			||||||
 | 
					                            'scope': channel.realm?.alias ?? 'global',
 | 
				
			||||||
 | 
					                            'alias': channel.alias,
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ).then((value) {
 | 
				
			||||||
 | 
					                          if (value == true) _refreshChannels();
 | 
				
			||||||
 | 
					                        });
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  return ListTile(
 | 
				
			||||||
 | 
					                    title: Text(channel.name),
 | 
				
			||||||
 | 
					                    subtitle: lastMessage != null
 | 
				
			||||||
 | 
					                        ? Text(
 | 
				
			||||||
 | 
					                            '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
				
			||||||
 | 
					                            maxLines: 1,
 | 
				
			||||||
 | 
					                            overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                          )
 | 
				
			||||||
 | 
					                        : Text(
 | 
				
			||||||
 | 
					                            channel.description,
 | 
				
			||||||
 | 
					                            maxLines: 1,
 | 
				
			||||||
 | 
					                            overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					                    leading: AccountImage(
 | 
				
			||||||
 | 
					                      content: null,
 | 
				
			||||||
 | 
					                      fallbackWidget: const Icon(Symbols.chat, size: 20),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                        'chatRoom',
 | 
				
			||||||
 | 
					                        pathParameters: {
 | 
				
			||||||
 | 
					                          'scope': channel.realm?.alias ?? 'global',
 | 
				
			||||||
 | 
					                          'alias': channel.alias,
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      ).then((value) {
 | 
				
			||||||
 | 
					                        if (value == true) _refreshChannels();
 | 
				
			||||||
 | 
					                      });
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										324
									
								
								lib/screens/chat/call_room.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,324 @@
 | 
				
			|||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:livekit_client/livekit_client.dart' as livekit;
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/chat_call.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/chat/call/call_controls.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/chat/call/call_participant.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CallRoomScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String scope;
 | 
				
			||||||
 | 
					  final String alias;
 | 
				
			||||||
 | 
					  const CallRoomScreen({super.key, required this.scope, required this.alias});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<CallRoomScreen> createState() => _CallRoomScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
				
			||||||
 | 
					  int _layoutMode = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _switchLayout() {
 | 
				
			||||||
 | 
					    if (_layoutMode < 1) {
 | 
				
			||||||
 | 
					      setState(() => _layoutMode++);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      setState(() => _layoutMode = 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildListLayout() {
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					    return Stack(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Container(
 | 
				
			||||||
 | 
					          color:
 | 
				
			||||||
 | 
					              Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
 | 
				
			||||||
 | 
					          child: call.focusTrack != null
 | 
				
			||||||
 | 
					              ? InteractiveParticipantWidget(
 | 
				
			||||||
 | 
					                  isFixedAvatar: false,
 | 
				
			||||||
 | 
					                  participant: call.focusTrack!,
 | 
				
			||||||
 | 
					                  onTap: () {},
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              : const SizedBox.shrink(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        Positioned(
 | 
				
			||||||
 | 
					          left: 0,
 | 
				
			||||||
 | 
					          right: 0,
 | 
				
			||||||
 | 
					          top: 0,
 | 
				
			||||||
 | 
					          child: SizedBox(
 | 
				
			||||||
 | 
					            height: 128,
 | 
				
			||||||
 | 
					            child: ListView.builder(
 | 
				
			||||||
 | 
					              scrollDirection: Axis.horizontal,
 | 
				
			||||||
 | 
					              itemCount: math.max(0, call.participantTracks.length),
 | 
				
			||||||
 | 
					              itemBuilder: (BuildContext context, int index) {
 | 
				
			||||||
 | 
					                final track = call.participantTracks[index];
 | 
				
			||||||
 | 
					                if (track.participant.sid == call.focusTrack?.participant.sid) {
 | 
				
			||||||
 | 
					                  return Container();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return Padding(
 | 
				
			||||||
 | 
					                  padding: const EdgeInsets.only(top: 8, left: 8),
 | 
				
			||||||
 | 
					                  child: ClipRRect(
 | 
				
			||||||
 | 
					                    borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
 | 
					                    child: InteractiveParticipantWidget(
 | 
				
			||||||
 | 
					                      isFixedAvatar: true,
 | 
				
			||||||
 | 
					                      width: 120,
 | 
				
			||||||
 | 
					                      height: 120,
 | 
				
			||||||
 | 
					                      color: Theme.of(context).cardColor,
 | 
				
			||||||
 | 
					                      participant: track,
 | 
				
			||||||
 | 
					                      onTap: () {
 | 
				
			||||||
 | 
					                        if (track.participant.sid !=
 | 
				
			||||||
 | 
					                            call.focusTrack?.participant.sid) {
 | 
				
			||||||
 | 
					                          call.setFocusTrack(track);
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildGridLayout() {
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return LayoutBuilder(builder: (context, constraints) {
 | 
				
			||||||
 | 
					      double screenWidth = constraints.maxWidth;
 | 
				
			||||||
 | 
					      double screenHeight = constraints.maxHeight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      int columns = (math.sqrt(call.participantTracks.length)).ceil();
 | 
				
			||||||
 | 
					      int rows = (call.participantTracks.length / columns).ceil();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      double tileWidth = screenWidth / columns;
 | 
				
			||||||
 | 
					      double tileHeight = screenHeight / rows;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return StyledWidget(GridView.builder(
 | 
				
			||||||
 | 
					        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
 | 
				
			||||||
 | 
					          crossAxisCount: columns,
 | 
				
			||||||
 | 
					          childAspectRatio: tileWidth / tileHeight,
 | 
				
			||||||
 | 
					          crossAxisSpacing: 8,
 | 
				
			||||||
 | 
					          mainAxisSpacing: 8,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        itemCount: math.max(0, call.participantTracks.length),
 | 
				
			||||||
 | 
					        itemBuilder: (BuildContext context, int index) {
 | 
				
			||||||
 | 
					          final track = call.participantTracks[index];
 | 
				
			||||||
 | 
					          return Card(
 | 
				
			||||||
 | 
					            child: ClipRRect(
 | 
				
			||||||
 | 
					              borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
 | 
					              child: InteractiveParticipantWidget(
 | 
				
			||||||
 | 
					                color: Theme.of(context)
 | 
				
			||||||
 | 
					                    .colorScheme
 | 
				
			||||||
 | 
					                    .surfaceContainerHigh
 | 
				
			||||||
 | 
					                    .withOpacity(0.75),
 | 
				
			||||||
 | 
					                participant: track,
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  if (track.participant.sid !=
 | 
				
			||||||
 | 
					                      call.focusTrack?.participant.sid) {
 | 
				
			||||||
 | 
					                    call.setFocusTrack(track);
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      )).padding(all: 8);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Future.delayed(Duration.zero, () {
 | 
				
			||||||
 | 
					      call
 | 
				
			||||||
 | 
					        ..setupRoom()
 | 
				
			||||||
 | 
					        ..enableDurationUpdater();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return ListenableBuilder(
 | 
				
			||||||
 | 
					        listenable: call,
 | 
				
			||||||
 | 
					        builder: (context, _) {
 | 
				
			||||||
 | 
					          return Scaffold(
 | 
				
			||||||
 | 
					            appBar: AppBar(
 | 
				
			||||||
 | 
					              title: RichText(
 | 
				
			||||||
 | 
					                textAlign: TextAlign.center,
 | 
				
			||||||
 | 
					                text: TextSpan(children: [
 | 
				
			||||||
 | 
					                  TextSpan(
 | 
				
			||||||
 | 
					                    text: 'call'.tr(),
 | 
				
			||||||
 | 
					                    style: Theme.of(context)
 | 
				
			||||||
 | 
					                        .textTheme
 | 
				
			||||||
 | 
					                        .titleLarge!
 | 
				
			||||||
 | 
					                        .copyWith(color: Colors.white),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  const TextSpan(text: '\n'),
 | 
				
			||||||
 | 
					                  TextSpan(
 | 
				
			||||||
 | 
					                    text: call.lastDuration.toString(),
 | 
				
			||||||
 | 
					                    style: Theme.of(context)
 | 
				
			||||||
 | 
					                        .textTheme
 | 
				
			||||||
 | 
					                        .bodySmall!
 | 
				
			||||||
 | 
					                        .copyWith(color: Colors.white),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ]),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            body: SafeArea(
 | 
				
			||||||
 | 
					              child: GestureDetector(
 | 
				
			||||||
 | 
					                behavior: HitTestBehavior.translucent,
 | 
				
			||||||
 | 
					                child: Column(
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    SizedBox(
 | 
				
			||||||
 | 
					                      width: MediaQuery.of(context).size.width,
 | 
				
			||||||
 | 
					                      height: 64,
 | 
				
			||||||
 | 
					                      child: Row(
 | 
				
			||||||
 | 
					                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Builder(builder: (context) {
 | 
				
			||||||
 | 
					                            final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					                            final connectionQuality =
 | 
				
			||||||
 | 
					                                call.room.localParticipant?.connectionQuality ??
 | 
				
			||||||
 | 
					                                    livekit.ConnectionQuality.unknown;
 | 
				
			||||||
 | 
					                            return Expanded(
 | 
				
			||||||
 | 
					                              child: Column(
 | 
				
			||||||
 | 
					                                mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                                children: [
 | 
				
			||||||
 | 
					                                  Row(
 | 
				
			||||||
 | 
					                                    children: [
 | 
				
			||||||
 | 
					                                      Text(
 | 
				
			||||||
 | 
					                                        call.channel?.name ?? 'unknown'.tr(),
 | 
				
			||||||
 | 
					                                        style: const TextStyle(
 | 
				
			||||||
 | 
					                                          fontWeight: FontWeight.bold,
 | 
				
			||||||
 | 
					                                        ),
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                      const Gap(6),
 | 
				
			||||||
 | 
					                                      Text(call.lastDuration.toString())
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                  Row(
 | 
				
			||||||
 | 
					                                    children: [
 | 
				
			||||||
 | 
					                                      Text(
 | 
				
			||||||
 | 
					                                        {
 | 
				
			||||||
 | 
					                                          livekit.ConnectionState.disconnected:
 | 
				
			||||||
 | 
					                                              'callStatusDisconnected'.tr(),
 | 
				
			||||||
 | 
					                                          livekit.ConnectionState.connected:
 | 
				
			||||||
 | 
					                                              'callStatusConnected'.tr(),
 | 
				
			||||||
 | 
					                                          livekit.ConnectionState.connecting:
 | 
				
			||||||
 | 
					                                              'callStatusConnecting'.tr(),
 | 
				
			||||||
 | 
					                                          livekit.ConnectionState.reconnecting:
 | 
				
			||||||
 | 
					                                              'callStatusReconnecting'.tr(),
 | 
				
			||||||
 | 
					                                        }[call.room.connectionState]!,
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                      const Gap(6),
 | 
				
			||||||
 | 
					                                      if (connectionQuality !=
 | 
				
			||||||
 | 
					                                          livekit.ConnectionQuality.unknown)
 | 
				
			||||||
 | 
					                                        Icon(
 | 
				
			||||||
 | 
					                                          {
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.excellent:
 | 
				
			||||||
 | 
					                                                Icons.signal_cellular_alt,
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.good:
 | 
				
			||||||
 | 
					                                                Icons.signal_cellular_alt_2_bar,
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.poor:
 | 
				
			||||||
 | 
					                                                Icons.signal_cellular_alt_1_bar,
 | 
				
			||||||
 | 
					                                          }[connectionQuality],
 | 
				
			||||||
 | 
					                                          color: {
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.excellent:
 | 
				
			||||||
 | 
					                                                Colors.green,
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.good:
 | 
				
			||||||
 | 
					                                                Colors.orange,
 | 
				
			||||||
 | 
					                                            livekit.ConnectionQuality.poor:
 | 
				
			||||||
 | 
					                                                Colors.red,
 | 
				
			||||||
 | 
					                                          }[connectionQuality],
 | 
				
			||||||
 | 
					                                          size: 16,
 | 
				
			||||||
 | 
					                                        )
 | 
				
			||||||
 | 
					                                      else
 | 
				
			||||||
 | 
					                                        const SizedBox(
 | 
				
			||||||
 | 
					                                          width: 12,
 | 
				
			||||||
 | 
					                                          height: 12,
 | 
				
			||||||
 | 
					                                          child: CircularProgressIndicator(
 | 
				
			||||||
 | 
					                                            color: Colors.white,
 | 
				
			||||||
 | 
					                                            strokeWidth: 2,
 | 
				
			||||||
 | 
					                                          ),
 | 
				
			||||||
 | 
					                                        ).padding(all: 3),
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ],
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                          }),
 | 
				
			||||||
 | 
					                          Row(
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              IconButton(
 | 
				
			||||||
 | 
					                                icon: _layoutMode == 0
 | 
				
			||||||
 | 
					                                    ? const Icon(Icons.view_list)
 | 
				
			||||||
 | 
					                                    : const Icon(Icons.grid_view),
 | 
				
			||||||
 | 
					                                onPressed: () {
 | 
				
			||||||
 | 
					                                  _switchLayout();
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ).padding(left: 20, right: 16),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    Expanded(
 | 
				
			||||||
 | 
					                      child: Material(
 | 
				
			||||||
 | 
					                        color:
 | 
				
			||||||
 | 
					                            Theme.of(context).colorScheme.surfaceContainerLow,
 | 
				
			||||||
 | 
					                        child: Builder(
 | 
				
			||||||
 | 
					                          builder: (context) {
 | 
				
			||||||
 | 
					                            switch (_layoutMode) {
 | 
				
			||||||
 | 
					                              case 1:
 | 
				
			||||||
 | 
					                                return _buildGridLayout();
 | 
				
			||||||
 | 
					                              default:
 | 
				
			||||||
 | 
					                                return _buildListLayout();
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    if (call.room.localParticipant != null)
 | 
				
			||||||
 | 
					                      SizedBox(
 | 
				
			||||||
 | 
					                        width: MediaQuery.of(context).size.width,
 | 
				
			||||||
 | 
					                        child: ControlsWidget(
 | 
				
			||||||
 | 
					                          call.room,
 | 
				
			||||||
 | 
					                          call.room.localParticipant!,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                onTap: () {},
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void deactivate() {
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					    call.disableDurationUpdater();
 | 
				
			||||||
 | 
					    super.deactivate();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void activate() {
 | 
				
			||||||
 | 
					    final call = context.read<ChatCallProvider>();
 | 
				
			||||||
 | 
					    call.enableDurationUpdater();
 | 
				
			||||||
 | 
					    super.activate();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										659
									
								
								lib/screens/chat/channel_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,659 @@
 | 
				
			|||||||
 | 
					import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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/channel.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChannelDetailScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String scope;
 | 
				
			||||||
 | 
					  final String alias;
 | 
				
			||||||
 | 
					  const ChannelDetailScreen({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					    required this.scope,
 | 
				
			||||||
 | 
					    required this.alias,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<ChannelDetailScreen> createState() => _ChannelDetailScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnChannel? _channel;
 | 
				
			||||||
 | 
					  SnChannelMember? _profile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchChannel() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final chan = context.read<ChatChannelProvider>();
 | 
				
			||||||
 | 
					      _channel = await chan.getChannel('${widget.scope}:${widget.alias}');
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchChannelProfile() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client
 | 
				
			||||||
 | 
					          .get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
				
			||||||
 | 
					      _profile = SnChannelMember.fromJson(resp.data);
 | 
				
			||||||
 | 
					      _notifyLevel = _profile!.notify;
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      await ud.getAccount(_profile!.accountId);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteChannel() async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'channelDelete'.tr(args: [_channel!.name]),
 | 
				
			||||||
 | 
					      'channelDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, false);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _leaveChannel() async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'channelLeave'.tr(args: [_channel!.name]),
 | 
				
			||||||
 | 
					      'channelLeaveDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, false);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int _notifyLevel = 0;
 | 
				
			||||||
 | 
					  bool _isUpdatingNotifyLevel = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final kNotifyLevels = {
 | 
				
			||||||
 | 
					    0: 'channelNotifyLevelAll'.tr(),
 | 
				
			||||||
 | 
					    1: 'channelNotifyLevelMentioned'.tr(),
 | 
				
			||||||
 | 
					    2: 'channelNotifyLevelNone'.tr(),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _updateNotifyLevel(int value) async {
 | 
				
			||||||
 | 
					    if (_isUpdatingNotifyLevel) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isUpdatingNotifyLevel = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.put(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
 | 
				
			||||||
 | 
					        data: {'notify_level': value},
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _notifyLevel = value;
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('channelNotifyLevelApplied'.tr());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isUpdatingNotifyLevel = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showChannelProfileDetail() {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _ChannelProfileDetailDialog(
 | 
				
			||||||
 | 
					        channel: _channel!,
 | 
				
			||||||
 | 
					        current: _profile!,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value != null && mounted) {
 | 
				
			||||||
 | 
					        Navigator.pop(context, true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showMemberList() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _ChannelMemberListWidget(
 | 
				
			||||||
 | 
					        channel: _channel!,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showMemberAdd() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _NewChannelMemberWidget(
 | 
				
			||||||
 | 
					        channel: _channel!,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchChannel().then((_) {
 | 
				
			||||||
 | 
					      _fetchChannelProfile();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
 | 
					        child: Column(
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					            const Gap(24),
 | 
				
			||||||
 | 
					            if (_channel != null)
 | 
				
			||||||
 | 
					              Column(
 | 
				
			||||||
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    _channel!.name,
 | 
				
			||||||
 | 
					                    style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    _channel!.description,
 | 
				
			||||||
 | 
					                    style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ).padding(horizontal: 24),
 | 
				
			||||||
 | 
					            const Gap(16),
 | 
				
			||||||
 | 
					            const Divider(),
 | 
				
			||||||
 | 
					            const Gap(12),
 | 
				
			||||||
 | 
					            if (_profile != null)
 | 
				
			||||||
 | 
					              Column(
 | 
				
			||||||
 | 
					                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  Text('channelDetailPersonalRegion')
 | 
				
			||||||
 | 
					                      .bold()
 | 
				
			||||||
 | 
					                      .fontSize(17)
 | 
				
			||||||
 | 
					                      .tr()
 | 
				
			||||||
 | 
					                      .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                  ListTile(
 | 
				
			||||||
 | 
					                    leading: const Icon(Symbols.notifications),
 | 
				
			||||||
 | 
					                    trailing: DropdownButtonHideUnderline(
 | 
				
			||||||
 | 
					                      child: DropdownButton2<int>(
 | 
				
			||||||
 | 
					                        isExpanded: true,
 | 
				
			||||||
 | 
					                        items: kNotifyLevels.entries
 | 
				
			||||||
 | 
					                            .map((item) => DropdownMenuItem<int>(
 | 
				
			||||||
 | 
					                                  enabled: !_isUpdatingNotifyLevel,
 | 
				
			||||||
 | 
					                                  value: item.key,
 | 
				
			||||||
 | 
					                                  child: Text(
 | 
				
			||||||
 | 
					                                    item.value,
 | 
				
			||||||
 | 
					                                    style: const TextStyle(
 | 
				
			||||||
 | 
					                                      fontSize: 14,
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ))
 | 
				
			||||||
 | 
					                            .toList(),
 | 
				
			||||||
 | 
					                        value: _notifyLevel,
 | 
				
			||||||
 | 
					                        onChanged: (int? value) {
 | 
				
			||||||
 | 
					                          if (value == null) return;
 | 
				
			||||||
 | 
					                          _updateNotifyLevel(value);
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        buttonStyleData: const ButtonStyleData(
 | 
				
			||||||
 | 
					                          padding: EdgeInsets.only(left: 16, right: 1),
 | 
				
			||||||
 | 
					                          height: 40,
 | 
				
			||||||
 | 
					                          width: 140,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        menuItemStyleData: const MenuItemStyleData(
 | 
				
			||||||
 | 
					                          height: 40,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    title: Text('channelNotifyLevel').tr(),
 | 
				
			||||||
 | 
					                    subtitle: Text('channelNotifyLevelDescription').tr(),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.only(left: 24, right: 20),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  ListTile(
 | 
				
			||||||
 | 
					                    leading: AccountImage(
 | 
				
			||||||
 | 
					                      content:
 | 
				
			||||||
 | 
					                          ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
				
			||||||
 | 
					                      radius: 18,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                    title: Text('channelEditProfile').tr(),
 | 
				
			||||||
 | 
					                    subtitle: Text(
 | 
				
			||||||
 | 
					                      (_profile?.nick?.isEmpty ?? true)
 | 
				
			||||||
 | 
					                          ? ud.getAccountFromCache(_profile!.accountId)!.nick
 | 
				
			||||||
 | 
					                          : _profile!.nick!,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.only(left: 20, right: 20),
 | 
				
			||||||
 | 
					                    onTap: _showChannelProfileDetail,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  if (!isOwned)
 | 
				
			||||||
 | 
					                    ListTile(
 | 
				
			||||||
 | 
					                      leading: const Icon(Symbols.exit_to_app),
 | 
				
			||||||
 | 
					                      trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                      title: Text('channelActionLeave').tr(),
 | 
				
			||||||
 | 
					                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
				
			||||||
 | 
					                      contentPadding:
 | 
				
			||||||
 | 
					                          const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                      onTap: _leaveChannel,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ).padding(bottom: 16),
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text('channelDetailMemberRegion')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.group),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  title: Text('channelMemberManage').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('channelMemberManageDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  onTap: _showMemberList,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.group_add),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  title: Text('channelMemberAdd').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('channelMemberAddDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  onTap: _showMemberAdd,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ).padding(bottom: 16),
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text('channelDetailAdminRegion')
 | 
				
			||||||
 | 
					                    .bold()
 | 
				
			||||||
 | 
					                    .fontSize(17)
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  title: Text('channelEdit').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('channelEditDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  onTap: () {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                      'chatManage',
 | 
				
			||||||
 | 
					                      queryParameters: {'editing': _channel!.keyPath},
 | 
				
			||||||
 | 
					                    ).then((value) {
 | 
				
			||||||
 | 
					                      if (value != null && context.mounted) {
 | 
				
			||||||
 | 
					                        Navigator.pop(context, value);
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                if (isOwned)
 | 
				
			||||||
 | 
					                  ListTile(
 | 
				
			||||||
 | 
					                    leading: const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                    title: Text('channelActionDelete').tr(),
 | 
				
			||||||
 | 
					                    subtitle: Text('channelActionDeleteDescription').tr(),
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                    onTap: _deleteChannel,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChannelProfileDetailDialog extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnChannel channel;
 | 
				
			||||||
 | 
					  final SnChannelMember current;
 | 
				
			||||||
 | 
					  const _ChannelProfileDetailDialog({
 | 
				
			||||||
 | 
					    required this.channel,
 | 
				
			||||||
 | 
					    required this.current,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_ChannelProfileDetailDialog> createState() =>
 | 
				
			||||||
 | 
					      _ChannelProfileDetailDialogState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChannelProfileDetailDialogState
 | 
				
			||||||
 | 
					    extends State<_ChannelProfileDetailDialog> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _nickController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _updateProfile() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.put(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${widget.channel.keyPath}/members/me',
 | 
				
			||||||
 | 
					        data: {'nick': _nickController.text},
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _nickController.text = widget.current.nick ?? '';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _nickController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AlertDialog(
 | 
				
			||||||
 | 
					      title: Text('channelProfileEdit').tr(),
 | 
				
			||||||
 | 
					      content: Column(
 | 
				
			||||||
 | 
					        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          TextField(
 | 
				
			||||||
 | 
					            controller: _nickController,
 | 
				
			||||||
 | 
					            decoration: InputDecoration(
 | 
				
			||||||
 | 
					              labelText: 'fieldChannelProfileNick'.tr(),
 | 
				
			||||||
 | 
					              helperText: 'fieldChannelProfileNickHint'.tr(),
 | 
				
			||||||
 | 
					              helperMaxLines: 2,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      actions: [
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : () => Navigator.pop(context),
 | 
				
			||||||
 | 
					          child: Text('dialogCancel').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        TextButton(
 | 
				
			||||||
 | 
					          onPressed: _isBusy ? null : _updateProfile,
 | 
				
			||||||
 | 
					          child: Text('apply').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChannelMemberListWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnChannel channel;
 | 
				
			||||||
 | 
					  const _ChannelMemberListWidget({super.key, required this.channel});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_ChannelMemberListWidget> createState() =>
 | 
				
			||||||
 | 
					      _ChannelMemberListWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					  final List<SnChannelMember> _members = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchMembers() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
 | 
					          '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
				
			||||||
 | 
					          queryParameters: {
 | 
				
			||||||
 | 
					            'take': 10,
 | 
				
			||||||
 | 
					            'offset': 0,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					      final out = List<SnChannelMember>.from(
 | 
				
			||||||
 | 
					        resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      _totalCount = resp.data['count'];
 | 
				
			||||||
 | 
					      _members.addAll(out);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await ud.listAccount(out.map((ele) => ele.accountId).toSet());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isUpdating = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteMember(SnChannelMember member) async {
 | 
				
			||||||
 | 
					    if (_isUpdating) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isUpdating = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${widget.channel.keyPath}/members/${member.id}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _members.clear();
 | 
				
			||||||
 | 
					      _fetchMembers();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isUpdating = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchMembers();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Row(
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					            const Gap(16),
 | 
				
			||||||
 | 
					            Text('channelMemberManage')
 | 
				
			||||||
 | 
					                .tr()
 | 
				
			||||||
 | 
					                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
				
			||||||
 | 
					        Expanded(
 | 
				
			||||||
 | 
					          child: RefreshIndicator(
 | 
				
			||||||
 | 
					            onRefresh: () {
 | 
				
			||||||
 | 
					              _members.clear();
 | 
				
			||||||
 | 
					              return _fetchMembers();
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            child: InfiniteList(
 | 
				
			||||||
 | 
					              itemCount: _members.length,
 | 
				
			||||||
 | 
					              hasReachedMax:
 | 
				
			||||||
 | 
					                  _totalCount != null && _members.length >= _totalCount!,
 | 
				
			||||||
 | 
					              isLoading: _isBusy,
 | 
				
			||||||
 | 
					              onFetchData: _fetchMembers,
 | 
				
			||||||
 | 
					              itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                final member = _members[index];
 | 
				
			||||||
 | 
					                return ListTile(
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
 | 
					                  leading: AccountImage(
 | 
				
			||||||
 | 
					                    content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  title: Text(
 | 
				
			||||||
 | 
					                    ud.getAccountFromCache(member.accountId)?.name ??
 | 
				
			||||||
 | 
					                        'unknown'.tr(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
				
			||||||
 | 
					                  trailing: SizedBox(
 | 
				
			||||||
 | 
					                    height: 48,
 | 
				
			||||||
 | 
					                    width: 120,
 | 
				
			||||||
 | 
					                    child: Row(
 | 
				
			||||||
 | 
					                      mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        IconButton(
 | 
				
			||||||
 | 
					                          onPressed:
 | 
				
			||||||
 | 
					                              _isUpdating ? null : () => _deleteMember(member),
 | 
				
			||||||
 | 
					                          icon: const Icon(Symbols.person_remove),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewChannelMemberWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnChannel channel;
 | 
				
			||||||
 | 
					  const _NewChannelMemberWidget({super.key, required this.channel});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_NewChannelMemberWidget> createState() =>
 | 
				
			||||||
 | 
					      _NewChannelMemberWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewChannelMemberWidgetState extends State<_NewChannelMemberWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _relatedController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _performAction() async {
 | 
				
			||||||
 | 
					    if (_relatedController.text.isEmpty) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${widget.channel.keyPath}/members',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'related': _relatedController.text,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					      context.showSnackbar('channelMemberAdded'.tr());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _relatedController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return StyledWidget(Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Text(
 | 
				
			||||||
 | 
					          'channelMemberAdd',
 | 
				
			||||||
 | 
					          style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        const Gap(12),
 | 
				
			||||||
 | 
					        TextField(
 | 
				
			||||||
 | 
					          controller: _relatedController,
 | 
				
			||||||
 | 
					          readOnly: _isBusy,
 | 
				
			||||||
 | 
					          autocorrect: false,
 | 
				
			||||||
 | 
					          autofocus: true,
 | 
				
			||||||
 | 
					          textCapitalization: TextCapitalization.none,
 | 
				
			||||||
 | 
					          decoration: InputDecoration(
 | 
				
			||||||
 | 
					            labelText: 'fieldMemberRelatedName'.tr(),
 | 
				
			||||||
 | 
					            suffix: SizedBox(
 | 
				
			||||||
 | 
					              height: 24,
 | 
				
			||||||
 | 
					              child: IconButton(
 | 
				
			||||||
 | 
					                onPressed: _isBusy ? null : () => _performAction(),
 | 
				
			||||||
 | 
					                icon: Icon(Symbols.send),
 | 
				
			||||||
 | 
					                visualDensity:
 | 
				
			||||||
 | 
					                    const VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    )).padding(all: 24);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										295
									
								
								lib/screens/chat/manage.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,295 @@
 | 
				
			|||||||
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
 | 
					import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.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_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatManageScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String? editingChannelAlias;
 | 
				
			||||||
 | 
					  const ChatManageScreen({super.key, this.editingChannelAlias});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<ChatManageScreen> createState() => _ChatManageScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final _aliasController = TextEditingController();
 | 
				
			||||||
 | 
					  final _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnRealm>? _realms;
 | 
				
			||||||
 | 
					  SnRealm? _belongToRealm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/realms/me/available');
 | 
				
			||||||
 | 
					      _realms = List<SnRealm>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnChannel? _editingChannel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchChannel() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${widget.editingChannelAlias}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _editingChannel = SnChannel.fromJson(resp.data);
 | 
				
			||||||
 | 
					      _aliasController.text = _editingChannel!.alias;
 | 
				
			||||||
 | 
					      _nameController.text = _editingChannel!.name;
 | 
				
			||||||
 | 
					      _descriptionController.text = _editingChannel!.description;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _performAction() async {
 | 
				
			||||||
 | 
					    final uuid = const Uuid();
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final scope = _belongToRealm != null ? _belongToRealm!.alias : 'global';
 | 
				
			||||||
 | 
					    final payload = {
 | 
				
			||||||
 | 
					      'alias': _aliasController.text.isNotEmpty
 | 
				
			||||||
 | 
					          ? _aliasController.text.toLowerCase()
 | 
				
			||||||
 | 
					          : uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
				
			||||||
 | 
					      'name': _nameController.text,
 | 
				
			||||||
 | 
					      'description': _descriptionController.text,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp = await sn.client.request(
 | 
				
			||||||
 | 
					        widget.editingChannelAlias != null
 | 
				
			||||||
 | 
					            ? '/cgi/im/channels/$scope/${widget.editingChannelAlias}'
 | 
				
			||||||
 | 
					            : '/cgi/im/channels/$scope',
 | 
				
			||||||
 | 
					        data: payload,
 | 
				
			||||||
 | 
					        options: Options(
 | 
				
			||||||
 | 
					          method: widget.editingChannelAlias != null ? 'PUT' : 'POST',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					      if (context.mounted) Navigator.pop(context, resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					      if (context.mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    if (widget.editingChannelAlias != null) _fetchChannel();
 | 
				
			||||||
 | 
					    _fetchRealms();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _aliasController.dispose();
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: widget.editingChannelAlias != null
 | 
				
			||||||
 | 
					            ? Text('screenChatManage').tr()
 | 
				
			||||||
 | 
					            : Text('screenChatNew').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
 | 
					        child: Column(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					            if (_editingChannel != null)
 | 
				
			||||||
 | 
					              MaterialBanner(
 | 
				
			||||||
 | 
					                leading: const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					                leadingPadding: const EdgeInsets.only(left: 10, right: 20),
 | 
				
			||||||
 | 
					                dividerColor: Colors.transparent,
 | 
				
			||||||
 | 
					                content: Text(
 | 
				
			||||||
 | 
					                  'channelEditingNotice'
 | 
				
			||||||
 | 
					                      .tr(args: ['#${_editingChannel!.alias}']),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                actions: [
 | 
				
			||||||
 | 
					                  TextButton(
 | 
				
			||||||
 | 
					                    child: Text('cancel').tr(),
 | 
				
			||||||
 | 
					                    onPressed: () {
 | 
				
			||||||
 | 
					                      Navigator.pop(context);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            DropdownButtonHideUnderline(
 | 
				
			||||||
 | 
					              child: DropdownButton2<SnRealm>(
 | 
				
			||||||
 | 
					                isExpanded: true,
 | 
				
			||||||
 | 
					                hint: Text(
 | 
				
			||||||
 | 
					                  'fieldChatBelongToRealm'.tr(),
 | 
				
			||||||
 | 
					                  style: TextStyle(
 | 
				
			||||||
 | 
					                    color: Theme.of(context).hintColor,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                items: [
 | 
				
			||||||
 | 
					                  ...(_realms?.map(
 | 
				
			||||||
 | 
					                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
				
			||||||
 | 
					                          value: item,
 | 
				
			||||||
 | 
					                          child: Row(
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              AccountImage(
 | 
				
			||||||
 | 
					                                content: item.avatar,
 | 
				
			||||||
 | 
					                                radius: 16,
 | 
				
			||||||
 | 
					                                fallbackWidget: const Icon(
 | 
				
			||||||
 | 
					                                  Symbols.group,
 | 
				
			||||||
 | 
					                                  size: 16,
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                              const Gap(12),
 | 
				
			||||||
 | 
					                              Expanded(
 | 
				
			||||||
 | 
					                                child: Column(
 | 
				
			||||||
 | 
					                                  mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                                  children: [
 | 
				
			||||||
 | 
					                                    Text(item.name).textStyle(Theme.of(context)
 | 
				
			||||||
 | 
					                                        .textTheme
 | 
				
			||||||
 | 
					                                        .bodyMedium!),
 | 
				
			||||||
 | 
					                                    Text(
 | 
				
			||||||
 | 
					                                      item.description,
 | 
				
			||||||
 | 
					                                      maxLines: 1,
 | 
				
			||||||
 | 
					                                      overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                                    ).textStyle(
 | 
				
			||||||
 | 
					                                        Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ) ??
 | 
				
			||||||
 | 
					                      []),
 | 
				
			||||||
 | 
					                  DropdownMenuItem<SnRealm>(
 | 
				
			||||||
 | 
					                    value: null,
 | 
				
			||||||
 | 
					                    child: Row(
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        CircleAvatar(
 | 
				
			||||||
 | 
					                          radius: 16,
 | 
				
			||||||
 | 
					                          backgroundColor: Colors.transparent,
 | 
				
			||||||
 | 
					                          foregroundColor:
 | 
				
			||||||
 | 
					                              Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
 | 
					                          child: const Icon(Symbols.clear),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        const Gap(12),
 | 
				
			||||||
 | 
					                        Expanded(
 | 
				
			||||||
 | 
					                          child: Column(
 | 
				
			||||||
 | 
					                            mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              Text('fieldChatBelongToRealmUnset')
 | 
				
			||||||
 | 
					                                  .tr()
 | 
				
			||||||
 | 
					                                  .textStyle(
 | 
				
			||||||
 | 
					                                    Theme.of(context).textTheme.bodyMedium!,
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                value: _belongToRealm,
 | 
				
			||||||
 | 
					                onChanged: (SnRealm? value) {
 | 
				
			||||||
 | 
					                  setState(() => _belongToRealm = value);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                buttonStyleData: const ButtonStyleData(
 | 
				
			||||||
 | 
					                  padding: EdgeInsets.only(right: 16),
 | 
				
			||||||
 | 
					                  height: 60,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                menuItemStyleData: const MenuItemStyleData(
 | 
				
			||||||
 | 
					                  height: 60,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            const Divider(height: 1),
 | 
				
			||||||
 | 
					            const Gap(12),
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _aliasController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldChatAlias'.tr(),
 | 
				
			||||||
 | 
					                    helperText: 'fieldChatAliasHint'.tr(),
 | 
				
			||||||
 | 
					                    helperMaxLines: 2,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(4),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _nameController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldChatName'.tr(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(4),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _descriptionController,
 | 
				
			||||||
 | 
					                  maxLines: null,
 | 
				
			||||||
 | 
					                  minLines: 3,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldChatDescription'.tr(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(12),
 | 
				
			||||||
 | 
					                Row(
 | 
				
			||||||
 | 
					                  mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    ElevatedButton.icon(
 | 
				
			||||||
 | 
					                      onPressed: _isBusy ? null : _performAction,
 | 
				
			||||||
 | 
					                      icon: const Icon(Symbols.save),
 | 
				
			||||||
 | 
					                      label: Text('apply').tr(),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ).padding(horizontal: 24),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										351
									
								
								lib/screens/chat/room.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,351 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:dio/dio.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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/controllers/chat_message_controller.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/channel.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/chat_call.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/websocket.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/chat.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/chat/call/call_prejoin.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/chat/chat_message.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/chat/chat_message_input.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../../providers/user_directory.dart';
 | 
				
			||||||
 | 
					import '../../providers/userinfo.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatRoomScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String scope;
 | 
				
			||||||
 | 
					  final String alias;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const ChatRoomScreen({super.key, required this.scope, required this.alias});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  bool _isCalling = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnChannel? _channel;
 | 
				
			||||||
 | 
					  SnChannelMember? _otherMember;
 | 
				
			||||||
 | 
					  SnChatCall? _ongoingCall;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
 | 
				
			||||||
 | 
					  late final ChatMessageController _messageController;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  StreamSubscription? _wsSubscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchChannel() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final chan = context.read<ChatChannelProvider>();
 | 
				
			||||||
 | 
					      _channel = await chan.getChannel('${widget.scope}:${widget.alias}');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted || _channel == null) return;
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					      if (_channel!.type == 1) {
 | 
				
			||||||
 | 
					        await ud.listAccount(
 | 
				
			||||||
 | 
					          _channel!.members
 | 
				
			||||||
 | 
					                  ?.cast<SnChannelMember?>()
 | 
				
			||||||
 | 
					                  .map((ele) => ele?.accountId)
 | 
				
			||||||
 | 
					                  .where((ele) => ele != null && ele != ua.user?.id)
 | 
				
			||||||
 | 
					                  .toSet() ??
 | 
				
			||||||
 | 
					              {},
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        _otherMember = _channel!.members?.cast<SnChannelMember?>().firstWhere(
 | 
				
			||||||
 | 
					              (ele) => ele?.accountId != ua.user?.id,
 | 
				
			||||||
 | 
					              orElse: () => null,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchOngoingCall() async {
 | 
				
			||||||
 | 
					    setState(() => _isCalling = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
 | 
				
			||||||
 | 
					        options: Options(
 | 
				
			||||||
 | 
					          validateStatus: (status) => status != null && status < 500,
 | 
				
			||||||
 | 
					          receiveTimeout: const Duration(seconds: 60),
 | 
				
			||||||
 | 
					          sendTimeout: const Duration(seconds: 60),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (resp.statusCode == 200) {
 | 
				
			||||||
 | 
					        _ongoingCall = SnChatCall.fromJson(resp.data);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      print((err as DioException).response?.data);
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isCalling = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _makeCall() async {
 | 
				
			||||||
 | 
					    setState(() => _isCalling = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
 | 
				
			||||||
 | 
					        options: Options(
 | 
				
			||||||
 | 
					          sendTimeout: const Duration(seconds: 30),
 | 
				
			||||||
 | 
					          receiveTimeout: const Duration(seconds: 30),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      if (_ongoingCall == null) {
 | 
				
			||||||
 | 
					        // ignore the error because the call is already ongoing
 | 
				
			||||||
 | 
					        context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isCalling = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _endCall() async {
 | 
				
			||||||
 | 
					    setState(() => _isCalling = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isCalling = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _onCallJoin() async {
 | 
				
			||||||
 | 
					    await showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => ChatCallPrejoinPopup(
 | 
				
			||||||
 | 
					        ongoingCall: _ongoingCall!,
 | 
				
			||||||
 | 
					        channel: _channel!,
 | 
				
			||||||
 | 
					        onJoin: _onCallResume,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onCallResume() {
 | 
				
			||||||
 | 
					    GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					      'chatCallRoom',
 | 
				
			||||||
 | 
					      pathParameters: {
 | 
				
			||||||
 | 
					        'scope': _channel!.realm!.alias,
 | 
				
			||||||
 | 
					        'alias': _channel!.alias,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) {
 | 
				
			||||||
 | 
					    if (a == null || b == null) return false;
 | 
				
			||||||
 | 
					    if (a.sender.accountId != b.sender.accountId) return false;
 | 
				
			||||||
 | 
					    return a.createdAt.difference(b.createdAt).inMinutes <= 3;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _messageController = ChatMessageController(context);
 | 
				
			||||||
 | 
					    _fetchChannel().then((_) async {
 | 
				
			||||||
 | 
					      await _messageController.initialize(_channel!);
 | 
				
			||||||
 | 
					      await _messageController.checkUpdate();
 | 
				
			||||||
 | 
					      await _fetchOngoingCall();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final ws = context.read<WebSocketProvider>();
 | 
				
			||||||
 | 
					    _wsSubscription = ws.stream.stream.listen((event) {
 | 
				
			||||||
 | 
					      switch (event.method) {
 | 
				
			||||||
 | 
					        case 'calls.new':
 | 
				
			||||||
 | 
					          final payload = SnChatCall.fromJson(event.payload!);
 | 
				
			||||||
 | 
					          if (payload.channelId == _channel?.id) {
 | 
				
			||||||
 | 
					            setState(() => _ongoingCall = payload);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					        case 'calls.end':
 | 
				
			||||||
 | 
					          final payload = SnChatCall.fromJson(event.payload!);
 | 
				
			||||||
 | 
					          if (payload.channelId == _channel?.id) {
 | 
				
			||||||
 | 
					            setState(() => _ongoingCall = null);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _wsSubscription?.cancel();
 | 
				
			||||||
 | 
					    _messageController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final call = context.watch<ChatCallProvider>();
 | 
				
			||||||
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: Text(
 | 
				
			||||||
 | 
					          _channel?.type == 1
 | 
				
			||||||
 | 
					              ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
 | 
				
			||||||
 | 
					              : _channel?.name ?? 'loading'.tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end),
 | 
				
			||||||
 | 
					            onPressed: _isCalling
 | 
				
			||||||
 | 
					                ? null
 | 
				
			||||||
 | 
					                : _ongoingCall == null
 | 
				
			||||||
 | 
					                    ? _makeCall
 | 
				
			||||||
 | 
					                    : _endCall,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.more_vert),
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              GoRouter.of(context).pushNamed('channelDetail', pathParameters: {
 | 
				
			||||||
 | 
					                'scope': widget.scope,
 | 
				
			||||||
 | 
					                'alias': widget.alias,
 | 
				
			||||||
 | 
					              }).then((value) {
 | 
				
			||||||
 | 
					                if (value == false && context.mounted) {
 | 
				
			||||||
 | 
					                  Navigator.pop(context, true);
 | 
				
			||||||
 | 
					                } else if (value != null && context.mounted) {
 | 
				
			||||||
 | 
					                  _fetchChannel();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: ListenableBuilder(
 | 
				
			||||||
 | 
					        listenable: _messageController,
 | 
				
			||||||
 | 
					        builder: (context, _) {
 | 
				
			||||||
 | 
					          return Column(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					              SingleChildScrollView(
 | 
				
			||||||
 | 
					                physics: const NeverScrollableScrollPhysics(),
 | 
				
			||||||
 | 
					                child: MaterialBanner(
 | 
				
			||||||
 | 
					                  dividerColor: Colors.transparent,
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.call_received),
 | 
				
			||||||
 | 
					                  content: Text('callOngoingNotice').tr().padding(top: 2),
 | 
				
			||||||
 | 
					                  actions: [
 | 
				
			||||||
 | 
					                    if (call.current == null)
 | 
				
			||||||
 | 
					                      TextButton(
 | 
				
			||||||
 | 
					                        onPressed: _onCallJoin,
 | 
				
			||||||
 | 
					                        child: Text('callJoin').tr(),
 | 
				
			||||||
 | 
					                      )
 | 
				
			||||||
 | 
					                    else if (call.current?.channelId == _channel?.id)
 | 
				
			||||||
 | 
					                      TextButton(
 | 
				
			||||||
 | 
					                        onPressed: _onCallResume,
 | 
				
			||||||
 | 
					                        child: Text('callResume').tr(),
 | 
				
			||||||
 | 
					                      )
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					                  .height(_ongoingCall != null ? 54 : 0, animate: true)
 | 
				
			||||||
 | 
					                  .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
 | 
				
			||||||
 | 
					              if (_messageController.isPending)
 | 
				
			||||||
 | 
					                Expanded(
 | 
				
			||||||
 | 
					                  child: const CircularProgressIndicator().center(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              if (!_messageController.isPending)
 | 
				
			||||||
 | 
					                Expanded(
 | 
				
			||||||
 | 
					                  child: InfiniteList(
 | 
				
			||||||
 | 
					                    reverse: true,
 | 
				
			||||||
 | 
					                    padding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					                      left: 12,
 | 
				
			||||||
 | 
					                      right: 12,
 | 
				
			||||||
 | 
					                      top: 12,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    hasReachedMax: _messageController.isAllLoaded,
 | 
				
			||||||
 | 
					                    itemCount: _messageController.messages.length,
 | 
				
			||||||
 | 
					                    isLoading: _messageController.isLoading,
 | 
				
			||||||
 | 
					                    onFetchData: () {
 | 
				
			||||||
 | 
					                      _messageController.loadMessages();
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                      final message = _messageController.messages[idx];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                      bool canMerge = false, canMergePrevious = false;
 | 
				
			||||||
 | 
					                      if (idx > 0) {
 | 
				
			||||||
 | 
					                        canMergePrevious = _checkMessageMergeable(
 | 
				
			||||||
 | 
					                          _messageController.messages[idx - 1],
 | 
				
			||||||
 | 
					                          _messageController.messages[idx],
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					                      if (idx + 1 < _messageController.messages.length) {
 | 
				
			||||||
 | 
					                        canMerge = _checkMessageMergeable(
 | 
				
			||||||
 | 
					                          _messageController.messages[idx],
 | 
				
			||||||
 | 
					                          _messageController.messages[idx + 1],
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                      return Align(
 | 
				
			||||||
 | 
					                        alignment: Alignment.centerLeft,
 | 
				
			||||||
 | 
					                        child: Container(
 | 
				
			||||||
 | 
					                          constraints: BoxConstraints(maxWidth: 480),
 | 
				
			||||||
 | 
					                          child: ChatMessage(
 | 
				
			||||||
 | 
					                            data: message,
 | 
				
			||||||
 | 
					                            isMerged: canMerge,
 | 
				
			||||||
 | 
					                            hasMerged: canMergePrevious,
 | 
				
			||||||
 | 
					                            isPending: _messageController.unconfirmedMessages.contains(message.uuid),
 | 
				
			||||||
 | 
					                            onReply: (value) {
 | 
				
			||||||
 | 
					                              _inputGlobalKey.currentState?.setReply(value);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            onEdit: (value) {
 | 
				
			||||||
 | 
					                              _inputGlobalKey.currentState?.setEdit(value);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            onDelete: (value) {
 | 
				
			||||||
 | 
					                              _inputGlobalKey.currentState?.deleteMessage(value);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              if (!_messageController.isPending)
 | 
				
			||||||
 | 
					                Material(
 | 
				
			||||||
 | 
					                  elevation: 2,
 | 
				
			||||||
 | 
					                  child: ChatMessageInput(
 | 
				
			||||||
 | 
					                    key: _inputGlobalKey,
 | 
				
			||||||
 | 
					                    otherMember: _otherMember,
 | 
				
			||||||
 | 
					                    controller: _messageController,
 | 
				
			||||||
 | 
					                  ).padding(bottom: MediaQuery.of(context).padding.bottom),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,10 +5,9 @@ import 'package:gap/gap.dart';
 | 
				
			|||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_item.dart';
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -32,35 +31,13 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
    final resp = await sn.client.get('/cgi/co/posts', queryParameters: {
 | 
					    final result = await pt.listPosts(take: 10, offset: _posts.length);
 | 
				
			||||||
      'take': 10,
 | 
					    final out = result.$1;
 | 
				
			||||||
      'offset': _posts.length,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    final List<SnPost> out =
 | 
					 | 
				
			||||||
        List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Set<String> rids = {};
 | 
					 | 
				
			||||||
    for (var i = 0; i < out.length; i++) {
 | 
					 | 
				
			||||||
      rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!mounted) return;
 | 
					    if (!mounted) return;
 | 
				
			||||||
    final attach = context.read<SnAttachmentProvider>();
 | 
					 | 
				
			||||||
    final attachments = await attach.getMultiple(rids.toList());
 | 
					 | 
				
			||||||
    for (var i = 0; i < out.length; i++) {
 | 
					 | 
				
			||||||
      out[i] = out[i].copyWith(
 | 
					 | 
				
			||||||
        preload: SnPostPreload(
 | 
					 | 
				
			||||||
          attachments: attachments
 | 
					 | 
				
			||||||
              .where(
 | 
					 | 
				
			||||||
                (ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
 | 
					 | 
				
			||||||
              )
 | 
					 | 
				
			||||||
              .toList(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _postCount = resp.data['count'];
 | 
					    _postCount = result.$2;
 | 
				
			||||||
    _posts.addAll(out);
 | 
					    _posts.addAll(out);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (mounted) setState(() => _isBusy = false);
 | 
					    if (mounted) setState(() => _isBusy = false);
 | 
				
			||||||
@@ -74,7 +51,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      floatingActionButtonLocation: ExpandableFab.location,
 | 
					      floatingActionButtonLocation: ExpandableFab.location,
 | 
				
			||||||
      floatingActionButton: ExpandableFab(
 | 
					      floatingActionButton: ExpandableFab(
 | 
				
			||||||
        key: _fabKey,
 | 
					        key: _fabKey,
 | 
				
			||||||
@@ -161,9 +138,19 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
        child: CustomScrollView(
 | 
					        child: CustomScrollView(
 | 
				
			||||||
          slivers: [
 | 
					          slivers: [
 | 
				
			||||||
            SliverAppBar(
 | 
					            SliverAppBar(
 | 
				
			||||||
 | 
					              leading: AutoAppBarLeading(),
 | 
				
			||||||
              title: Text('screenExplore').tr(),
 | 
					              title: Text('screenExplore').tr(),
 | 
				
			||||||
              floating: true,
 | 
					              floating: true,
 | 
				
			||||||
              snap: true,
 | 
					              snap: true,
 | 
				
			||||||
 | 
					              actions: [
 | 
				
			||||||
 | 
					                IconButton(
 | 
				
			||||||
 | 
					                  icon: const Icon(Symbols.search),
 | 
				
			||||||
 | 
					                  onPressed: () {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed('postSearch');
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(8),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            SliverInfiniteList(
 | 
					            SliverInfiniteList(
 | 
				
			||||||
              itemCount: _posts.length,
 | 
					              itemCount: _posts.length,
 | 
				
			||||||
@@ -173,7 +160,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
				
			|||||||
              onFetchData: _fetchPosts,
 | 
					              onFetchData: _fetchPosts,
 | 
				
			||||||
              itemBuilder: (context, idx) {
 | 
					              itemBuilder: (context, idx) {
 | 
				
			||||||
                return GestureDetector(
 | 
					                return GestureDetector(
 | 
				
			||||||
                  child: PostItem(data: _posts[idx]),
 | 
					                  child: PostItem(
 | 
				
			||||||
 | 
					                    data: _posts[idx],
 | 
				
			||||||
 | 
					                    maxWidth: 640,
 | 
				
			||||||
 | 
					                    onChanged: (data) {
 | 
				
			||||||
 | 
					                      setState(() => _posts[idx] = data);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    onDeleted: () {
 | 
				
			||||||
 | 
					                      _posts.clear();
 | 
				
			||||||
 | 
					                      _fetchPosts();
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
                  onTap: () {
 | 
					                  onTap: () {
 | 
				
			||||||
                    GoRouter.of(context).pushNamed(
 | 
					                    GoRouter.of(context).pushNamed(
 | 
				
			||||||
                      'postDetail',
 | 
					                      'postDetail',
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										515
									
								
								lib/screens/friend.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,515 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/relationship.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../providers/userinfo.dart';
 | 
				
			||||||
 | 
					import '../widgets/unauthorized_hint.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const kFriendStatus = {
 | 
				
			||||||
 | 
					  0: 'friendStatusPending',
 | 
				
			||||||
 | 
					  1: 'friendStatusActive',
 | 
				
			||||||
 | 
					  2: 'friendStatusBlocked',
 | 
				
			||||||
 | 
					  3: 'friendStatusWaiting',
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FriendScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const FriendScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<FriendScreen> createState() => _FriendScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _FriendScreenState extends State<FriendScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnRelationship> _requests = List.empty();
 | 
				
			||||||
 | 
					  List<SnRelationship> _relations = List.empty();
 | 
				
			||||||
 | 
					  List<SnRelationship> _blocks = List.empty();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRelations() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
 | 
				
			||||||
 | 
					      _relations = List<SnRelationship>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRequests() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
 | 
				
			||||||
 | 
					      _requests = List<SnRelationship>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchBlocks() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
 | 
				
			||||||
 | 
					      _blocks = List<SnRelationship>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isUpdating = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _changeRelation(SnRelationship relation, int dstStatus) async {
 | 
				
			||||||
 | 
					    setState(() => _isUpdating = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.updateRelationship(
 | 
				
			||||||
 | 
					        relation.relatedId,
 | 
				
			||||||
 | 
					        dstStatus,
 | 
				
			||||||
 | 
					        relation.permNodes,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _fetchRelations();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isUpdating = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteRelation(SnRelationship relation) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
				
			||||||
 | 
					      'friendDeleteDescription'.tr(args: [
 | 
				
			||||||
 | 
					        relation.related?.nick ?? 'unknown'.tr(),
 | 
				
			||||||
 | 
					      ]),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isUpdating = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.deleteRelationship(relation.relatedId);
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _fetchRelations();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isUpdating = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showRequests() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _FriendshipListWidget(relations: _requests),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value != null) {
 | 
				
			||||||
 | 
					        _fetchRequests();
 | 
				
			||||||
 | 
					        _fetchRelations();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showBlocks() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _FriendshipListWidget(relations: _blocks),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value != null) {
 | 
				
			||||||
 | 
					        _fetchBlocks();
 | 
				
			||||||
 | 
					        _fetchRelations();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchRelations();
 | 
				
			||||||
 | 
					    _fetchRequests();
 | 
				
			||||||
 | 
					    _fetchBlocks();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      return Scaffold(
 | 
				
			||||||
 | 
					        appBar: AppBar(
 | 
				
			||||||
 | 
					          leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					          title: Text('screenFriend').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        body: Center(
 | 
				
			||||||
 | 
					          child: UnauthorizedHint(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					        title: Text('screenFriend').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      floatingActionButton: FloatingActionButton(
 | 
				
			||||||
 | 
					        child: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					        onPressed: () {
 | 
				
			||||||
 | 
					          showModalBottomSheet(
 | 
				
			||||||
 | 
					            context: context,
 | 
				
			||||||
 | 
					            builder: (context) => _NewFriendWidget(),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy || _isUpdating),
 | 
				
			||||||
 | 
					          if (_requests.isNotEmpty)
 | 
				
			||||||
 | 
					            ListTile(
 | 
				
			||||||
 | 
					              title: Text('friendRequests').tr(),
 | 
				
			||||||
 | 
					              subtitle: Text(
 | 
				
			||||||
 | 
					                'friendRequestsDescription',
 | 
				
			||||||
 | 
					              ).plural(_requests.length),
 | 
				
			||||||
 | 
					              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					              leading: const Icon(Symbols.group_add),
 | 
				
			||||||
 | 
					              trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					              onTap: _showRequests,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          if (_blocks.isNotEmpty)
 | 
				
			||||||
 | 
					            ListTile(
 | 
				
			||||||
 | 
					              title: Text('friendBlocklist').tr(),
 | 
				
			||||||
 | 
					              subtitle: Text(
 | 
				
			||||||
 | 
					                'friendBlocklistDescription',
 | 
				
			||||||
 | 
					              ).plural(_blocks.length),
 | 
				
			||||||
 | 
					              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					              leading: const Icon(Symbols.block),
 | 
				
			||||||
 | 
					              trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					              onTap: _showBlocks,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          if (_requests.isNotEmpty || _blocks.isNotEmpty)
 | 
				
			||||||
 | 
					            const Divider(height: 1),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: RefreshIndicator(
 | 
				
			||||||
 | 
					              onRefresh: () => Future.wait([
 | 
				
			||||||
 | 
					                _fetchRelations(),
 | 
				
			||||||
 | 
					                _fetchRequests(),
 | 
				
			||||||
 | 
					              ]),
 | 
				
			||||||
 | 
					              child: ListView.builder(
 | 
				
			||||||
 | 
					                itemCount: _relations.length,
 | 
				
			||||||
 | 
					                itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                  final relation = _relations[index];
 | 
				
			||||||
 | 
					                  final other = relation.related;
 | 
				
			||||||
 | 
					                  return ListTile(
 | 
				
			||||||
 | 
					                    contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
 | 
					                    leading: AccountImage(content: other?.avatar),
 | 
				
			||||||
 | 
					                    title: Text(other?.nick ?? 'unknown'),
 | 
				
			||||||
 | 
					                    subtitle: Text(other?.nick ?? 'unknown'),
 | 
				
			||||||
 | 
					                    trailing: SizedBox(
 | 
				
			||||||
 | 
					                      height: 48,
 | 
				
			||||||
 | 
					                      width: 120,
 | 
				
			||||||
 | 
					                      child: Column(
 | 
				
			||||||
 | 
					                        mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                        mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					                        crossAxisAlignment: CrossAxisAlignment.end,
 | 
				
			||||||
 | 
					                        children: [
 | 
				
			||||||
 | 
					                          Row(
 | 
				
			||||||
 | 
					                            mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                            children: [
 | 
				
			||||||
 | 
					                              InkWell(
 | 
				
			||||||
 | 
					                                onTap: _isUpdating
 | 
				
			||||||
 | 
					                                    ? null
 | 
				
			||||||
 | 
					                                    : () => _changeRelation(relation, 2),
 | 
				
			||||||
 | 
					                                child: Text('friendBlock').tr(),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                              const Gap(8),
 | 
				
			||||||
 | 
					                              InkWell(
 | 
				
			||||||
 | 
					                                onTap: _isUpdating
 | 
				
			||||||
 | 
					                                    ? null
 | 
				
			||||||
 | 
					                                    : () => _deleteRelation(relation),
 | 
				
			||||||
 | 
					                                child: Text('friendDeleteAction').tr(),
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ],
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewFriendWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _NewFriendWidget({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_NewFriendWidget> createState() => _NewFriendWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewFriendWidgetState extends State<_NewFriendWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _relatedController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _sendRequest() async {
 | 
				
			||||||
 | 
					    if (_relatedController.text.isEmpty) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/id/users/me/relations', data: {
 | 
				
			||||||
 | 
					        'related': _relatedController.text,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					      context.showSnackbar('friendRequestSent'.tr());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _relatedController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return StyledWidget(Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Text(
 | 
				
			||||||
 | 
					          'friendNew',
 | 
				
			||||||
 | 
					          style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        const Gap(12),
 | 
				
			||||||
 | 
					        TextField(
 | 
				
			||||||
 | 
					          controller: _relatedController,
 | 
				
			||||||
 | 
					          readOnly: _isBusy,
 | 
				
			||||||
 | 
					          autocorrect: false,
 | 
				
			||||||
 | 
					          autofocus: true,
 | 
				
			||||||
 | 
					          textCapitalization: TextCapitalization.none,
 | 
				
			||||||
 | 
					          decoration: InputDecoration(
 | 
				
			||||||
 | 
					            labelText: 'fieldFriendRelatedName'.tr(),
 | 
				
			||||||
 | 
					            suffix: SizedBox(
 | 
				
			||||||
 | 
					              height: 24,
 | 
				
			||||||
 | 
					              child: IconButton(
 | 
				
			||||||
 | 
					                onPressed: _isBusy ? null : () => _sendRequest(),
 | 
				
			||||||
 | 
					                icon: Icon(Symbols.send),
 | 
				
			||||||
 | 
					                visualDensity:
 | 
				
			||||||
 | 
					                    const VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    )).padding(all: 24);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _FriendshipListWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final List<SnRelationship> relations;
 | 
				
			||||||
 | 
					  const _FriendshipListWidget({super.key, required this.relations});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_FriendshipListWidget> createState() => _FriendshipListWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _acceptRequest(SnRelationship relation) async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.acceptFriendRequest(relation.relatedId);
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _declineRequest(SnRelationship relation) async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.declineFriendRequest(relation.relatedId);
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _changeRelation(SnRelationship relation, int dstStatus) async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.updateRelationship(
 | 
				
			||||||
 | 
					        relation.relatedId,
 | 
				
			||||||
 | 
					        dstStatus,
 | 
				
			||||||
 | 
					        relation.permNodes,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteRelation(SnRelationship relation) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
 | 
				
			||||||
 | 
					      'friendDeleteDescription'.tr(args: [
 | 
				
			||||||
 | 
					        relation.related?.nick ?? 'unknown'.tr(),
 | 
				
			||||||
 | 
					      ]),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.deleteRelationship(relation.relatedId);
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return ListView.builder(
 | 
				
			||||||
 | 
					      itemCount: widget.relations.length,
 | 
				
			||||||
 | 
					      itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					        final relation = widget.relations[index];
 | 
				
			||||||
 | 
					        final other = relation.related;
 | 
				
			||||||
 | 
					        return ListTile(
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
 | 
					          leading: AccountImage(content: other?.avatar),
 | 
				
			||||||
 | 
					          title: Text(other?.nick ?? 'unknown'.tr()),
 | 
				
			||||||
 | 
					          subtitle: Text(other?.nick ?? 'unknown'.tr()),
 | 
				
			||||||
 | 
					          trailing: SizedBox(
 | 
				
			||||||
 | 
					            height: 48,
 | 
				
			||||||
 | 
					            width: 120,
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
 | 
					              mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					              mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.end,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text(kFriendStatus[relation.status] ?? 'unknown')
 | 
				
			||||||
 | 
					                    .tr()
 | 
				
			||||||
 | 
					                    .opacity(0.75),
 | 
				
			||||||
 | 
					                if (relation.status == 0)
 | 
				
			||||||
 | 
					                  Row(
 | 
				
			||||||
 | 
					                    mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      InkWell(
 | 
				
			||||||
 | 
					                        onTap: _isBusy ? null : () => _acceptRequest(relation),
 | 
				
			||||||
 | 
					                        child: Text('friendRequestAccept').tr(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(8),
 | 
				
			||||||
 | 
					                      InkWell(
 | 
				
			||||||
 | 
					                        onTap: _isBusy ? null : () => _declineRequest(relation),
 | 
				
			||||||
 | 
					                        child: Text('friendRequestDecline').tr(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                else if (relation.status == 2)
 | 
				
			||||||
 | 
					                  Row(
 | 
				
			||||||
 | 
					                    mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      InkWell(
 | 
				
			||||||
 | 
					                        onTap:
 | 
				
			||||||
 | 
					                            _isBusy ? null : () => _changeRelation(relation, 1),
 | 
				
			||||||
 | 
					                        child: Text('friendUnblock').tr(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(8),
 | 
				
			||||||
 | 
					                      InkWell(
 | 
				
			||||||
 | 
					                        onTap: _isBusy ? null : () => _deleteRelation(relation),
 | 
				
			||||||
 | 
					                        child: Text('friendDeleteAction').tr(),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,36 @@
 | 
				
			|||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					import 'dart:ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.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:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HomeScreenDashEntry {
 | 
				
			||||||
 | 
					  final String name;
 | 
				
			||||||
 | 
					  final Widget child;
 | 
				
			||||||
 | 
					  final int rows, cols;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const HomeScreenDashEntry({
 | 
				
			||||||
 | 
					    required this.name,
 | 
				
			||||||
 | 
					    required this.child,
 | 
				
			||||||
 | 
					    this.rows = 1,
 | 
				
			||||||
 | 
					    this.cols = 1,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class HomeScreen extends StatefulWidget {
 | 
					class HomeScreen extends StatefulWidget {
 | 
				
			||||||
  const HomeScreen({super.key});
 | 
					  const HomeScreen({super.key});
 | 
				
			||||||
@@ -12,26 +40,447 @@ class HomeScreen extends StatefulWidget {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _HomeScreenState extends State<HomeScreen> {
 | 
					class _HomeScreenState extends State<HomeScreen> {
 | 
				
			||||||
 | 
					  static const List<HomeScreenDashEntry> kCards = [
 | 
				
			||||||
 | 
					    HomeScreenDashEntry(
 | 
				
			||||||
 | 
					      name: 'dashEntryRecommendation',
 | 
				
			||||||
 | 
					      cols: 2,
 | 
				
			||||||
 | 
					      rows: 2,
 | 
				
			||||||
 | 
					      child: _HomeDashRecommendationPostWidget(),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    HomeScreenDashEntry(
 | 
				
			||||||
 | 
					      name: 'dashEntryCheckIn',
 | 
				
			||||||
 | 
					      child: _HomeDashCheckInWidget(),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    HomeScreenDashEntry(
 | 
				
			||||||
 | 
					      name: 'dashEntryNotification',
 | 
				
			||||||
 | 
					      child: _HomeDashNotificationWidget(),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
        title: Text("screenHome").tr(),
 | 
					        title: Text("screenHome").tr(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: Column(
 | 
					      body: LayoutBuilder(
 | 
				
			||||||
 | 
					        builder: (context, constraints) {
 | 
				
			||||||
 | 
					          return Align(
 | 
				
			||||||
 | 
					            alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
 | 
				
			||||||
 | 
					            child: Container(
 | 
				
			||||||
 | 
					              constraints: const BoxConstraints(maxWidth: 640),
 | 
				
			||||||
 | 
					              child: SingleChildScrollView(
 | 
				
			||||||
 | 
					                child: Column(
 | 
				
			||||||
 | 
					                  mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    _HomeDashSpecialDayWidget().padding(top: 8, horizontal: 8),
 | 
				
			||||||
 | 
					                    StaggeredGrid.extent(
 | 
				
			||||||
 | 
					                      maxCrossAxisExtent: 280,
 | 
				
			||||||
 | 
					                      mainAxisSpacing: 8,
 | 
				
			||||||
 | 
					                      crossAxisSpacing: 8,
 | 
				
			||||||
 | 
					                      children: kCards.map((card) {
 | 
				
			||||||
 | 
					                        return StaggeredGridTile.count(
 | 
				
			||||||
 | 
					                          crossAxisCellCount: card.cols,
 | 
				
			||||||
 | 
					                          mainAxisCellCount: card.rows,
 | 
				
			||||||
 | 
					                          child: card.child,
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                      }).toList(),
 | 
				
			||||||
 | 
					                    ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ).padding(vertical: 8),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashSpecialDayWidget extends StatelessWidget {
 | 
				
			||||||
 | 
					  const _HomeDashSpecialDayWidget({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @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']),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ).padding(bottom: 8),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashCheckInWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _HomeDashCheckInWidget({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnCheckInRecord? _todayRecord;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const int kSuggestionPositiveHintCount = 6;
 | 
				
			||||||
 | 
					  static const int kSuggestionNegativeHintCount = 6;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _pullCheckIn() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/check-in/today');
 | 
				
			||||||
 | 
					      _todayRecord = SnCheckInRecord.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _doCheckIn() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.post('/cgi/id/check-in');
 | 
				
			||||||
 | 
					      _todayRecord = SnCheckInRecord.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildDetailChunk(int index, bool positive) {
 | 
				
			||||||
 | 
					    final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
 | 
				
			||||||
 | 
					    final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
 | 
				
			||||||
 | 
					    final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Text(
 | 
				
			||||||
 | 
					          prefix.tr(args: ['$prefix$pos'.tr()]),
 | 
				
			||||||
 | 
					          style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        Text(
 | 
				
			||||||
 | 
					          '$prefix${pos}Description',
 | 
				
			||||||
 | 
					          style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showCheckInDetail() {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      useRootNavigator: true,
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) {
 | 
				
			||||||
 | 
					        return AlertDialog(
 | 
				
			||||||
 | 
					          title: Text('dailyCheckDetailTitle'.tr(args: [
 | 
				
			||||||
 | 
					            DateFormat('MM/dd').format(DateTime.now().toUtc()),
 | 
				
			||||||
 | 
					          ])),
 | 
				
			||||||
 | 
					          content: Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              if (_todayRecord?.resultTier != 0)
 | 
				
			||||||
 | 
					                Column(
 | 
				
			||||||
 | 
					                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    _buildDetailChunk(0, true),
 | 
				
			||||||
 | 
					                    const Gap(8),
 | 
				
			||||||
 | 
					                    _buildDetailChunk(1, true),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              else
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  'dailyCheckEverythingIsNegative',
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
 | 
					                ).tr(),
 | 
				
			||||||
 | 
					              const Gap(8),
 | 
				
			||||||
 | 
					              if (_todayRecord?.resultTier != 4)
 | 
				
			||||||
 | 
					                Column(
 | 
				
			||||||
 | 
					                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                  mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    _buildDetailChunk(2, false),
 | 
				
			||||||
 | 
					                    const Gap(8),
 | 
				
			||||||
 | 
					                    _buildDetailChunk(3, false),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              else
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  'dailyCheckEverythingIsPositive',
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
 | 
				
			||||||
 | 
					                ).tr(),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          actions: [
 | 
				
			||||||
 | 
					            TextButton(
 | 
				
			||||||
 | 
					              onPressed: () => Navigator.pop(context),
 | 
				
			||||||
 | 
					              child: Text('dialogDismiss').tr(),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    Future.delayed(const Duration(milliseconds: 500), () async {
 | 
				
			||||||
 | 
					      if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					      await _pullCheckIn();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Card(
 | 
				
			||||||
 | 
					      child: Column(
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
        children: [
 | 
					        children: [
 | 
				
			||||||
          MaterialBanner(
 | 
					          Expanded(
 | 
				
			||||||
            leading: const Icon(Symbols.construction),
 | 
					            child: AnimatedSwitcher(
 | 
				
			||||||
            content: Column(
 | 
					              switchInCurve: Curves.fastOutSlowIn,
 | 
				
			||||||
 | 
					              switchOutCurve: Curves.fastOutSlowIn,
 | 
				
			||||||
 | 
					              duration: const Duration(milliseconds: 300),
 | 
				
			||||||
 | 
					              transitionBuilder: (child, animation) {
 | 
				
			||||||
 | 
					                return ScaleTransition(
 | 
				
			||||||
 | 
					                  scale: animation,
 | 
				
			||||||
 | 
					                  child: child,
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              child: _todayRecord == null
 | 
				
			||||||
 | 
					                  ? Column(
 | 
				
			||||||
 | 
					                      key: Key('daily-check-in-overview-none'),
 | 
				
			||||||
 | 
					                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          'dailyCheckIn',
 | 
				
			||||||
 | 
					                          style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					                        ).tr(),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          'dailyCheckInNone',
 | 
				
			||||||
 | 
					                          style: Theme.of(context).textTheme.bodyLarge,
 | 
				
			||||||
 | 
					                        ).tr(),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                  : Column(
 | 
				
			||||||
 | 
					                      key: Key('daily-check-in-overview-has'),
 | 
				
			||||||
 | 
					                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          _todayRecord!.symbol,
 | 
				
			||||||
 | 
					                          style: GoogleFonts.notoSerifHk(
 | 
				
			||||||
 | 
					                            textStyle: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          '+${_todayRecord!.resultExperience} EXP',
 | 
				
			||||||
 | 
					                          style: Theme.of(context).textTheme.bodyLarge,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          Row(
 | 
				
			||||||
 | 
					            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                DateFormat('EEE\nMM/dd').format(DateTime.now().toUtc()),
 | 
				
			||||||
 | 
					              ).fontSize(13).opacity(0.75),
 | 
				
			||||||
 | 
					              Container(
 | 
				
			||||||
 | 
					                decoration: BoxDecoration(
 | 
				
			||||||
 | 
					                  shape: BoxShape.circle,
 | 
				
			||||||
 | 
					                  color: Theme.of(context).colorScheme.surfaceContainer,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                child: AnimatedSwitcher(
 | 
				
			||||||
 | 
					                  switchInCurve: Curves.fastOutSlowIn,
 | 
				
			||||||
 | 
					                  switchOutCurve: Curves.fastOutSlowIn,
 | 
				
			||||||
 | 
					                  duration: const Duration(milliseconds: 300),
 | 
				
			||||||
 | 
					                  child: _todayRecord == null
 | 
				
			||||||
 | 
					                      ? IconButton(
 | 
				
			||||||
 | 
					                          key: UniqueKey(),
 | 
				
			||||||
 | 
					                          tooltip: 'dailyCheckAction'.tr(),
 | 
				
			||||||
 | 
					                          icon: const Icon(Symbols.local_fire_department),
 | 
				
			||||||
 | 
					                          onPressed: _isBusy ? null : _doCheckIn,
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                      : IconButton(
 | 
				
			||||||
 | 
					                          key: UniqueKey(),
 | 
				
			||||||
 | 
					                          tooltip: 'dailyCheckDetail'.tr(),
 | 
				
			||||||
 | 
					                          icon: const Icon(Symbols.help),
 | 
				
			||||||
 | 
					                          onPressed: _showCheckInDetail,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ).padding(all: 24),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashNotificationWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _HomeDashNotificationWidget({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
 | 
				
			||||||
 | 
					  int? _count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchNotificationCount() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      setState(() => _count = 0);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    final resp = await sn.client.get('/cgi/id/notifications/count');
 | 
				
			||||||
 | 
					    _count = resp.data['count'];
 | 
				
			||||||
 | 
					    setState(() {});
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchNotificationCount();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Card(
 | 
				
			||||||
 | 
					      child: Column(
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('nextVersionAlert').tr().bold(),
 | 
					                Text(
 | 
				
			||||||
                Text('nextVersionNotice').tr(),
 | 
					                  'notification',
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					                ).tr(),
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  _count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.bodyLarge,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ).padding(vertical: 16),
 | 
					            ),
 | 
				
			||||||
            actions: [
 | 
					          ),
 | 
				
			||||||
              const SizedBox(),
 | 
					          Align(
 | 
				
			||||||
 | 
					            alignment: Alignment.centerRight,
 | 
				
			||||||
 | 
					            child: Container(
 | 
				
			||||||
 | 
					              decoration: BoxDecoration(
 | 
				
			||||||
 | 
					                shape: BoxShape.circle,
 | 
				
			||||||
 | 
					                color: Theme.of(context).colorScheme.surfaceContainer,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              child: IconButton(
 | 
				
			||||||
 | 
					                icon: const Icon(Symbols.arrow_right_alt),
 | 
				
			||||||
 | 
					                onPressed: () {
 | 
				
			||||||
 | 
					                  GoRouter.of(context).goNamed('notification');
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ).padding(all: 24),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashRecommendationPostWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  const _HomeDashRecommendationPostWidget({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  List<SnPost>? _posts;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRecommendationPosts() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
 | 
					      _posts = await pt.listRecommendations();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchRecommendationPosts();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    if (_isBusy) {
 | 
				
			||||||
 | 
					      return Card(
 | 
				
			||||||
 | 
					        child: CircularProgressIndicator().center(),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Card(
 | 
				
			||||||
 | 
					      child: Column(
 | 
				
			||||||
 | 
					        crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          Row(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              const Icon(Symbols.star),
 | 
				
			||||||
 | 
					              const Gap(8),
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                'postRecommendation',
 | 
				
			||||||
 | 
					                style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					              ).tr()
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
 | 
					          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: PageView.builder(
 | 
				
			||||||
 | 
					              scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
 | 
				
			||||||
 | 
					                PointerDeviceKind.mouse,
 | 
				
			||||||
 | 
					                PointerDeviceKind.touch,
 | 
				
			||||||
 | 
					              }),
 | 
				
			||||||
 | 
					              itemCount: _posts?.length ?? 0,
 | 
				
			||||||
 | 
					              itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                return SingleChildScrollView(
 | 
				
			||||||
 | 
					                  child: GestureDetector(
 | 
				
			||||||
 | 
					                    child: PostItem(
 | 
				
			||||||
 | 
					                      data: _posts![index],
 | 
				
			||||||
 | 
					                      showMenu: false,
 | 
				
			||||||
 | 
					                    ).padding(bottom: 8),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      GoRouter.of(context).pushNamed('postDetail', pathParameters: {
 | 
				
			||||||
 | 
					                        'slug': _posts![index].id.toString(),
 | 
				
			||||||
 | 
					                      });
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										286
									
								
								lib/screens/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,286 @@
 | 
				
			|||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					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/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/notification.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/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/markdown_content.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../providers/userinfo.dart';
 | 
				
			||||||
 | 
					import '../widgets/unauthorized_hint.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NotificationScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const NotificationScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<NotificationScreen> createState() => _NotificationScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NotificationScreenState extends State<NotificationScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  bool _isFirstLoading = true;
 | 
				
			||||||
 | 
					  bool _isSubmitting = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final List<SnNotification> _notifications = List.empty(growable: true);
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const Map<String, IconData> kNotificationTopicIcons = {
 | 
				
			||||||
 | 
					    'passport.security.alert': Symbols.gpp_maybe,
 | 
				
			||||||
 | 
					    'interactive.subscription': Symbols.subscriptions,
 | 
				
			||||||
 | 
					    'interactive.feedback': Symbols.add_reaction,
 | 
				
			||||||
 | 
					    'messaging.callStart': Symbols.call_received,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchNotifications() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/notifications?take=10');
 | 
				
			||||||
 | 
					      _totalCount = resp.data['count'];
 | 
				
			||||||
 | 
					      _notifications.addAll(
 | 
				
			||||||
 | 
					        resp.data['data']
 | 
				
			||||||
 | 
					                ?.map((e) => SnNotification.fromJson(e))
 | 
				
			||||||
 | 
					                .cast<SnNotification>() ??
 | 
				
			||||||
 | 
					            [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      _isFirstLoading = false;
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _markAllAsRead() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_notifications.isEmpty) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'notificationMarkAllRead'.tr(),
 | 
				
			||||||
 | 
					      'notificationMarkAllReadDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					    setState(() => _isSubmitting = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    List<int> markList = List.empty(growable: true);
 | 
				
			||||||
 | 
					    for (final element in _notifications) {
 | 
				
			||||||
 | 
					      if (element.id <= 0) continue;
 | 
				
			||||||
 | 
					      if (element.readAt != null) continue;
 | 
				
			||||||
 | 
					      markList.add(element.id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.put('/cgi/id/notifications/read', data: {
 | 
				
			||||||
 | 
					        'messages': markList,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      _notifications.clear();
 | 
				
			||||||
 | 
					      _fetchNotifications();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar(
 | 
				
			||||||
 | 
					        'notificationMarkAllReadPrompt'.plural(markList.length),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isSubmitting = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _markOneAsRead(SnNotification notification) async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (notification.readAt != null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isSubmitting = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.put('/cgi/id/notifications/read/${notification.id}');
 | 
				
			||||||
 | 
					      _notifications.clear();
 | 
				
			||||||
 | 
					      _fetchNotifications();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar(
 | 
				
			||||||
 | 
					        'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isSubmitting = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchNotifications();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      return Scaffold(
 | 
				
			||||||
 | 
					        appBar: AppBar(
 | 
				
			||||||
 | 
					          leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					          title: Text('screenNotification').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        body: Center(
 | 
				
			||||||
 | 
					          child: UnauthorizedHint(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					        title: Text('screenNotification').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.checklist),
 | 
				
			||||||
 | 
					            onPressed: _isSubmitting ? null : _markAllAsRead,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isFirstLoading),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: RefreshIndicator(
 | 
				
			||||||
 | 
					              onRefresh: () {
 | 
				
			||||||
 | 
					                _notifications.clear();
 | 
				
			||||||
 | 
					                return _fetchNotifications();
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              child: InfiniteList(
 | 
				
			||||||
 | 
					                padding: EdgeInsets.only(
 | 
				
			||||||
 | 
					                  top: 16,
 | 
				
			||||||
 | 
					                  bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                itemCount: _notifications.length,
 | 
				
			||||||
 | 
					                onFetchData: () {
 | 
				
			||||||
 | 
					                  _fetchNotifications();
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                isLoading: _isBusy,
 | 
				
			||||||
 | 
					                hasReachedMax: _totalCount != null &&
 | 
				
			||||||
 | 
					                    _notifications.length >= _totalCount!,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  final nty = _notifications[idx];
 | 
				
			||||||
 | 
					                  return Row(
 | 
				
			||||||
 | 
					                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      Icon(kNotificationTopicIcons[nty.topic]),
 | 
				
			||||||
 | 
					                      const Gap(16),
 | 
				
			||||||
 | 
					                      Expanded(
 | 
				
			||||||
 | 
					                        child: Column(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            if (nty.readAt == null)
 | 
				
			||||||
 | 
					                              StyledWidget(Badge(
 | 
				
			||||||
 | 
					                                label: Text('notificationUnread').tr(),
 | 
				
			||||||
 | 
					                              )).padding(bottom: 4),
 | 
				
			||||||
 | 
					                            Text(
 | 
				
			||||||
 | 
					                              nty.title,
 | 
				
			||||||
 | 
					                              style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            if (nty.subtitle != null)
 | 
				
			||||||
 | 
					                              Text(
 | 
				
			||||||
 | 
					                                nty.subtitle!,
 | 
				
			||||||
 | 
					                                style: Theme.of(context).textTheme.titleSmall,
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            if (nty.subtitle != null) const Gap(4),
 | 
				
			||||||
 | 
					                            MarkdownTextContent(
 | 
				
			||||||
 | 
					                              content: nty.body,
 | 
				
			||||||
 | 
					                              isAutoWarp: true,
 | 
				
			||||||
 | 
					                              isSelectable: true,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            if ([
 | 
				
			||||||
 | 
					                                  'interactive.feedback',
 | 
				
			||||||
 | 
					                                  'interactive.subscription'
 | 
				
			||||||
 | 
					                                ].contains(nty.topic) &&
 | 
				
			||||||
 | 
					                                nty.metadata['related_post'] != null)
 | 
				
			||||||
 | 
					                              StyledWidget(Container(
 | 
				
			||||||
 | 
					                                decoration: BoxDecoration(
 | 
				
			||||||
 | 
					                                  borderRadius: const BorderRadius.all(
 | 
				
			||||||
 | 
					                                      Radius.circular(8)),
 | 
				
			||||||
 | 
					                                  border: Border.all(
 | 
				
			||||||
 | 
					                                    color: Theme.of(context).dividerColor,
 | 
				
			||||||
 | 
					                                    width: 1,
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                child: PostItem(
 | 
				
			||||||
 | 
					                                  data: SnPost.fromJson(
 | 
				
			||||||
 | 
					                                    nty.metadata['related_post']!,
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                  showComments: false,
 | 
				
			||||||
 | 
					                                  showReactions: false,
 | 
				
			||||||
 | 
					                                  showMenu: false,
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              )).padding(top: 8),
 | 
				
			||||||
 | 
					                            const Gap(8),
 | 
				
			||||||
 | 
					                            Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                Text(
 | 
				
			||||||
 | 
					                                  DateFormat('yy/MM/dd').format(nty.createdAt),
 | 
				
			||||||
 | 
					                                ).fontSize(12),
 | 
				
			||||||
 | 
					                                const Gap(4),
 | 
				
			||||||
 | 
					                                Text(
 | 
				
			||||||
 | 
					                                  '·',
 | 
				
			||||||
 | 
					                                  style: TextStyle(fontSize: 12),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                const Gap(4),
 | 
				
			||||||
 | 
					                                Text(
 | 
				
			||||||
 | 
					                                  RelativeTime(context).format(nty.createdAt),
 | 
				
			||||||
 | 
					                                ).fontSize(12),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ).opacity(0.75),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(16),
 | 
				
			||||||
 | 
					                      IconButton(
 | 
				
			||||||
 | 
					                        icon: const Icon(Symbols.check),
 | 
				
			||||||
 | 
					                        padding: EdgeInsets.all(0),
 | 
				
			||||||
 | 
					                        visualDensity:
 | 
				
			||||||
 | 
					                            const VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                        onPressed:
 | 
				
			||||||
 | 
					                            _isSubmitting ? null : () => _markOneAsRead(nty),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ).padding(horizontal: 16);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                separatorBuilder: (_, __) => const Divider(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,13 +6,13 @@ import 'package:gap/gap.dart';
 | 
				
			|||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:provider/provider.dart';
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:responsive_framework/responsive_framework.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_attachment.dart';
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
import 'package:surface/widgets/post/post_comment_list.dart';
 | 
					import 'package:surface/widgets/post/post_comment_list.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_item.dart';
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
					import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
				
			||||||
@@ -20,6 +20,7 @@ import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
				
			|||||||
class PostDetailScreen extends StatefulWidget {
 | 
					class PostDetailScreen extends StatefulWidget {
 | 
				
			||||||
  final String slug;
 | 
					  final String slug;
 | 
				
			||||||
  final SnPost? preload;
 | 
					  final SnPost? preload;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const PostDetailScreen({
 | 
					  const PostDetailScreen({
 | 
				
			||||||
    super.key,
 | 
					    super.key,
 | 
				
			||||||
    required this.slug,
 | 
					    required this.slug,
 | 
				
			||||||
@@ -39,19 +40,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
    setState(() => _isBusy = true);
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
      final attach = context.read<SnAttachmentProvider>();
 | 
					      final post = await pt.getPost(widget.slug);
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/posts/${widget.slug}');
 | 
					 | 
				
			||||||
      if (!mounted) return;
 | 
					      if (!mounted) return;
 | 
				
			||||||
      final attachments = await attach.getMultiple(
 | 
					      _data = post;
 | 
				
			||||||
        resp.data['body']['attachments']?.cast<String>() ?? [],
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      if (!mounted) return;
 | 
					 | 
				
			||||||
      _data = SnPost.fromJson(resp.data).copyWith(
 | 
					 | 
				
			||||||
        preload: SnPostPreload(
 | 
					 | 
				
			||||||
          attachments: attachments,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      context.showErrorDialog(err);
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
@@ -72,34 +64,40 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ua = context.watch<UserProvider>();
 | 
				
			||||||
    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
					    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      appBar: AppBar(
 | 
					      appBar: AppBar(
 | 
				
			||||||
        leading: BackButton(
 | 
					        leading: BackButton(
 | 
				
			||||||
          onPressed: () {
 | 
					          onPressed: () {
 | 
				
			||||||
            if (GoRouter.of(context).canPop()) {
 | 
					            if (GoRouter.of(context).canPop()) {
 | 
				
			||||||
              Navigator.pop(context);
 | 
					              GoRouter.of(context).pop(context);
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            GoRouter.of(context).replaceNamed('explore');
 | 
					            GoRouter.of(context).replaceNamed('explore');
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        flexibleSpace: Column(
 | 
					        title: _data?.body['title'] != null
 | 
				
			||||||
          mainAxisAlignment: MainAxisAlignment.center,
 | 
					            ? RichText(
 | 
				
			||||||
          children: [
 | 
					                textAlign: TextAlign.center,
 | 
				
			||||||
            if (_data?.body['title'] != null)
 | 
					                text: TextSpan(children: [
 | 
				
			||||||
              Text(_data?.body['title'] ?? 'postNoun'.tr())
 | 
					                  TextSpan(
 | 
				
			||||||
                  .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
					                    text: _data?.body['title'] ?? 'postNoun'.tr(),
 | 
				
			||||||
                  .textColor(Colors.white),
 | 
					                    style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
				
			||||||
            if (_data?.body['title'] != null)
 | 
					                          color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
				
			||||||
              Text('postDetail'.tr())
 | 
					                        ),
 | 
				
			||||||
                  .textColor(Colors.white.withAlpha((255 * 0.9).round()))
 | 
					                  ),
 | 
				
			||||||
            else
 | 
					                  const TextSpan(text: '\n'),
 | 
				
			||||||
              Text('postDetail'.tr())
 | 
					                  TextSpan(
 | 
				
			||||||
                  .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
					                    text: 'postDetail'.tr(),
 | 
				
			||||||
                  .textColor(Colors.white),
 | 
					                    style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
				
			||||||
          ],
 | 
					                          color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
				
			||||||
        ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
 | 
					                        ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ]),
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            : Text('postDetail').tr(),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
      body: CustomScrollView(
 | 
					      body: CustomScrollView(
 | 
				
			||||||
        slivers: [
 | 
					        slivers: [
 | 
				
			||||||
@@ -110,39 +108,60 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
            SliverToBoxAdapter(
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
              child: PostItem(
 | 
					              child: PostItem(
 | 
				
			||||||
                data: _data!,
 | 
					                data: _data!,
 | 
				
			||||||
 | 
					                maxWidth: 640,
 | 
				
			||||||
                showComments: false,
 | 
					                showComments: false,
 | 
				
			||||||
 | 
					                showFullPost: true,
 | 
				
			||||||
 | 
					                onChanged: (data) {
 | 
				
			||||||
 | 
					                  setState(() => _data = data);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                onDeleted: () {
 | 
				
			||||||
 | 
					                  Navigator.pop(context);
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
					          const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
				
			||||||
          if (_data != null)
 | 
					          if (_data != null)
 | 
				
			||||||
            SliverToBoxAdapter(
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
              child: Row(
 | 
					              child: Container(
 | 
				
			||||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
					                constraints: const BoxConstraints(maxWidth: 640),
 | 
				
			||||||
                children: [
 | 
					                child: Row(
 | 
				
			||||||
                  const Icon(Symbols.comment, size: 24),
 | 
					                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
				
			||||||
                  const Gap(16),
 | 
					                  children: [
 | 
				
			||||||
                  Text('postCommentsDetailed')
 | 
					                    const Icon(Symbols.comment, size: 24),
 | 
				
			||||||
                      .plural(_data!.metric.replyCount)
 | 
					                    const Gap(16),
 | 
				
			||||||
                      .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
					                    Text('postCommentsDetailed')
 | 
				
			||||||
                ],
 | 
					                        .plural(_data!.metric.replyCount)
 | 
				
			||||||
              ).padding(horizontal: 20, vertical: 12),
 | 
					                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ).padding(horizontal: 20, vertical: 12).center(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          if (_data != null)
 | 
					          if (_data != null && ua.isAuthorized)
 | 
				
			||||||
            SliverToBoxAdapter(
 | 
					            SliverToBoxAdapter(
 | 
				
			||||||
              child: Container(
 | 
					              child: Container(
 | 
				
			||||||
                height: 240,
 | 
					                height: 240,
 | 
				
			||||||
 | 
					                constraints: const BoxConstraints(maxWidth: 640),
 | 
				
			||||||
 | 
					                margin:
 | 
				
			||||||
 | 
					                    ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.all(8) : EdgeInsets.zero,
 | 
				
			||||||
                decoration: BoxDecoration(
 | 
					                decoration: BoxDecoration(
 | 
				
			||||||
                  border: Border.symmetric(
 | 
					                  borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
				
			||||||
                    horizontal: BorderSide(
 | 
					                      ? const BorderRadius.all(Radius.circular(8))
 | 
				
			||||||
                      color: Theme.of(context).dividerColor,
 | 
					                      : BorderRadius.zero,
 | 
				
			||||||
                      width: 1 / devicePixelRatio,
 | 
					                  border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
 | 
				
			||||||
                    ),
 | 
					                      ? Border.all(
 | 
				
			||||||
                  ),
 | 
					                          color: Theme.of(context).dividerColor,
 | 
				
			||||||
 | 
					                          width: 1 / devicePixelRatio,
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                      : Border.symmetric(
 | 
				
			||||||
 | 
					                          horizontal: BorderSide(
 | 
				
			||||||
 | 
					                            color: Theme.of(context).dividerColor,
 | 
				
			||||||
 | 
					                            width: 1 / devicePixelRatio,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                child: PostMiniEditor(
 | 
					                child: PostMiniEditor(
 | 
				
			||||||
                  postReplyId: _data!.id,
 | 
					                  postReplyId: _data!.id,
 | 
				
			||||||
                  onPost: () {
 | 
					                  onPost: () {
 | 
				
			||||||
                    _childListKey.currentState!.refresh();
 | 
					 | 
				
			||||||
                    setState(() {
 | 
					                    setState(() {
 | 
				
			||||||
                      _data = _data!.copyWith(
 | 
					                      _data = _data!.copyWith(
 | 
				
			||||||
                        metric: _data!.metric.copyWith(
 | 
					                        metric: _data!.metric.copyWith(
 | 
				
			||||||
@@ -150,14 +169,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
				
			|||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                      );
 | 
					                      );
 | 
				
			||||||
                    });
 | 
					                    });
 | 
				
			||||||
 | 
					                    _childListKey.currentState!.refresh();
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ).center(),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          if (_data != null)
 | 
					          if (_data != null)
 | 
				
			||||||
            PostCommentSliverList(
 | 
					            PostCommentSliverList(
 | 
				
			||||||
              key: _childListKey,
 | 
					              key: _childListKey,
 | 
				
			||||||
              parentPostId: _data!.id,
 | 
					              parentPostId: _data!.id,
 | 
				
			||||||
 | 
					              maxWidth: 640,
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
          SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
					          SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,21 +1,22 @@
 | 
				
			|||||||
import 'dart:math' as math;
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:collection/collection.dart';
 | 
					import 'package:collection/collection.dart';
 | 
				
			||||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
					import 'package:dropdown_button2/dropdown_button2.dart';
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/gestures.dart';
 | 
					import 'package:flutter/gestures.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:go_router/go_router.dart';
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:image_picker/image_picker.dart';
 | 
					import 'package:image_picker/image_picker.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:pasteboard/pasteboard.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
					import 'package:surface/controllers/post_write_controller.dart';
 | 
				
			||||||
import 'package:surface/providers/sn_network.dart';
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
import 'package:surface/types/post.dart';
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
import 'package:surface/widgets/account/account_image.dart';
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
import 'package:surface/widgets/post/post_item.dart';
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
					import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
				
			||||||
import 'package:surface/widgets/post/post_meta_editor.dart';
 | 
					import 'package:surface/widgets/post/post_meta_editor.dart';
 | 
				
			||||||
@@ -27,6 +28,7 @@ class PostEditorScreen extends StatefulWidget {
 | 
				
			|||||||
  final int? postEditId;
 | 
					  final int? postEditId;
 | 
				
			||||||
  final int? postReplyId;
 | 
					  final int? postReplyId;
 | 
				
			||||||
  final int? postRepostId;
 | 
					  final int? postRepostId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const PostEditorScreen({
 | 
					  const PostEditorScreen({
 | 
				
			||||||
    super.key,
 | 
					    super.key,
 | 
				
			||||||
    required this.mode,
 | 
					    required this.mode,
 | 
				
			||||||
@@ -43,6 +45,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
  final PostWriteController _writeController = PostWriteController();
 | 
					  final PostWriteController _writeController = PostWriteController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool _isFetching = false;
 | 
					  bool _isFetching = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get _isLoading => _isFetching || _writeController.isLoading;
 | 
					  bool get _isLoading => _isFetching || _writeController.isLoading;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  List<SnPublisher>? _publishers;
 | 
					  List<SnPublisher>? _publishers;
 | 
				
			||||||
@@ -52,7 +55,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      final sn = context.read<SnNetworkProvider>();
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
      final resp = await sn.client.get('/cgi/co/publishers');
 | 
					      final resp = await sn.client.get('/cgi/co/publishers/me');
 | 
				
			||||||
      _publishers = List<SnPublisher>.from(
 | 
					      _publishers = List<SnPublisher>.from(
 | 
				
			||||||
        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
					        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -75,13 +78,34 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  final _imagePicker = ImagePicker();
 | 
					  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 {
 | 
					  void _selectMedia() async {
 | 
				
			||||||
    final result = await _imagePicker.pickMultipleMedia();
 | 
					    final result = await _imagePicker.pickMultipleMedia();
 | 
				
			||||||
    if (result.isEmpty) return;
 | 
					    if (result.isEmpty) return;
 | 
				
			||||||
    _writeController.addAttachments(
 | 
					    _writeController.addAttachments(
 | 
				
			||||||
      result.map((e) => PostWriteMedia.fromFile(e)),
 | 
					      result.map((e) => PostWriteMedia.fromFile(e)),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    setState(() {});
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _pasteMedia() async {
 | 
				
			||||||
 | 
					    final imageBytes = await Pasteboard.image;
 | 
				
			||||||
 | 
					    if (imageBytes == null) return;
 | 
				
			||||||
 | 
					    _writeController.addAttachments([
 | 
				
			||||||
 | 
					      PostWriteMedia.fromBytes(
 | 
				
			||||||
 | 
					        imageBytes,
 | 
				
			||||||
 | 
					        'attachmentPastedImage'.tr(),
 | 
				
			||||||
 | 
					        PostWriteMediaType.image,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
@@ -96,6 +120,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
					    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
				
			||||||
      context.showErrorDialog('Unknown post type');
 | 
					      context.showErrorDialog('Unknown post type');
 | 
				
			||||||
      Navigator.pop(context);
 | 
					      Navigator.pop(context);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      _writeController.setMode(widget.mode);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    _fetchPublishers();
 | 
					    _fetchPublishers();
 | 
				
			||||||
    _writeController.fetchRelatedPost(
 | 
					    _writeController.fetchRelatedPost(
 | 
				
			||||||
@@ -111,30 +137,37 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
    return ListenableBuilder(
 | 
					    return ListenableBuilder(
 | 
				
			||||||
      listenable: _writeController,
 | 
					      listenable: _writeController,
 | 
				
			||||||
      builder: (context, _) {
 | 
					      builder: (context, _) {
 | 
				
			||||||
        return AppScaffold(
 | 
					        return Scaffold(
 | 
				
			||||||
          appBar: AppBar(
 | 
					          appBar: AppBar(
 | 
				
			||||||
            leading: BackButton(
 | 
					            leading: BackButton(
 | 
				
			||||||
              onPressed: () {
 | 
					              onPressed: () {
 | 
				
			||||||
                Navigator.pop(context);
 | 
					                Navigator.pop(context);
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            flexibleSpace: Column(
 | 
					            title: RichText(
 | 
				
			||||||
              children: [
 | 
					              textAlign: TextAlign.center,
 | 
				
			||||||
                Text(_writeController.title.isNotEmpty
 | 
					              text: TextSpan(children: [
 | 
				
			||||||
                        ? _writeController.title
 | 
					                TextSpan(
 | 
				
			||||||
                        : 'untitled'.tr())
 | 
					                  text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
 | 
				
			||||||
                    .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
					                  style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
				
			||||||
                    .textColor(Colors.white),
 | 
					                    color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
				
			||||||
                Text(PostWriteController.kTitleMap[widget.mode]!)
 | 
					                  ),
 | 
				
			||||||
                    .tr()
 | 
					                ),
 | 
				
			||||||
                    .textColor(Colors.white.withAlpha((255 * 0.9).round())),
 | 
					                const TextSpan(text: '\n'),
 | 
				
			||||||
              ],
 | 
					                TextSpan(
 | 
				
			||||||
            ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
 | 
					                  text: PostWriteController.kTitleMap[widget.mode]!.tr(),
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
				
			||||||
 | 
					                    color: Theme.of(context).appBarTheme.foregroundColor!,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ]),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
            actions: [
 | 
					            actions: [
 | 
				
			||||||
              IconButton(
 | 
					              IconButton(
 | 
				
			||||||
                icon: const Icon(Symbols.tune),
 | 
					                icon: const Icon(Symbols.tune),
 | 
				
			||||||
                onPressed: _writeController.isBusy ? null : _updateMeta,
 | 
					                onPressed: _writeController.isBusy ? null : _updateMeta,
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					              const Gap(8),
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          body: Column(
 | 
					          body: Column(
 | 
				
			||||||
@@ -161,17 +194,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                                Expanded(
 | 
					                                Expanded(
 | 
				
			||||||
                                  child: Column(
 | 
					                                  child: Column(
 | 
				
			||||||
                                    mainAxisSize: MainAxisSize.min,
 | 
					                                    mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
                                    crossAxisAlignment:
 | 
					                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                                        CrossAxisAlignment.start,
 | 
					 | 
				
			||||||
                                    children: [
 | 
					                                    children: [
 | 
				
			||||||
                                      Text(item.nick).textStyle(
 | 
					                                      Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
				
			||||||
                                          Theme.of(context)
 | 
					 | 
				
			||||||
                                              .textTheme
 | 
					 | 
				
			||||||
                                              .bodyMedium!),
 | 
					 | 
				
			||||||
                                      Text('@${item.name}')
 | 
					                                      Text('@${item.name}')
 | 
				
			||||||
                                          .textStyle(Theme.of(context)
 | 
					                                          .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
				
			||||||
                                              .textTheme
 | 
					 | 
				
			||||||
                                              .bodySmall!)
 | 
					 | 
				
			||||||
                                          .fontSize(12),
 | 
					                                          .fontSize(12),
 | 
				
			||||||
                                    ],
 | 
					                                    ],
 | 
				
			||||||
                                  ),
 | 
					                                  ),
 | 
				
			||||||
@@ -188,8 +215,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                          CircleAvatar(
 | 
					                          CircleAvatar(
 | 
				
			||||||
                            radius: 16,
 | 
					                            radius: 16,
 | 
				
			||||||
                            backgroundColor: Colors.transparent,
 | 
					                            backgroundColor: Colors.transparent,
 | 
				
			||||||
                            foregroundColor:
 | 
					                            foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
                                Theme.of(context).colorScheme.onSurface,
 | 
					 | 
				
			||||||
                            child: const Icon(Symbols.add),
 | 
					                            child: const Icon(Symbols.add),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                          const Gap(8),
 | 
					                          const Gap(8),
 | 
				
			||||||
@@ -198,8 +224,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                              mainAxisSize: MainAxisSize.min,
 | 
					                              mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                              children: [
 | 
					                              children: [
 | 
				
			||||||
                                Text('publishersNew').tr().textStyle(
 | 
					                                Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
				
			||||||
                                    Theme.of(context).textTheme.bodyMedium!),
 | 
					 | 
				
			||||||
                              ],
 | 
					                              ],
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
@@ -210,9 +235,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                  value: _writeController.publisher,
 | 
					                  value: _writeController.publisher,
 | 
				
			||||||
                  onChanged: (SnPublisher? value) {
 | 
					                  onChanged: (SnPublisher? value) {
 | 
				
			||||||
                    if (value == null) {
 | 
					                    if (value == null) {
 | 
				
			||||||
                      GoRouter.of(context)
 | 
					                      GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
				
			||||||
                          .pushNamed('accountPublisherNew')
 | 
					 | 
				
			||||||
                          .then((value) {
 | 
					 | 
				
			||||||
                        if (value == true) {
 | 
					                        if (value == true) {
 | 
				
			||||||
                          _publishers = null;
 | 
					                          _publishers = null;
 | 
				
			||||||
                          _fetchPublishers();
 | 
					                          _fetchPublishers();
 | 
				
			||||||
@@ -247,16 +270,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                              child: ExpansionTile(
 | 
					                              child: ExpansionTile(
 | 
				
			||||||
                                minTileHeight: 48,
 | 
					                                minTileHeight: 48,
 | 
				
			||||||
                                leading:
 | 
					                                leading: const Icon(Symbols.reply).padding(left: 4),
 | 
				
			||||||
                                    const Icon(Symbols.reply).padding(left: 4),
 | 
					 | 
				
			||||||
                                title: Text('postReplyingNotice')
 | 
					                                title: Text('postReplyingNotice')
 | 
				
			||||||
                                    .fontSize(15)
 | 
					                                    .fontSize(15)
 | 
				
			||||||
                                    .tr(args: [
 | 
					                                    .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
 | 
				
			||||||
                                  '@${_writeController.replyingPost!.publisher.name}'
 | 
					                                children: <Widget>[PostItem(data: _writeController.replyingPost!)],
 | 
				
			||||||
                                ]),
 | 
					 | 
				
			||||||
                                children: <Widget>[
 | 
					 | 
				
			||||||
                                  PostItem(data: _writeController.replyingPost!)
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                            const Divider(height: 1),
 | 
					                            const Divider(height: 1),
 | 
				
			||||||
@@ -272,16 +290,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                              child: ExpansionTile(
 | 
					                              child: ExpansionTile(
 | 
				
			||||||
                                minTileHeight: 48,
 | 
					                                minTileHeight: 48,
 | 
				
			||||||
                                leading: const Icon(Symbols.forward)
 | 
					                                leading: const Icon(Symbols.forward).padding(left: 4),
 | 
				
			||||||
                                    .padding(left: 4),
 | 
					 | 
				
			||||||
                                title: Text('postRepostingNotice')
 | 
					                                title: Text('postRepostingNotice')
 | 
				
			||||||
                                    .fontSize(15)
 | 
					                                    .fontSize(15)
 | 
				
			||||||
                                    .tr(args: [
 | 
					                                    .tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
 | 
				
			||||||
                                  '@${_writeController.repostingPost!.publisher.name}'
 | 
					 | 
				
			||||||
                                ]),
 | 
					 | 
				
			||||||
                                children: <Widget>[
 | 
					                                children: <Widget>[
 | 
				
			||||||
                                  PostItem(
 | 
					                                  PostItem(
 | 
				
			||||||
                                      data: _writeController.repostingPost!)
 | 
					                                    data: _writeController.repostingPost!,
 | 
				
			||||||
 | 
					                                  )
 | 
				
			||||||
                                ],
 | 
					                                ],
 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
@@ -298,16 +314,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                              child: ExpansionTile(
 | 
					                              child: ExpansionTile(
 | 
				
			||||||
                                minTileHeight: 48,
 | 
					                                minTileHeight: 48,
 | 
				
			||||||
                                leading: const Icon(Symbols.edit_note)
 | 
					                                leading: const Icon(Symbols.edit_note).padding(left: 4),
 | 
				
			||||||
                                    .padding(left: 4),
 | 
					 | 
				
			||||||
                                title: Text('postEditingNotice')
 | 
					                                title: Text('postEditingNotice')
 | 
				
			||||||
                                    .fontSize(15)
 | 
					                                    .fontSize(15)
 | 
				
			||||||
                                    .tr(args: [
 | 
					                                    .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
				
			||||||
                                  '@${_writeController.editingPost!.publisher.name}'
 | 
					                                children: <Widget>[PostItem(data: _writeController.editingPost!)],
 | 
				
			||||||
                                ]),
 | 
					 | 
				
			||||||
                                children: <Widget>[
 | 
					 | 
				
			||||||
                                  PostItem(data: _writeController.editingPost!)
 | 
					 | 
				
			||||||
                                ],
 | 
					 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
                            const Divider(height: 1),
 | 
					                            const Divider(height: 1),
 | 
				
			||||||
@@ -326,14 +337,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                          border: InputBorder.none,
 | 
					                          border: InputBorder.none,
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        onTapOutside: (_) =>
 | 
					                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                            FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                    ]
 | 
					                    ]
 | 
				
			||||||
                        .expandIndexed(
 | 
					                        .expandIndexed(
 | 
				
			||||||
                          (idx, ele) => [
 | 
					                          (idx, ele) => [
 | 
				
			||||||
                            if (idx != 0 || _writeController.isRelatedNull)
 | 
					                            if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
 | 
				
			||||||
                              const Gap(8),
 | 
					 | 
				
			||||||
                            ele,
 | 
					                            ele,
 | 
				
			||||||
                          ],
 | 
					                          ],
 | 
				
			||||||
                        )
 | 
					                        )
 | 
				
			||||||
@@ -341,9 +350,38 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
              if (_writeController.attachments.isNotEmpty)
 | 
					              if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
 | 
				
			||||||
                PostMediaPendingList(
 | 
					                PostMediaPendingList(
 | 
				
			||||||
                  controller: _writeController,
 | 
					                  thumbnail: _writeController.thumbnail,
 | 
				
			||||||
 | 
					                  attachments: _writeController.attachments,
 | 
				
			||||||
 | 
					                  isBusy: _writeController.isBusy,
 | 
				
			||||||
 | 
					                  onUpload: (int idx) async {
 | 
				
			||||||
 | 
					                    await _writeController.uploadSingleAttachment(context, idx);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onPostSetThumbnail: (int? idx) {
 | 
				
			||||||
 | 
					                    _writeController.setThumbnail(idx);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onInsertLink: (int idx) async {
 | 
				
			||||||
 | 
					                    _writeController.contentController.text +=
 | 
				
			||||||
 | 
					                        '\n';
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onUpdate: (int idx, PostWriteMedia updatedMedia) async {
 | 
				
			||||||
 | 
					                    _writeController.setIsBusy(true);
 | 
				
			||||||
 | 
					                    try {
 | 
				
			||||||
 | 
					                      _writeController.setAttachmentAt(idx, updatedMedia);
 | 
				
			||||||
 | 
					                    } finally {
 | 
				
			||||||
 | 
					                      _writeController.setIsBusy(false);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onRemove: (int idx) async {
 | 
				
			||||||
 | 
					                    _writeController.setIsBusy(true);
 | 
				
			||||||
 | 
					                    try {
 | 
				
			||||||
 | 
					                      _writeController.removeAttachmentAt(idx);
 | 
				
			||||||
 | 
					                    } finally {
 | 
				
			||||||
 | 
					                      _writeController.setIsBusy(false);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onUpdateBusy: (state) => _writeController.setIsBusy(state),
 | 
				
			||||||
                ).padding(bottom: 8),
 | 
					                ).padding(bottom: 8),
 | 
				
			||||||
              Material(
 | 
					              Material(
 | 
				
			||||||
                elevation: 2,
 | 
					                elevation: 2,
 | 
				
			||||||
@@ -351,13 +389,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                  children: [
 | 
					                  children: [
 | 
				
			||||||
                    LoadingIndicator(isActive: _isLoading),
 | 
					                    LoadingIndicator(isActive: _isLoading),
 | 
				
			||||||
                    if (_writeController.isBusy &&
 | 
					                    if (_writeController.isBusy && _writeController.progress != null)
 | 
				
			||||||
                        _writeController.progress != null)
 | 
					 | 
				
			||||||
                      TweenAnimationBuilder<double>(
 | 
					                      TweenAnimationBuilder<double>(
 | 
				
			||||||
                        tween: Tween(begin: 0, end: _writeController.progress),
 | 
					                        tween: Tween(begin: 0, end: _writeController.progress),
 | 
				
			||||||
                        duration: Duration(milliseconds: 300),
 | 
					                        duration: Duration(milliseconds: 300),
 | 
				
			||||||
                        builder: (context, value, _) =>
 | 
					                        builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
 | 
				
			||||||
                            LinearProgressIndicator(value: value, minHeight: 2),
 | 
					 | 
				
			||||||
                      )
 | 
					                      )
 | 
				
			||||||
                    else if (_writeController.isBusy)
 | 
					                    else if (_writeController.isBusy)
 | 
				
			||||||
                      const LinearProgressIndicator(value: null, minHeight: 2),
 | 
					                      const LinearProgressIndicator(value: null, minHeight: 2),
 | 
				
			||||||
@@ -371,15 +407,63 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                              scrollDirection: Axis.vertical,
 | 
					                              scrollDirection: Axis.vertical,
 | 
				
			||||||
                              child: Row(
 | 
					                              child: Row(
 | 
				
			||||||
                                children: [
 | 
					                                children: [
 | 
				
			||||||
                                  IconButton(
 | 
					                                  PopupMenuButton(
 | 
				
			||||||
                                    onPressed: _writeController.isBusy
 | 
					 | 
				
			||||||
                                        ? null
 | 
					 | 
				
			||||||
                                        : _selectMedia,
 | 
					 | 
				
			||||||
                                    icon: Icon(
 | 
					                                    icon: Icon(
 | 
				
			||||||
                                      Symbols.add_photo_alternate,
 | 
					                                      Symbols.add_photo_alternate,
 | 
				
			||||||
                                      color:
 | 
					                                      color: Theme.of(context).colorScheme.primary,
 | 
				
			||||||
                                          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();
 | 
				
			||||||
 | 
					                                        },
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
                                  ),
 | 
					                                  ),
 | 
				
			||||||
                                ],
 | 
					                                ],
 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
@@ -387,8 +471,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                          ),
 | 
					                          ),
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        TextButton.icon(
 | 
					                        TextButton.icon(
 | 
				
			||||||
                          onPressed: (_writeController.isBusy ||
 | 
					                          onPressed: (_writeController.isBusy || _writeController.publisher == null)
 | 
				
			||||||
                                  _writeController.publisher == null)
 | 
					 | 
				
			||||||
                              ? null
 | 
					                              ? null
 | 
				
			||||||
                              : () {
 | 
					                              : () {
 | 
				
			||||||
                                  _writeController.post(context).then((_) {
 | 
					                                  _writeController.post(context).then((_) {
 | 
				
			||||||
@@ -403,7 +486,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
				
			|||||||
                    ).padding(horizontal: 16),
 | 
					                    ).padding(horizontal: 16),
 | 
				
			||||||
                  ],
 | 
					                  ],
 | 
				
			||||||
                ).padding(
 | 
					                ).padding(
 | 
				
			||||||
                  bottom: MediaQuery.of(context).padding.bottom,
 | 
					                  bottom: MediaQuery.of(context).padding.bottom + 8,
 | 
				
			||||||
                  top: 4,
 | 
					                  top: 4,
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										205
									
								
								lib/screens/post/post_search.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,205 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
 | 
					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});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<PostSearchScreen> createState() => _PostSearchScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<String> _searchTags = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final List<SnPost> _posts = List.empty(growable: true);
 | 
				
			||||||
 | 
					  int? _postCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String _searchTerm = '';
 | 
				
			||||||
 | 
					  Duration? _lastTook;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPosts() async {
 | 
				
			||||||
 | 
					    if (_searchTerm.isEmpty && _searchTags.isEmpty) return;
 | 
				
			||||||
 | 
					    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final stopwatch = Stopwatch()..start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
 | 
					      final result = await pt.searchPosts(
 | 
				
			||||||
 | 
					        _searchTerm,
 | 
				
			||||||
 | 
					        take: 10,
 | 
				
			||||||
 | 
					        offset: _posts.length,
 | 
				
			||||||
 | 
					        tags: _searchTags,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final List<SnPost> out = result.$1;
 | 
				
			||||||
 | 
					      _postCount = result.$2;
 | 
				
			||||||
 | 
					      _posts.addAll(out);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      stopwatch.stop();
 | 
				
			||||||
 | 
					      _lastTook = stopwatch.elapsed;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (mounted) setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showAdvancedSearchTune() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          PostTagsField(
 | 
				
			||||||
 | 
					            labelText: 'fieldPostTags'.tr(),
 | 
				
			||||||
 | 
					            initialTags: _searchTags,
 | 
				
			||||||
 | 
					            onUpdate: (value) {
 | 
				
			||||||
 | 
					              setState(() => _searchTags = value);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ).padding(horizontal: 24, vertical: 16),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    const labelShadows = <Shadow>[
 | 
				
			||||||
 | 
					      Shadow(
 | 
				
			||||||
 | 
					        offset: Offset(1, 1),
 | 
				
			||||||
 | 
					        blurRadius: 8.0,
 | 
				
			||||||
 | 
					        color: Color.fromARGB(255, 0, 0, 0),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: Text('screenPostSearch').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: const Icon(Symbols.tune),
 | 
				
			||||||
 | 
					            onPressed: _showAdvancedSearchTune,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Stack(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          InfiniteList(
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(top: 100),
 | 
				
			||||||
 | 
					            itemCount: _posts.length,
 | 
				
			||||||
 | 
					            isLoading: _isBusy,
 | 
				
			||||||
 | 
					            hasReachedMax: _postCount != null && _posts.length >= _postCount!,
 | 
				
			||||||
 | 
					            onFetchData: () {
 | 
				
			||||||
 | 
					              _fetchPosts();
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					              return GestureDetector(
 | 
				
			||||||
 | 
					                child: PostItem(
 | 
				
			||||||
 | 
					                  data: _posts[idx],
 | 
				
			||||||
 | 
					                  maxWidth: 640,
 | 
				
			||||||
 | 
					                  onChanged: (data) {
 | 
				
			||||||
 | 
					                    setState(() => _posts[idx] = data);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onDeleted: () {
 | 
				
			||||||
 | 
					                    _posts.clear();
 | 
				
			||||||
 | 
					                    _fetchPosts();
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                    'postDetail',
 | 
				
			||||||
 | 
					                    pathParameters: {'slug': _posts[idx].id.toString()},
 | 
				
			||||||
 | 
					                    extra: _posts[idx],
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            separatorBuilder: (context, index) => const Divider(height: 1),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          Positioned(
 | 
				
			||||||
 | 
					            top: 16,
 | 
				
			||||||
 | 
					            left: 16,
 | 
				
			||||||
 | 
					            right: 16,
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                SearchBar(
 | 
				
			||||||
 | 
					                  elevation: const WidgetStatePropertyAll(1),
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.search),
 | 
				
			||||||
 | 
					                  padding: const WidgetStatePropertyAll(
 | 
				
			||||||
 | 
					                    EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    _searchTerm = value;
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onSubmitted: (value) {
 | 
				
			||||||
 | 
					                    setState(() => _posts.clear());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    _searchTerm = value;
 | 
				
			||||||
 | 
					                    _fetchPosts();
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                if (_lastTook != null)
 | 
				
			||||||
 | 
					                  Row(
 | 
				
			||||||
 | 
					                    mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      Icon(
 | 
				
			||||||
 | 
					                        Symbols.summarize,
 | 
				
			||||||
 | 
					                        color: Colors.white,
 | 
				
			||||||
 | 
					                        shadows: labelShadows,
 | 
				
			||||||
 | 
					                        size: 16,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(4),
 | 
				
			||||||
 | 
					                      Text(
 | 
				
			||||||
 | 
					                        'postSearchResult'.plural(_postCount ?? 0),
 | 
				
			||||||
 | 
					                        style: TextStyle(
 | 
				
			||||||
 | 
					                          color: Colors.white,
 | 
				
			||||||
 | 
					                          shadows: labelShadows,
 | 
				
			||||||
 | 
					                          fontSize: 13,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(8),
 | 
				
			||||||
 | 
					                      Icon(
 | 
				
			||||||
 | 
					                        Symbols.pace,
 | 
				
			||||||
 | 
					                        color: Colors.white,
 | 
				
			||||||
 | 
					                        shadows: labelShadows,
 | 
				
			||||||
 | 
					                        size: 16,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      const Gap(4),
 | 
				
			||||||
 | 
					                      Text(
 | 
				
			||||||
 | 
					                        'postSearchTook'.tr(args: [
 | 
				
			||||||
 | 
					                          '${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s',
 | 
				
			||||||
 | 
					                        ]),
 | 
				
			||||||
 | 
					                        style: TextStyle(
 | 
				
			||||||
 | 
					                          color: Colors.white,
 | 
				
			||||||
 | 
					                          shadows: labelShadows,
 | 
				
			||||||
 | 
					                          fontSize: 13,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ).padding(vertical: 8),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										608
									
								
								lib/screens/post/publisher_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,608 @@
 | 
				
			|||||||
 | 
					import 'dart:ui';
 | 
				
			||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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:sliver_tools/sliver_tools.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/account.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/post.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/post/post_item.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../../providers/relationship.dart';
 | 
				
			||||||
 | 
					import '../abuse_report.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PostPublisherScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const PostPublisherScreen({super.key, required this.name});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<PostPublisherScreen> createState() => _PostPublisherScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
 | 
				
			||||||
 | 
					  late final ScrollController _scrollController = ScrollController();
 | 
				
			||||||
 | 
					  late final TabController _tabController = TabController(length: 3, vsync: this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnPublisher? _publisher;
 | 
				
			||||||
 | 
					  SnAccount? _account;
 | 
				
			||||||
 | 
					  SnRelationship? _accountRelationship;
 | 
				
			||||||
 | 
					  SnRealm? _realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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((_) {
 | 
				
			||||||
 | 
					        if (mounted) Navigator.pop(context);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isSubscribing = false;
 | 
				
			||||||
 | 
					  SnSubscription? _subscription;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchSubscription() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setState(() => _isSubscribing = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get(
 | 
				
			||||||
 | 
					        '/cgi/co/subscriptions/users/${_publisher!.id}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _subscription = SnSubscription.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (_) {
 | 
				
			||||||
 | 
					      // ignore due to maybe 404
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isSubscribing = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _toggleSubscription() async {
 | 
				
			||||||
 | 
					    if (_subscription == null) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        setState(() => _isSubscribing = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					        final resp = await sn.client.post(
 | 
				
			||||||
 | 
					          '/cgi/co/subscriptions/users/${_publisher!.id}',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        _subscription = SnSubscription.fromJson(resp.data);
 | 
				
			||||||
 | 
					      } catch (err) {
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        setState(() => _isSubscribing = false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        setState(() => _isSubscribing = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					        await sn.client.delete(
 | 
				
			||||||
 | 
					          '/cgi/co/subscriptions/users/${_publisher!.id}',
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        _subscription = null;
 | 
				
			||||||
 | 
					      } catch (err) {
 | 
				
			||||||
 | 
					        if (!mounted) return;
 | 
				
			||||||
 | 
					        context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        setState(() => _isSubscribing = false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  double _appBarBlur = 0.0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  late final _appBarWidth = MediaQuery.of(context).size.width;
 | 
				
			||||||
 | 
					  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _updateAppBarBlur() {
 | 
				
			||||||
 | 
					    if (_scrollController.offset > _appBarHeight) return;
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int? _postCount;
 | 
				
			||||||
 | 
					  final List<SnPost> _posts = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPosts() async {
 | 
				
			||||||
 | 
					    if (_isBusy) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final pt = context.read<SnPostContentProvider>();
 | 
				
			||||||
 | 
					      final result = await pt.listPosts(
 | 
				
			||||||
 | 
					        offset: _posts.length,
 | 
				
			||||||
 | 
					        author: widget.name,
 | 
				
			||||||
 | 
					        type: switch (_tabController.index) {
 | 
				
			||||||
 | 
					          1 => 'story',
 | 
				
			||||||
 | 
					          2 => 'article',
 | 
				
			||||||
 | 
					          _ => null,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      _postCount = result.$2;
 | 
				
			||||||
 | 
					      _posts.addAll(result.$1);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isWorking = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _blockPublisher() async {
 | 
				
			||||||
 | 
					    if (_isWorking) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'publisherBlockHint'.tr(args: ['@${_publisher?.name ?? 'unknown'.tr()}']),
 | 
				
			||||||
 | 
					      'publisherBlockHintDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isWorking = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post('/cgi/id/users/me/relations/block', data: {
 | 
				
			||||||
 | 
					        'related': _account!.name,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isWorking = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _unblockPublisher() async {
 | 
				
			||||||
 | 
					    if (_isWorking) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isWorking = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final rel = context.read<SnRelationshipProvider>();
 | 
				
			||||||
 | 
					      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isWorking = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _updateFetchType() {
 | 
				
			||||||
 | 
					    _posts.clear();
 | 
				
			||||||
 | 
					    _fetchPosts();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showAbuseReportDialog() {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => AbuseReportDialog(
 | 
				
			||||||
 | 
					        resourceLocation: 'pub:${_publisher?.name}',
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ).then((value) {
 | 
				
			||||||
 | 
					      if (value == true && mounted) {
 | 
				
			||||||
 | 
					        _fetchPosts();
 | 
				
			||||||
 | 
					        context.showSnackbar('abuseReportSubmitted'.tr());
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchPublisher().then((_) {
 | 
				
			||||||
 | 
					      _fetchPosts();
 | 
				
			||||||
 | 
					      _fetchSubscription();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    _scrollController.addListener(_updateAppBarBlur);
 | 
				
			||||||
 | 
					    _tabController.addListener(_updateFetchType);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _scrollController.removeListener(_updateAppBarBlur);
 | 
				
			||||||
 | 
					    _scrollController.dispose();
 | 
				
			||||||
 | 
					    _tabController.removeListener(_updateFetchType);
 | 
				
			||||||
 | 
					    _tabController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const kBannerAspectRatio = 7 / 16;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final imageHeight = _appBarHeight + kToolbarHeight + 8;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const labelShadows = <Shadow>[
 | 
				
			||||||
 | 
					      Shadow(
 | 
				
			||||||
 | 
					        offset: Offset(1, 1),
 | 
				
			||||||
 | 
					        blurRadius: 5.0,
 | 
				
			||||||
 | 
					        color: Color.fromARGB(255, 0, 0, 0),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      body: NestedScrollView(
 | 
				
			||||||
 | 
					        controller: _scrollController,
 | 
				
			||||||
 | 
					        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
				
			||||||
 | 
					          return <Widget>[
 | 
				
			||||||
 | 
					            SliverOverlapAbsorber(
 | 
				
			||||||
 | 
					              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,
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              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,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  if (_publisher != null)
 | 
				
			||||||
 | 
					                    SliverToBoxAdapter(
 | 
				
			||||||
 | 
					                      child: Container(
 | 
				
			||||||
 | 
					                        constraints: const BoxConstraints(maxWidth: 640),
 | 
				
			||||||
 | 
					                        child: Column(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                AccountImage(
 | 
				
			||||||
 | 
					                                  content: _publisher!.avatar,
 | 
				
			||||||
 | 
					                                  radius: 28,
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                const Gap(16),
 | 
				
			||||||
 | 
					                                Expanded(
 | 
				
			||||||
 | 
					                                  child: Column(
 | 
				
			||||||
 | 
					                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                                    children: [
 | 
				
			||||||
 | 
					                                      Text(
 | 
				
			||||||
 | 
					                                        _publisher!.nick,
 | 
				
			||||||
 | 
					                                        style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					                                      ).bold(),
 | 
				
			||||||
 | 
					                                      Text('@${_publisher!.name}').fontSize(13),
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                if (_subscription == null)
 | 
				
			||||||
 | 
					                                  ElevatedButton.icon(
 | 
				
			||||||
 | 
					                                    style: ButtonStyle(
 | 
				
			||||||
 | 
					                                      elevation: WidgetStatePropertyAll(0),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    onPressed: _isSubscribing ? null : _toggleSubscription,
 | 
				
			||||||
 | 
					                                    label: Text('subscribe').tr(),
 | 
				
			||||||
 | 
					                                    icon: const Icon(Symbols.add),
 | 
				
			||||||
 | 
					                                  )
 | 
				
			||||||
 | 
					                                else
 | 
				
			||||||
 | 
					                                  OutlinedButton.icon(
 | 
				
			||||||
 | 
					                                    style: ButtonStyle(
 | 
				
			||||||
 | 
					                                      elevation: WidgetStatePropertyAll(0),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    onPressed: _isSubscribing ? null : _toggleSubscription,
 | 
				
			||||||
 | 
					                                    label: Text('unsubscribe').tr(),
 | 
				
			||||||
 | 
					                                    icon: const Icon(Symbols.remove),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                PopupMenuButton(
 | 
				
			||||||
 | 
					                                  padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                                  style: ButtonStyle(
 | 
				
			||||||
 | 
					                                    visualDensity: VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                  itemBuilder: (BuildContext context) => [
 | 
				
			||||||
 | 
					                                    PopupMenuItem(
 | 
				
			||||||
 | 
					                                      child: Row(
 | 
				
			||||||
 | 
					                                        children: [
 | 
				
			||||||
 | 
					                                          const Icon(Symbols.flag),
 | 
				
			||||||
 | 
					                                          const Gap(16),
 | 
				
			||||||
 | 
					                                          Text('report').tr(),
 | 
				
			||||||
 | 
					                                        ],
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                      onTap: () => _showAbuseReportDialog(),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    if (_accountRelationship?.status != 2)
 | 
				
			||||||
 | 
					                                      PopupMenuItem(
 | 
				
			||||||
 | 
					                                        onTap: _blockPublisher,
 | 
				
			||||||
 | 
					                                        child: Row(
 | 
				
			||||||
 | 
					                                          children: [
 | 
				
			||||||
 | 
					                                            const Icon(Symbols.block),
 | 
				
			||||||
 | 
					                                            const Gap(16),
 | 
				
			||||||
 | 
					                                            Text('friendBlock').tr(),
 | 
				
			||||||
 | 
					                                          ],
 | 
				
			||||||
 | 
					                                        ),
 | 
				
			||||||
 | 
					                                      )
 | 
				
			||||||
 | 
					                                    else
 | 
				
			||||||
 | 
					                                      PopupMenuItem(
 | 
				
			||||||
 | 
					                                        onTap: _unblockPublisher,
 | 
				
			||||||
 | 
					                                        child: Row(
 | 
				
			||||||
 | 
					                                          children: [
 | 
				
			||||||
 | 
					                                            const Icon(Symbols.block),
 | 
				
			||||||
 | 
					                                            const Gap(16),
 | 
				
			||||||
 | 
					                                            Text('friendUnblock').tr(),
 | 
				
			||||||
 | 
					                                          ],
 | 
				
			||||||
 | 
					                                        ),
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            const Gap(12),
 | 
				
			||||||
 | 
					                            Text(_publisher!.description).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                            const Gap(12),
 | 
				
			||||||
 | 
					                            Column(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                Row(
 | 
				
			||||||
 | 
					                                  children: [
 | 
				
			||||||
 | 
					                                    const Icon(Symbols.calendar_add_on),
 | 
				
			||||||
 | 
					                                    const Gap(8),
 | 
				
			||||||
 | 
					                                    Text('publisherJoinedAt')
 | 
				
			||||||
 | 
					                                        .tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                Row(
 | 
				
			||||||
 | 
					                                  children: [
 | 
				
			||||||
 | 
					                                    const Icon(Symbols.trending_up),
 | 
				
			||||||
 | 
					                                    const Gap(8),
 | 
				
			||||||
 | 
					                                    Text('publisherSocialPointTotal').plural(
 | 
				
			||||||
 | 
					                                      _publisher!.totalUpvote - _publisher!.totalDownvote,
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                                if (_realm != null)
 | 
				
			||||||
 | 
					                                  Row(
 | 
				
			||||||
 | 
					                                    children: [
 | 
				
			||||||
 | 
					                                      const Icon(Symbols.group_work),
 | 
				
			||||||
 | 
					                                      const Gap(8),
 | 
				
			||||||
 | 
					                                      InkWell(
 | 
				
			||||||
 | 
					                                        child: Text('publisherAffiliatedBy').tr(args: [
 | 
				
			||||||
 | 
					                                          '@${_realm?.alias ?? 'unknown'}',
 | 
				
			||||||
 | 
					                                        ]),
 | 
				
			||||||
 | 
					                                        onTap: () {
 | 
				
			||||||
 | 
					                                          GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                                            'realmDetail',
 | 
				
			||||||
 | 
					                                            pathParameters: {'alias': _realm!.alias},
 | 
				
			||||||
 | 
					                                          );
 | 
				
			||||||
 | 
					                                        },
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                      const Gap(8),
 | 
				
			||||||
 | 
					                                      AccountImage(content: _realm?.avatar, radius: 8),
 | 
				
			||||||
 | 
					                                    ],
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                Row(
 | 
				
			||||||
 | 
					                                  children: [
 | 
				
			||||||
 | 
					                                    const Icon(Symbols.tools_wrench),
 | 
				
			||||||
 | 
					                                    const Gap(8),
 | 
				
			||||||
 | 
					                                    InkWell(
 | 
				
			||||||
 | 
					                                      child: Text('publisherRunBy').tr(args: [
 | 
				
			||||||
 | 
					                                        '@${_account?.name ?? 'unknown'}',
 | 
				
			||||||
 | 
					                                      ]),
 | 
				
			||||||
 | 
					                                      onTap: () {
 | 
				
			||||||
 | 
					                                        GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                                          'accountProfilePage',
 | 
				
			||||||
 | 
					                                          pathParameters: {
 | 
				
			||||||
 | 
					                                            'name': _account!.name,
 | 
				
			||||||
 | 
					                                          },
 | 
				
			||||||
 | 
					                                        );
 | 
				
			||||||
 | 
					                                      },
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                    const Gap(8),
 | 
				
			||||||
 | 
					                                    AccountImage(content: _account?.avatar, radius: 8),
 | 
				
			||||||
 | 
					                                  ],
 | 
				
			||||||
 | 
					                                ),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ).padding(horizontal: 8),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ).padding(all: 16),
 | 
				
			||||||
 | 
					                      ).center(),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  SliverToBoxAdapter(child: const Divider(height: 1)),
 | 
				
			||||||
 | 
					                  TabBar(
 | 
				
			||||||
 | 
					                    controller: _tabController,
 | 
				
			||||||
 | 
					                    tabs: [
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                        icon: Icon(
 | 
				
			||||||
 | 
					                          Symbols.pages,
 | 
				
			||||||
 | 
					                          color: Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                        icon: Icon(
 | 
				
			||||||
 | 
					                          Symbols.sticky_note_2,
 | 
				
			||||||
 | 
					                          color: Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      Tab(
 | 
				
			||||||
 | 
					                        icon: Icon(
 | 
				
			||||||
 | 
					                          Symbols.article,
 | 
				
			||||||
 | 
					                          color: Theme.of(context).colorScheme.onSurface,
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  SliverToBoxAdapter(child: const Divider(height: 1)),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ];
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        body: Column(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            Gap(math.max(MediaQuery.of(context).padding.top, 64)),
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: TabBarView(
 | 
				
			||||||
 | 
					                controller: _tabController,
 | 
				
			||||||
 | 
					                children: List.filled(
 | 
				
			||||||
 | 
					                  3,
 | 
				
			||||||
 | 
					                  _PublisherPostList(
 | 
				
			||||||
 | 
					                    isBusy: _isBusy,
 | 
				
			||||||
 | 
					                    postCount: _postCount,
 | 
				
			||||||
 | 
					                    posts: _posts,
 | 
				
			||||||
 | 
					                    fetchPosts: _fetchPosts,
 | 
				
			||||||
 | 
					                    onChanged: (idx, data) {
 | 
				
			||||||
 | 
					                      setState(() => _posts[idx] = data);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    onDeleted: () {
 | 
				
			||||||
 | 
					                      _posts.clear();
 | 
				
			||||||
 | 
					                      _fetchPosts();
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PublisherPostList extends StatelessWidget {
 | 
				
			||||||
 | 
					  final bool isBusy;
 | 
				
			||||||
 | 
					  final int? postCount;
 | 
				
			||||||
 | 
					  final List<SnPost> posts;
 | 
				
			||||||
 | 
					  final void Function() fetchPosts;
 | 
				
			||||||
 | 
					  final void Function(int index, SnPost data) onChanged;
 | 
				
			||||||
 | 
					  final void Function() onDeleted;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _PublisherPostList({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					    required this.isBusy,
 | 
				
			||||||
 | 
					    required this.postCount,
 | 
				
			||||||
 | 
					    required this.posts,
 | 
				
			||||||
 | 
					    required this.fetchPosts,
 | 
				
			||||||
 | 
					    required this.onChanged,
 | 
				
			||||||
 | 
					    required this.onDeleted,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return InfiniteList(
 | 
				
			||||||
 | 
					      itemCount: posts.length,
 | 
				
			||||||
 | 
					      isLoading: isBusy,
 | 
				
			||||||
 | 
					      hasReachedMax: postCount != null && posts.length >= postCount!,
 | 
				
			||||||
 | 
					      onFetchData: fetchPosts,
 | 
				
			||||||
 | 
					      itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					        return GestureDetector(
 | 
				
			||||||
 | 
					          child: PostItem(
 | 
				
			||||||
 | 
					            data: posts[idx],
 | 
				
			||||||
 | 
					            maxWidth: 640,
 | 
				
			||||||
 | 
					            onChanged: (data) {
 | 
				
			||||||
 | 
					              onChanged(idx, data);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            onDeleted: onDeleted,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					              'postDetail',
 | 
				
			||||||
 | 
					              pathParameters: {'slug': posts[idx].id.toString()},
 | 
				
			||||||
 | 
					              extra: posts[idx],
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      separatorBuilder: (context, index) => const Divider(height: 1),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										247
									
								
								lib/screens/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,247 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/app_bar_leading.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/unauthorized_hint.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RealmScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  const RealmScreen({super.key});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<RealmScreen> createState() => _RealmScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmScreenState extends State<RealmScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					  bool _isCompactView = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnRealm>? _realms;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealms() async {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/realms/me/available');
 | 
				
			||||||
 | 
					      _realms = List<SnRealm>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteRealm(SnRealm realm) async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'realmDelete'.tr(args: ['#${realm.alias}']),
 | 
				
			||||||
 | 
					      'realmDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/id/realms/${realm.alias}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showSnackbar('realmDeleted'.tr(args: ['#${realm.alias}']));
 | 
				
			||||||
 | 
					      _fetchRealms();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchRealms();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!ua.isAuthorized) {
 | 
				
			||||||
 | 
					      return Scaffold(
 | 
				
			||||||
 | 
					        appBar: AppBar(
 | 
				
			||||||
 | 
					          leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					          title: Text('screenRealm').tr(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        body: Center(
 | 
				
			||||||
 | 
					          child: UnauthorizedHint(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        leading: AutoAppBarLeading(),
 | 
				
			||||||
 | 
					        title: Text('screenRealm').tr(),
 | 
				
			||||||
 | 
					        actions: [
 | 
				
			||||||
 | 
					          IconButton(
 | 
				
			||||||
 | 
					            icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              setState(() => _isCompactView = !_isCompactView);
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(8),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      floatingActionButton: FloatingActionButton(
 | 
				
			||||||
 | 
					        child: const Icon(Symbols.group_add),
 | 
				
			||||||
 | 
					        onPressed: () {
 | 
				
			||||||
 | 
					          GoRouter.of(context).pushNamed('realmManage');
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: Column(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: RefreshIndicator(
 | 
				
			||||||
 | 
					              onRefresh: _fetchRealms,
 | 
				
			||||||
 | 
					              child: ListView.builder(
 | 
				
			||||||
 | 
					                itemCount: _realms?.length ?? 0,
 | 
				
			||||||
 | 
					                itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					                  final realm = _realms![idx];
 | 
				
			||||||
 | 
					                  if (_isCompactView) {
 | 
				
			||||||
 | 
					                    return ListTile(
 | 
				
			||||||
 | 
					                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
				
			||||||
 | 
					                      leading: AccountImage(
 | 
				
			||||||
 | 
					                        content: realm.avatar,
 | 
				
			||||||
 | 
					                        fallbackWidget: const Icon(Symbols.group, size: 20),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      title: Text(realm.name),
 | 
				
			||||||
 | 
					                      subtitle: Text(
 | 
				
			||||||
 | 
					                        realm.description,
 | 
				
			||||||
 | 
					                        maxLines: 1,
 | 
				
			||||||
 | 
					                        overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      trailing: PopupMenuButton(
 | 
				
			||||||
 | 
					                        itemBuilder: (BuildContext context) => [
 | 
				
			||||||
 | 
					                          PopupMenuItem(
 | 
				
			||||||
 | 
					                            child: Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					                                const Gap(16),
 | 
				
			||||||
 | 
					                                Text('edit').tr(),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            onTap: () {
 | 
				
			||||||
 | 
					                              GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                                'realmManage',
 | 
				
			||||||
 | 
					                                queryParameters: {'editing': realm.alias},
 | 
				
			||||||
 | 
					                              ).then((value) {
 | 
				
			||||||
 | 
					                                if (value != null) {
 | 
				
			||||||
 | 
					                                  _fetchRealms();
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                              });
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          PopupMenuItem(
 | 
				
			||||||
 | 
					                            child: Row(
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					                                const Gap(16),
 | 
				
			||||||
 | 
					                                Text('delete').tr(),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            onTap: () {
 | 
				
			||||||
 | 
					                              _deleteRealm(realm);
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ],
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      onTap: () {
 | 
				
			||||||
 | 
					                        GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                          'realmDetail',
 | 
				
			||||||
 | 
					                          pathParameters: {'alias': realm.alias},
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  return Container(
 | 
				
			||||||
 | 
					                    constraints: BoxConstraints(maxWidth: 640),
 | 
				
			||||||
 | 
					                    child: Card(
 | 
				
			||||||
 | 
					                      margin: const EdgeInsets.all(12),
 | 
				
			||||||
 | 
					                      child: InkWell(
 | 
				
			||||||
 | 
					                        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
 | 
					                        child: Column(
 | 
				
			||||||
 | 
					                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                          children: [
 | 
				
			||||||
 | 
					                            AspectRatio(
 | 
				
			||||||
 | 
					                              aspectRatio: 16 / 7,
 | 
				
			||||||
 | 
					                              child: Stack(
 | 
				
			||||||
 | 
					                                clipBehavior: Clip.none,
 | 
				
			||||||
 | 
					                                fit: StackFit.expand,
 | 
				
			||||||
 | 
					                                children: [
 | 
				
			||||||
 | 
					                                  Container(
 | 
				
			||||||
 | 
					                                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
				
			||||||
 | 
					                                    child: (realm.banner?.isEmpty ?? true)
 | 
				
			||||||
 | 
					                                        ? const SizedBox.shrink()
 | 
				
			||||||
 | 
					                                        : AutoResizeUniversalImage(
 | 
				
			||||||
 | 
					                                            sn.getAttachmentUrl(realm.banner!),
 | 
				
			||||||
 | 
					                                            fit: BoxFit.cover,
 | 
				
			||||||
 | 
					                                          ),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                  Positioned(
 | 
				
			||||||
 | 
					                                    bottom: -30,
 | 
				
			||||||
 | 
					                                    left: 18,
 | 
				
			||||||
 | 
					                                    child: AccountImage(
 | 
				
			||||||
 | 
					                                      content: realm.avatar,
 | 
				
			||||||
 | 
					                                      radius: 24,
 | 
				
			||||||
 | 
					                                      fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					                                    ),
 | 
				
			||||||
 | 
					                                  ),
 | 
				
			||||||
 | 
					                                ],
 | 
				
			||||||
 | 
					                              ),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            const Gap(20 + 12),
 | 
				
			||||||
 | 
					                            Column(
 | 
				
			||||||
 | 
					                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                              children: [
 | 
				
			||||||
 | 
					                                Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
 | 
				
			||||||
 | 
					                                Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
				
			||||||
 | 
					                              ],
 | 
				
			||||||
 | 
					                            ).padding(horizontal: 24, bottom: 14),
 | 
				
			||||||
 | 
					                          ],
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        onTap: () {
 | 
				
			||||||
 | 
					                          GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                            'realmDetail',
 | 
				
			||||||
 | 
					                            pathParameters: {'alias': realm.alias},
 | 
				
			||||||
 | 
					                          );
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ).center();
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										312
									
								
								lib/screens/realm/manage.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,312 @@
 | 
				
			|||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					import 'dart:ui';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:croppy/croppy.dart';
 | 
				
			||||||
 | 
					import 'package:dio/dio.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:image_picker/image_picker.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:path/path.dart' show basename;
 | 
				
			||||||
 | 
					import 'package:provider/provider.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_attachment.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/loading_indicator.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/universal_image.dart';
 | 
				
			||||||
 | 
					import 'package:uuid/uuid.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RealmManageScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String? editingRealmAlias;
 | 
				
			||||||
 | 
					  const RealmManageScreen({super.key, this.editingRealmAlias});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<RealmManageScreen> createState() => _RealmManageScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnRealm? _editingRealm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealm() async {
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final resp =
 | 
				
			||||||
 | 
					          await sn.client.get('/cgi/id/realms/${widget.editingRealmAlias}');
 | 
				
			||||||
 | 
					      final out = SnRealm.fromJson(resp.data);
 | 
				
			||||||
 | 
					      _editingRealm = out;
 | 
				
			||||||
 | 
					      _avatar = out.avatar;
 | 
				
			||||||
 | 
					      _banner = out.banner;
 | 
				
			||||||
 | 
					      _aliasController.text = out.alias;
 | 
				
			||||||
 | 
					      _nameController.text = out.name;
 | 
				
			||||||
 | 
					      _descriptionController.text = out.description;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					      if (context.mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String? _avatar;
 | 
				
			||||||
 | 
					  String? _banner;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final _aliasController = TextEditingController();
 | 
				
			||||||
 | 
					  final _nameController = TextEditingController();
 | 
				
			||||||
 | 
					  final _descriptionController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final _imagePicker = ImagePicker();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _updateImage(String place) async {
 | 
				
			||||||
 | 
					    final image = await _imagePicker.pickImage(source: ImageSource.gallery);
 | 
				
			||||||
 | 
					    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 result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
				
			||||||
 | 
					        ? await showCupertinoImageCropper(
 | 
				
			||||||
 | 
					            // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					            context,
 | 
				
			||||||
 | 
					            allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					            imageProvider: imageProvider,
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        : await showMaterialImageCropper(
 | 
				
			||||||
 | 
					            // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					            context,
 | 
				
			||||||
 | 
					            allowedAspectRatios: aspectRatios,
 | 
				
			||||||
 | 
					            imageProvider: imageProvider,
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (result == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					    final attach = context.read<SnAttachmentProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final rawBytes =
 | 
				
			||||||
 | 
					        (await result.uiImage.toByteData(format: ImageByteFormat.png))!
 | 
				
			||||||
 | 
					            .buffer
 | 
				
			||||||
 | 
					            .asUint8List();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final attachment = await attach.directUploadOne(
 | 
				
			||||||
 | 
					        rawBytes,
 | 
				
			||||||
 | 
					        basename(image.path),
 | 
				
			||||||
 | 
					        'avatar',
 | 
				
			||||||
 | 
					        null,
 | 
				
			||||||
 | 
					        mimetype: 'image/png',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      switch (place) {
 | 
				
			||||||
 | 
					        case 'avatar':
 | 
				
			||||||
 | 
					          _avatar = attachment.rid;
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					        case 'banner':
 | 
				
			||||||
 | 
					          _banner = attachment.rid;
 | 
				
			||||||
 | 
					          break;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _performAction() async {
 | 
				
			||||||
 | 
					    final uuid = const Uuid();
 | 
				
			||||||
 | 
					    final payload = {
 | 
				
			||||||
 | 
					      'alias': _aliasController.text.isNotEmpty
 | 
				
			||||||
 | 
					          ? _aliasController.text.toLowerCase()
 | 
				
			||||||
 | 
					          : uuid.v4().replaceAll('-', '').substring(0, 12),
 | 
				
			||||||
 | 
					      'name': _nameController.text,
 | 
				
			||||||
 | 
					      'description': _descriptionController.text,
 | 
				
			||||||
 | 
					      'avatar': _avatar,
 | 
				
			||||||
 | 
					      'banner': _banner,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.request(
 | 
				
			||||||
 | 
					        widget.editingRealmAlias != null
 | 
				
			||||||
 | 
					            ? '/cgi/id/realms/${widget.editingRealmAlias}'
 | 
				
			||||||
 | 
					            : '/cgi/id/realms',
 | 
				
			||||||
 | 
					        data: payload,
 | 
				
			||||||
 | 
					        options: Options(
 | 
				
			||||||
 | 
					          method: widget.editingRealmAlias != null ? 'PUT' : 'POST',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final out = SnRealm.fromJson(resp.data);
 | 
				
			||||||
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					      if (context.mounted) Navigator.pop(context, out);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      // ignore: use_build_context_synchronously
 | 
				
			||||||
 | 
					      if (context.mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    if (widget.editingRealmAlias != null) _fetchRealm();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _aliasController.dispose();
 | 
				
			||||||
 | 
					    _nameController.dispose();
 | 
				
			||||||
 | 
					    _descriptionController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Scaffold(
 | 
				
			||||||
 | 
					      appBar: AppBar(
 | 
				
			||||||
 | 
					        title: widget.editingRealmAlias != null
 | 
				
			||||||
 | 
					            ? Text('screenRealmManage').tr()
 | 
				
			||||||
 | 
					            : Text('screenRealmNew').tr(),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
 | 
					        child: Column(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            LoadingIndicator(isActive: _isBusy),
 | 
				
			||||||
 | 
					            if (_editingRealm != null)
 | 
				
			||||||
 | 
					              MaterialBanner(
 | 
				
			||||||
 | 
					                leading: const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					                leadingPadding: const EdgeInsets.only(left: 10, right: 20),
 | 
				
			||||||
 | 
					                dividerColor: Colors.transparent,
 | 
				
			||||||
 | 
					                content: Text(
 | 
				
			||||||
 | 
					                  'realmEditingNotice'.tr(args: ['#${_editingRealm!.alias}']),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                actions: [
 | 
				
			||||||
 | 
					                  TextButton(
 | 
				
			||||||
 | 
					                    child: Text('cancel').tr(),
 | 
				
			||||||
 | 
					                    onPressed: () {
 | 
				
			||||||
 | 
					                      Navigator.pop(context);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            const Gap(24),
 | 
				
			||||||
 | 
					            Stack(
 | 
				
			||||||
 | 
					              clipBehavior: Clip.none,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Material(
 | 
				
			||||||
 | 
					                  elevation: 0,
 | 
				
			||||||
 | 
					                  child: InkWell(
 | 
				
			||||||
 | 
					                    child: ClipRRect(
 | 
				
			||||||
 | 
					                      borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
 | 
					                      child: AspectRatio(
 | 
				
			||||||
 | 
					                        aspectRatio: 16 / 9,
 | 
				
			||||||
 | 
					                        child: Container(
 | 
				
			||||||
 | 
					                          color: Theme.of(context)
 | 
				
			||||||
 | 
					                              .colorScheme
 | 
				
			||||||
 | 
					                              .surfaceContainerHigh,
 | 
				
			||||||
 | 
					                          child: _banner != null
 | 
				
			||||||
 | 
					                              ? AutoResizeUniversalImage(
 | 
				
			||||||
 | 
					                                  sn.getAttachmentUrl(_banner!),
 | 
				
			||||||
 | 
					                                  fit: BoxFit.cover,
 | 
				
			||||||
 | 
					                                )
 | 
				
			||||||
 | 
					                              : const SizedBox.shrink(),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    onTap: () {
 | 
				
			||||||
 | 
					                      _updateImage('banner');
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                Positioned(
 | 
				
			||||||
 | 
					                  bottom: -28,
 | 
				
			||||||
 | 
					                  left: 16,
 | 
				
			||||||
 | 
					                  child: Material(
 | 
				
			||||||
 | 
					                    elevation: 2,
 | 
				
			||||||
 | 
					                    borderRadius: const BorderRadius.all(Radius.circular(40)),
 | 
				
			||||||
 | 
					                    child: InkWell(
 | 
				
			||||||
 | 
					                      child: AccountImage(
 | 
				
			||||||
 | 
					                        content: _avatar,
 | 
				
			||||||
 | 
					                        radius: 40,
 | 
				
			||||||
 | 
					                        fallbackWidget: const Icon(Symbols.group, size: 40),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                      onTap: () {
 | 
				
			||||||
 | 
					                        _updateImage('avatar');
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ).padding(horizontal: 24),
 | 
				
			||||||
 | 
					            const Gap(8 + 28),
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _aliasController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldRealmAlias'.tr(),
 | 
				
			||||||
 | 
					                    helperText: 'fieldRealmAliasHint'.tr(),
 | 
				
			||||||
 | 
					                    helperMaxLines: 2,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(4),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _nameController,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldRealmName'.tr(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(4),
 | 
				
			||||||
 | 
					                TextField(
 | 
				
			||||||
 | 
					                  controller: _descriptionController,
 | 
				
			||||||
 | 
					                  maxLines: null,
 | 
				
			||||||
 | 
					                  minLines: 3,
 | 
				
			||||||
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
 | 
					                    border: const UnderlineInputBorder(),
 | 
				
			||||||
 | 
					                    labelText: 'fieldRealmDescription'.tr(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTapOutside: (_) =>
 | 
				
			||||||
 | 
					                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                const Gap(12),
 | 
				
			||||||
 | 
					                Row(
 | 
				
			||||||
 | 
					                  mainAxisAlignment: MainAxisAlignment.end,
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    ElevatedButton.icon(
 | 
				
			||||||
 | 
					                      onPressed: _isBusy ? null : _performAction,
 | 
				
			||||||
 | 
					                      icon: const Icon(Symbols.save),
 | 
				
			||||||
 | 
					                      label: Text('apply').tr(),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ).padding(horizontal: 24 + 8),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										461
									
								
								lib/screens/realm/realm_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,461 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					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/sn_network.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/user_directory.dart';
 | 
				
			||||||
 | 
					import 'package:surface/providers/userinfo.dart';
 | 
				
			||||||
 | 
					import 'package:surface/types/realm.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/account/account_image.dart';
 | 
				
			||||||
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
 | 
					import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import '../../types/post.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RealmDetailScreen extends StatefulWidget {
 | 
				
			||||||
 | 
					  final String alias;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const RealmDetailScreen({super.key, required this.alias});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<RealmDetailScreen> createState() => _RealmDetailScreenState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
				
			||||||
 | 
					  SnRealm? _realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchRealm() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
 | 
				
			||||||
 | 
					      _realm = SnRealm.fromJson(resp.data);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<SnPublisher>? _publishers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchPublishers() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
 | 
				
			||||||
 | 
					      _publishers = List<SnPublisher>.from(
 | 
				
			||||||
 | 
					        resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (mounted) context.showErrorDialog(err);
 | 
				
			||||||
 | 
					      rethrow;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchRealm().then((_) {
 | 
				
			||||||
 | 
					      _fetchPublishers();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return DefaultTabController(
 | 
				
			||||||
 | 
					      length: 3,
 | 
				
			||||||
 | 
					      child: Scaffold(
 | 
				
			||||||
 | 
					        body: NestedScrollView(
 | 
				
			||||||
 | 
					          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
				
			||||||
 | 
					            // These are the slivers that show up in the "outer" scroll view.
 | 
				
			||||||
 | 
					            return <Widget>[
 | 
				
			||||||
 | 
					              SliverOverlapAbsorber(
 | 
				
			||||||
 | 
					                // This widget takes the overlapping behavior of the SliverAppBar,
 | 
				
			||||||
 | 
					                // and redirects it to the SliverOverlapInjector below. If it is
 | 
				
			||||||
 | 
					                // missing, then it is possible for the nested "inner" scroll view
 | 
				
			||||||
 | 
					                // below to end up under the SliverAppBar even when the inner
 | 
				
			||||||
 | 
					                // scroll view thinks it has not been scrolled.
 | 
				
			||||||
 | 
					                // This is not necessary if the "headerSliverBuilder" only builds
 | 
				
			||||||
 | 
					                // widgets that do not overlap the next sliver.
 | 
				
			||||||
 | 
					                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
				
			||||||
 | 
					                sliver: SliverAppBar(
 | 
				
			||||||
 | 
					                  title: Text(_realm?.name ?? 'loading'.tr()),
 | 
				
			||||||
 | 
					                  bottom: TabBar(
 | 
				
			||||||
 | 
					                    tabs: [
 | 
				
			||||||
 | 
					                      Tab(icon: const Icon(Symbols.home)),
 | 
				
			||||||
 | 
					                      Tab(icon: const Icon(Symbols.group)),
 | 
				
			||||||
 | 
					                      Tab(icon: const Icon(Symbols.settings)),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ];
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          body: TabBarView(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              _RealmDetailHomeWidget(realm: _realm, publishers: _publishers),
 | 
				
			||||||
 | 
					              _RealmMemberListWidget(realm: _realm),
 | 
				
			||||||
 | 
					              _RealmSettingsWidget(
 | 
				
			||||||
 | 
					                realm: _realm,
 | 
				
			||||||
 | 
					                onUpdate: () {
 | 
				
			||||||
 | 
					                  _fetchRealm();
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmDetailHomeWidget extends StatelessWidget {
 | 
				
			||||||
 | 
					  final SnRealm? realm;
 | 
				
			||||||
 | 
					  final List<SnPublisher>? publishers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _RealmDetailHomeWidget({super.key, required this.realm, this.publishers});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        const Gap(24),
 | 
				
			||||||
 | 
					        if (realm != null)
 | 
				
			||||||
 | 
					          Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                realm!.name,
 | 
				
			||||||
 | 
					                style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                realm!.description,
 | 
				
			||||||
 | 
					                style: Theme.of(context).textTheme.bodyMedium,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ).padding(horizontal: 24),
 | 
				
			||||||
 | 
					        const Gap(16),
 | 
				
			||||||
 | 
					        const Divider(),
 | 
				
			||||||
 | 
					        Expanded(
 | 
				
			||||||
 | 
					          child: ListView.builder(
 | 
				
			||||||
 | 
					            padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					            itemCount: publishers?.length ?? 0,
 | 
				
			||||||
 | 
					            itemBuilder: (context, idx) {
 | 
				
			||||||
 | 
					              final ele = publishers![idx];
 | 
				
			||||||
 | 
					              return ListTile(
 | 
				
			||||||
 | 
					                contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
				
			||||||
 | 
					                leading: AccountImage(
 | 
				
			||||||
 | 
					                  content: ele.avatar,
 | 
				
			||||||
 | 
					                  fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                title: Text(ele.nick),
 | 
				
			||||||
 | 
					                subtitle: Text('@${ele.name}'),
 | 
				
			||||||
 | 
					                trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                onTap: () {
 | 
				
			||||||
 | 
					                  GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                    'postPublisher',
 | 
				
			||||||
 | 
					                    pathParameters: {'name': ele.name},
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmMemberListWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnRealm? realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _RealmMemberListWidget({super.key, this.realm});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  int? _totalCount;
 | 
				
			||||||
 | 
					  final List<SnRealmMember> _members = List.empty(growable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _fetchMembers() async {
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
 | 
				
			||||||
 | 
					        'take': 10,
 | 
				
			||||||
 | 
					        'offset': 0,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final out = List<SnRealmMember>.from(
 | 
				
			||||||
 | 
					        resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await ud.listAccount(out.map((ele) => ele.accountId).toSet());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      _totalCount = resp.data['count'];
 | 
				
			||||||
 | 
					      _members.addAll(out);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isUpdating = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteMember(SnRealmMember member) async {
 | 
				
			||||||
 | 
					    if (_isUpdating) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isUpdating = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.delete(
 | 
				
			||||||
 | 
					        '/cgi/id/realms/${widget.realm!.alias}/members/${member.id}',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      _members.clear();
 | 
				
			||||||
 | 
					      _fetchMembers();
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isUpdating = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _showMemberAdd() {
 | 
				
			||||||
 | 
					    showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (context) => _NewRealmMemberWidget(
 | 
				
			||||||
 | 
					        realm: widget.realm!,
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _fetchMembers();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ud = context.read<UserDirectoryProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return CustomScrollView(
 | 
				
			||||||
 | 
					      slivers: [
 | 
				
			||||||
 | 
					        SliverToBoxAdapter(
 | 
				
			||||||
 | 
					          child: ListTile(
 | 
				
			||||||
 | 
					            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					            leading: const Icon(Symbols.group_add),
 | 
				
			||||||
 | 
					            trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					            title: Text('realmMemberAdd').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('realmMemberAddDescription').tr(),
 | 
				
			||||||
 | 
					            onTap: _showMemberAdd,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        SliverToBoxAdapter(child: const Divider(height: 1)),
 | 
				
			||||||
 | 
					        SliverInfiniteList(
 | 
				
			||||||
 | 
					          // padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					          itemCount: _members.length,
 | 
				
			||||||
 | 
					          isLoading: _isBusy,
 | 
				
			||||||
 | 
					          hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
 | 
				
			||||||
 | 
					          onFetchData: _fetchMembers,
 | 
				
			||||||
 | 
					          itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					            final member = _members[index];
 | 
				
			||||||
 | 
					            return ListTile(
 | 
				
			||||||
 | 
					              contentPadding: const EdgeInsets.only(right: 24, left: 16),
 | 
				
			||||||
 | 
					              leading: AccountImage(
 | 
				
			||||||
 | 
					                content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
				
			||||||
 | 
					                fallbackWidget: const Icon(Symbols.group, size: 24),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              title: Text(
 | 
				
			||||||
 | 
					                ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              subtitle: Text(
 | 
				
			||||||
 | 
					                ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              trailing: IconButton(
 | 
				
			||||||
 | 
					                icon: const Icon(Symbols.person_remove),
 | 
				
			||||||
 | 
					                onPressed: _isUpdating ? null : () => _deleteMember(member),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewRealmMemberWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnRealm realm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _NewRealmMemberWidget({super.key, required this.realm});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final TextEditingController _relatedController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _performAction() async {
 | 
				
			||||||
 | 
					    if (_relatedController.text.isEmpty) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					      await sn.client.post(
 | 
				
			||||||
 | 
					        '/cgi/id/realms/${widget.realm.alias}/members',
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          'related': _relatedController.text,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					      context.showSnackbar('channelMemberAdded'.tr());
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _relatedController.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return StyledWidget(Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Text(
 | 
				
			||||||
 | 
					          'realmMemberAdd',
 | 
				
			||||||
 | 
					          style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					        ).tr(),
 | 
				
			||||||
 | 
					        const Gap(12),
 | 
				
			||||||
 | 
					        TextField(
 | 
				
			||||||
 | 
					          controller: _relatedController,
 | 
				
			||||||
 | 
					          readOnly: _isBusy,
 | 
				
			||||||
 | 
					          autocorrect: false,
 | 
				
			||||||
 | 
					          autofocus: true,
 | 
				
			||||||
 | 
					          textCapitalization: TextCapitalization.none,
 | 
				
			||||||
 | 
					          decoration: InputDecoration(
 | 
				
			||||||
 | 
					            labelText: 'fieldMemberRelatedName'.tr(),
 | 
				
			||||||
 | 
					            suffix: SizedBox(
 | 
				
			||||||
 | 
					              height: 24,
 | 
				
			||||||
 | 
					              child: IconButton(
 | 
				
			||||||
 | 
					                onPressed: _isBusy ? null : () => _performAction(),
 | 
				
			||||||
 | 
					                icon: Icon(Symbols.send),
 | 
				
			||||||
 | 
					                visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
				
			||||||
 | 
					                padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    )).padding(all: 24);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmSettingsWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final SnRealm? realm;
 | 
				
			||||||
 | 
					  final Function() onUpdate;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const _RealmSettingsWidget({super.key, required this.realm, required this.onUpdate});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
 | 
				
			||||||
 | 
					  bool _isBusy = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _deleteRealm() async {
 | 
				
			||||||
 | 
					    final confirm = await context.showConfirmDialog(
 | 
				
			||||||
 | 
					      'realmDelete'.tr(args: ['#${widget.realm!.alias}']),
 | 
				
			||||||
 | 
					      'realmDeleteDescription'.tr(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (!confirm) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!mounted) return;
 | 
				
			||||||
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() => _isBusy = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}');
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      Navigator.pop(context, true);
 | 
				
			||||||
 | 
					      context.showSnackbar('realmDeleted'.tr(args: [
 | 
				
			||||||
 | 
					        '#${widget.realm!.alias}',
 | 
				
			||||||
 | 
					      ]));
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!mounted) return;
 | 
				
			||||||
 | 
					      context.showErrorDialog(err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setState(() => _isBusy = false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final ua = context.read<UserProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final isOwned = ua.isAuthorized && widget.realm?.accountId == ua.user?.id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        const Gap(16),
 | 
				
			||||||
 | 
					        ListTile(
 | 
				
			||||||
 | 
					          leading: const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					          title: Text('realmEdit').tr(),
 | 
				
			||||||
 | 
					          subtitle: Text('realmEditDescription').tr(),
 | 
				
			||||||
 | 
					          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					          onTap: () {
 | 
				
			||||||
 | 
					            GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					              'realmManage',
 | 
				
			||||||
 | 
					              queryParameters: {'editing': widget.realm!.alias},
 | 
				
			||||||
 | 
					            ).then((value) {
 | 
				
			||||||
 | 
					              if (value != null) {
 | 
				
			||||||
 | 
					                widget.onUpdate();
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        if (isOwned)
 | 
				
			||||||
 | 
					          ListTile(
 | 
				
			||||||
 | 
					            leading: const Icon(Symbols.delete),
 | 
				
			||||||
 | 
					            trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					            title: Text('realmActionDelete').tr(),
 | 
				
			||||||
 | 
					            subtitle: Text('realmActionDeleteDescription').tr(),
 | 
				
			||||||
 | 
					            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					            onTap: _isBusy ? null : _deleteRealm,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
 | 
				
			|||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
import 'package:image_picker/image_picker.dart';
 | 
					import 'package:image_picker/image_picker.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:path_provider/path_provider.dart';
 | 
					import 'package:path_provider/path_provider.dart';
 | 
				
			||||||
@@ -15,7 +16,6 @@ import 'package:surface/providers/sn_network.dart';
 | 
				
			|||||||
import 'package:surface/providers/theme.dart';
 | 
					import 'package:surface/providers/theme.dart';
 | 
				
			||||||
import 'package:surface/theme.dart';
 | 
					import 'package:surface/theme.dart';
 | 
				
			||||||
import 'package:surface/widgets/dialog.dart';
 | 
					import 'package:surface/widgets/dialog.dart';
 | 
				
			||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SettingsScreen extends StatefulWidget {
 | 
					class SettingsScreen extends StatefulWidget {
 | 
				
			||||||
  const SettingsScreen({super.key});
 | 
					  const SettingsScreen({super.key});
 | 
				
			||||||
@@ -42,8 +42,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
    SharedPreferences.getInstance().then((prefs) {
 | 
					    SharedPreferences.getInstance().then((prefs) {
 | 
				
			||||||
      setState(() {
 | 
					      setState(() {
 | 
				
			||||||
        _prefs = prefs;
 | 
					        _prefs = prefs;
 | 
				
			||||||
        _serverUrlController.text =
 | 
					        _serverUrlController.text = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
				
			||||||
            prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
 | 
					 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -58,7 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    final sn = context.read<SnNetworkProvider>();
 | 
					    final sn = context.read<SnNetworkProvider>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppScaffold(
 | 
					    return Scaffold(
 | 
				
			||||||
      body: SingleChildScrollView(
 | 
					      body: SingleChildScrollView(
 | 
				
			||||||
        child: Column(
 | 
					        child: Column(
 | 
				
			||||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
					          crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
@@ -66,11 +65,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsAppearance')
 | 
					                Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                    .bold()
 | 
					 | 
				
			||||||
                    .fontSize(17)
 | 
					 | 
				
			||||||
                    .tr()
 | 
					 | 
				
			||||||
                    .padding(horizontal: 20, bottom: 4),
 | 
					 | 
				
			||||||
                if (!kIsWeb)
 | 
					                if (!kIsWeb)
 | 
				
			||||||
                  ListTile(
 | 
					                  ListTile(
 | 
				
			||||||
                    title: Text('settingsBackgroundImage').tr(),
 | 
					                    title: Text('settingsBackgroundImage').tr(),
 | 
				
			||||||
@@ -79,20 +74,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                    leading: const Icon(Symbols.image),
 | 
					                    leading: const Icon(Symbols.image),
 | 
				
			||||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
					                    trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                    onTap: () async {
 | 
					                    onTap: () async {
 | 
				
			||||||
                      final image = await ImagePicker()
 | 
					                      final image = await ImagePicker().pickImage(source: ImageSource.gallery);
 | 
				
			||||||
                          .pickImage(source: ImageSource.gallery);
 | 
					 | 
				
			||||||
                      if (image == null) return;
 | 
					                      if (image == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                      await File(image.path)
 | 
					                      await File(image.path).copy('$_docBasepath/app_background_image');
 | 
				
			||||||
                          .copy('$_docBasepath/app_background_image');
 | 
					                      _prefs?.setBool('has_background_image', true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                      setState(() {});
 | 
					                      setState(() {});
 | 
				
			||||||
                    },
 | 
					                    },
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                if (!kIsWeb)
 | 
					                if (!kIsWeb)
 | 
				
			||||||
                  FutureBuilder<bool>(
 | 
					                  FutureBuilder<bool>(
 | 
				
			||||||
                      future:
 | 
					                      future: File('$_docBasepath/app_background_image').exists(),
 | 
				
			||||||
                          File('$_docBasepath/app_background_image').exists(),
 | 
					 | 
				
			||||||
                      builder: (context, snapshot) {
 | 
					                      builder: (context, snapshot) {
 | 
				
			||||||
                        if (!snapshot.hasData || !snapshot.data!) {
 | 
					                        if (!snapshot.hasData || !snapshot.data!) {
 | 
				
			||||||
                          return const SizedBox.shrink();
 | 
					                          return const SizedBox.shrink();
 | 
				
			||||||
@@ -100,16 +93,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                        return ListTile(
 | 
					                        return ListTile(
 | 
				
			||||||
                          title: Text('settingsBackgroundImageClear').tr(),
 | 
					                          title: Text('settingsBackgroundImageClear').tr(),
 | 
				
			||||||
                          subtitle:
 | 
					                          subtitle: Text('settingsBackgroundImageClearDescription').tr(),
 | 
				
			||||||
                              Text('settingsBackgroundImageClearDescription')
 | 
					                          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
                                  .tr(),
 | 
					 | 
				
			||||||
                          contentPadding:
 | 
					 | 
				
			||||||
                              const EdgeInsets.symmetric(horizontal: 24),
 | 
					 | 
				
			||||||
                          leading: const Icon(Symbols.texture),
 | 
					                          leading: const Icon(Symbols.texture),
 | 
				
			||||||
                          trailing: const Icon(Symbols.chevron_right),
 | 
					                          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
                          onTap: () {
 | 
					                          onTap: () {
 | 
				
			||||||
                            File('$_docBasepath/app_background_image')
 | 
					                            File('$_docBasepath/app_background_image').deleteSync();
 | 
				
			||||||
                                .deleteSync();
 | 
					                            _prefs?.remove('has_background_image');
 | 
				
			||||||
                            setState(() {});
 | 
					                            setState(() {});
 | 
				
			||||||
                          },
 | 
					                          },
 | 
				
			||||||
                        );
 | 
					                        );
 | 
				
			||||||
@@ -137,11 +127,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
            Column(
 | 
					            Column(
 | 
				
			||||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
              children: [
 | 
					              children: [
 | 
				
			||||||
                Text('settingsNetwork')
 | 
					                Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
                    .bold()
 | 
					 | 
				
			||||||
                    .fontSize(17)
 | 
					 | 
				
			||||||
                    .tr()
 | 
					 | 
				
			||||||
                    .padding(horizontal: 20, bottom: 4),
 | 
					 | 
				
			||||||
                TextField(
 | 
					                TextField(
 | 
				
			||||||
                  controller: _serverUrlController,
 | 
					                  controller: _serverUrlController,
 | 
				
			||||||
                  decoration: InputDecoration(
 | 
					                  decoration: InputDecoration(
 | 
				
			||||||
@@ -162,8 +148,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                      },
 | 
					                      },
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                  ),
 | 
					                  ),
 | 
				
			||||||
                  onTapOutside: (_) =>
 | 
					                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
				
			||||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
					 | 
				
			||||||
                ).padding(horizontal: 16, top: 8, bottom: 4),
 | 
					                ).padding(horizontal: 16, top: 8, bottom: 4),
 | 
				
			||||||
                ListTile(
 | 
					                ListTile(
 | 
				
			||||||
                  title: Text('settingsNetworkServerPreset').tr(),
 | 
					                  title: Text('settingsNetworkServerPreset').tr(),
 | 
				
			||||||
@@ -175,9 +160,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                      isExpanded: true,
 | 
					                      isExpanded: true,
 | 
				
			||||||
                      items: [
 | 
					                      items: [
 | 
				
			||||||
                        ...kNetworkServerDirectory,
 | 
					                        ...kNetworkServerDirectory,
 | 
				
			||||||
                        if (!kNetworkServerDirectory
 | 
					                        if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text))
 | 
				
			||||||
                            .map((ele) => ele.$2)
 | 
					 | 
				
			||||||
                            .contains(_serverUrlController.text))
 | 
					 | 
				
			||||||
                          ('Custom', _serverUrlController.text),
 | 
					                          ('Custom', _serverUrlController.text),
 | 
				
			||||||
                      ]
 | 
					                      ]
 | 
				
			||||||
                          .map(
 | 
					                          .map(
 | 
				
			||||||
@@ -189,8 +172,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
					                                crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
                                children: [
 | 
					                                children: [
 | 
				
			||||||
                                  Text(item.$1).fontSize(14),
 | 
					                                  Text(item.$1).fontSize(14),
 | 
				
			||||||
                                  Text(item.$2, overflow: TextOverflow.ellipsis)
 | 
					                                  Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11)
 | 
				
			||||||
                                      .fontSize(11)
 | 
					 | 
				
			||||||
                                ],
 | 
					                                ],
 | 
				
			||||||
                              ),
 | 
					                              ),
 | 
				
			||||||
                            ),
 | 
					                            ),
 | 
				
			||||||
@@ -210,7 +192,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                          vertical: 5,
 | 
					                          vertical: 5,
 | 
				
			||||||
                        ),
 | 
					                        ),
 | 
				
			||||||
                        height: 40,
 | 
					                        height: 40,
 | 
				
			||||||
                        width: 140,
 | 
					                        width: 160,
 | 
				
			||||||
                      ),
 | 
					                      ),
 | 
				
			||||||
                      menuItemStyleData: const MenuItemStyleData(
 | 
					                      menuItemStyleData: const MenuItemStyleData(
 | 
				
			||||||
                        height: 60,
 | 
					                        height: 60,
 | 
				
			||||||
@@ -233,6 +215,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ],
 | 
					              ],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
 | 
					            Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
				
			||||||
 | 
					                ListTile(
 | 
				
			||||||
 | 
					                  title: Text('settingsMiscAbout').tr(),
 | 
				
			||||||
 | 
					                  subtitle: Text('settingsMiscAboutDescription').tr(),
 | 
				
			||||||
 | 
					                  contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
				
			||||||
 | 
					                  leading: const Icon(Symbols.info),
 | 
				
			||||||
 | 
					                  trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                  onTap: () async {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed('about');
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
          ].expand((ele) => [ele, const Gap(16)]).toList(),
 | 
					          ].expand((ele) => [ele, const Gap(16)]).toList(),
 | 
				
			||||||
        ).padding(vertical: 20),
 | 
					        ).padding(vertical: 20),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 
 | 
				
			|||||||