Compare commits
	
		
			95 Commits
		
	
	
		
			2.0.0+4
			...
			4a9ccc7c7a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
@@ -1,5 +1,9 @@
 | 
			
		||||
plugins {
 | 
			
		||||
    id "com.android.application"
 | 
			
		||||
    // START: FlutterFire Configuration
 | 
			
		||||
    id 'com.google.gms.google-services'
 | 
			
		||||
    id 'com.google.firebase.crashlytics'
 | 
			
		||||
    // END: FlutterFire Configuration
 | 
			
		||||
    id "kotlin-android"
 | 
			
		||||
    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
 | 
			
		||||
    id "dev.flutter.flutter-gradle-plugin"
 | 
			
		||||
@@ -8,15 +12,15 @@ plugins {
 | 
			
		||||
android {
 | 
			
		||||
    namespace = "dev.solsynth.solian"
 | 
			
		||||
    compileSdk = flutter.compileSdkVersion
 | 
			
		||||
    ndkVersion = flutter.ndkVersion
 | 
			
		||||
    ndkVersion = "27.0.12077973"
 | 
			
		||||
 | 
			
		||||
    compileOptions {
 | 
			
		||||
        sourceCompatibility = JavaVersion.VERSION_1_8
 | 
			
		||||
        targetCompatibility = JavaVersion.VERSION_1_8
 | 
			
		||||
      sourceCompatibility JavaVersion.VERSION_17
 | 
			
		||||
      targetCompatibility JavaVersion.VERSION_17
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    kotlinOptions {
 | 
			
		||||
        jvmTarget = JavaVersion.VERSION_1_8
 | 
			
		||||
        jvmTarget = JavaVersion.VERSION_17
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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,21 @@
 | 
			
		||||
<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" />
 | 
			
		||||
 | 
			
		||||
    <application
 | 
			
		||||
        android:label="surface"
 | 
			
		||||
        android:label="Solian"
 | 
			
		||||
        android:name="${applicationName}"
 | 
			
		||||
        android:icon="@mipmap/ic_launcher">
 | 
			
		||||
        android:icon="@mipmap/ic_launcher"
 | 
			
		||||
        android:requestLegacyExternalStorage="true">
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name=".MainActivity"
 | 
			
		||||
            android:exported="true"
 | 
			
		||||
@@ -17,12 +30,12 @@
 | 
			
		||||
                 while the Flutter UI initializes. After that, this theme continues
 | 
			
		||||
                 to determine the Window background behind the Flutter UI. -->
 | 
			
		||||
            <meta-data
 | 
			
		||||
              android:name="io.flutter.embedding.android.NormalTheme"
 | 
			
		||||
              android:resource="@style/NormalTheme"
 | 
			
		||||
              />
 | 
			
		||||
                android:name="io.flutter.embedding.android.NormalTheme"
 | 
			
		||||
                android:resource="@style/NormalTheme"
 | 
			
		||||
            />
 | 
			
		||||
            <intent-filter>
 | 
			
		||||
                <action android:name="android.intent.action.MAIN"/>
 | 
			
		||||
                <category android:name="android.intent.category.LAUNCHER"/>
 | 
			
		||||
                <action android:name="android.intent.action.MAIN" />
 | 
			
		||||
                <category android:name="android.intent.category.LAUNCHER" />
 | 
			
		||||
            </intent-filter>
 | 
			
		||||
        </activity>
 | 
			
		||||
        <!-- Don't delete the meta-data below.
 | 
			
		||||
@@ -38,8 +51,8 @@
 | 
			
		||||
         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
 | 
			
		||||
    <queries>
 | 
			
		||||
        <intent>
 | 
			
		||||
            <action android:name="android.intent.action.PROCESS_TEXT"/>
 | 
			
		||||
            <data android:mimeType="text/plain"/>
 | 
			
		||||
            <action android:name="android.intent.action.PROCESS_TEXT" />
 | 
			
		||||
            <data android:mimeType="text/plain" />
 | 
			
		||||
        </intent>
 | 
			
		||||
    </queries>
 | 
			
		||||
</manifest>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB  | 
@@ -1,6 +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"/>
 | 
			
		||||
  <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
 | 
			
		||||
    <background android:drawable="@color/ic_launcher_background"/>
 | 
			
		||||
    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
 | 
			
		||||
</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
 | 
			
		||||
zipStoreBase=GRADLE_USER_HOME
 | 
			
		||||
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 {
 | 
			
		||||
    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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/icon/icon-w-padding.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 44 KiB  | 
@@ -17,6 +17,14 @@
 | 
			
		||||
  "screenSettings": "Settings",
 | 
			
		||||
  "screenAlbum": "Album",
 | 
			
		||||
  "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",
 | 
			
		||||
  "dialogCancel": "Cancel",
 | 
			
		||||
  "dialogConfirm": "Confirm",
 | 
			
		||||
@@ -28,10 +36,12 @@
 | 
			
		||||
  "errorRequestNotFound": "The resource that you looking for is not found.",
 | 
			
		||||
  "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.",
 | 
			
		||||
  "unknown": "Unknown",
 | 
			
		||||
  "prev": "Previous",
 | 
			
		||||
  "next": "Next",
 | 
			
		||||
  "edit": "Edit",
 | 
			
		||||
  "apply": "Apply",
 | 
			
		||||
  "cancel": "Cancel",
 | 
			
		||||
  "create": "Create",
 | 
			
		||||
  "preview": "Preview",
 | 
			
		||||
  "loading": "Loading...",
 | 
			
		||||
@@ -41,11 +51,33 @@
 | 
			
		||||
  "compress": "Compress",
 | 
			
		||||
  "report": "Report",
 | 
			
		||||
  "repost": "Repost",
 | 
			
		||||
  "replyPost": "Reply",
 | 
			
		||||
  "reply": "Reply",
 | 
			
		||||
  "unset": "Unset",
 | 
			
		||||
  "untitled": "Untitled",
 | 
			
		||||
  "postDetail": "Post detail",
 | 
			
		||||
  "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",
 | 
			
		||||
  "fieldNickname": "Nickname",
 | 
			
		||||
  "fieldEmail": "Email address",
 | 
			
		||||
@@ -81,12 +113,25 @@
 | 
			
		||||
  "publishersNew": "New Publisher",
 | 
			
		||||
  "publisherNewSubtitle": "Create a new publisher identity.",
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "publisherRunBy": "Run by {}",
 | 
			
		||||
  "fieldPublisherBelongToRealm": "Belongs to",
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
			
		||||
  "writePostTypeStory": "Post a story",
 | 
			
		||||
  "writePostTypeArticle": "Write an article",
 | 
			
		||||
  "fieldPostPublisher": "Post publisher",
 | 
			
		||||
  "fieldPostContent": "What happened?!",
 | 
			
		||||
  "fieldPostTitle": "Title",
 | 
			
		||||
  "fieldPostDescription": "Description",
 | 
			
		||||
  "fieldPostTags": "Tags",
 | 
			
		||||
  "postPublish": "Publish",
 | 
			
		||||
  "postPosted": "Post has been posted.",
 | 
			
		||||
  "postPublishedAt": "Published At",
 | 
			
		||||
@@ -96,10 +141,20 @@
 | 
			
		||||
  "postRepostingNotice": "You're about to repost a post that posted {}.",
 | 
			
		||||
  "postReact": "React",
 | 
			
		||||
  "postReactions": "Reactions of Post",
 | 
			
		||||
  "postReactionPoints": {
 | 
			
		||||
    "zero": "{} pt",
 | 
			
		||||
    "one": "{} pt",
 | 
			
		||||
    "other": "{} pts"
 | 
			
		||||
  "postReactionUpvote": {
 | 
			
		||||
    "zero": "0 upvote",
 | 
			
		||||
    "one": "{} upvote",
 | 
			
		||||
    "other": "{} upvotes"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactionDownvote": {
 | 
			
		||||
    "zero": "0 downvote",
 | 
			
		||||
    "one": "{} downvote",
 | 
			
		||||
    "other": "{} downvotes"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactionSocialPoint": {
 | 
			
		||||
    "zero": "0 point",
 | 
			
		||||
    "one": "{} point",
 | 
			
		||||
    "other": "{} points"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactCompleted": "Reaction has been added.",
 | 
			
		||||
  "postReactUncompleted": "Reaction has been removed.",
 | 
			
		||||
@@ -133,5 +188,204 @@
 | 
			
		||||
  "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
 | 
			
		||||
  "sensitiveContentReveal": "Reveal",
 | 
			
		||||
  "serverConnecting": "Connecting to server...",
 | 
			
		||||
  "serverDisconnected": "Lost connection from 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 {}",
 | 
			
		||||
  "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",
 | 
			
		||||
  "attachmentPastedImage": "Pasted Image",
 | 
			
		||||
  "attachmentInsertLink": "Insert Link",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
 | 
			
		||||
  "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
 | 
			
		||||
  "attachmentSetThumbnail": "Set thumbnail",
 | 
			
		||||
  "attachmentUpload": "Upload",
 | 
			
		||||
  "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 LLC Staff",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network Native",
 | 
			
		||||
  "accountStatus": "Status",
 | 
			
		||||
  "accountStatusOnline": "Online",
 | 
			
		||||
  "accountStatusOffline": "Offline",
 | 
			
		||||
  "accountStatusLastSeen": "Last seen at {}",
 | 
			
		||||
  "postArticle": "Article on the Solar Network",
 | 
			
		||||
  "articleWrittenAt": "Written at {}",
 | 
			
		||||
  "articleEditedAt": "Edited at {}",
 | 
			
		||||
  "attachmentSaved": "Saved to album",
 | 
			
		||||
  "openInAlbum": "Open in album"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,14 @@
 | 
			
		||||
  "screenSettings": "设置",
 | 
			
		||||
  "screenAlbum": "相册",
 | 
			
		||||
  "screenChat": "聊天",
 | 
			
		||||
  "screenChatManage": "编辑聊天频道",
 | 
			
		||||
  "screenChatNew": "新建聊天频道",
 | 
			
		||||
  "screenRealm": "领域",
 | 
			
		||||
  "screenRealmManage": "编辑领域",
 | 
			
		||||
  "screenRealmNew": "新建领域",
 | 
			
		||||
  "screenNotification": "通知",
 | 
			
		||||
  "screenPostSearch": "搜索帖子",
 | 
			
		||||
  "screenFriend": "好友",
 | 
			
		||||
  "dialogOkay": "好的",
 | 
			
		||||
  "dialogCancel": "取消",
 | 
			
		||||
  "dialogConfirm": "确认",
 | 
			
		||||
@@ -28,11 +36,13 @@
 | 
			
		||||
  "errorRequestNotFound": "您正查找的资源无法被找到。",
 | 
			
		||||
  "errorRequestConnection": "网络连接错误,请检查您的网络状态或者检查我们的服务状态。",
 | 
			
		||||
  "errorRequestUnknown": "位置请求错误,您可能想将此对话框截图并发送给我们。",
 | 
			
		||||
  "unknown": "未知",
 | 
			
		||||
  "loading": "加载中…",
 | 
			
		||||
  "prev": "上一步",
 | 
			
		||||
  "next": "下一步",
 | 
			
		||||
  "edit": "编辑",
 | 
			
		||||
  "apply": "应用",
 | 
			
		||||
  "cancel": "取消",
 | 
			
		||||
  "create": "创建",
 | 
			
		||||
  "preview": "预览",
 | 
			
		||||
  "delete": "删除",
 | 
			
		||||
@@ -41,11 +51,19 @@
 | 
			
		||||
  "compress": "压缩",
 | 
			
		||||
  "report": "检举",
 | 
			
		||||
  "repost": "转帖",
 | 
			
		||||
  "reply": "回贴",
 | 
			
		||||
  "replyPost": "回贴",
 | 
			
		||||
  "reply": "回复",
 | 
			
		||||
  "unset": "未设置",
 | 
			
		||||
  "untitled": "无题",
 | 
			
		||||
  "postDetail": "帖子详情",
 | 
			
		||||
  "postNoun": "帖子",
 | 
			
		||||
  "postReadMore": "阅读更多",
 | 
			
		||||
  "postReadEstimate": "预计花费 {} 阅读",
 | 
			
		||||
  "postTotalLength": {
 | 
			
		||||
    "zero": "没有内容",
 | 
			
		||||
    "one": "总计 {} 字",
 | 
			
		||||
    "other": "总计 {} 字"
 | 
			
		||||
  },
 | 
			
		||||
  "fieldUsername": "用户名",
 | 
			
		||||
  "fieldNickname": "显示名",
 | 
			
		||||
  "fieldEmail": "电子邮箱地址",
 | 
			
		||||
@@ -81,25 +99,62 @@
 | 
			
		||||
  "publishersNew": "新发布者",
 | 
			
		||||
  "publisherNewSubtitle": "创建一个新的公共身份。",
 | 
			
		||||
  "publisherSyncWithAccount": "同步账户信息",
 | 
			
		||||
  "publisherTotalUpvote": "总顶数",
 | 
			
		||||
  "publisherTotalDownvote": "总踩数",
 | 
			
		||||
  "publisherSocialPoint": "社会信用点",
 | 
			
		||||
  "publisherJoinedAt": "加入于 {}",
 | 
			
		||||
  "publisherSocialPointTotal": {
 | 
			
		||||
    "zero": "无社会信用点",
 | 
			
		||||
    "one": "{} 点社会信用点",
 | 
			
		||||
    "other": "{} 点社会信用点"
 | 
			
		||||
  },
 | 
			
		||||
  "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": "帖子的反应",
 | 
			
		||||
  "postReactionPoints": {
 | 
			
		||||
    "zero": "{} 点",
 | 
			
		||||
    "one": "{} 点",
 | 
			
		||||
    "other": "{} 点"
 | 
			
		||||
  "postReactionUpvote": {
 | 
			
		||||
    "zero": "0 个顶",
 | 
			
		||||
    "one": "{} 个顶",
 | 
			
		||||
    "other": "{} 个顶"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactionDownvote": {
 | 
			
		||||
    "zero": "0 个踩",
 | 
			
		||||
    "one": "{} 个踩",
 | 
			
		||||
    "other": "{} 个踩"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactionSocialPoint": {
 | 
			
		||||
    "zero": "无社会信用点变更",
 | 
			
		||||
    "one": "{} 点社会信用点变更",
 | 
			
		||||
    "other": "{} 点社会信用点变更"
 | 
			
		||||
  },
 | 
			
		||||
  "postReactCompleted": "反应已被添加。",
 | 
			
		||||
  "postReactUncompleted": "反应已被移除。",
 | 
			
		||||
@@ -133,5 +188,204 @@
 | 
			
		||||
  "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
 | 
			
		||||
  "sensitiveContentReveal": "显示内容",
 | 
			
		||||
  "serverConnecting": "正在连接服务器…",
 | 
			
		||||
  "serverDisconnected": "已与服务器断开连接"
 | 
			
		||||
  "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": "在 {} 中发消息",
 | 
			
		||||
  "eventResourceTag": "消息 {}",
 | 
			
		||||
  "messageDelete": "删除消息 {}",
 | 
			
		||||
  "messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。",
 | 
			
		||||
  "messageDeleted": "消息 {} 已被删除",
 | 
			
		||||
  "messageEdited": "消息 {} 已被编辑",
 | 
			
		||||
  "messageEditedHint": "已编辑",
 | 
			
		||||
  "messageUnsupported": "不支持的消息 {}",
 | 
			
		||||
  "messageFileHint": {
 | 
			
		||||
    "zero": "没有附件",
 | 
			
		||||
    "one": "{} 个附件",
 | 
			
		||||
    "other": "{} 个附件"
 | 
			
		||||
  },
 | 
			
		||||
  "addAttachmentFromAlbum": "从相册中添加附件",
 | 
			
		||||
  "addAttachmentFromClipboard": "粘贴附件",
 | 
			
		||||
  "attachmentPastedImage": "粘贴的图片",
 | 
			
		||||
  "attachmentInsertLink": "插入连接",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
 | 
			
		||||
  "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
 | 
			
		||||
  "attachmentSetThumbnail": "设置缩略图",
 | 
			
		||||
  "attachmentUpload": "上传",
 | 
			
		||||
  "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": "索尔辛茨士大夫 · Staff",
 | 
			
		||||
  "badgeSiteMigration": "Solar Network 原住民",
 | 
			
		||||
  "accountStatus": "状态",
 | 
			
		||||
  "accountStatusOnline": "在线",
 | 
			
		||||
  "accountStatusOffline": "离线",
 | 
			
		||||
  "accountStatusLastSeen": "最后一次在 {} 上线",
 | 
			
		||||
  "postArticle": "Solar Network 上的文章",
 | 
			
		||||
  "articleWrittenAt": "发表于 {}",
 | 
			
		||||
  "articleEditedAt": "编辑于 {}",
 | 
			
		||||
  "attachmentSaved": "已保存到相册",
 | 
			
		||||
  "openInAlbum": "在相册中打开"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
# platform :ios, '12.0'
 | 
			
		||||
platform :ios, '13.0'
 | 
			
		||||
 | 
			
		||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 | 
			
		||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
 | 
			
		||||
@@ -40,5 +40,9 @@ end
 | 
			
		||||
post_install do |installer|
 | 
			
		||||
  installer.pods_project.targets.each do |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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										274
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						@@ -6,6 +6,8 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - cupertino_http (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - device_info_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - DKImagePickerController/Core (4.3.9):
 | 
			
		||||
    - DKImagePickerController/ImageDataManager
 | 
			
		||||
    - DKImagePickerController/Resource
 | 
			
		||||
@@ -40,19 +42,164 @@ PODS:
 | 
			
		||||
  - file_picker (0.0.1):
 | 
			
		||||
    - DKImagePickerController/PhotoGallery
 | 
			
		||||
    - 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.5.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_native_splash (0.0.1):
 | 
			
		||||
  - flutter_native_splash (2.4.3):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_secure_storage (6.0.0):
 | 
			
		||||
  - flutter_udid (0.0.1):
 | 
			
		||||
    - 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):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - livekit_client (2.3.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - 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):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - SDWebImage (5.19.7):
 | 
			
		||||
    - SDWebImage/Core (= 5.19.7)
 | 
			
		||||
  - SDWebImage/Core (5.19.7)
 | 
			
		||||
  - permission_handler_apple (9.3.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - 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)
 | 
			
		||||
  - Sentry/HybridSDK (8.40.1)
 | 
			
		||||
  - sentry_flutter (8.10.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - Sentry/HybridSDK (= 8.40.1)
 | 
			
		||||
  - shared_preferences_foundation (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
@@ -62,27 +209,63 @@ PODS:
 | 
			
		||||
  - SwiftyGif (5.4.5)
 | 
			
		||||
  - url_launcher_ios (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - volume_controller (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - wakelock_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - WebRTC-SDK (125.6422.06)
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
 | 
			
		||||
  - 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`)
 | 
			
		||||
  - 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_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`)
 | 
			
		||||
  - 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`)
 | 
			
		||||
  - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
 | 
			
		||||
  - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
 | 
			
		||||
  - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
 | 
			
		||||
  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
			
		||||
  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
			
		||||
  - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
 | 
			
		||||
  - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
 | 
			
		||||
  - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
 | 
			
		||||
 | 
			
		||||
SPEC REPOS:
 | 
			
		||||
  trunk:
 | 
			
		||||
    - DKImagePickerController
 | 
			
		||||
    - DKPhotoGallery
 | 
			
		||||
    - Firebase
 | 
			
		||||
    - FirebaseAnalytics
 | 
			
		||||
    - FirebaseCore
 | 
			
		||||
    - FirebaseCoreInternal
 | 
			
		||||
    - FirebaseInstallations
 | 
			
		||||
    - FirebaseMessaging
 | 
			
		||||
    - GoogleAppMeasurement
 | 
			
		||||
    - GoogleDataTransport
 | 
			
		||||
    - GoogleUtilities
 | 
			
		||||
    - nanopb
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
    - SDWebImage
 | 
			
		||||
    - Sentry
 | 
			
		||||
    - SwiftyGif
 | 
			
		||||
    - WebRTC-SDK
 | 
			
		||||
 | 
			
		||||
EXTERNAL SOURCES:
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
@@ -91,43 +274,108 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
			
		||||
  cupertino_http:
 | 
			
		||||
    :path: ".symlinks/plugins/cupertino_http/ios"
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
			
		||||
  file_picker:
 | 
			
		||||
    :path: ".symlinks/plugins/file_picker/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:
 | 
			
		||||
    :path: Flutter
 | 
			
		||||
  flutter_native_splash:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
			
		||||
  flutter_secure_storage:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_secure_storage/ios"
 | 
			
		||||
  flutter_udid:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_udid/ios"
 | 
			
		||||
  flutter_webrtc:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_webrtc/ios"
 | 
			
		||||
  gal:
 | 
			
		||||
    :path: ".symlinks/plugins/gal/darwin"
 | 
			
		||||
  image_picker_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: ".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"
 | 
			
		||||
  sentry_flutter:
 | 
			
		||||
    :path: ".symlinks/plugins/sentry_flutter/ios"
 | 
			
		||||
  shared_preferences_foundation:
 | 
			
		||||
    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
			
		||||
  sqflite_darwin:
 | 
			
		||||
    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
			
		||||
  url_launcher_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:
 | 
			
		||||
  connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
 | 
			
		||||
  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
			
		||||
  cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
 | 
			
		||||
  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
			
		||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
			
		||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
			
		||||
  file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
 | 
			
		||||
  Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
 | 
			
		||||
  firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75
 | 
			
		||||
  firebase_core: 418aed674e9a0b8b6088aec16cde82a811f6261f
 | 
			
		||||
  firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9
 | 
			
		||||
  FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
 | 
			
		||||
  FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
 | 
			
		||||
  FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604
 | 
			
		||||
  FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
 | 
			
		||||
  FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
 | 
			
		||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
			
		||||
  flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
 | 
			
		||||
  flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
 | 
			
		||||
  flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
 | 
			
		||||
  flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
 | 
			
		||||
  flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
 | 
			
		||||
  gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
 | 
			
		||||
  GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
			
		||||
  livekit_client: dbb906ef427fe96dde5854471c3dda0a50cc15f9
 | 
			
		||||
  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
 | 
			
		||||
  SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
 | 
			
		||||
  permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
 | 
			
		||||
  PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
 | 
			
		||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
			
		||||
  screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
 | 
			
		||||
  SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
 | 
			
		||||
  Sentry: e9215d7b17f7902692b4f8700e061e4f853e3521
 | 
			
		||||
  sentry_flutter: 927eed60d66951d1b0f1db37fe94ff5cb7c80231
 | 
			
		||||
  shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
 | 
			
		||||
  sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
 | 
			
		||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
			
		||||
  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
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@
 | 
			
		||||
		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
 | 
			
		||||
		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
 | 
			
		||||
		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 */; };
 | 
			
		||||
		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 | 
			
		||||
		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
 | 
			
		||||
@@ -53,6 +54,7 @@
 | 
			
		||||
		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>"; };
 | 
			
		||||
		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>"; };
 | 
			
		||||
		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
 | 
			
		||||
		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 | 
			
		||||
		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
@@ -64,6 +66,7 @@
 | 
			
		||||
		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>"; };
 | 
			
		||||
		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; };
 | 
			
		||||
/* End PBXFileReference section */
 | 
			
		||||
 | 
			
		||||
@@ -124,6 +127,7 @@
 | 
			
		||||
				331C8082294A63A400263BE5 /* RunnerTests */,
 | 
			
		||||
				F5165E3BD1F2519F85CD4BE2 /* Pods */,
 | 
			
		||||
				09229EB4EB35A0678AB9738D /* Frameworks */,
 | 
			
		||||
				A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */,
 | 
			
		||||
			);
 | 
			
		||||
			sourceTree = "<group>";
 | 
			
		||||
		};
 | 
			
		||||
@@ -139,6 +143,7 @@
 | 
			
		||||
		97C146F01CF9000F007C117D /* Runner */ = {
 | 
			
		||||
			isa = PBXGroup;
 | 
			
		||||
			children = (
 | 
			
		||||
				73111C212CEE3D5E004CF4B3 /* Runner.entitlements */,
 | 
			
		||||
				97C146FA1CF9000F007C117D /* Main.storyboard */,
 | 
			
		||||
				97C146FD1CF9000F007C117D /* Assets.xcassets */,
 | 
			
		||||
				97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
 | 
			
		||||
@@ -198,6 +203,8 @@
 | 
			
		||||
				9705A1C41CF9048500538489 /* Embed Frameworks */,
 | 
			
		||||
				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
 | 
			
		||||
				FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */,
 | 
			
		||||
				244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
 | 
			
		||||
				43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */,
 | 
			
		||||
			);
 | 
			
		||||
			buildRules = (
 | 
			
		||||
			);
 | 
			
		||||
@@ -263,12 +270,31 @@
 | 
			
		||||
				3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
 | 
			
		||||
				97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
 | 
			
		||||
				97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
 | 
			
		||||
				8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */,
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
/* End PBXResourcesBuildPhase 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 */ = {
 | 
			
		||||
			isa = PBXShellScriptBuildPhase;
 | 
			
		||||
			alwaysOutOfDate = 1;
 | 
			
		||||
@@ -285,6 +311,23 @@
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			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 */ = {
 | 
			
		||||
			isa = PBXShellScriptBuildPhase;
 | 
			
		||||
			alwaysOutOfDate = 1;
 | 
			
		||||
@@ -469,11 +512,13 @@
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				CLANG_ENABLE_MODULES = YES;
 | 
			
		||||
				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
@@ -653,11 +698,13 @@
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				CLANG_ENABLE_MODULES = YES;
 | 
			
		||||
				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
@@ -677,11 +724,13 @@
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				CLANG_ENABLE_MODULES = YES;
 | 
			
		||||
				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>CADisableMinimumFrameDurationOnPhone</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>CFBundleDevelopmentRegion</key>
 | 
			
		||||
	<string>$(DEVELOPMENT_LANGUAGE)</string>
 | 
			
		||||
	<key>CFBundleDisplayName</key>
 | 
			
		||||
@@ -12,6 +14,11 @@
 | 
			
		||||
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
 | 
			
		||||
	<key>CFBundleInfoDictionaryVersion</key>
 | 
			
		||||
	<string>6.0</string>
 | 
			
		||||
	<key>CFBundleLocalizations</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>en</string>
 | 
			
		||||
		<string>zh_CN</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>CFBundleName</key>
 | 
			
		||||
	<string>Solian</string>
 | 
			
		||||
	<key>CFBundlePackageType</key>
 | 
			
		||||
@@ -22,12 +29,33 @@
 | 
			
		||||
	<string>????</string>
 | 
			
		||||
	<key>CFBundleVersion</key>
 | 
			
		||||
	<string>$(FLUTTER_BUILD_NUMBER)</string>
 | 
			
		||||
	<key>ITSAppUsesNonExemptEncryption</key>
 | 
			
		||||
	<false/>
 | 
			
		||||
	<key>LSRequiresIPhoneOS</key>
 | 
			
		||||
	<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>NSPhotoLibraryUsageDescription</key>
 | 
			
		||||
	<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
 | 
			
		||||
	<key>NSPhotoLibraryAddUsageDescription</key>
 | 
			
		||||
	<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
 | 
			
		||||
	<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>
 | 
			
		||||
	<string>LaunchScreen</string>
 | 
			
		||||
	<key>UIMainStoryboardFile</key>
 | 
			
		||||
	<string>Main</string>
 | 
			
		||||
	<key>UIStatusBarHidden</key>
 | 
			
		||||
	<false/>
 | 
			
		||||
	<key>UISupportedInterfaceOrientations</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>UIInterfaceOrientationPortrait</string>
 | 
			
		||||
@@ -41,24 +69,5 @@
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeRight</string>
 | 
			
		||||
	</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>
 | 
			
		||||
</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>
 | 
			
		||||
							
								
								
									
										433
									
								
								lib/controllers/chat_message_controller.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,433 @@
 | 
			
		||||
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}',
 | 
			
		||||
      );
 | 
			
		||||
      messages.removeWhere((x) => x.id == 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:mime/mime.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
@@ -27,6 +28,8 @@ class PostWriteMedia {
 | 
			
		||||
  final XFile? file;
 | 
			
		||||
  final Uint8List? raw;
 | 
			
		||||
 | 
			
		||||
  PostWriteMedia? thumbnail;
 | 
			
		||||
 | 
			
		||||
  PostWriteMedia(this.attachment, {this.file, this.raw}) {
 | 
			
		||||
    name = attachment!.name;
 | 
			
		||||
 | 
			
		||||
@@ -66,8 +69,7 @@ class PostWriteMedia {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  PostWriteMedia.fromBytes(this.raw, this.name, this.type,
 | 
			
		||||
      {this.attachment, this.file});
 | 
			
		||||
  PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
 | 
			
		||||
 | 
			
		||||
  bool get isEmpty => attachment == null && file == null && raw == null;
 | 
			
		||||
 | 
			
		||||
@@ -86,7 +88,10 @@ class PostWriteMedia {
 | 
			
		||||
    if (file != null) {
 | 
			
		||||
      return file!;
 | 
			
		||||
    } else if (raw != null) {
 | 
			
		||||
      return XFile.fromData(raw!, name: name);
 | 
			
		||||
      return XFile.fromData(
 | 
			
		||||
        raw!,
 | 
			
		||||
        name: name,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
@@ -98,8 +103,7 @@ class PostWriteMedia {
 | 
			
		||||
  }) {
 | 
			
		||||
    if (attachment != null) {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ImageProvider provider =
 | 
			
		||||
          UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
			
		||||
      final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
 | 
			
		||||
      if (width != null && height != null) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -110,8 +114,7 @@ class PostWriteMedia {
 | 
			
		||||
      }
 | 
			
		||||
      return provider;
 | 
			
		||||
    } else if (file != null) {
 | 
			
		||||
      final ImageProvider provider =
 | 
			
		||||
          kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
			
		||||
      final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
 | 
			
		||||
      if (width != null && height != null) {
 | 
			
		||||
        return ResizeImage(
 | 
			
		||||
          provider,
 | 
			
		||||
@@ -158,9 +161,10 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  String mode = kTitleMap.keys.first;
 | 
			
		||||
 | 
			
		||||
  String get title => titleController.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;
 | 
			
		||||
  double? progress;
 | 
			
		||||
@@ -168,6 +172,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  SnPublisher? publisher;
 | 
			
		||||
  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);
 | 
			
		||||
  DateTime? publishedAt, publishedUntil;
 | 
			
		||||
 | 
			
		||||
@@ -177,53 +186,41 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    int? reposting,
 | 
			
		||||
    int? replying,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
 | 
			
		||||
    isLoading = true;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (editing != null) {
 | 
			
		||||
        final resp = await sn.client.get('/cgi/co/posts/$editing');
 | 
			
		||||
        final post = SnPost.fromJson(resp.data);
 | 
			
		||||
        final alts = await attach
 | 
			
		||||
            .getMultiple(post.body['attachments']?.cast<String>() ?? []);
 | 
			
		||||
        final post = await pt.getPost(editing);
 | 
			
		||||
        publisher = post.publisher;
 | 
			
		||||
        titleController.text = post.body['title'] ?? '';
 | 
			
		||||
        descriptionController.text = post.body['description'] ?? '';
 | 
			
		||||
        contentController.text = post.body['content'] ?? '';
 | 
			
		||||
        publishedAt = post.publishedAt;
 | 
			
		||||
        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(
 | 
			
		||||
          preload: SnPostPreload(
 | 
			
		||||
            attachments: alts,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
 | 
			
		||||
          thumbnail = PostWriteMedia(post.preload!.thumbnail);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        editingPost = post;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (replying != null) {
 | 
			
		||||
        final resp = await sn.client.get('/cgi/co/posts/$replying');
 | 
			
		||||
        final post = SnPost.fromJson(resp.data);
 | 
			
		||||
        replyingPost = post.copyWith(
 | 
			
		||||
          preload: SnPostPreload(
 | 
			
		||||
            attachments: await attach
 | 
			
		||||
                .getMultiple(post.body['attachments']?.cast<String>() ?? []),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        final post = await pt.getPost(replying);
 | 
			
		||||
        replyingPost = post;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (reposting != null) {
 | 
			
		||||
        final resp = await sn.client.get('/cgi/co/posts/$reposting');
 | 
			
		||||
        final post = SnPost.fromJson(resp.data);
 | 
			
		||||
        repostingPost = post.copyWith(
 | 
			
		||||
          preload: SnPostPreload(
 | 
			
		||||
            attachments: await attach
 | 
			
		||||
                .getMultiple(post.body['attachments']?.cast<String>() ?? []),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        final post = await pt.getPost(reposting);
 | 
			
		||||
        repostingPost = post;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      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 {
 | 
			
		||||
    if (isBusy || publisher == null) return;
 | 
			
		||||
 | 
			
		||||
@@ -246,6 +281,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
    // Uploading attachments
 | 
			
		||||
    try {
 | 
			
		||||
      if (thumbnail != null && thumbnail!.attachment == null) {
 | 
			
		||||
        final thumb = await _uploadAttachment(context, thumbnail!);
 | 
			
		||||
        thumbnail = PostWriteMedia(thumb);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (int i = 0; i < attachments.length; i++) {
 | 
			
		||||
        final media = attachments[i];
 | 
			
		||||
        if (media.attachment != null) continue; // Already uploaded, skip
 | 
			
		||||
@@ -256,6 +296,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          media.name,
 | 
			
		||||
          'interactive',
 | 
			
		||||
          null,
 | 
			
		||||
          mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        final item = await attach.chunkedUploadParts(
 | 
			
		||||
@@ -264,8 +305,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          place.$2,
 | 
			
		||||
          onProgress: (progress) {
 | 
			
		||||
            // Calculate overall progress for attachments
 | 
			
		||||
            progress = ((i + progress) / attachments.length) *
 | 
			
		||||
                kAttachmentProgressWeight;
 | 
			
		||||
            progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
 | 
			
		||||
            notifyListeners();
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
@@ -295,28 +335,24 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          'publisher': publisher!.id,
 | 
			
		||||
          'content': contentController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty)
 | 
			
		||||
            'description': descriptionController.text,
 | 
			
		||||
          'attachments': attachments
 | 
			
		||||
              .where((e) => e.attachment != null)
 | 
			
		||||
              .map((e) => e.attachment!.rid)
 | 
			
		||||
              .toList(),
 | 
			
		||||
          if (publishedAt != null)
 | 
			
		||||
            'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null)
 | 
			
		||||
            'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
 | 
			
		||||
          'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
 | 
			
		||||
          'tags': tags.map((ele) => {'alias': ele}).toList(),
 | 
			
		||||
          'visibility': visibility,
 | 
			
		||||
          'visible_users_list': visibleUsers,
 | 
			
		||||
          'invisible_users_list': invisibleUsers,
 | 
			
		||||
          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
			
		||||
        },
 | 
			
		||||
        onSendProgress: (count, total) {
 | 
			
		||||
          progress =
 | 
			
		||||
              baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        onReceiveProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal +
 | 
			
		||||
              (kPostingProgressWeight / 2) +
 | 
			
		||||
              (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        },
 | 
			
		||||
        options: Options(
 | 
			
		||||
@@ -338,12 +374,34 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAttachmentAt(int idx, PostWriteMedia item) {
 | 
			
		||||
    attachments[idx] = item;
 | 
			
		||||
    if (idx == -1) {
 | 
			
		||||
      thumbnail = item;
 | 
			
		||||
    } else {
 | 
			
		||||
      attachments[idx] = item;
 | 
			
		||||
    }
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -362,11 +420,41 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    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) {
 | 
			
		||||
    isBusy = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setMode(String value) {
 | 
			
		||||
    mode = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void reset() {
 | 
			
		||||
    publishedAt = 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',
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,28 +1,71 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:croppy/croppy.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.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/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:sentry_flutter/sentry_flutter.dart';
 | 
			
		||||
import 'package:surface/firebase_options.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/chat_call.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_network.dart';
 | 
			
		||||
import 'package:surface/providers/theme.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.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;
 | 
			
		||||
 | 
			
		||||
void main() async {
 | 
			
		||||
  WidgetsFlutterBinding.ensureInitialized();
 | 
			
		||||
  await EasyLocalization.ensureInitialized();
 | 
			
		||||
 | 
			
		||||
  if (!kReleaseMode) {
 | 
			
		||||
    debugInvertOversizedImages = true;
 | 
			
		||||
  await Hive.initFlutter();
 | 
			
		||||
  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());
 | 
			
		||||
  await SentryFlutter.init(
 | 
			
		||||
    (options) {
 | 
			
		||||
      options.dsn =
 | 
			
		||||
          'https://c218d44126d59d69301e730498494def@o4506965897117696.ingest.us.sentry.io/4508346768228352';
 | 
			
		||||
      options.tracesSampleRate = 1.0;
 | 
			
		||||
      options.profilesSampleRate = 1.0;
 | 
			
		||||
    },
 | 
			
		||||
    appRunner: () => runApp(const SolianApp()),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SolianApp extends StatelessWidget {
 | 
			
		||||
@@ -46,9 +89,15 @@ class SolianApp extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
            // 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)),
 | 
			
		||||
            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(),
 | 
			
		||||
        ),
 | 
			
		||||
@@ -69,6 +118,8 @@ class AppMainContent extends StatelessWidget {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    context.read<NavigationProvider>();
 | 
			
		||||
    context.read<WebSocketProvider>();
 | 
			
		||||
    context.read<ChatChannelProvider>();
 | 
			
		||||
    context.read<NotificationProvider>();
 | 
			
		||||
 | 
			
		||||
    final th = context.watch<ThemeProvider>();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -24,6 +24,14 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  int? get currentIndex => _currentIndex;
 | 
			
		||||
 | 
			
		||||
  static const List<String> kShowBottomNavScreen = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'account',
 | 
			
		||||
    'album',
 | 
			
		||||
    'chat',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static const List<AppNavDestination> kAllDestination = [
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
 | 
			
		||||
@@ -35,26 +43,42 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
      screen: 'explore',
 | 
			
		||||
      label: 'screenExplore',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'chat',
 | 
			
		||||
      label: 'screenChat',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'account',
 | 
			
		||||
      label: 'screenAccount',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'realm',
 | 
			
		||||
      label: 'screenRealm',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'album',
 | 
			
		||||
      label: 'screenAlbum',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'chat',
 | 
			
		||||
      label: 'screenChat',
 | 
			
		||||
      icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'friend',
 | 
			
		||||
      label: 'screenFriend',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'notification',
 | 
			
		||||
      label: 'screenNotification',
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
  static const List<String> kDefaultPinnedDestination = [
 | 
			
		||||
    'home',
 | 
			
		||||
    'explore',
 | 
			
		||||
    'account'
 | 
			
		||||
    'chat',
 | 
			
		||||
    'account',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  List<AppNavDestination> destinations = [];
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										63
									
								
								lib/providers/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,63 @@
 | 
			
		||||
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>();
 | 
			
		||||
 | 
			
		||||
    // Delay to wait user provider ready to use
 | 
			
		||||
    Future.delayed(const Duration(milliseconds: 3000), () async {
 | 
			
		||||
      if (!_ua.isAuthorized) return;
 | 
			
		||||
      log("Registering push notifications...");
 | 
			
		||||
      await registerPushNotifications();
 | 
			
		||||
      log("Registered push notification subscriber successfully!");
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> registerPushNotifications() async {
 | 
			
		||||
    if (kIsWeb) return;
 | 
			
		||||
    if (!_ua.isAuthorized) return;
 | 
			
		||||
 | 
			
		||||
    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,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										136
									
								
								lib/providers/post.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,136 @@
 | 
			
		||||
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']);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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>, 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,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
 | 
			
		||||
      'take': take,
 | 
			
		||||
      'offset': offset,
 | 
			
		||||
      'probe': searchTerm,
 | 
			
		||||
    });
 | 
			
		||||
    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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										34
									
								
								lib/providers/relationship.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,34 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
 | 
			
		||||
class SnRelationshipProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
 | 
			
		||||
  SnRelationshipProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
    if (!noCache && _cache.containsKey(rid)) {
 | 
			
		||||
      return _cache[rid]!;
 | 
			
		||||
@@ -26,37 +34,49 @@ class SnAttachmentProvider {
 | 
			
		||||
 | 
			
		||||
    final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
 | 
			
		||||
    final out = SnAttachment.fromJson(resp.data);
 | 
			
		||||
    _cache[rid] = out;
 | 
			
		||||
    if (out.isAnalyzed && out.isUploaded) {
 | 
			
		||||
      _cache[rid] = out;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<SnAttachment>> getMultiple(List<String> rids,
 | 
			
		||||
  Future<List<SnAttachment?>> getMultiple(List<String> rids,
 | 
			
		||||
      {noCache = false}) async {
 | 
			
		||||
    final pendingFetch =
 | 
			
		||||
        noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList();
 | 
			
		||||
    final result = List<SnAttachment?>.filled(rids.length, null);
 | 
			
		||||
    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) {
 | 
			
		||||
      return rids.map((rid) => _cache[rid]!).toList();
 | 
			
		||||
    if (pendingFetch.isNotEmpty) {
 | 
			
		||||
      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: {
 | 
			
		||||
      '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();
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Map<String, String> mimetypeOverrides = {
 | 
			
		||||
@@ -110,8 +130,9 @@ class SnAttachmentProvider {
 | 
			
		||||
    int size,
 | 
			
		||||
    String filename,
 | 
			
		||||
    String pool,
 | 
			
		||||
    Map<String, dynamic>? metadata,
 | 
			
		||||
  ) async {
 | 
			
		||||
    Map<String, dynamic>? metadata, {
 | 
			
		||||
    String? mimetype,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final fileAlt = filename.contains('.')
 | 
			
		||||
        ? filename.substring(0, filename.lastIndexOf('.'))
 | 
			
		||||
        : filename;
 | 
			
		||||
@@ -119,8 +140,10 @@ class SnAttachmentProvider {
 | 
			
		||||
        filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
 | 
			
		||||
 | 
			
		||||
    String? mimetypeOverride;
 | 
			
		||||
    if (mimetypeOverrides.keys.contains(fileExt)) {
 | 
			
		||||
    if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
 | 
			
		||||
      mimetypeOverride = mimetypeOverrides[fileExt];
 | 
			
		||||
    } else {
 | 
			
		||||
      mimetypeOverride = mimetype;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
 | 
			
		||||
import 'package:flutter_secure_storage/flutter_secure_storage.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 kRtkStoreKey = 'nex_user_rtk';
 | 
			
		||||
@@ -20,10 +20,9 @@ const kNetworkServerDirectory = [
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
class SnNetworkProvider {
 | 
			
		||||
  late Dio client;
 | 
			
		||||
  late final Dio client;
 | 
			
		||||
 | 
			
		||||
  late final SharedPreferences _prefs;
 | 
			
		||||
  late final FlutterSecureStorage _storage = FlutterSecureStorage();
 | 
			
		||||
 | 
			
		||||
  SnNetworkProvider() {
 | 
			
		||||
    client = Dio();
 | 
			
		||||
@@ -53,8 +52,6 @@ class SnNetworkProvider {
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    client = addClientAdapter(client);
 | 
			
		||||
 | 
			
		||||
    SharedPreferences.getInstance().then((prefs) {
 | 
			
		||||
      _prefs = prefs;
 | 
			
		||||
      client.options.baseUrl =
 | 
			
		||||
@@ -62,9 +59,19 @@ class SnNetworkProvider {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final tkLock = Lock();
 | 
			
		||||
 | 
			
		||||
  Completer<String?>? _refreshCompleter;
 | 
			
		||||
 | 
			
		||||
  Future<String?> getFreshAtk() async {
 | 
			
		||||
    if (_refreshCompleter != null) {
 | 
			
		||||
      return await _refreshCompleter!.future;
 | 
			
		||||
    } else {
 | 
			
		||||
      _refreshCompleter = Completer<String?>();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      var atk = await _storage.read(key: kAtkStoreKey);
 | 
			
		||||
      var atk = _prefs.getString(kAtkStoreKey);
 | 
			
		||||
      if (atk != null) {
 | 
			
		||||
        final atkParts = atk.split('.');
 | 
			
		||||
        if (atkParts.length != 3) {
 | 
			
		||||
@@ -94,13 +101,18 @@ class SnNetworkProvider {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
@@ -111,22 +123,18 @@ class SnNetworkProvider {
 | 
			
		||||
    return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> setTokenPair(String atk, String rtk) async {
 | 
			
		||||
    await Future.wait([
 | 
			
		||||
      _storage.write(key: kAtkStoreKey, value: atk),
 | 
			
		||||
      _storage.write(key: kRtkStoreKey, value: rtk),
 | 
			
		||||
    ]);
 | 
			
		||||
  void setTokenPair(String atk, String rtk) {
 | 
			
		||||
    _prefs.setString(kAtkStoreKey, atk);
 | 
			
		||||
    _prefs.setString(kRtkStoreKey, rtk);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> clearTokenPair() async {
 | 
			
		||||
    await Future.wait([
 | 
			
		||||
      _storage.delete(key: kAtkStoreKey),
 | 
			
		||||
      _storage.delete(key: kRtkStoreKey),
 | 
			
		||||
    ]);
 | 
			
		||||
  void clearTokenPair() {
 | 
			
		||||
    _prefs.remove(kAtkStoreKey);
 | 
			
		||||
    _prefs.remove(kRtkStoreKey);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<String?> refreshToken() async {
 | 
			
		||||
    final rtk = await _storage.read(key: kRtkStoreKey);
 | 
			
		||||
    final rtk = _prefs.getString(kRtkStoreKey);
 | 
			
		||||
    if (rtk == null) return null;
 | 
			
		||||
 | 
			
		||||
    final dio = Dio();
 | 
			
		||||
@@ -139,7 +147,7 @@ class SnNetworkProvider {
 | 
			
		||||
 | 
			
		||||
    final atk = resp.data['access_token'];
 | 
			
		||||
    final nRtk = resp.data['refresh_token'];
 | 
			
		||||
    await setTokenPair(atk, nRtk);
 | 
			
		||||
    setTokenPair(atk, nRtk);
 | 
			
		||||
 | 
			
		||||
    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 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:shared_preferences/shared_preferences.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
 | 
			
		||||
@@ -11,14 +11,17 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
  SnAccount? user;
 | 
			
		||||
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final FlutterSecureStorage _storage = FlutterSecureStorage();
 | 
			
		||||
 | 
			
		||||
  Future<String?> get atk => _storage.read(key: kAtkStoreKey);
 | 
			
		||||
  Future<String?> get atk async {
 | 
			
		||||
    final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
    return prefs.getString(kAtkStoreKey);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  UserProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    _storage.read(key: kAtkStoreKey).then((value) {
 | 
			
		||||
    SharedPreferences.getInstance().then((prefs) {
 | 
			
		||||
      final value = prefs.getString(kAtkStoreKey);
 | 
			
		||||
      isAuthorized = value != null;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
      refreshUser().then((value) {
 | 
			
		||||
@@ -41,7 +44,7 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void logoutUser() async {
 | 
			
		||||
    await _sn.clearTokenPair();
 | 
			
		||||
    _sn.clearTokenPair();
 | 
			
		||||
    isAuthorized = false;
 | 
			
		||||
    user = null;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    try {
 | 
			
		||||
      conn = WebSocketChannel.connect(uri);
 | 
			
		||||
      await conn!.ready;
 | 
			
		||||
      listen();
 | 
			
		||||
      log('[WebSocket] Connected to server!');
 | 
			
		||||
      isConnected = true;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -88,12 +89,6 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
			
		||||
        log('Websocket incoming message: ${packet.method} ${packet.message}');
 | 
			
		||||
        stream.sink.add(packet);
 | 
			
		||||
        // TODO handle notification
 | 
			
		||||
        // if (packet.method == 'notifications.new') {
 | 
			
		||||
        //   final NotificationProvider nty = Get.find();
 | 
			
		||||
        //   nty.notifications.add(Notification.fromJson(packet.payload!));
 | 
			
		||||
        //   nty.notificationUnread.value++;
 | 
			
		||||
        // }
 | 
			
		||||
      },
 | 
			
		||||
      onDone: () {
 | 
			
		||||
        isConnected = false;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										263
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						@@ -1,5 +1,8 @@
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.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/publishers/publisher_edit.dart';
 | 
			
		||||
import 'package:surface/screens/account/publishers/publisher_new.dart';
 | 
			
		||||
@@ -8,122 +11,288 @@ import 'package:surface/screens/album.dart';
 | 
			
		||||
import 'package:surface/screens/auth/login.dart';
 | 
			
		||||
import 'package:surface/screens/auth/register.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/friend.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_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/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_background.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
final _appRoutes = [
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppScaffold(
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(
 | 
			
		||||
      body: child,
 | 
			
		||||
      showBottomNavigation: true,
 | 
			
		||||
      showAppBar: false,
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/',
 | 
			
		||||
        name: 'home',
 | 
			
		||||
        builder: (context, state) => const HomeScreen(),
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const HomeScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/posts',
 | 
			
		||||
        name: 'explore',
 | 
			
		||||
        builder: (context, state) => const ExploreScreen(),
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const ExploreScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
        routes: [
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/write/:mode',
 | 
			
		||||
            name: 'postEditor',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              isLessOptimization: true,
 | 
			
		||||
              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(
 | 
			
		||||
              isLessOptimization: true,
 | 
			
		||||
              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',
 | 
			
		||||
        builder: (context, state) => const AccountScreen(),
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const AccountScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
        routes: [
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:name',
 | 
			
		||||
            name: 'accountProfilePage',
 | 
			
		||||
            pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
              child: UserScreen(name: state.pathParameters['name']!),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/chat',
 | 
			
		||||
        name: 'chat',
 | 
			
		||||
        builder: (context, state) => const ChatScreen(),
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const ChatScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
        routes: [
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:scope/:alias',
 | 
			
		||||
            name: 'chatRoom',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              isLessOptimization: true,
 | 
			
		||||
              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(
 | 
			
		||||
                    isLessOptimization: true,
 | 
			
		||||
                    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(
 | 
			
		||||
                    isLessOptimization: true,
 | 
			
		||||
                    child: child,
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/album',
 | 
			
		||||
        name: 'album',
 | 
			
		||||
        builder: (context, state) => const AlbumScreen(),
 | 
			
		||||
        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) => 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),
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/auth/login',
 | 
			
		||||
        name: 'authLogin',
 | 
			
		||||
        builder: (context, state) => const LoginScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: LoginScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/auth/register',
 | 
			
		||||
        name: 'authRegister',
 | 
			
		||||
        builder: (context, state) => const RegisterScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: RegisterScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/profile/edit',
 | 
			
		||||
        name: 'accountProfileEdit',
 | 
			
		||||
        builder: (context, state) => const ProfileEditScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: ProfileEditScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers',
 | 
			
		||||
        name: 'accountPublishers',
 | 
			
		||||
        builder: (context, state) => const PublisherScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: PublisherScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers/new',
 | 
			
		||||
        name: 'accountPublisherNew',
 | 
			
		||||
        builder: (context, state) => const AccountPublisherNewScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: AccountPublisherNewScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers/edit/:name',
 | 
			
		||||
        name: 'accountPublisherEdit',
 | 
			
		||||
        builder: (context, state) => AccountPublisherEditScreen(
 | 
			
		||||
          name: state.pathParameters['name']!,
 | 
			
		||||
        builder: (context, state) => AppBackground(
 | 
			
		||||
          child: AccountPublisherEditScreen(
 | 
			
		||||
            name: state.pathParameters['name']!,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppScaffold(body: child),
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/settings',
 | 
			
		||||
        name: 'settings',
 | 
			
		||||
        builder: (context, state) => const SettingsScreen(),
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: SettingsScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
@@ -132,8 +301,8 @@ final _appRoutes = [
 | 
			
		||||
final appRouter = GoRouter(
 | 
			
		||||
  routes: [
 | 
			
		||||
    ShellRoute(
 | 
			
		||||
      builder: (context, state, child) => AppRootScaffold(body: child),
 | 
			
		||||
      routes: _appRoutes,
 | 
			
		||||
      builder: (context, state, child) => AppRootScaffold(body: child),
 | 
			
		||||
    ),
 | 
			
		||||
  ],
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
 | 
			
		||||
class AccountScreen extends StatelessWidget {
 | 
			
		||||
@@ -18,6 +19,7 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text("screenAccount").tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
@@ -26,6 +28,7 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
              GoRouter.of(context).pushNamed('settings');
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
@@ -156,7 +159,14 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
          leading: const Icon(Symbols.login),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          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(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										327
									
								
								lib/screens/account/pfp.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,327 @@
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
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: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/account.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(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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((_) {
 | 
			
		||||
      _fetchStatus();
 | 
			
		||||
    });
 | 
			
		||||
    _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: Colors.white,
 | 
			
		||||
                              shadows: labelShadows,
 | 
			
		||||
                            ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const TextSpan(text: '\n'),
 | 
			
		||||
                      TextSpan(
 | 
			
		||||
                        text: '@${_account!.name}',
 | 
			
		||||
                        style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                              color: Colors.white,
 | 
			
		||||
                              shadows: labelShadows,
 | 
			
		||||
                            ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ]),
 | 
			
		||||
                  ),
 | 
			
		||||
            pinned: true,
 | 
			
		||||
            flexibleSpace: _account != null
 | 
			
		||||
                ? Stack(
 | 
			
		||||
                    fit: StackFit.expand,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      UniversalImage(
 | 
			
		||||
                        sn.getAttachmentUrl(_account!.banner),
 | 
			
		||||
                        fit: BoxFit.cover,
 | 
			
		||||
                        height: imageHeight,
 | 
			
		||||
                        width: _appBarWidth,
 | 
			
		||||
                        cacheHeight: imageHeight,
 | 
			
		||||
                        cacheWidth: _appBarWidth,
 | 
			
		||||
                      ),
 | 
			
		||||
                      Positioned(
 | 
			
		||||
                        top: 0,
 | 
			
		||||
                        left: 0,
 | 
			
		||||
                        right: 0,
 | 
			
		||||
                        height: 56 + MediaQuery.of(context).padding.top,
 | 
			
		||||
                        child: ClipRect(
 | 
			
		||||
                          child: BackdropFilter(
 | 
			
		||||
                            filter: ImageFilter.blur(
 | 
			
		||||
                              sigmaX: _appBarBlur,
 | 
			
		||||
                              sigmaY: _appBarBlur,
 | 
			
		||||
                            ),
 | 
			
		||||
                            child: Container(
 | 
			
		||||
                              color: Colors.black.withOpacity(
 | 
			
		||||
                                clampDouble(_appBarBlur * 0.1, 0, 0.5),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  )
 | 
			
		||||
                : null,
 | 
			
		||||
          ),
 | 
			
		||||
          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),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).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(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ).padding(vertical: 8, horizontal: 12),
 | 
			
		||||
                  ),
 | 
			
		||||
                  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),
 | 
			
		||||
                                    ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -148,20 +148,14 @@ class _AccountPublisherEditScreenState
 | 
			
		||||
        mimetype: 'image/png',
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put(
 | 
			
		||||
        '/cgi/id/users/me/$place',
 | 
			
		||||
        data: {'attachment': attachment.rid},
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await ua.refreshUser();
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('accountProfileEditApplied'.tr());
 | 
			
		||||
      _syncWidget();
 | 
			
		||||
      switch (place) {
 | 
			
		||||
        case 'avatar':
 | 
			
		||||
          _avatar = attachment.rid;
 | 
			
		||||
          break;
 | 
			
		||||
        case 'banner':
 | 
			
		||||
          _banner = attachment.rid;
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -273,11 +267,14 @@ class _AccountPublisherEditScreenState
 | 
			
		||||
            Row(
 | 
			
		||||
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
              children: [
 | 
			
		||||
                TextButton.icon(
 | 
			
		||||
                  onPressed: _syncWithAccount,
 | 
			
		||||
                  label: Text('publisherSyncWithAccount').tr(),
 | 
			
		||||
                  icon: const Icon(Symbols.sync),
 | 
			
		||||
                ),
 | 
			
		||||
                if (_publisher?.type == 0)
 | 
			
		||||
                  TextButton.icon(
 | 
			
		||||
                    onPressed: _syncWithAccount,
 | 
			
		||||
                    label: Text('publisherSyncWithAccount').tr(),
 | 
			
		||||
                    icon: const Icon(Symbols.sync),
 | 
			
		||||
                  )
 | 
			
		||||
                else
 | 
			
		||||
                  const SizedBox(),
 | 
			
		||||
                ElevatedButton.icon(
 | 
			
		||||
                  onPressed: _isBusy ? null : _performAction,
 | 
			
		||||
                  label: Text('apply').tr(),
 | 
			
		||||
@@ -286,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:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
@@ -6,6 +7,7 @@ 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/dialog.dart';
 | 
			
		||||
 | 
			
		||||
@@ -47,6 +49,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
 | 
			
		||||
            ),
 | 
			
		||||
            switch (mode) {
 | 
			
		||||
              'personal' => const _PublisherNewPersonal(),
 | 
			
		||||
              'organization' => const _PublisherNewOrganization(),
 | 
			
		||||
              _ => const Placeholder(),
 | 
			
		||||
            },
 | 
			
		||||
          ],
 | 
			
		||||
@@ -66,6 +69,10 @@ class _PublisherNewPersonal extends StatefulWidget {
 | 
			
		||||
class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
			
		||||
  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>();
 | 
			
		||||
@@ -74,15 +81,48 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      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
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
@@ -90,10 +130,41 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text('preview')
 | 
			
		||||
            .tr()
 | 
			
		||||
            .textStyle(Theme.of(context).textTheme.titleMedium!)
 | 
			
		||||
            .padding(horizontal: 16, vertical: 4),
 | 
			
		||||
        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,
 | 
			
		||||
@@ -105,10 +176,254 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.baseline,
 | 
			
		||||
                  textBaseline: TextBaseline.alphabetic,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(ua.user!.nick)
 | 
			
		||||
                    Text(_nickController.text)
 | 
			
		||||
                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                    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!),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,128 @@
 | 
			
		||||
import 'package:dismissible_page/dismissible_page.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.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/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});
 | 
			
		||||
 | 
			
		||||
  @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 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()));
 | 
			
		||||
 | 
			
		||||
      _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
 | 
			
		||||
  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(),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -33,67 +33,67 @@ class _LoginScreenState extends State<LoginScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 280),
 | 
			
		||||
      child: Theme(
 | 
			
		||||
        data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
 | 
			
		||||
        child: SingleChildScrollView(
 | 
			
		||||
          child: PageTransitionSwitcher(
 | 
			
		||||
            transitionBuilder: (
 | 
			
		||||
              Widget child,
 | 
			
		||||
              Animation<double> primaryAnimation,
 | 
			
		||||
              Animation<double> secondaryAnimation,
 | 
			
		||||
            ) {
 | 
			
		||||
              return SharedAxisTransition(
 | 
			
		||||
                animation: primaryAnimation,
 | 
			
		||||
                secondaryAnimation: secondaryAnimation,
 | 
			
		||||
                transitionType: SharedAxisTransitionType.horizontal,
 | 
			
		||||
    return Theme(
 | 
			
		||||
      data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
 | 
			
		||||
      child: SingleChildScrollView(
 | 
			
		||||
        child: PageTransitionSwitcher(
 | 
			
		||||
          transitionBuilder: (
 | 
			
		||||
            Widget child,
 | 
			
		||||
            Animation<double> primaryAnimation,
 | 
			
		||||
            Animation<double> secondaryAnimation,
 | 
			
		||||
          ) {
 | 
			
		||||
            return SharedAxisTransition(
 | 
			
		||||
              animation: primaryAnimation,
 | 
			
		||||
              secondaryAnimation: secondaryAnimation,
 | 
			
		||||
              transitionType: SharedAxisTransitionType.horizontal,
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: BoxConstraints(maxWidth: 380),
 | 
			
		||||
                child: child,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            child: switch (_period % 3) {
 | 
			
		||||
              1 => _LoginPickerScreen(
 | 
			
		||||
                  key: const ValueKey(1),
 | 
			
		||||
                  ticket: _currentTicket,
 | 
			
		||||
                  factors: _factors,
 | 
			
		||||
                  onTicket: (p0) => setState(() {
 | 
			
		||||
                    _currentTicket = p0;
 | 
			
		||||
                  }),
 | 
			
		||||
                  onPickFactor: (p0) => setState(() {
 | 
			
		||||
                    _factorPicked = p0;
 | 
			
		||||
                  }),
 | 
			
		||||
                  onNext: () => setState(() {
 | 
			
		||||
                    _period++;
 | 
			
		||||
                  }),
 | 
			
		||||
                ),
 | 
			
		||||
              2 => _LoginCheckScreen(
 | 
			
		||||
                  key: const ValueKey(2),
 | 
			
		||||
                  ticket: _currentTicket,
 | 
			
		||||
                  factor: _factorPicked,
 | 
			
		||||
                  onTicket: (p0) => setState(() {
 | 
			
		||||
                    _currentTicket = p0;
 | 
			
		||||
                  }),
 | 
			
		||||
                  onNext: () => setState(() {
 | 
			
		||||
                    _period = 1;
 | 
			
		||||
                  }),
 | 
			
		||||
                ),
 | 
			
		||||
              _ => _LoginLookupScreen(
 | 
			
		||||
                  key: const ValueKey(0),
 | 
			
		||||
                  ticket: _currentTicket,
 | 
			
		||||
                  onTicket: (p0) => setState(() {
 | 
			
		||||
                    _currentTicket = p0;
 | 
			
		||||
                  }),
 | 
			
		||||
                  onFactor: (p0) => setState(() {
 | 
			
		||||
                    _factors = p0;
 | 
			
		||||
                  }),
 | 
			
		||||
                  onNext: () => setState(() {
 | 
			
		||||
                    _period++;
 | 
			
		||||
                  }),
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
          ).padding(all: 24),
 | 
			
		||||
        ).center(),
 | 
			
		||||
      ),
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
          child: switch (_period % 3) {
 | 
			
		||||
            1 => _LoginPickerScreen(
 | 
			
		||||
                key: const ValueKey(1),
 | 
			
		||||
                ticket: _currentTicket,
 | 
			
		||||
                factors: _factors,
 | 
			
		||||
                onTicket: (p0) => setState(() {
 | 
			
		||||
                  _currentTicket = p0;
 | 
			
		||||
                }),
 | 
			
		||||
                onPickFactor: (p0) => setState(() {
 | 
			
		||||
                  _factorPicked = p0;
 | 
			
		||||
                }),
 | 
			
		||||
                onNext: () => setState(() {
 | 
			
		||||
                  _period++;
 | 
			
		||||
                }),
 | 
			
		||||
              ),
 | 
			
		||||
            2 => _LoginCheckScreen(
 | 
			
		||||
                key: const ValueKey(2),
 | 
			
		||||
                ticket: _currentTicket,
 | 
			
		||||
                factor: _factorPicked,
 | 
			
		||||
                onTicket: (p0) => setState(() {
 | 
			
		||||
                  _currentTicket = p0;
 | 
			
		||||
                }),
 | 
			
		||||
                onNext: () => setState(() {
 | 
			
		||||
                  _period = 1;
 | 
			
		||||
                }),
 | 
			
		||||
              ),
 | 
			
		||||
            _ => _LoginLookupScreen(
 | 
			
		||||
                key: const ValueKey(0),
 | 
			
		||||
                ticket: _currentTicket,
 | 
			
		||||
                onTicket: (p0) => setState(() {
 | 
			
		||||
                  _currentTicket = p0;
 | 
			
		||||
                }),
 | 
			
		||||
                onFactor: (p0) => setState(() {
 | 
			
		||||
                  _factors = p0;
 | 
			
		||||
                }),
 | 
			
		||||
                onNext: () => setState(() {
 | 
			
		||||
                  _period++;
 | 
			
		||||
                }),
 | 
			
		||||
              ),
 | 
			
		||||
          },
 | 
			
		||||
        ).padding(all: 24),
 | 
			
		||||
      ).center(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -151,16 +151,12 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
 | 
			
		||||
      });
 | 
			
		||||
      final atk = tokenResp.data['access_token'];
 | 
			
		||||
      final rtk = tokenResp.data['refresh_token'];
 | 
			
		||||
      await sn.setTokenPair(atk, rtk);
 | 
			
		||||
      sn.setTokenPair(atk, rtk);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final user = context.read<UserProvider>();
 | 
			
		||||
      final userinfo = await user.refreshUser();
 | 
			
		||||
      context.showSnackbar('loginSuccess'.tr(args: [
 | 
			
		||||
        '@${userinfo!.name} (${userinfo.nick})',
 | 
			
		||||
      ]));
 | 
			
		||||
      await Future.delayed(const Duration(milliseconds: 1850), () {
 | 
			
		||||
        Navigator.pop(context);
 | 
			
		||||
      });
 | 
			
		||||
      await user.refreshUser();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.pop(context, true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
      return;
 | 
			
		||||
 
 | 
			
		||||
@@ -52,109 +52,107 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 280),
 | 
			
		||||
      child: StyledWidget(
 | 
			
		||||
        SingleChildScrollView(
 | 
			
		||||
          child: Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            children: [
 | 
			
		||||
              Align(
 | 
			
		||||
                alignment: Alignment.centerLeft,
 | 
			
		||||
                child: CircleAvatar(
 | 
			
		||||
                  radius: 26,
 | 
			
		||||
                  child: const Icon(
 | 
			
		||||
                    Symbols.person_add,
 | 
			
		||||
                    size: 28,
 | 
			
		||||
                  ),
 | 
			
		||||
                ).padding(bottom: 8),
 | 
			
		||||
    return StyledWidget(Container(
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 380),
 | 
			
		||||
      child: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Align(
 | 
			
		||||
              alignment: Alignment.centerLeft,
 | 
			
		||||
              child: CircleAvatar(
 | 
			
		||||
                radius: 26,
 | 
			
		||||
                child: const Icon(
 | 
			
		||||
                  Symbols.person_add,
 | 
			
		||||
                  size: 28,
 | 
			
		||||
                ),
 | 
			
		||||
              ).padding(bottom: 8),
 | 
			
		||||
            ),
 | 
			
		||||
            Text(
 | 
			
		||||
              'screenAuthRegister',
 | 
			
		||||
              style: const TextStyle(
 | 
			
		||||
                fontSize: 28,
 | 
			
		||||
                fontWeight: FontWeight.w900,
 | 
			
		||||
              ),
 | 
			
		||||
              Text(
 | 
			
		||||
                'screenAuthRegister',
 | 
			
		||||
                style: const TextStyle(
 | 
			
		||||
                  fontSize: 28,
 | 
			
		||||
                  fontWeight: FontWeight.w900,
 | 
			
		||||
            ).tr().padding(left: 4, bottom: 16),
 | 
			
		||||
            Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                TextField(
 | 
			
		||||
                  autocorrect: false,
 | 
			
		||||
                  enableSuggestions: false,
 | 
			
		||||
                  controller: _usernameController,
 | 
			
		||||
                  autofillHints: const [AutofillHints.username],
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    isDense: true,
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              ).tr().padding(left: 4, bottom: 16),
 | 
			
		||||
              Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  TextField(
 | 
			
		||||
                    autocorrect: false,
 | 
			
		||||
                    enableSuggestions: false,
 | 
			
		||||
                    controller: _usernameController,
 | 
			
		||||
                    autofillHints: const [AutofillHints.username],
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      isDense: true,
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldUsername'.tr(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) =>
 | 
			
		||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  TextField(
 | 
			
		||||
                    autocorrect: false,
 | 
			
		||||
                    enableSuggestions: false,
 | 
			
		||||
                    controller: _nicknameController,
 | 
			
		||||
                    autofillHints: const [AutofillHints.nickname],
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      isDense: true,
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldNickname'.tr(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) =>
 | 
			
		||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  TextField(
 | 
			
		||||
                    autocorrect: false,
 | 
			
		||||
                    enableSuggestions: false,
 | 
			
		||||
                    controller: _emailController,
 | 
			
		||||
                    autofillHints: const [AutofillHints.email],
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      isDense: true,
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldEmail'.tr(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) =>
 | 
			
		||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  TextField(
 | 
			
		||||
                    obscureText: true,
 | 
			
		||||
                    autocorrect: false,
 | 
			
		||||
                    enableSuggestions: false,
 | 
			
		||||
                    autofillHints: const [AutofillHints.password],
 | 
			
		||||
                    controller: _passwordController,
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      isDense: true,
 | 
			
		||||
                      border: const UnderlineInputBorder(),
 | 
			
		||||
                      labelText: 'fieldPassword'.tr(),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) =>
 | 
			
		||||
                        FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                    onSubmitted: (_) => _performAction(context),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(horizontal: 7),
 | 
			
		||||
              const Gap(16),
 | 
			
		||||
              Align(
 | 
			
		||||
                alignment: Alignment.centerRight,
 | 
			
		||||
                child: TextButton(
 | 
			
		||||
                  onPressed: () => _performAction(context),
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text('next').tr(),
 | 
			
		||||
                      const Icon(Symbols.chevron_right),
 | 
			
		||||
                    ],
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  autocorrect: false,
 | 
			
		||||
                  enableSuggestions: false,
 | 
			
		||||
                  controller: _nicknameController,
 | 
			
		||||
                  autofillHints: const [AutofillHints.nickname],
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    isDense: true,
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldNickname'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  autocorrect: false,
 | 
			
		||||
                  enableSuggestions: false,
 | 
			
		||||
                  controller: _emailController,
 | 
			
		||||
                  autofillHints: const [AutofillHints.email],
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    isDense: true,
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldEmail'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  obscureText: true,
 | 
			
		||||
                  autocorrect: false,
 | 
			
		||||
                  enableSuggestions: false,
 | 
			
		||||
                  autofillHints: const [AutofillHints.password],
 | 
			
		||||
                  controller: _passwordController,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    isDense: true,
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldPassword'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onSubmitted: (_) => _performAction(context),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 7),
 | 
			
		||||
            const Gap(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,129 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.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/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
 | 
			
		||||
class ChatScreen extends StatelessWidget {
 | 
			
		||||
class ChatScreen extends StatefulWidget {
 | 
			
		||||
  const ChatScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ChatScreen> createState() => _ChatScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
 | 
			
		||||
  List<SnChannel>? _channels;
 | 
			
		||||
  Map<int, SnChatMessage>? _lastMessages;
 | 
			
		||||
 | 
			
		||||
  void _refreshChannels() {
 | 
			
		||||
    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) setState(() => _channels = channels);
 | 
			
		||||
    })
 | 
			
		||||
      ..onError((err) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        context.showErrorDialog(err);
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      })
 | 
			
		||||
      ..onDone(() {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _refreshChannels();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return const Placeholder();
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenChat').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      floatingActionButton: FloatingActionButton(
 | 
			
		||||
        child: const Icon(Symbols.chat_add_on),
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          GoRouter.of(context).pushNamed('chatManage').then((value) {
 | 
			
		||||
            if (value != null && context.mounted) _refreshChannels();
 | 
			
		||||
          });
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      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];
 | 
			
		||||
                  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),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										316
									
								
								lib/screens/chat/room.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,316 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
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';
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
  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}');
 | 
			
		||||
    } 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,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      if (resp.statusCode == 200) {
 | 
			
		||||
        _ongoingCall = SnChatCall.fromJson(resp.data);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isCalling = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _makeCall() async {
 | 
			
		||||
    setState(() => _isCalling = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.post(
 | 
			
		||||
        '/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
 | 
			
		||||
        options: Options(
 | 
			
		||||
          sendTimeout: const Duration(seconds: 30),
 | 
			
		||||
          receiveTimeout: const Duration(seconds: 30),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      log(jsonDecode(resp.data));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      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>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text(_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 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,
 | 
			
		||||
                    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:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
@@ -32,35 +31,13 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final resp = await sn.client.get('/cgi/co/posts', queryParameters: {
 | 
			
		||||
      'take': 10,
 | 
			
		||||
      '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>() ?? []);
 | 
			
		||||
    }
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.listPosts(take: 10, offset: _posts.length);
 | 
			
		||||
    final out = result.$1;
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
 | 
			
		||||
    if (mounted) setState(() => _isBusy = false);
 | 
			
		||||
@@ -161,9 +138,19 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
        child: CustomScrollView(
 | 
			
		||||
          slivers: [
 | 
			
		||||
            SliverAppBar(
 | 
			
		||||
              leading: AutoAppBarLeading(),
 | 
			
		||||
              title: Text('screenExplore').tr(),
 | 
			
		||||
              floating: true,
 | 
			
		||||
              snap: true,
 | 
			
		||||
              actions: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  icon: const Icon(Symbols.search),
 | 
			
		||||
                  onPressed: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed('postSearch');
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            SliverInfiniteList(
 | 
			
		||||
              itemCount: _posts.length,
 | 
			
		||||
@@ -173,10 +160,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
              onFetchData: _fetchPosts,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                return GestureDetector(
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                    child: PostItem(data: _posts[idx]),
 | 
			
		||||
                  ).center(),
 | 
			
		||||
                  child: PostItem(
 | 
			
		||||
                    data: _posts[idx],
 | 
			
		||||
                    maxWidth: 640,
 | 
			
		||||
                    onChanged: (data) {
 | 
			
		||||
                      setState(() => _posts[idx] = data);
 | 
			
		||||
                    },
 | 
			
		||||
                    onDeleted: () {
 | 
			
		||||
                      _posts.clear();
 | 
			
		||||
                      _fetchPosts();
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed(
 | 
			
		||||
                      'postDetail',
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										489
									
								
								lib/screens/friend.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,489 @@
 | 
			
		||||
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';
 | 
			
		||||
 | 
			
		||||
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 {
 | 
			
		||||
    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 {
 | 
			
		||||
    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 {
 | 
			
		||||
    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) {
 | 
			
		||||
    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,7 +1,31 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
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:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.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 {
 | 
			
		||||
  const HomeScreen({super.key});
 | 
			
		||||
@@ -11,29 +35,364 @@ class HomeScreen extends StatefulWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
  static const List<HomeScreenDashEntry> kCards = [
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryCheckIn',
 | 
			
		||||
      child: _HomeDashCheckInWidget(),
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text("screenHome").tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          MaterialBanner(
 | 
			
		||||
            leading: const Icon(Symbols.construction),
 | 
			
		||||
            content: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('nextVersionAlert').tr().bold(),
 | 
			
		||||
                Text('nextVersionNotice').tr(),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(vertical: 16),
 | 
			
		||||
            actions: [
 | 
			
		||||
              const SizedBox(),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      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: [
 | 
			
		||||
                    if (constraints.maxWidth <= 640) const Gap(8),
 | 
			
		||||
                    Card(
 | 
			
		||||
                      child: ListTile(
 | 
			
		||||
                        isThreeLine: true,
 | 
			
		||||
                        leading: const Icon(Symbols.construction),
 | 
			
		||||
                        title: Text('nextVersionAlert').tr(),
 | 
			
		||||
                        subtitle: Text('nextVersionNotice').tr(),
 | 
			
		||||
                        contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                            vertical: 8, horizontal: 16),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ).padding(horizontal: 8),
 | 
			
		||||
                    _HomeDashSpecialDayWidget().padding(top: 8, horizontal: 8),
 | 
			
		||||
                    StaggeredGrid.count(
 | 
			
		||||
                      crossAxisCount: 2,
 | 
			
		||||
                      mainAxisSpacing: 8,
 | 
			
		||||
                      crossAxisSpacing: 8,
 | 
			
		||||
                      children: kCards.map((card) {
 | 
			
		||||
                        return StaggeredGridTile.count(
 | 
			
		||||
                          crossAxisCellCount: card.cols,
 | 
			
		||||
                          mainAxisCellCount: card.rows,
 | 
			
		||||
                          child: card.child,
 | 
			
		||||
                        );
 | 
			
		||||
                      }).toList(),
 | 
			
		||||
                    ).padding(horizontal: 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: [
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: AnimatedSwitcher(
 | 
			
		||||
              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 _HomeDashLinkWidget extends StatelessWidget {
 | 
			
		||||
  final String title;
 | 
			
		||||
  final String subtitle;
 | 
			
		||||
  const _HomeDashLinkWidget({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.title,
 | 
			
		||||
    required this.subtitle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Card(
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text(
 | 
			
		||||
                  title,
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                ),
 | 
			
		||||
                Text(
 | 
			
		||||
                  subtitle,
 | 
			
		||||
                  style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          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: () {},
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(all: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										260
									
								
								lib/screens/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,260 @@
 | 
			
		||||
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';
 | 
			
		||||
 | 
			
		||||
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 {
 | 
			
		||||
    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 {
 | 
			
		||||
    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 {
 | 
			
		||||
    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) {
 | 
			
		||||
    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(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,8 +7,7 @@ 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_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
@@ -39,19 +38,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/co/posts/${widget.slug}');
 | 
			
		||||
      final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
      final post = await pt.getPost(widget.slug);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final attachments = await attach.getMultiple(
 | 
			
		||||
        resp.data['body']['attachments']?.cast<String>() ?? [],
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      _data = SnPost.fromJson(resp.data).copyWith(
 | 
			
		||||
        preload: SnPostPreload(
 | 
			
		||||
          attachments: attachments,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      _data = post;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
@@ -80,27 +70,34 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
        leading: BackButton(
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            if (GoRouter.of(context).canPop()) {
 | 
			
		||||
              Navigator.pop(context);
 | 
			
		||||
              GoRouter.of(context).pop(context);
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            GoRouter.of(context).replaceNamed('explore');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        flexibleSpace: Column(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (_data?.body['title'] != null)
 | 
			
		||||
              Text(_data?.body['title'] ?? 'postNoun'.tr())
 | 
			
		||||
                  .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
			
		||||
                  .textColor(Colors.white),
 | 
			
		||||
            if (_data?.body['title'] != null)
 | 
			
		||||
              Text('postDetail'.tr())
 | 
			
		||||
                  .textColor(Colors.white.withAlpha((255 * 0.9).round()))
 | 
			
		||||
            else
 | 
			
		||||
              Text('postDetail'.tr())
 | 
			
		||||
                  .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
			
		||||
                  .textColor(Colors.white),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
 | 
			
		||||
        title: _data?.body['title'] != null
 | 
			
		||||
            ? RichText(
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                text: TextSpan(children: [
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: _data?.body['title'] ?? 'postNoun'.tr(),
 | 
			
		||||
                    style: Theme.of(context)
 | 
			
		||||
                        .textTheme
 | 
			
		||||
                        .titleLarge!
 | 
			
		||||
                        .copyWith(color: Colors.white),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const TextSpan(text: '\n'),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: 'postDetail'.tr(),
 | 
			
		||||
                    style: Theme.of(context)
 | 
			
		||||
                        .textTheme
 | 
			
		||||
                        .bodySmall!
 | 
			
		||||
                        .copyWith(color: Colors.white),
 | 
			
		||||
                  ),
 | 
			
		||||
                ]),
 | 
			
		||||
              )
 | 
			
		||||
            : Text('postDetail').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
        slivers: [
 | 
			
		||||
@@ -109,32 +106,41 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
          ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                child: PostItem(
 | 
			
		||||
                  data: _data!,
 | 
			
		||||
                  showComments: false,
 | 
			
		||||
                ),
 | 
			
		||||
              ).center(),
 | 
			
		||||
              child: PostItem(
 | 
			
		||||
                data: _data!,
 | 
			
		||||
                maxWidth: 640,
 | 
			
		||||
                showComments: false,
 | 
			
		||||
                showFullPost: true,
 | 
			
		||||
                onChanged: (data) {
 | 
			
		||||
                  setState(() => _data = data);
 | 
			
		||||
                },
 | 
			
		||||
                onDeleted: () {
 | 
			
		||||
                  Navigator.pop(context);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          const SliverToBoxAdapter(child: Divider(height: 1)),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Row(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                children: [
 | 
			
		||||
                  const Icon(Symbols.comment, size: 24),
 | 
			
		||||
                  const Gap(16),
 | 
			
		||||
                  Text('postCommentsDetailed')
 | 
			
		||||
                      .plural(_data!.metric.replyCount)
 | 
			
		||||
                      .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(horizontal: 20, vertical: 12),
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    const Icon(Symbols.comment, size: 24),
 | 
			
		||||
                    const Gap(16),
 | 
			
		||||
                    Text('postCommentsDetailed')
 | 
			
		||||
                        .plural(_data!.metric.replyCount)
 | 
			
		||||
                        .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 20, vertical: 12).center(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null && ua.isAuthorized)
 | 
			
		||||
            SliverToBoxAdapter(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                height: 240,
 | 
			
		||||
                constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  border: Border.symmetric(
 | 
			
		||||
                    horizontal: BorderSide(
 | 
			
		||||
@@ -146,7 +152,6 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                child: PostMiniEditor(
 | 
			
		||||
                  postReplyId: _data!.id,
 | 
			
		||||
                  onPost: () {
 | 
			
		||||
                    _childListKey.currentState!.refresh();
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _data = _data!.copyWith(
 | 
			
		||||
                        metric: _data!.metric.copyWith(
 | 
			
		||||
@@ -154,9 +159,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
                        ),
 | 
			
		||||
                      );
 | 
			
		||||
                    });
 | 
			
		||||
                    _childListKey.currentState!.refresh();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              ).center(),
 | 
			
		||||
            ),
 | 
			
		||||
          if (_data != null)
 | 
			
		||||
            PostCommentSliverList(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,3 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -9,6 +7,7 @@ import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:image_picker/image_picker.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
@@ -26,6 +25,7 @@ class PostEditorScreen extends StatefulWidget {
 | 
			
		||||
  final int? postEditId;
 | 
			
		||||
  final int? postReplyId;
 | 
			
		||||
  final int? postRepostId;
 | 
			
		||||
 | 
			
		||||
  const PostEditorScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.mode,
 | 
			
		||||
@@ -42,6 +42,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
  final PostWriteController _writeController = PostWriteController();
 | 
			
		||||
 | 
			
		||||
  bool _isFetching = false;
 | 
			
		||||
 | 
			
		||||
  bool get _isLoading => _isFetching || _writeController.isLoading;
 | 
			
		||||
 | 
			
		||||
  List<SnPublisher>? _publishers;
 | 
			
		||||
@@ -80,7 +81,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    _writeController.addAttachments(
 | 
			
		||||
      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
 | 
			
		||||
@@ -95,6 +107,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
			
		||||
      context.showErrorDialog('Unknown post type');
 | 
			
		||||
      Navigator.pop(context);
 | 
			
		||||
    } else {
 | 
			
		||||
      _writeController.setMode(widget.mode);
 | 
			
		||||
    }
 | 
			
		||||
    _fetchPublishers();
 | 
			
		||||
    _writeController.fetchRelatedPost(
 | 
			
		||||
@@ -117,23 +131,26 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                Navigator.pop(context);
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            flexibleSpace: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                Text(_writeController.title.isNotEmpty
 | 
			
		||||
                        ? _writeController.title
 | 
			
		||||
                        : 'untitled'.tr())
 | 
			
		||||
                    .textStyle(Theme.of(context).textTheme.titleLarge!)
 | 
			
		||||
                    .textColor(Colors.white),
 | 
			
		||||
                Text(PostWriteController.kTitleMap[widget.mode]!)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .textColor(Colors.white.withAlpha((255 * 0.9).round())),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
 | 
			
		||||
            title: RichText(
 | 
			
		||||
              textAlign: TextAlign.center,
 | 
			
		||||
              text: TextSpan(children: [
 | 
			
		||||
                TextSpan(
 | 
			
		||||
                  text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
 | 
			
		||||
                ),
 | 
			
		||||
                const TextSpan(text: '\n'),
 | 
			
		||||
                TextSpan(
 | 
			
		||||
                  text: PostWriteController.kTitleMap[widget.mode]!.tr(),
 | 
			
		||||
                  style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
 | 
			
		||||
                ),
 | 
			
		||||
              ]),
 | 
			
		||||
            ),
 | 
			
		||||
            actions: [
 | 
			
		||||
              IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.tune),
 | 
			
		||||
                onPressed: _writeController.isBusy ? null : _updateMeta,
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          body: Column(
 | 
			
		||||
@@ -160,17 +177,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                                Expanded(
 | 
			
		||||
                                  child: Column(
 | 
			
		||||
                                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                    crossAxisAlignment:
 | 
			
		||||
                                        CrossAxisAlignment.start,
 | 
			
		||||
                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      Text(item.nick).textStyle(
 | 
			
		||||
                                          Theme.of(context)
 | 
			
		||||
                                              .textTheme
 | 
			
		||||
                                              .bodyMedium!),
 | 
			
		||||
                                      Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                      Text('@${item.name}')
 | 
			
		||||
                                          .textStyle(Theme.of(context)
 | 
			
		||||
                                              .textTheme
 | 
			
		||||
                                              .bodySmall!)
 | 
			
		||||
                                          .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
			
		||||
                                          .fontSize(12),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
@@ -187,8 +198,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                          CircleAvatar(
 | 
			
		||||
                            radius: 16,
 | 
			
		||||
                            backgroundColor: Colors.transparent,
 | 
			
		||||
                            foregroundColor:
 | 
			
		||||
                                Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                            foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                            child: const Icon(Symbols.add),
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
@@ -197,8 +207,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text('publishersNew').tr().textStyle(
 | 
			
		||||
                                    Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
@@ -209,9 +218,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                  value: _writeController.publisher,
 | 
			
		||||
                  onChanged: (SnPublisher? value) {
 | 
			
		||||
                    if (value == null) {
 | 
			
		||||
                      GoRouter.of(context)
 | 
			
		||||
                          .pushNamed('accountPublisherNew')
 | 
			
		||||
                          .then((value) {
 | 
			
		||||
                      GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
			
		||||
                        if (value == true) {
 | 
			
		||||
                          _publishers = null;
 | 
			
		||||
                          _fetchPublishers();
 | 
			
		||||
@@ -246,16 +253,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: ExpansionTile(
 | 
			
		||||
                                minTileHeight: 48,
 | 
			
		||||
                                leading:
 | 
			
		||||
                                    const Icon(Symbols.reply).padding(left: 4),
 | 
			
		||||
                                leading: const Icon(Symbols.reply).padding(left: 4),
 | 
			
		||||
                                title: Text('postReplyingNotice')
 | 
			
		||||
                                    .fontSize(15)
 | 
			
		||||
                                    .tr(args: [
 | 
			
		||||
                                  '@${_writeController.replyingPost!.publisher.name}'
 | 
			
		||||
                                ]),
 | 
			
		||||
                                children: <Widget>[
 | 
			
		||||
                                  PostItem(data: _writeController.replyingPost!)
 | 
			
		||||
                                ],
 | 
			
		||||
                                    .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
 | 
			
		||||
                                children: <Widget>[PostItem(data: _writeController.replyingPost!)],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
@@ -271,16 +273,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: ExpansionTile(
 | 
			
		||||
                                minTileHeight: 48,
 | 
			
		||||
                                leading: const Icon(Symbols.forward)
 | 
			
		||||
                                    .padding(left: 4),
 | 
			
		||||
                                leading: const Icon(Symbols.forward).padding(left: 4),
 | 
			
		||||
                                title: Text('postRepostingNotice')
 | 
			
		||||
                                    .fontSize(15)
 | 
			
		||||
                                    .tr(args: [
 | 
			
		||||
                                  '@${_writeController.repostingPost!.publisher.name}'
 | 
			
		||||
                                ]),
 | 
			
		||||
                                    .tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
 | 
			
		||||
                                children: <Widget>[
 | 
			
		||||
                                  PostItem(
 | 
			
		||||
                                      data: _writeController.repostingPost!)
 | 
			
		||||
                                    data: _writeController.repostingPost!,
 | 
			
		||||
                                  )
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
@@ -297,16 +297,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: ExpansionTile(
 | 
			
		||||
                                minTileHeight: 48,
 | 
			
		||||
                                leading: const Icon(Symbols.edit_note)
 | 
			
		||||
                                    .padding(left: 4),
 | 
			
		||||
                                leading: const Icon(Symbols.edit_note).padding(left: 4),
 | 
			
		||||
                                title: Text('postEditingNotice')
 | 
			
		||||
                                    .fontSize(15)
 | 
			
		||||
                                    .tr(args: [
 | 
			
		||||
                                  '@${_writeController.editingPost!.publisher.name}'
 | 
			
		||||
                                ]),
 | 
			
		||||
                                children: <Widget>[
 | 
			
		||||
                                  PostItem(data: _writeController.editingPost!)
 | 
			
		||||
                                ],
 | 
			
		||||
                                    .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
			
		||||
                                children: <Widget>[PostItem(data: _writeController.editingPost!)],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
@@ -325,14 +320,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                          ),
 | 
			
		||||
                          border: InputBorder.none,
 | 
			
		||||
                        ),
 | 
			
		||||
                        onTapOutside: (_) =>
 | 
			
		||||
                            FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ]
 | 
			
		||||
                        .expandIndexed(
 | 
			
		||||
                          (idx, ele) => [
 | 
			
		||||
                            if (idx != 0 || _writeController.isRelatedNull)
 | 
			
		||||
                              const Gap(8),
 | 
			
		||||
                            if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
 | 
			
		||||
                            ele,
 | 
			
		||||
                          ],
 | 
			
		||||
                        )
 | 
			
		||||
@@ -340,9 +333,38 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              if (_writeController.attachments.isNotEmpty)
 | 
			
		||||
              if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
 | 
			
		||||
                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),
 | 
			
		||||
              Material(
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
@@ -350,13 +372,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    LoadingIndicator(isActive: _isLoading),
 | 
			
		||||
                    if (_writeController.isBusy &&
 | 
			
		||||
                        _writeController.progress != null)
 | 
			
		||||
                    if (_writeController.isBusy && _writeController.progress != null)
 | 
			
		||||
                      TweenAnimationBuilder<double>(
 | 
			
		||||
                        tween: Tween(begin: 0, end: _writeController.progress),
 | 
			
		||||
                        duration: Duration(milliseconds: 300),
 | 
			
		||||
                        builder: (context, value, _) =>
 | 
			
		||||
                            LinearProgressIndicator(value: value, minHeight: 2),
 | 
			
		||||
                        builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
 | 
			
		||||
                      )
 | 
			
		||||
                    else if (_writeController.isBusy)
 | 
			
		||||
                      const LinearProgressIndicator(value: null, minHeight: 2),
 | 
			
		||||
@@ -370,15 +390,37 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                              scrollDirection: Axis.vertical,
 | 
			
		||||
                              child: Row(
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  IconButton(
 | 
			
		||||
                                    onPressed: _writeController.isBusy
 | 
			
		||||
                                        ? null
 | 
			
		||||
                                        : _selectMedia,
 | 
			
		||||
                                  PopupMenuButton(
 | 
			
		||||
                                    icon: Icon(
 | 
			
		||||
                                      Symbols.add_photo_alternate,
 | 
			
		||||
                                      color:
 | 
			
		||||
                                          Theme.of(context).colorScheme.primary,
 | 
			
		||||
                                      color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    itemBuilder: (context) => [
 | 
			
		||||
                                      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();
 | 
			
		||||
                                        },
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
@@ -386,8 +428,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        TextButton.icon(
 | 
			
		||||
                          onPressed: (_writeController.isBusy ||
 | 
			
		||||
                                  _writeController.publisher == null)
 | 
			
		||||
                          onPressed: (_writeController.isBusy || _writeController.publisher == null)
 | 
			
		||||
                              ? null
 | 
			
		||||
                              : () {
 | 
			
		||||
                                  _writeController.post(context).then((_) {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										188
									
								
								lib/screens/post/post_search.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,188 @@
 | 
			
		||||
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/post/post_item.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;
 | 
			
		||||
 | 
			
		||||
  final List<SnPost> _posts = List.empty(growable: true);
 | 
			
		||||
  int? _postCount;
 | 
			
		||||
 | 
			
		||||
  String _searchTerm = '';
 | 
			
		||||
  Duration? _lastTook;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchPosts() async {
 | 
			
		||||
    if (_searchTerm.isEmpty) return;
 | 
			
		||||
    if (_postCount != null && _posts.length >= _postCount!) return;
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final stopwatch = Stopwatch()..start();
 | 
			
		||||
 | 
			
		||||
    final pt = context.read<SnPostContentProvider>();
 | 
			
		||||
    final result = await pt.searchPosts(
 | 
			
		||||
      _searchTerm,
 | 
			
		||||
      take: 10,
 | 
			
		||||
      offset: _posts.length,
 | 
			
		||||
    );
 | 
			
		||||
    final List<SnPost> out = result.$1;
 | 
			
		||||
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    stopwatch.stop();
 | 
			
		||||
 | 
			
		||||
    _lastTook = stopwatch.elapsed;
 | 
			
		||||
    _postCount = result.$2;
 | 
			
		||||
    _posts.addAll(out);
 | 
			
		||||
 | 
			
		||||
    if (mounted) setState(() => _isBusy = false);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showAdvancedSearchTune() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => Column(
 | 
			
		||||
        children: [],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @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),
 | 
			
		||||
                  ),
 | 
			
		||||
                  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),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										489
									
								
								lib/screens/post/publisher_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,489 @@
 | 
			
		||||
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/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';
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchPublisher() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      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);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err).then((_) {
 | 
			
		||||
        if (mounted) Navigator.pop(context);
 | 
			
		||||
      });
 | 
			
		||||
    } 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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _updateFetchType() {
 | 
			
		||||
    _posts.clear();
 | 
			
		||||
    _fetchPosts();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @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: Colors.white,
 | 
			
		||||
                                      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: [
 | 
			
		||||
                              UniversalImage(
 | 
			
		||||
                                sn.getAttachmentUrl(_publisher!.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 (_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),
 | 
			
		||||
                                  ),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ).padding(right: 8),
 | 
			
		||||
                            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,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                                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),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										234
									
								
								lib/screens/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,234 @@
 | 
			
		||||
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/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/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 {
 | 
			
		||||
    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>();
 | 
			
		||||
 | 
			
		||||
    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 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},
 | 
			
		||||
                        );
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										414
									
								
								lib/screens/realm/realm_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,414 @@
 | 
			
		||||
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';
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchRealm();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @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),
 | 
			
		||||
              _RealmMemberListWidget(realm: _realm),
 | 
			
		||||
              _RealmSettingsWidget(
 | 
			
		||||
                realm: _realm,
 | 
			
		||||
                onUpdate: () {
 | 
			
		||||
                  _fetchRealm();
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _RealmDetailHomeWidget extends StatelessWidget {
 | 
			
		||||
  final SnRealm? realm;
 | 
			
		||||
  const _RealmDetailHomeWidget({super.key, required this.realm});
 | 
			
		||||
 | 
			
		||||
  @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(),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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: [
 | 
			
		||||
        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,
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -39,6 +39,9 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
      opticalSize: 20,
 | 
			
		||||
      color: colorScheme.onSurface,
 | 
			
		||||
    ),
 | 
			
		||||
    appBarTheme: AppBarTheme(
 | 
			
		||||
      centerTitle: true,
 | 
			
		||||
    ),
 | 
			
		||||
    scaffoldBackgroundColor: Colors.transparent,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,29 +1,33 @@
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
 | 
			
		||||
part 'account.freezed.dart';
 | 
			
		||||
part 'account.g.dart';
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnAccount with _$SnAccount {
 | 
			
		||||
  const SnAccount._();
 | 
			
		||||
 | 
			
		||||
  const factory SnAccount({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required int? affiliatedId,
 | 
			
		||||
    required int? affiliatedTo,
 | 
			
		||||
    required int? automatedBy,
 | 
			
		||||
    required int? automatedId,
 | 
			
		||||
    @HiveField(0) required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required DateTime? confirmedAt,
 | 
			
		||||
    required List<SnAccountContact>? contacts,
 | 
			
		||||
    required String avatar,
 | 
			
		||||
    required String banner,
 | 
			
		||||
    required DateTime? confirmedAt,
 | 
			
		||||
    required List<SnAccountContact> contacts,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String description,
 | 
			
		||||
    required String name,
 | 
			
		||||
    required String nick,
 | 
			
		||||
    required Map<String, dynamic> permNodes,
 | 
			
		||||
    required SnAccountProfile? profile,
 | 
			
		||||
    @Default([]) List<SnAccountBadge> badges,
 | 
			
		||||
    required DateTime? suspendedAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required int? affiliatedId,
 | 
			
		||||
    required int? affiliatedTo,
 | 
			
		||||
    required int? automatedBy,
 | 
			
		||||
    required int? automatedId,
 | 
			
		||||
  }) = _SnAccount;
 | 
			
		||||
 | 
			
		||||
  factory SnAccount.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
@@ -67,3 +71,51 @@ class SnAccountProfile with _$SnAccountProfile {
 | 
			
		||||
  factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAccountProfileFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnRelationship with _$SnRelationship {
 | 
			
		||||
  const factory SnRelationship({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
    required int relatedId,
 | 
			
		||||
    required SnAccount? account,
 | 
			
		||||
    required SnAccount? related,
 | 
			
		||||
    required int status,
 | 
			
		||||
    @Default({}) Map<String, dynamic> permNodes,
 | 
			
		||||
  }) = _SnRelationship;
 | 
			
		||||
 | 
			
		||||
  factory SnRelationship.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnRelationshipFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnAccountBadge with _$SnAccountBadge {
 | 
			
		||||
  const factory SnAccountBadge({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required dynamic deletedAt,
 | 
			
		||||
    required String type,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
    @Default({}) Map<String, dynamic> metadata,
 | 
			
		||||
  }) = _SnAccountBadge;
 | 
			
		||||
 | 
			
		||||
  factory SnAccountBadge.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAccountBadgeFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnAccountStatusInfo with _$SnAccountStatusInfo {
 | 
			
		||||
  const factory SnAccountStatusInfo({
 | 
			
		||||
    required bool isDisturbable,
 | 
			
		||||
    required bool isOnline,
 | 
			
		||||
    required DateTime? lastSeenAt,
 | 
			
		||||
    required dynamic status,
 | 
			
		||||
  }) = _SnAccountStatusInfo;
 | 
			
		||||
 | 
			
		||||
  factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
      _$SnAccountStatusInfoFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,22 +9,19 @@ part of 'account.dart';
 | 
			
		||||
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnAccountImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
 | 
			
		||||
      affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
 | 
			
		||||
      automatedBy: (json['automated_by'] as num?)?.toInt(),
 | 
			
		||||
      automatedId: (json['automated_id'] as num?)?.toInt(),
 | 
			
		||||
      avatar: json['avatar'] as String,
 | 
			
		||||
      banner: json['banner'] as String,
 | 
			
		||||
      confirmedAt: json['confirmed_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['confirmed_at'] as String),
 | 
			
		||||
      contacts: (json['contacts'] as List<dynamic>)
 | 
			
		||||
          .map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      confirmedAt: json['confirmed_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['confirmed_at'] as String),
 | 
			
		||||
      contacts: (json['contacts'] as List<dynamic>?)
 | 
			
		||||
          ?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      avatar: json['avatar'] as String,
 | 
			
		||||
      banner: json['banner'] as String,
 | 
			
		||||
      description: json['description'] as String,
 | 
			
		||||
      name: json['name'] as String,
 | 
			
		||||
      nick: json['nick'] as String,
 | 
			
		||||
@@ -32,32 +29,40 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      profile: json['profile'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
 | 
			
		||||
      badges: (json['badges'] as List<dynamic>?)
 | 
			
		||||
              ?.map((e) => SnAccountBadge.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
              .toList() ??
 | 
			
		||||
          const [],
 | 
			
		||||
      suspendedAt: json['suspended_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['suspended_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      affiliatedId: (json['affiliated_id'] as num?)?.toInt(),
 | 
			
		||||
      affiliatedTo: (json['affiliated_to'] as num?)?.toInt(),
 | 
			
		||||
      automatedBy: (json['automated_by'] as num?)?.toInt(),
 | 
			
		||||
      automatedId: (json['automated_id'] as num?)?.toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'affiliated_id': instance.affiliatedId,
 | 
			
		||||
      'affiliated_to': instance.affiliatedTo,
 | 
			
		||||
      'automated_by': instance.automatedBy,
 | 
			
		||||
      'automated_id': instance.automatedId,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'confirmed_at': instance.confirmedAt?.toIso8601String(),
 | 
			
		||||
      'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'avatar': instance.avatar,
 | 
			
		||||
      'banner': instance.banner,
 | 
			
		||||
      'confirmed_at': instance.confirmedAt?.toIso8601String(),
 | 
			
		||||
      'contacts': instance.contacts.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'description': instance.description,
 | 
			
		||||
      'name': instance.name,
 | 
			
		||||
      'nick': instance.nick,
 | 
			
		||||
      'perm_nodes': instance.permNodes,
 | 
			
		||||
      'profile': instance.profile?.toJson(),
 | 
			
		||||
      'badges': instance.badges.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'suspended_at': instance.suspendedAt?.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'affiliated_id': instance.affiliatedId,
 | 
			
		||||
      'affiliated_to': instance.affiliatedTo,
 | 
			
		||||
      'automated_by': instance.automatedBy,
 | 
			
		||||
      'automated_id': instance.automatedId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
 | 
			
		||||
@@ -129,3 +134,81 @@ Map<String, dynamic> _$$SnAccountProfileImplToJson(
 | 
			
		||||
      'last_seen_at': instance.lastSeenAt?.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnRelationshipImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
      relatedId: (json['related_id'] as num).toInt(),
 | 
			
		||||
      account: json['account'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
 | 
			
		||||
      related: json['related'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccount.fromJson(json['related'] as Map<String, dynamic>),
 | 
			
		||||
      status: (json['status'] as num).toInt(),
 | 
			
		||||
      permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnRelationshipImplToJson(
 | 
			
		||||
        _$SnRelationshipImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
      'related_id': instance.relatedId,
 | 
			
		||||
      'account': instance.account?.toJson(),
 | 
			
		||||
      'related': instance.related?.toJson(),
 | 
			
		||||
      'status': instance.status,
 | 
			
		||||
      'perm_nodes': instance.permNodes,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnAccountBadgeImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'],
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
      metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnAccountBadgeImplToJson(
 | 
			
		||||
        _$SnAccountBadgeImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt,
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
      'metadata': instance.metadata,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
 | 
			
		||||
        Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnAccountStatusInfoImpl(
 | 
			
		||||
      isDisturbable: json['is_disturbable'] as bool,
 | 
			
		||||
      isOnline: json['is_online'] as bool,
 | 
			
		||||
      lastSeenAt: json['last_seen_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['last_seen_at'] as String),
 | 
			
		||||
      status: json['status'],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
 | 
			
		||||
        _$SnAccountStatusInfoImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'is_disturbable': instance.isDisturbable,
 | 
			
		||||
      'is_online': instance.isOnline,
 | 
			
		||||
      'last_seen_at': instance.lastSeenAt?.toIso8601String(),
 | 
			
		||||
      'status': instance.status,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										143
									
								
								lib/types/chat.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,143 @@
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:livekit_client/livekit_client.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
 | 
			
		||||
part 'chat.freezed.dart';
 | 
			
		||||
part 'chat.g.dart';
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnChannel with _$SnChannel {
 | 
			
		||||
  const SnChannel._();
 | 
			
		||||
 | 
			
		||||
  @HiveType(typeId: 2)
 | 
			
		||||
  const factory SnChannel({
 | 
			
		||||
    @HiveField(0) required int id,
 | 
			
		||||
    @HiveField(1) required DateTime createdAt,
 | 
			
		||||
    @HiveField(2) required DateTime updatedAt,
 | 
			
		||||
    @HiveField(3) required dynamic deletedAt,
 | 
			
		||||
    @HiveField(4) required String alias,
 | 
			
		||||
    @HiveField(5) required String name,
 | 
			
		||||
    @HiveField(6) required String description,
 | 
			
		||||
    @HiveField(7) required List<dynamic>? members,
 | 
			
		||||
    List<SnChatMessage>? messages,
 | 
			
		||||
    @HiveField(8) required int type,
 | 
			
		||||
    @HiveField(9) required int accountId,
 | 
			
		||||
    @HiveField(10) required SnRealm? realm,
 | 
			
		||||
    @HiveField(11) required int? realmId,
 | 
			
		||||
    @HiveField(12) required bool isPublic,
 | 
			
		||||
    @HiveField(13) required bool isCommunity,
 | 
			
		||||
  }) = _SnChannel;
 | 
			
		||||
 | 
			
		||||
  factory SnChannel.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnChannelFromJson(json);
 | 
			
		||||
 | 
			
		||||
  String get key => '${realm?.alias ?? 'global'}:$alias';
 | 
			
		||||
  String get keyPath => '${realm?.alias ?? 'global'}/$alias';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnChannelMember with _$SnChannelMember {
 | 
			
		||||
  const SnChannelMember._();
 | 
			
		||||
 | 
			
		||||
  @HiveType(typeId: 3)
 | 
			
		||||
  const factory SnChannelMember({
 | 
			
		||||
    @HiveField(0) required int id,
 | 
			
		||||
    @HiveField(1) required DateTime createdAt,
 | 
			
		||||
    @HiveField(2) required DateTime updatedAt,
 | 
			
		||||
    @HiveField(3) required DateTime? deletedAt,
 | 
			
		||||
    @HiveField(4) required int channelId,
 | 
			
		||||
    @HiveField(5) required int accountId,
 | 
			
		||||
    @HiveField(6) required String? nick,
 | 
			
		||||
    @HiveField(7) required SnChannel? channel,
 | 
			
		||||
    @HiveField(8) required SnAccount? account,
 | 
			
		||||
    @Default(0) int notify,
 | 
			
		||||
    @HiveField(9) required int powerLevel,
 | 
			
		||||
    dynamic calls,
 | 
			
		||||
    dynamic events,
 | 
			
		||||
  }) = _SnChannelMember;
 | 
			
		||||
 | 
			
		||||
  factory SnChannelMember.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnChannelMemberFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnChatMessage with _$SnChatMessage {
 | 
			
		||||
  const SnChatMessage._();
 | 
			
		||||
 | 
			
		||||
  @HiveType(typeId: 4)
 | 
			
		||||
  const factory SnChatMessage({
 | 
			
		||||
    @HiveField(0) required int id,
 | 
			
		||||
    @HiveField(1) required DateTime createdAt,
 | 
			
		||||
    @HiveField(2) required DateTime updatedAt,
 | 
			
		||||
    @HiveField(3) required DateTime? deletedAt,
 | 
			
		||||
    @HiveField(4) required String uuid,
 | 
			
		||||
    @HiveField(5) @Default({}) Map<String, dynamic> body,
 | 
			
		||||
    @HiveField(6) required String type,
 | 
			
		||||
    @HiveField(7) required SnChannel channel,
 | 
			
		||||
    @HiveField(8) required SnChannelMember sender,
 | 
			
		||||
    @HiveField(9) required int channelId,
 | 
			
		||||
    @HiveField(10) required int senderId,
 | 
			
		||||
    @HiveField(11) required int? quoteEventId,
 | 
			
		||||
    @HiveField(12) required int? relatedEventId,
 | 
			
		||||
    SnChatMessagePreload? preload,
 | 
			
		||||
  }) = _SnChatMessage;
 | 
			
		||||
 | 
			
		||||
  factory SnChatMessage.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnChatMessageFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnChatMessagePreload with _$SnChatMessagePreload {
 | 
			
		||||
  const SnChatMessagePreload._();
 | 
			
		||||
 | 
			
		||||
  const factory SnChatMessagePreload({
 | 
			
		||||
    List<SnAttachment?>? attachments,
 | 
			
		||||
    SnChatMessage? quoteEvent,
 | 
			
		||||
  }) = _SnChatMessagePreload;
 | 
			
		||||
 | 
			
		||||
  factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnChatMessagePreloadFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnChatCall with _$SnChatCall {
 | 
			
		||||
  const factory SnChatCall({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required DateTime? endedAt,
 | 
			
		||||
    required String externalId,
 | 
			
		||||
    required int founderId,
 | 
			
		||||
    required int channelId,
 | 
			
		||||
    required SnChannelMember founder,
 | 
			
		||||
    @Default([]) List<dynamic> participants,
 | 
			
		||||
  }) = _SnChatCall;
 | 
			
		||||
 | 
			
		||||
  factory SnChatCall.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnChatCallFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Call stuff
 | 
			
		||||
 | 
			
		||||
enum ParticipantStatsType {
 | 
			
		||||
  unknown,
 | 
			
		||||
  localAudioSender,
 | 
			
		||||
  localVideoSender,
 | 
			
		||||
  remoteAudioReceiver,
 | 
			
		||||
  remoteVideoReceiver,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ParticipantTrack {
 | 
			
		||||
  ParticipantTrack(
 | 
			
		||||
      {required this.participant,
 | 
			
		||||
      required this.videoTrack,
 | 
			
		||||
      required this.isScreenShare});
 | 
			
		||||
 | 
			
		||||
  VideoTrack? videoTrack;
 | 
			
		||||
  Participant participant;
 | 
			
		||||
  bool isScreenShare;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2142
									
								
								lib/types/chat.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										393
									
								
								lib/types/chat.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,393 @@
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
 | 
			
		||||
part of 'chat.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// TypeAdapterGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
class SnChannelImplAdapter extends TypeAdapter<_$SnChannelImpl> {
 | 
			
		||||
  @override
 | 
			
		||||
  final int typeId = 2;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  _$SnChannelImpl read(BinaryReader reader) {
 | 
			
		||||
    final numOfFields = reader.readByte();
 | 
			
		||||
    final fields = <int, dynamic>{
 | 
			
		||||
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
 | 
			
		||||
    };
 | 
			
		||||
    return _$SnChannelImpl(
 | 
			
		||||
      id: fields[0] as int,
 | 
			
		||||
      createdAt: fields[1] as DateTime,
 | 
			
		||||
      updatedAt: fields[2] as DateTime,
 | 
			
		||||
      deletedAt: fields[3] as dynamic,
 | 
			
		||||
      alias: fields[4] as String,
 | 
			
		||||
      name: fields[5] as String,
 | 
			
		||||
      description: fields[6] as String,
 | 
			
		||||
      members: (fields[7] as List?)?.cast<dynamic>(),
 | 
			
		||||
      type: fields[8] as int,
 | 
			
		||||
      accountId: fields[9] as int,
 | 
			
		||||
      realm: fields[10] as SnRealm?,
 | 
			
		||||
      realmId: fields[11] as int?,
 | 
			
		||||
      isPublic: fields[12] as bool,
 | 
			
		||||
      isCommunity: fields[13] as bool,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void write(BinaryWriter writer, _$SnChannelImpl obj) {
 | 
			
		||||
    writer
 | 
			
		||||
      ..writeByte(14)
 | 
			
		||||
      ..writeByte(0)
 | 
			
		||||
      ..write(obj.id)
 | 
			
		||||
      ..writeByte(1)
 | 
			
		||||
      ..write(obj.createdAt)
 | 
			
		||||
      ..writeByte(2)
 | 
			
		||||
      ..write(obj.updatedAt)
 | 
			
		||||
      ..writeByte(3)
 | 
			
		||||
      ..write(obj.deletedAt)
 | 
			
		||||
      ..writeByte(4)
 | 
			
		||||
      ..write(obj.alias)
 | 
			
		||||
      ..writeByte(5)
 | 
			
		||||
      ..write(obj.name)
 | 
			
		||||
      ..writeByte(6)
 | 
			
		||||
      ..write(obj.description)
 | 
			
		||||
      ..writeByte(8)
 | 
			
		||||
      ..write(obj.type)
 | 
			
		||||
      ..writeByte(9)
 | 
			
		||||
      ..write(obj.accountId)
 | 
			
		||||
      ..writeByte(10)
 | 
			
		||||
      ..write(obj.realm)
 | 
			
		||||
      ..writeByte(11)
 | 
			
		||||
      ..write(obj.realmId)
 | 
			
		||||
      ..writeByte(12)
 | 
			
		||||
      ..write(obj.isPublic)
 | 
			
		||||
      ..writeByte(13)
 | 
			
		||||
      ..write(obj.isCommunity)
 | 
			
		||||
      ..writeByte(7)
 | 
			
		||||
      ..write(obj.members);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => typeId.hashCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      other is SnChannelImplAdapter &&
 | 
			
		||||
          runtimeType == other.runtimeType &&
 | 
			
		||||
          typeId == other.typeId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnChannelMemberImplAdapter extends TypeAdapter<_$SnChannelMemberImpl> {
 | 
			
		||||
  @override
 | 
			
		||||
  final int typeId = 3;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  _$SnChannelMemberImpl read(BinaryReader reader) {
 | 
			
		||||
    final numOfFields = reader.readByte();
 | 
			
		||||
    final fields = <int, dynamic>{
 | 
			
		||||
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
 | 
			
		||||
    };
 | 
			
		||||
    return _$SnChannelMemberImpl(
 | 
			
		||||
      id: fields[0] as int,
 | 
			
		||||
      createdAt: fields[1] as DateTime,
 | 
			
		||||
      updatedAt: fields[2] as DateTime,
 | 
			
		||||
      deletedAt: fields[3] as DateTime?,
 | 
			
		||||
      channelId: fields[4] as int,
 | 
			
		||||
      accountId: fields[5] as int,
 | 
			
		||||
      nick: fields[6] as String?,
 | 
			
		||||
      channel: fields[7] as SnChannel?,
 | 
			
		||||
      account: fields[8] as SnAccount?,
 | 
			
		||||
      powerLevel: fields[9] as int,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void write(BinaryWriter writer, _$SnChannelMemberImpl obj) {
 | 
			
		||||
    writer
 | 
			
		||||
      ..writeByte(10)
 | 
			
		||||
      ..writeByte(0)
 | 
			
		||||
      ..write(obj.id)
 | 
			
		||||
      ..writeByte(1)
 | 
			
		||||
      ..write(obj.createdAt)
 | 
			
		||||
      ..writeByte(2)
 | 
			
		||||
      ..write(obj.updatedAt)
 | 
			
		||||
      ..writeByte(3)
 | 
			
		||||
      ..write(obj.deletedAt)
 | 
			
		||||
      ..writeByte(4)
 | 
			
		||||
      ..write(obj.channelId)
 | 
			
		||||
      ..writeByte(5)
 | 
			
		||||
      ..write(obj.accountId)
 | 
			
		||||
      ..writeByte(6)
 | 
			
		||||
      ..write(obj.nick)
 | 
			
		||||
      ..writeByte(7)
 | 
			
		||||
      ..write(obj.channel)
 | 
			
		||||
      ..writeByte(8)
 | 
			
		||||
      ..write(obj.account)
 | 
			
		||||
      ..writeByte(9)
 | 
			
		||||
      ..write(obj.powerLevel);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => typeId.hashCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      other is SnChannelMemberImplAdapter &&
 | 
			
		||||
          runtimeType == other.runtimeType &&
 | 
			
		||||
          typeId == other.typeId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> {
 | 
			
		||||
  @override
 | 
			
		||||
  final int typeId = 4;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  _$SnChatMessageImpl read(BinaryReader reader) {
 | 
			
		||||
    final numOfFields = reader.readByte();
 | 
			
		||||
    final fields = <int, dynamic>{
 | 
			
		||||
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
 | 
			
		||||
    };
 | 
			
		||||
    return _$SnChatMessageImpl(
 | 
			
		||||
      id: fields[0] as int,
 | 
			
		||||
      createdAt: fields[1] as DateTime,
 | 
			
		||||
      updatedAt: fields[2] as DateTime,
 | 
			
		||||
      deletedAt: fields[3] as DateTime?,
 | 
			
		||||
      uuid: fields[4] as String,
 | 
			
		||||
      body: (fields[5] as Map).cast<String, dynamic>(),
 | 
			
		||||
      type: fields[6] as String,
 | 
			
		||||
      channel: fields[7] as SnChannel,
 | 
			
		||||
      sender: fields[8] as SnChannelMember,
 | 
			
		||||
      channelId: fields[9] as int,
 | 
			
		||||
      senderId: fields[10] as int,
 | 
			
		||||
      quoteEventId: fields[11] as int?,
 | 
			
		||||
      relatedEventId: fields[12] as int?,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void write(BinaryWriter writer, _$SnChatMessageImpl obj) {
 | 
			
		||||
    writer
 | 
			
		||||
      ..writeByte(13)
 | 
			
		||||
      ..writeByte(0)
 | 
			
		||||
      ..write(obj.id)
 | 
			
		||||
      ..writeByte(1)
 | 
			
		||||
      ..write(obj.createdAt)
 | 
			
		||||
      ..writeByte(2)
 | 
			
		||||
      ..write(obj.updatedAt)
 | 
			
		||||
      ..writeByte(3)
 | 
			
		||||
      ..write(obj.deletedAt)
 | 
			
		||||
      ..writeByte(4)
 | 
			
		||||
      ..write(obj.uuid)
 | 
			
		||||
      ..writeByte(6)
 | 
			
		||||
      ..write(obj.type)
 | 
			
		||||
      ..writeByte(7)
 | 
			
		||||
      ..write(obj.channel)
 | 
			
		||||
      ..writeByte(8)
 | 
			
		||||
      ..write(obj.sender)
 | 
			
		||||
      ..writeByte(9)
 | 
			
		||||
      ..write(obj.channelId)
 | 
			
		||||
      ..writeByte(10)
 | 
			
		||||
      ..write(obj.senderId)
 | 
			
		||||
      ..writeByte(11)
 | 
			
		||||
      ..write(obj.quoteEventId)
 | 
			
		||||
      ..writeByte(12)
 | 
			
		||||
      ..write(obj.relatedEventId)
 | 
			
		||||
      ..writeByte(5)
 | 
			
		||||
      ..write(obj.body);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => typeId.hashCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      other is SnChatMessageImplAdapter &&
 | 
			
		||||
          runtimeType == other.runtimeType &&
 | 
			
		||||
          typeId == other.typeId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// JsonSerializableGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
_$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnChannelImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'],
 | 
			
		||||
      alias: json['alias'] as String,
 | 
			
		||||
      name: json['name'] as String,
 | 
			
		||||
      description: json['description'] as String,
 | 
			
		||||
      members: json['members'] as List<dynamic>?,
 | 
			
		||||
      messages: (json['messages'] as List<dynamic>?)
 | 
			
		||||
          ?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      type: (json['type'] as num).toInt(),
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
      realm: json['realm'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
 | 
			
		||||
      realmId: (json['realm_id'] as num?)?.toInt(),
 | 
			
		||||
      isPublic: json['is_public'] as bool,
 | 
			
		||||
      isCommunity: json['is_community'] as bool,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt,
 | 
			
		||||
      'alias': instance.alias,
 | 
			
		||||
      'name': instance.name,
 | 
			
		||||
      'description': instance.description,
 | 
			
		||||
      'members': instance.members,
 | 
			
		||||
      'messages': instance.messages?.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
      'realm': instance.realm?.toJson(),
 | 
			
		||||
      'realm_id': instance.realmId,
 | 
			
		||||
      'is_public': instance.isPublic,
 | 
			
		||||
      'is_community': instance.isCommunity,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
 | 
			
		||||
        Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnChannelMemberImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      channelId: (json['channel_id'] as num).toInt(),
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
      nick: json['nick'] as String?,
 | 
			
		||||
      channel: json['channel'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnChannel.fromJson(json['channel'] as Map<String, dynamic>),
 | 
			
		||||
      account: json['account'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
 | 
			
		||||
      notify: (json['notify'] as num?)?.toInt() ?? 0,
 | 
			
		||||
      powerLevel: (json['power_level'] as num).toInt(),
 | 
			
		||||
      calls: json['calls'],
 | 
			
		||||
      events: json['events'],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnChannelMemberImplToJson(
 | 
			
		||||
        _$SnChannelMemberImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'channel_id': instance.channelId,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
      'nick': instance.nick,
 | 
			
		||||
      'channel': instance.channel?.toJson(),
 | 
			
		||||
      'account': instance.account?.toJson(),
 | 
			
		||||
      'notify': instance.notify,
 | 
			
		||||
      'power_level': instance.powerLevel,
 | 
			
		||||
      'calls': instance.calls,
 | 
			
		||||
      'events': instance.events,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnChatMessageImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      uuid: json['uuid'] as String,
 | 
			
		||||
      body: json['body'] as Map<String, dynamic>? ?? const {},
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      channel: SnChannel.fromJson(json['channel'] as Map<String, dynamic>),
 | 
			
		||||
      sender: SnChannelMember.fromJson(json['sender'] as Map<String, dynamic>),
 | 
			
		||||
      channelId: (json['channel_id'] as num).toInt(),
 | 
			
		||||
      senderId: (json['sender_id'] as num).toInt(),
 | 
			
		||||
      quoteEventId: (json['quote_event_id'] as num?)?.toInt(),
 | 
			
		||||
      relatedEventId: (json['related_event_id'] as num?)?.toInt(),
 | 
			
		||||
      preload: json['preload'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnChatMessagePreload.fromJson(
 | 
			
		||||
              json['preload'] as Map<String, dynamic>),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'uuid': instance.uuid,
 | 
			
		||||
      'body': instance.body,
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'channel': instance.channel.toJson(),
 | 
			
		||||
      'sender': instance.sender.toJson(),
 | 
			
		||||
      'channel_id': instance.channelId,
 | 
			
		||||
      'sender_id': instance.senderId,
 | 
			
		||||
      'quote_event_id': instance.quoteEventId,
 | 
			
		||||
      'related_event_id': instance.relatedEventId,
 | 
			
		||||
      'preload': instance.preload?.toJson(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
 | 
			
		||||
        Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnChatMessagePreloadImpl(
 | 
			
		||||
      attachments: (json['attachments'] as List<dynamic>?)
 | 
			
		||||
          ?.map((e) => e == null
 | 
			
		||||
              ? null
 | 
			
		||||
              : SnAttachment.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      quoteEvent: json['quote_event'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
 | 
			
		||||
        _$SnChatMessagePreloadImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
 | 
			
		||||
      'quote_event': instance.quoteEvent?.toJson(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnChatCallImpl(
 | 
			
		||||
      id: (json['id'] as num).toInt(),
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt: json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      endedAt: json['ended_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['ended_at'] as String),
 | 
			
		||||
      externalId: json['external_id'] as String,
 | 
			
		||||
      founderId: (json['founder_id'] as num).toInt(),
 | 
			
		||||
      channelId: (json['channel_id'] as num).toInt(),
 | 
			
		||||
      founder:
 | 
			
		||||
          SnChannelMember.fromJson(json['founder'] as Map<String, dynamic>),
 | 
			
		||||
      participants: json['participants'] as List<dynamic>? ?? const [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'ended_at': instance.endedAt?.toIso8601String(),
 | 
			
		||||
      'external_id': instance.externalId,
 | 
			
		||||
      'founder_id': instance.founderId,
 | 
			
		||||
      'channel_id': instance.channelId,
 | 
			
		||||
      'founder': instance.founder.toJson(),
 | 
			
		||||
      'participants': instance.participants,
 | 
			
		||||
    };
 | 
			
		||||
							
								
								
									
										31
									
								
								lib/types/check_in.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,31 @@
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
 | 
			
		||||
part 'check_in.freezed.dart';
 | 
			
		||||
part 'check_in.g.dart';
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnCheckInRecord with _$SnCheckInRecord {
 | 
			
		||||
  const SnCheckInRecord._();
 | 
			
		||||
 | 
			
		||||
  const factory SnCheckInRecord({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required int resultTier,
 | 
			
		||||
    required int resultExperience,
 | 
			
		||||
    required List<int> resultModifiers,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnCheckInRecord;
 | 
			
		||||
 | 
			
		||||
  factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnCheckInRecordFromJson(json);
 | 
			
		||||
 | 
			
		||||
  String get symbol => switch (resultTier) {
 | 
			
		||||
        0 => '大凶',
 | 
			
		||||
        1 => '凶',
 | 
			
		||||
        2 => '中平',
 | 
			
		||||
        3 => '吉',
 | 
			
		||||
        _ => '大吉',
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										334
									
								
								lib/types/check_in.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,334 @@
 | 
			
		||||
// coverage:ignore-file
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
// ignore_for_file: type=lint
 | 
			
		||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
 | 
			
		||||
 | 
			
		||||
part of 'check_in.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// FreezedGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
T _$identity<T>(T value) => value;
 | 
			
		||||
 | 
			
		||||
final _privateConstructorUsedError = UnsupportedError(
 | 
			
		||||
    'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
 | 
			
		||||
 | 
			
		||||
SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) {
 | 
			
		||||
  return _SnCheckInRecord.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnCheckInRecord {
 | 
			
		||||
  int get id => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get createdAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get updatedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get deletedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  int get resultTier => throw _privateConstructorUsedError;
 | 
			
		||||
  int get resultExperience => throw _privateConstructorUsedError;
 | 
			
		||||
  List<int> get resultModifiers => throw _privateConstructorUsedError;
 | 
			
		||||
  int get accountId => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnCheckInRecord to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnCheckInRecord
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  $SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class $SnCheckInRecordCopyWith<$Res> {
 | 
			
		||||
  factory $SnCheckInRecordCopyWith(
 | 
			
		||||
          SnCheckInRecord value, $Res Function(SnCheckInRecord) then) =
 | 
			
		||||
      _$SnCheckInRecordCopyWithImpl<$Res, SnCheckInRecord>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
 | 
			
		||||
    implements $SnCheckInRecordCopyWith<$Res> {
 | 
			
		||||
  _$SnCheckInRecordCopyWithImpl(this._value, this._then);
 | 
			
		||||
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Val _value;
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Res Function($Val) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnCheckInRecord
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _value.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _value.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _value.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      resultTier: null == resultTier
 | 
			
		||||
          ? _value.resultTier
 | 
			
		||||
          : resultTier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultExperience: null == resultExperience
 | 
			
		||||
          ? _value.resultExperience
 | 
			
		||||
          : resultExperience // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _value.resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<int>,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _value.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class _$$SnCheckInRecordImplCopyWith<$Res>
 | 
			
		||||
    implements $SnCheckInRecordCopyWith<$Res> {
 | 
			
		||||
  factory _$$SnCheckInRecordImplCopyWith(_$SnCheckInRecordImpl value,
 | 
			
		||||
          $Res Function(_$SnCheckInRecordImpl) then) =
 | 
			
		||||
      __$$SnCheckInRecordImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$$SnCheckInRecordImplCopyWithImpl<$Res>
 | 
			
		||||
    extends _$SnCheckInRecordCopyWithImpl<$Res, _$SnCheckInRecordImpl>
 | 
			
		||||
    implements _$$SnCheckInRecordImplCopyWith<$Res> {
 | 
			
		||||
  __$$SnCheckInRecordImplCopyWithImpl(
 | 
			
		||||
      _$SnCheckInRecordImpl _value, $Res Function(_$SnCheckInRecordImpl) _then)
 | 
			
		||||
      : super(_value, _then);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnCheckInRecord
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? createdAt = null,
 | 
			
		||||
    Object? updatedAt = null,
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnCheckInRecordImpl(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _value.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _value.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _value.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      resultTier: null == resultTier
 | 
			
		||||
          ? _value.resultTier
 | 
			
		||||
          : resultTier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultExperience: null == resultExperience
 | 
			
		||||
          ? _value.resultExperience
 | 
			
		||||
          : resultExperience // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _value._resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<int>,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _value.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
  const _$SnCheckInRecordImpl(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.resultTier,
 | 
			
		||||
      required this.resultExperience,
 | 
			
		||||
      required final List<int> resultModifiers,
 | 
			
		||||
      required this.accountId})
 | 
			
		||||
      : _resultModifiers = resultModifiers,
 | 
			
		||||
        super._();
 | 
			
		||||
 | 
			
		||||
  factory _$SnCheckInRecordImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$$SnCheckInRecordImplFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final int resultTier;
 | 
			
		||||
  @override
 | 
			
		||||
  final int resultExperience;
 | 
			
		||||
  final List<int> _resultModifiers;
 | 
			
		||||
  @override
 | 
			
		||||
  List<int> get resultModifiers {
 | 
			
		||||
    if (_resultModifiers is EqualUnmodifiableListView) return _resultModifiers;
 | 
			
		||||
    // ignore: implicit_dynamic_type
 | 
			
		||||
    return EqualUnmodifiableListView(_resultModifiers);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int accountId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _$SnCheckInRecordImpl &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            (identical(other.deletedAt, deletedAt) ||
 | 
			
		||||
                other.deletedAt == deletedAt) &&
 | 
			
		||||
            (identical(other.resultTier, resultTier) ||
 | 
			
		||||
                other.resultTier == resultTier) &&
 | 
			
		||||
            (identical(other.resultExperience, resultExperience) ||
 | 
			
		||||
                other.resultExperience == resultExperience) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._resultModifiers, _resultModifiers) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      resultTier,
 | 
			
		||||
      resultExperience,
 | 
			
		||||
      const DeepCollectionEquality().hash(_resultModifiers),
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnCheckInRecord
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
 | 
			
		||||
      __$$SnCheckInRecordImplCopyWithImpl<_$SnCheckInRecordImpl>(
 | 
			
		||||
          this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$$SnCheckInRecordImplToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
  const factory _SnCheckInRecord(
 | 
			
		||||
      {required final int id,
 | 
			
		||||
      required final DateTime createdAt,
 | 
			
		||||
      required final DateTime updatedAt,
 | 
			
		||||
      required final DateTime? deletedAt,
 | 
			
		||||
      required final int resultTier,
 | 
			
		||||
      required final int resultExperience,
 | 
			
		||||
      required final List<int> resultModifiers,
 | 
			
		||||
      required final int accountId}) = _$SnCheckInRecordImpl;
 | 
			
		||||
  const _SnCheckInRecord._() : super._();
 | 
			
		||||
 | 
			
		||||
  factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnCheckInRecordImpl.fromJson;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get id;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  int get resultTier;
 | 
			
		||||
  @override
 | 
			
		||||
  int get resultExperience;
 | 
			
		||||
  @override
 | 
			
		||||
  List<int> get resultModifiers;
 | 
			
		||||
  @override
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnCheckInRecord
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  _$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||