Compare commits
	
		
			92 Commits
		
	
	
		
			2.2.2+51
			...
			f0a3bbe023
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f0a3bbe023 | |||
| df81c84438 | |||
| 8b12395fca | |||
| cb2b71d194 | |||
| 7ed508e2bb | |||
| dad869967e | |||
| 2d5b3b554e | |||
| 74882116e3 | |||
| a97c3bce3a | |||
| 1aa70827dc | |||
| fe028860e9 | |||
| a2d2ce4d38 | |||
| 167c11b9eb | |||
| 8cb3933fcc | |||
| 3818328afe | |||
| 11627e2455 | |||
| 3f82c06ff8 | |||
| 2350f59131 | |||
| 9fe7c9530a | |||
| 52f1826e91 | |||
| 28a4c86dbf | |||
| 85e48ce03b | |||
| efef61a8ea | |||
| 10ead95af9 | |||
| 838ee4d55d | |||
| 13e42429a9 | |||
| c6ce3fe2b7 | |||
| ae9a7eb0fd | |||
| 5d6fb2442f | |||
| 5a85985534 | |||
| c80499db03 | |||
| b8dcdb2315 | |||
| b7b921f1f4 | |||
| 319d5c7d7f | |||
| 4b5b001739 | |||
| db8871a455 | |||
| 38dcaa6066 | |||
| 03275b46ca | |||
| cf3b482fef | |||
| aa4c04d4ef | |||
| 73b82f65e4 | |||
| 9471fe40fe | |||
| 0d1e18735e | |||
| 8bb62b5992 | |||
| 1e8a6dea5b | |||
| 5c2804cc4d | |||
| 0dbb8f132a | |||
| 3395f3dbd0 | |||
| d258ba776e | |||
| 0dcfcaad56 | |||
| 687e720956 | |||
| 180876949e | |||
| 9718965809 | |||
| 5377161fb0 | |||
| 963e538ae5 | |||
| a355e3bf90 | |||
| cb4a2598c8 | |||
| 950612dc07 | |||
| cbd1eaf1af | |||
| ac41cbd99f | |||
| 9f9c90abc4 | |||
| 87029e3538 | |||
| 127d9adc09 | |||
| c82dc7ad85 | |||
| 36bcff7a7c | |||
| 38201b547a | |||
| ed0334fcda | |||
| fbb486b90b | |||
| 9b34f385d5 | |||
| bb7b731602 | |||
| 19076f8136 | |||
| dc77a936ce | |||
| 7f58710c6f | |||
| 068ddcdcdc | |||
| f4e9252ca0 | |||
| 3b1e918117 | |||
| ed7981fdaf | |||
| 9698ca53e4 | |||
| ddc1dc7daf | |||
| 1625a957f8 | |||
| 2dc50d627e | |||
| 2ffde9a3dd | |||
| 5967a91ae1 | |||
| 32c1effcb5 | |||
| 9d0e19c56f | |||
| acf4e634fe | |||
| 25942c2338 | |||
| a4f81f6ba1 | |||
| c1b9090e51 | |||
| f494f70003 | |||
| fb2a55a909 | |||
| 4edfa7fd50 | 
@@ -1,12 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
  "sync": {
 | 
			
		||||
    "region": "solian-next",
 | 
			
		||||
    "region": "solian",
 | 
			
		||||
    "configPath": "roadsign.toml"
 | 
			
		||||
  },
 | 
			
		||||
  "deployments": [
 | 
			
		||||
    {
 | 
			
		||||
      "region": "solian-next",
 | 
			
		||||
      "site": "solian-next-web",
 | 
			
		||||
      "region": "solian",
 | 
			
		||||
      "site": "solian-web",
 | 
			
		||||
      "path": "build/web"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
 
 | 
			
		||||
@@ -17,11 +17,16 @@
 | 
			
		||||
        android:label="Solian"
 | 
			
		||||
        android:name="${applicationName}"
 | 
			
		||||
        android:icon="@mipmap/ic_launcher"
 | 
			
		||||
        android:enableOnBackInvokedCallback="true"
 | 
			
		||||
        android:requestLegacyExternalStorage="true">
 | 
			
		||||
        <meta-data
 | 
			
		||||
            android:name="flutterEmbedding"
 | 
			
		||||
            android:value="2" />
 | 
			
		||||
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name=".MainActivity"
 | 
			
		||||
            android:exported="true"
 | 
			
		||||
            android:launchMode="singleTask"
 | 
			
		||||
            android:launchMode="singleInstance"
 | 
			
		||||
            android:taskAffinity=""
 | 
			
		||||
            android:theme="@style/LaunchTheme"
 | 
			
		||||
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
 | 
			
		||||
 
 | 
			
		||||
@@ -12,9 +12,9 @@ post {
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "alias": "AteChip",
 | 
			
		||||
    "name": "Cat ate chips",
 | 
			
		||||
    "attachment_id": "d0b692cc64054463",
 | 
			
		||||
    "pack_id": 2
 | 
			
		||||
    "alias": "BaLoading",
 | 
			
		||||
    "name": "BaLoading",
 | 
			
		||||
    "attachment_id": "2JCI2uh21mKkfk9P",
 | 
			
		||||
    "pack_id": 3
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Paperclip/Stickers/Get Sticker Packs.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Paperclip/Stickers/Get Sticker Packs.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Sticker Packs
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 3
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/uc/stickers/packs
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								api/Paperclip/Stickers/Get Stickers.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								api/Paperclip/Stickers/Get Stickers.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Stickers
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/uc/stickers?take=10
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
params:query {
 | 
			
		||||
  take: 10
 | 
			
		||||
}
 | 
			
		||||
@@ -15,11 +15,11 @@ body:json {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "Merry Christmas!",
 | 
			
		||||
    "subject": "新年快乐!",
 | 
			
		||||
    "subtitle": "一条来自 Solar Network 团队的信息",
 | 
			
		||||
    "content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
 | 
			
		||||
    "content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
 | 
			
		||||
    "metadata": {
 | 
			
		||||
      "image": "6EqsYQwmFRCkbmhR"
 | 
			
		||||
      "image": "D2EDbcrsTugs3xk5"
 | 
			
		||||
    },
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								api/Passport/Developer Notify One User.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								api/Passport/Developer Notify One User.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Developer Notify One User
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/id/dev/notify/1
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "{{third_client_id}}",
 | 
			
		||||
    "client_secret":"{{third_client_tk}}",
 | 
			
		||||
    "type": "general",
 | 
			
		||||
    "subject": "测试",
 | 
			
		||||
    "subtitle": "Alphabot です",
 | 
			
		||||
    "content": "全新通知动画",
 | 
			
		||||
    "metadata": {
 | 
			
		||||
      "image": "D2EDbcrsTugs3xk5"
 | 
			
		||||
    },
 | 
			
		||||
    "priority": 10
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								api/Reader/List News Sources.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/Reader/List News Sources.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: List News Sources
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 3
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/re/well-known/sources
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								api/Reader/List News.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								api/Reader/List News.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: List News
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/re/news?take=10&offset=0&source=shadiao
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
params:query {
 | 
			
		||||
  take: 10
 | 
			
		||||
  offset: 0
 | 
			
		||||
  source: shadiao
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								api/Reader/Trigger Scan News.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/Reader/Trigger Scan News.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Trigger Scan News
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/re/admin/scan
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "sources": ["taiwan-ltn"],
 | 
			
		||||
    "eager": true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Create Order.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Create Order.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Create Order
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/orders
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								api/Wallet/Create Transaction.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								api/Wallet/Create Transaction.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Create Transaction
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 3
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/transactions
 | 
			
		||||
  body: json
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "alphabot",
 | 
			
		||||
    "client_secret": "_uR0sVnHTh",
 | 
			
		||||
    "remark": "新年红包",
 | 
			
		||||
    "amount": 9705,
 | 
			
		||||
    "payee_id": 2
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Get Order.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Get Order.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Order
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/orders/4
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								api/Wallet/Get Transaction.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/Wallet/Get Transaction.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Get Transaction
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
get {
 | 
			
		||||
  url: {{endpoint}}/cgi/wa/transactions/67
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body:json {
 | 
			
		||||
  {
 | 
			
		||||
    "client_id": "highland-mc",
 | 
			
		||||
    "client_secret": "(3^DLAvo3v",
 | 
			
		||||
    "remark": "我是秦始皇,现在被困香港,现在 SN 转我 500 源点,帮助我回到咸阳,到时候封你为太监一职。",
 | 
			
		||||
    "amount": 500
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								api/WatchTower/Run Database Maintenance.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								api/WatchTower/Run Database Maintenance.bru
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
meta {
 | 
			
		||||
  name: Run Database Maintenance
 | 
			
		||||
  type: http
 | 
			
		||||
  seq: 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
post {
 | 
			
		||||
  url: {{endpoint}}/wt/maintenance/database
 | 
			
		||||
  body: none
 | 
			
		||||
  auth: inherit
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/icon/tray-icon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/tray-icon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 16 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								assets/icon/tray-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icon/tray-icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 228 KiB  | 
@@ -17,6 +17,10 @@
 | 
			
		||||
  "screenAccountProfileEdit": "Edit Profile",
 | 
			
		||||
  "screenAbuseReport": "Abuse Reports",
 | 
			
		||||
  "screenSettings": "Settings",
 | 
			
		||||
  "screenAccountSettings": "Account Settings",
 | 
			
		||||
  "screenFactorSettings": "Auth Factors",
 | 
			
		||||
  "screenAccountWallet": "Wallet",
 | 
			
		||||
  "screenNews": "News",
 | 
			
		||||
  "screenAlbum": "Album",
 | 
			
		||||
  "screenChat": "Chat",
 | 
			
		||||
  "screenChatManage": "Edit Channel",
 | 
			
		||||
@@ -103,8 +107,18 @@
 | 
			
		||||
  },
 | 
			
		||||
  "loginEnterPassword": "Enter the code",
 | 
			
		||||
  "loginSuccess": "Logged in as {}",
 | 
			
		||||
  "authFactorDelete": "Delete Auth Factor",
 | 
			
		||||
  "authFactorDeleteDescription": "Are you sure you want delete auth factor {}?",
 | 
			
		||||
  "authFactorPassword": "Password",
 | 
			
		||||
  "authFactorPasswordDescription": "The password you set when you registered.",
 | 
			
		||||
  "authFactorEmail": "Email verification code",
 | 
			
		||||
  "authFactorEmailDescription": "An one-time code sent to the email address you set when you registered.",
 | 
			
		||||
  "authFactorTOTP": "Time-based OTP",
 | 
			
		||||
  "authFactorTOTPDescription": "A one-time code generated by a TOTP authenticator such as Google Authenticator or Authy.",
 | 
			
		||||
  "authFactorInAppNotify": "In-app notification",
 | 
			
		||||
  "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.",
 | 
			
		||||
  "authFactorAdd": "Add a factor",
 | 
			
		||||
  "authFactorAddSubtitle": "Provide another way to login your account.",
 | 
			
		||||
  "accountIntroTitle": "Hello there!",
 | 
			
		||||
  "accountIntroSubtitle": "Pick an option below to get started.",
 | 
			
		||||
  "accountLogout": "Logout",
 | 
			
		||||
@@ -113,8 +127,14 @@
 | 
			
		||||
  "accountLogoutConfirm": "You will need to re-enter your account password, even if you have already done so. This is required to login again.",
 | 
			
		||||
  "accountPublishers": "Your publishers",
 | 
			
		||||
  "accountPublishersSubtitle": "Manage your publish identities.",
 | 
			
		||||
  "accountSettings": "Account Settings",
 | 
			
		||||
  "accountSettingsSubtitle": "Manage your account and make it yours.",
 | 
			
		||||
  "accountProfileEdit": "Edit your profile",
 | 
			
		||||
  "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
 | 
			
		||||
  "accountWallet": "Wallet",
 | 
			
		||||
  "accountWalletSubtitle": "View your balance and transactions.",
 | 
			
		||||
  "factorSettings": "Auth Factors",
 | 
			
		||||
  "factorSettingsSubtitle": "Manage your authentication factors.",
 | 
			
		||||
  "accountProfileEditApplied": "Profile modification applied.",
 | 
			
		||||
  "publishersNew": "New Publisher",
 | 
			
		||||
  "publisherNewSubtitle": "Create a new publisher identity.",
 | 
			
		||||
@@ -134,9 +154,12 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
 | 
			
		||||
  "writePostTypeStory": "Post a story",
 | 
			
		||||
  "writePostTypeArticle": "Write an article",
 | 
			
		||||
  "writePostTypeQuestion": "Ask a question",
 | 
			
		||||
  "writePostTypeVideo": "Post a video",
 | 
			
		||||
  "fieldPostPublisher": "Post publisher",
 | 
			
		||||
  "fieldPostContent": "What happened?!",
 | 
			
		||||
  "fieldPostTitle": "Title",
 | 
			
		||||
  "fieldPostQuestionReward": "Answer Rewards (Source Points)",
 | 
			
		||||
  "fieldPostDescription": "Description",
 | 
			
		||||
  "fieldPostTags": "Tags",
 | 
			
		||||
  "fieldPostCategories": "Categories",
 | 
			
		||||
@@ -146,9 +169,9 @@
 | 
			
		||||
  "postPosted": "Post has been posted.",
 | 
			
		||||
  "postPublishedAt": "Published At",
 | 
			
		||||
  "postPublishedUntil": "Published Until",
 | 
			
		||||
  "postEditingNotice": "You're about to editing a post that posted {}.",
 | 
			
		||||
  "postReplyingNotice": "You're about to reply to a post that posted {}.",
 | 
			
		||||
  "postRepostingNotice": "You're about to repost a post that posted {}.",
 | 
			
		||||
  "postEditingNotice": "You're about to editing a post that posted by {}.",
 | 
			
		||||
  "postReplyingNotice": "You're about to reply to a post that posted by {}.",
 | 
			
		||||
  "postRepostingNotice": "You're about to repost a post that posted by {}.",
 | 
			
		||||
  "postReact": "React",
 | 
			
		||||
  "postReactions": "Reactions of Post",
 | 
			
		||||
  "postReactionUpvote": {
 | 
			
		||||
@@ -179,6 +202,9 @@
 | 
			
		||||
    "other": "{} comments"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsAppearance": "Appearance",
 | 
			
		||||
  "settingsDisplayLanguage": "Display Language",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "Set the application language.",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "Follow System",
 | 
			
		||||
  "settingsAppBarTransparent": "Transparent App Bar",
 | 
			
		||||
  "settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
 | 
			
		||||
  "settingsDrawerPreferCollapse": "Prefer Drawer Collapse",
 | 
			
		||||
@@ -193,6 +219,13 @@
 | 
			
		||||
  "settingsColorSchemeDescription": "Set the application primary color.",
 | 
			
		||||
  "settingsColorSeed": "Color Seed",
 | 
			
		||||
  "settingsColorSeedDescription": "Select one of the present color schemes.",
 | 
			
		||||
  "settingsFeatures": "Features",
 | 
			
		||||
  "settingsNotifyWithHaptic": "Haptic when Notified",
 | 
			
		||||
  "settingsNotifyWithHapticDescription": "Vibrate lightly when a new notification appears in the foreground.",
 | 
			
		||||
  "settingsExpandPostLink": "Expand Post Link",
 | 
			
		||||
  "settingsExpandPostLinkDescription": "Expand the post link in the post list.",
 | 
			
		||||
  "settingsExpandChatLink": "Expand Chat Link",
 | 
			
		||||
  "settingsExpandChatLinkDescription": "Expand the chat link in the chat list.",
 | 
			
		||||
  "settingsNetwork": "Network",
 | 
			
		||||
  "settingsNetworkServer": "HyperNet Server",
 | 
			
		||||
  "settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
 | 
			
		||||
@@ -211,12 +244,15 @@
 | 
			
		||||
  "settingsMisc": "Misc",
 | 
			
		||||
  "settingsMiscAbout": "About",
 | 
			
		||||
  "settingsMiscAboutDescription": "View the version information of Solian.",
 | 
			
		||||
  "settingsAccountLanguage": "Account Language",
 | 
			
		||||
  "settingsAccountLanguageDescription": "Set the language for email, notification, and other account-related content.",
 | 
			
		||||
  "sensitiveContent": "Sensitive Content",
 | 
			
		||||
  "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
 | 
			
		||||
  "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",
 | 
			
		||||
  "serverConnecting": "Connecting...",
 | 
			
		||||
  "serverDisconnected": "Connection Lost",
 | 
			
		||||
  "serverConnected": "Connected",
 | 
			
		||||
  "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",
 | 
			
		||||
@@ -294,6 +330,7 @@
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "Take photo",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "Take video",
 | 
			
		||||
  "addAttachmentFromRandomId": "Link via RID",
 | 
			
		||||
  "attachmentDetailInfo": "Attachment details",
 | 
			
		||||
  "attachmentPastedImage": "Pasted Image",
 | 
			
		||||
  "attachmentInsertLink": "Insert Link",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
 | 
			
		||||
@@ -522,11 +559,15 @@
 | 
			
		||||
  "postImageShareAds": "Explore posts on the Solar Network",
 | 
			
		||||
  "postShare": "Share",
 | 
			
		||||
  "postShareImage": "Share via Image",
 | 
			
		||||
  "postGetInsight": "Get Insight",
 | 
			
		||||
  "postGetInsightTitle": "AI Insight",
 | 
			
		||||
  "postGetInsightDescription": "AI may make mistakes, check important information.",
 | 
			
		||||
  "appInitializing": "Initializing",
 | 
			
		||||
  "poweredBy": "Powered by {}",
 | 
			
		||||
  "shareIntent": "Share",
 | 
			
		||||
  "shareIntentDescription": "What do you want to do with the content you are sharing?",
 | 
			
		||||
  "shareIntentPostStory": "Post a Story",
 | 
			
		||||
  "shareIntentSendChannel": "Share to Channel",
 | 
			
		||||
  "updateAvailable": "Update Available",
 | 
			
		||||
  "updateOngoing": "Updating, please wait...",
 | 
			
		||||
  "custom": "Custom",
 | 
			
		||||
@@ -539,6 +580,7 @@
 | 
			
		||||
  "colorSchemeWhite": "White",
 | 
			
		||||
  "colorSchemeBlack": "Black",
 | 
			
		||||
  "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
 | 
			
		||||
  "postFeaturedComment": "Featured Comment",
 | 
			
		||||
  "postCategoryTechnology": "Technology",
 | 
			
		||||
  "postCategoryGaming": "Gaming",
 | 
			
		||||
  "postCategoryLife": "Life",
 | 
			
		||||
@@ -549,5 +591,33 @@
 | 
			
		||||
  "postCategoryKnowledge": "Knowledge",
 | 
			
		||||
  "postCategoryLiterature": "Literature",
 | 
			
		||||
  "postCategoryFunny": "Funny",
 | 
			
		||||
  "postCategoryUncategorized": "Uncategorized"
 | 
			
		||||
  "postCategoryUncategorized": "Uncategorized",
 | 
			
		||||
  "newsAllSources": "All News",
 | 
			
		||||
  "newsReadingProviderSwap": "Swap",
 | 
			
		||||
  "newsReadingFromReader": "You're reading from HyperNet.Reader",
 | 
			
		||||
  "newsReadingFromOriginal": "You're reading the original article",
 | 
			
		||||
  "newsDisclaimer": "This article is fetched from the Internet, we do not guarantee its authenticity, please judge for yourself. All content in this article belongs to the original author.",
 | 
			
		||||
  "newsToday": "Today's News",
 | 
			
		||||
  "totpPostSetup": "One More Thing",
 | 
			
		||||
  "totpPostSetupDescription": "Scan the QR Code below with Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden or any of kind of authenticator app which supports TOTP.",
 | 
			
		||||
  "totpNeverShare": "Never share this QR Code",
 | 
			
		||||
  "needHelp": "Need Help?",
 | 
			
		||||
  "needHelpLaunch": "Check out our Goatpedia!",
 | 
			
		||||
  "walletCreate": "Create a Wallet",
 | 
			
		||||
  "walletCreateSubtitle": "Create a wallet to start using Source Points",
 | 
			
		||||
  "walletCreatePassword": "Set a payment password for your new wallet below",
 | 
			
		||||
  "walletCurrencyShort": "SRC",
 | 
			
		||||
  "walletCurrency": {
 | 
			
		||||
    "one": "{} Source Point",
 | 
			
		||||
    "other": "{} Source Points"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI Thinking Process",
 | 
			
		||||
  "accountSettingsApplied": "Account settings have been applied.",
 | 
			
		||||
  "trayMenuExit": "Exit",
 | 
			
		||||
  "postQuestionUnanswered": "Unanswered Question",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
 | 
			
		||||
  "postQuestionAnswered": "Answered Question",
 | 
			
		||||
  "postQuestionAnswerSelect": "Select as Answer",
 | 
			
		||||
  "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
 | 
			
		||||
  "postVideoUpload": "Upload Video"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,10 @@
 | 
			
		||||
  "screenAccountProfileEdit": "编辑资料",
 | 
			
		||||
  "screenAbuseReport": "滥用检举",
 | 
			
		||||
  "screenSettings": "设置",
 | 
			
		||||
  "screenAccountSettings": "账号设置",
 | 
			
		||||
  "screenFactorSettings": "验证因子",
 | 
			
		||||
  "screenAccountWallet": "钱包",
 | 
			
		||||
  "screenNews": "新闻",
 | 
			
		||||
  "screenAlbum": "相册",
 | 
			
		||||
  "screenChat": "聊天",
 | 
			
		||||
  "screenChatManage": "编辑聊天频道",
 | 
			
		||||
@@ -87,8 +91,18 @@
 | 
			
		||||
  },
 | 
			
		||||
  "loginEnterPassword": "验证代码",
 | 
			
		||||
  "loginSuccess": "登录为 {}",
 | 
			
		||||
  "authFactorDelete": "删除验证因子",
 | 
			
		||||
  "authFactorDeleteDescription": "你确定要删除 {} 验证因子吗?",
 | 
			
		||||
  "authFactorPassword": "密码",
 | 
			
		||||
  "authFactorPasswordDescription": "注册时选择设置的密码。",
 | 
			
		||||
  "authFactorEmail": "电邮一次性验证码",
 | 
			
		||||
  "authFactorEmailDescription": "由我们生成并发送到绑定的的电子邮箱的一次性验证码。",
 | 
			
		||||
  "authFactorTOTP": "时序验证码",
 | 
			
		||||
  "authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等验证器生成的一次性验证码。",
 | 
			
		||||
  "authFactorInAppNotify": "应用内通知验证码",
 | 
			
		||||
  "authFactorInAppNotifyDescription": "通过站内通知推送的一次性验证码。",
 | 
			
		||||
  "authFactorAdd": "添加新验证因子",
 | 
			
		||||
  "authFactorAddSubtitle": "给你的帐户登陆时提供另一个方案。",
 | 
			
		||||
  "accountIntroTitle": "喜欢您来!",
 | 
			
		||||
  "accountIntroSubtitle": "登陆以探索更广大的世界。",
 | 
			
		||||
  "accountLogout": "退出登录",
 | 
			
		||||
@@ -97,8 +111,14 @@
 | 
			
		||||
  "accountLogoutConfirm": "您需要重新输入账号密码,甚至可能需要多步验证来再次登陆。",
 | 
			
		||||
  "accountPublishers": "你的发布者",
 | 
			
		||||
  "accountPublishersSubtitle": "管理你的公共形象。",
 | 
			
		||||
  "accountSettings": "帐号设置",
 | 
			
		||||
  "accountSettingsSubtitle": "管理你的帐号并让它更好的服务你。",
 | 
			
		||||
  "accountProfileEdit": "编辑资料",
 | 
			
		||||
  "accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
 | 
			
		||||
  "accountWallet": "钱包",
 | 
			
		||||
  "accountWalletSubtitle": "查看你的余额和交易记录。",
 | 
			
		||||
  "factorSettings": "验证因子",
 | 
			
		||||
  "factorSettingsSubtitle": "管理你的登陆验证方式。",
 | 
			
		||||
  "accountProfileEditApplied": "个人资料修改已被应用。",
 | 
			
		||||
  "publishersNew": "新发布者",
 | 
			
		||||
  "publisherNewSubtitle": "创建一个新的公共身份。",
 | 
			
		||||
@@ -118,9 +138,12 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
 | 
			
		||||
  "writePostTypeStory": "发动态",
 | 
			
		||||
  "writePostTypeArticle": "写文章",
 | 
			
		||||
  "writePostTypeQuestion": "提问题",
 | 
			
		||||
  "writePostTypeVideo": "发视频",
 | 
			
		||||
  "fieldPostPublisher": "帖子发布者",
 | 
			
		||||
  "fieldPostContent": "发生什么事了?!",
 | 
			
		||||
  "fieldPostTitle": "标题",
 | 
			
		||||
  "fieldPostQuestionReward": "回答奖励源点",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "标签",
 | 
			
		||||
  "fieldPostCategories": "分类",
 | 
			
		||||
@@ -177,6 +200,9 @@
 | 
			
		||||
    "other": "{} 条评论"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsAppearance": "外观",
 | 
			
		||||
  "settingsDisplayLanguage": "显示语言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "设置应用程序使用的语言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟随系统",
 | 
			
		||||
  "settingsBackgroundImage": "背景图片",
 | 
			
		||||
  "settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
 | 
			
		||||
  "settingsBackgroundImageClear": "清除现存背景图",
 | 
			
		||||
@@ -191,6 +217,13 @@
 | 
			
		||||
  "settingsColorSchemeDescription": "设置应用主题色。",
 | 
			
		||||
  "settingsColorSeed": "预设色彩主题",
 | 
			
		||||
  "settingsColorSeedDescription": "选择一个预设色彩主题。",
 | 
			
		||||
  "settingsFeatures": "功能",
 | 
			
		||||
  "settingsNotifyWithHaptic": "新通知时振动",
 | 
			
		||||
  "settingsNotifyWithHapticDescription": "在应用在前台时收到新通知出现时出发轻量的振动。",
 | 
			
		||||
  "settingsExpandPostLink": "展开帖子链接",
 | 
			
		||||
  "settingsExpandPostLinkDescription": "在帖子列表中展开显示帖子中的链接。",
 | 
			
		||||
  "settingsExpandChatLink": "展开聊天链接",
 | 
			
		||||
  "settingsExpandChatLinkDescription": "在聊天信息中展开显示内容中的链接。",
 | 
			
		||||
  "settingsNetwork": "网络",
 | 
			
		||||
  "settingsNetworkServer": "HyperNet 服务器",
 | 
			
		||||
  "settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
 | 
			
		||||
@@ -209,12 +242,15 @@
 | 
			
		||||
  "settingsMisc": "杂项",
 | 
			
		||||
  "settingsMiscAbout": "关于",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帐号偏好语言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "设置邮件、通知和其他帐号相关内容的语言。",
 | 
			
		||||
  "sensitiveContent": "敏感内容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感内容已折叠。",
 | 
			
		||||
  "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
 | 
			
		||||
  "sensitiveContentReveal": "显示内容",
 | 
			
		||||
  "serverConnecting": "正在连接服务器…",
 | 
			
		||||
  "serverDisconnected": "已与服务器断开连接",
 | 
			
		||||
  "serverConnecting": "正在连接…",
 | 
			
		||||
  "serverDisconnected": "已断开连接",
 | 
			
		||||
  "serverConnected": "已连接",
 | 
			
		||||
  "fieldChatAlias": "频道别名",
 | 
			
		||||
  "fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。",
 | 
			
		||||
  "fieldChatName": "名称",
 | 
			
		||||
@@ -292,6 +328,7 @@
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍摄照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍摄视频",
 | 
			
		||||
  "addAttachmentFromRandomId": "通过访问 ID 链接",
 | 
			
		||||
  "attachmentDetailInfo": "附件详细信息",
 | 
			
		||||
  "attachmentPastedImage": "粘贴的图片",
 | 
			
		||||
  "attachmentInsertLink": "插入连接",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
 | 
			
		||||
@@ -520,11 +557,15 @@
 | 
			
		||||
  "postImageShareAds": "来 Solar Network 探索更多有趣帖子",
 | 
			
		||||
  "postShare": "分享",
 | 
			
		||||
  "postShareImage": "分享帖图",
 | 
			
		||||
  "postGetInsight": "获取见解",
 | 
			
		||||
  "postGetInsightTitle": "AI 见解",
 | 
			
		||||
  "postGetInsightDescription": "AI 可能会出错,检查信息真实性。",
 | 
			
		||||
  "appInitializing": "正在初始化",
 | 
			
		||||
  "poweredBy": "由 {} 提供支持",
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想对您分享的内容做些什么?",
 | 
			
		||||
  "shareIntentPostStory": "发布动态",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天频道",
 | 
			
		||||
  "updateAvailable": "检测到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,请稍后……",
 | 
			
		||||
  "custom": "自定义",
 | 
			
		||||
@@ -537,6 +578,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
 | 
			
		||||
  "postFeaturedComment": "精选评论",
 | 
			
		||||
  "postCategoryTechnology": "技术",
 | 
			
		||||
  "postCategoryGaming": "游戏",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -547,5 +589,34 @@
 | 
			
		||||
  "postCategoryKnowledge": "知识",
 | 
			
		||||
  "postCategoryLiterature": "文学",
 | 
			
		||||
  "postCategoryFunny": "搞笑",
 | 
			
		||||
  "postCategoryUncategorized": "未分类"
 | 
			
		||||
  "postCategoryUncategorized": "未分类",
 | 
			
		||||
  "newsAllSources": "所有新闻",
 | 
			
		||||
  "newsReadingProviderSwap": "切换",
 | 
			
		||||
  "newsReadingFromReader": "你正在从 HyperNet.Reader 阅读文章",
 | 
			
		||||
  "newsReadingFromOriginal": "你正在阅读原始文章",
 | 
			
		||||
  "newsDisclaimer": "本文由 HyperNet.Reader 从互联网上获取,我们不担保其内容的真实性,请自行判断。本文章的所有内容版权归原作者所有。",
 | 
			
		||||
  "newsToday": "快讯",
 | 
			
		||||
  "totpPostSetup": "还有一件事",
 | 
			
		||||
  "totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的验证器扫描本 QR Code 来添加。",
 | 
			
		||||
  "totpNeverShare": "永远不要分享这个 QR Code",
 | 
			
		||||
  "needHelp": "需要帮助?",
 | 
			
		||||
  "needHelpLaunch": "查看我们的山羊维基!",
 | 
			
		||||
  "walletCreate": "创建钱包",
 | 
			
		||||
  "walletCreateSubtitle": "创建于一个钱包来开始使用源点。",
 | 
			
		||||
  "walletCreatePassword": "在下方设置你的付款密码",
 | 
			
		||||
  "walletCurrencyShort": "源点",
 | 
			
		||||
  "walletCurrency": {
 | 
			
		||||
    "one": "{} 源点",
 | 
			
		||||
    "other": "{} 源点"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考过程",
 | 
			
		||||
  "accountSettingsApplied": "帐号设置已应用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的问题",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的问题,悬赏源点 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的问题",
 | 
			
		||||
  "postQuestionAnswerTitle": "精选解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "选择解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已选择,奖励已发放。",
 | 
			
		||||
  "postVideoUpload": "上传视频"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,10 @@
 | 
			
		||||
  "screenAccountProfileEdit": "編輯資料",
 | 
			
		||||
  "screenAbuseReport": "濫用檢舉",
 | 
			
		||||
  "screenSettings": "設置",
 | 
			
		||||
  "screenAccountSettings": "賬號設置",
 | 
			
		||||
  "screenFactorSettings": "驗證因子",
 | 
			
		||||
  "screenAccountWallet": "錢包",
 | 
			
		||||
  "screenNews": "新聞",
 | 
			
		||||
  "screenAlbum": "相冊",
 | 
			
		||||
  "screenChat": "聊天",
 | 
			
		||||
  "screenChatManage": "編輯聊天頻道",
 | 
			
		||||
@@ -87,8 +91,18 @@
 | 
			
		||||
  },
 | 
			
		||||
  "loginEnterPassword": "驗證代碼",
 | 
			
		||||
  "loginSuccess": "登錄為 {}",
 | 
			
		||||
  "authFactorDelete": "刪除驗證因子",
 | 
			
		||||
  "authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
 | 
			
		||||
  "authFactorPassword": "密碼",
 | 
			
		||||
  "authFactorPasswordDescription": "註冊時選擇設置的密碼。",
 | 
			
		||||
  "authFactorEmail": "電郵一次性驗證碼",
 | 
			
		||||
  "authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
 | 
			
		||||
  "authFactorTOTP": "時序驗證碼",
 | 
			
		||||
  "authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
 | 
			
		||||
  "authFactorInAppNotify": "應用內通知驗證碼",
 | 
			
		||||
  "authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
 | 
			
		||||
  "authFactorAdd": "添加新驗證因子",
 | 
			
		||||
  "authFactorAddSubtitle": "給你的帳户登陸時提供另一個方案。",
 | 
			
		||||
  "accountIntroTitle": "喜歡您來!",
 | 
			
		||||
  "accountIntroSubtitle": "登陸以探索更廣大的世界。",
 | 
			
		||||
  "accountLogout": "退出登錄",
 | 
			
		||||
@@ -97,8 +111,14 @@
 | 
			
		||||
  "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
 | 
			
		||||
  "accountPublishers": "你的發佈者",
 | 
			
		||||
  "accountPublishersSubtitle": "管理你的公共形象。",
 | 
			
		||||
  "accountSettings": "帳號設置",
 | 
			
		||||
  "accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
 | 
			
		||||
  "accountProfileEdit": "編輯資料",
 | 
			
		||||
  "accountProfileEditSubtitle": "使你的 Solarpass 賬户更像你。",
 | 
			
		||||
  "accountWallet": "錢包",
 | 
			
		||||
  "accountWalletSubtitle": "查看你的餘額和交易記錄。",
 | 
			
		||||
  "factorSettings": "驗證因子",
 | 
			
		||||
  "factorSettingsSubtitle": "管理你的登陸驗證方式。",
 | 
			
		||||
  "accountProfileEditApplied": "個人資料修改已被應用。",
 | 
			
		||||
  "publishersNew": "新發布者",
 | 
			
		||||
  "publisherNewSubtitle": "創建一個新的公共身份。",
 | 
			
		||||
@@ -118,9 +138,11 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
  "fieldPostPublisher": "帖子發佈者",
 | 
			
		||||
  "fieldPostContent": "發生什麼事了?!",
 | 
			
		||||
  "fieldPostTitle": "標題",
 | 
			
		||||
  "fieldPostQuestionReward": "回答獎勵源點",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "標籤",
 | 
			
		||||
  "fieldPostCategories": "分類",
 | 
			
		||||
@@ -177,6 +199,9 @@
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsDisplayLanguage": "顯示語言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
			
		||||
  "settingsBackgroundImage": "背景圖片",
 | 
			
		||||
  "settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
 | 
			
		||||
  "settingsBackgroundImageClear": "清除現存背景圖",
 | 
			
		||||
@@ -191,6 +216,13 @@
 | 
			
		||||
  "settingsColorSchemeDescription": "設置應用主題色。",
 | 
			
		||||
  "settingsColorSeed": "預設色彩主題",
 | 
			
		||||
  "settingsColorSeedDescription": "選擇一個預設色彩主題。",
 | 
			
		||||
  "settingsFeatures": "功能",
 | 
			
		||||
  "settingsNotifyWithHaptic": "新通知時振動",
 | 
			
		||||
  "settingsNotifyWithHapticDescription": "在應用在前台時收到新通知出現時出發輕量的振動。",
 | 
			
		||||
  "settingsExpandPostLink": "展開帖子鏈接",
 | 
			
		||||
  "settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。",
 | 
			
		||||
  "settingsExpandChatLink": "展開聊天鏈接",
 | 
			
		||||
  "settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。",
 | 
			
		||||
  "settingsNetwork": "網絡",
 | 
			
		||||
  "settingsNetworkServer": "HyperNet 服務器",
 | 
			
		||||
  "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
 | 
			
		||||
@@ -209,12 +241,15 @@
 | 
			
		||||
  "settingsMisc": "雜項",
 | 
			
		||||
  "settingsMiscAbout": "關於",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帳號偏好語言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
 | 
			
		||||
  "sensitiveContent": "敏感內容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
			
		||||
  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
 | 
			
		||||
  "sensitiveContentReveal": "顯示內容",
 | 
			
		||||
  "serverConnecting": "正在連接服務器…",
 | 
			
		||||
  "serverDisconnected": "已與服務器斷開連接",
 | 
			
		||||
  "serverConnecting": "正在連接…",
 | 
			
		||||
  "serverDisconnected": "已斷開連接",
 | 
			
		||||
  "serverConnected": "已連接",
 | 
			
		||||
  "fieldChatAlias": "頻道別名",
 | 
			
		||||
  "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
			
		||||
  "fieldChatName": "名稱",
 | 
			
		||||
@@ -292,6 +327,7 @@
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
			
		||||
  "attachmentDetailInfo": "附件詳細信息",
 | 
			
		||||
  "attachmentPastedImage": "粘貼的圖片",
 | 
			
		||||
  "attachmentInsertLink": "插入連接",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
			
		||||
@@ -520,11 +556,15 @@
 | 
			
		||||
  "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
 | 
			
		||||
  "postShare": "分享",
 | 
			
		||||
  "postShareImage": "分享帖圖",
 | 
			
		||||
  "postGetInsight": "獲取見解",
 | 
			
		||||
  "postGetInsightTitle": "AI 見解",
 | 
			
		||||
  "postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
 | 
			
		||||
  "appInitializing": "正在初始化",
 | 
			
		||||
  "poweredBy": "由 {} 提供支持",
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想對您分享的內容做些什麼?",
 | 
			
		||||
  "shareIntentPostStory": "發佈動態",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天頻道",
 | 
			
		||||
  "updateAvailable": "檢測到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,請稍後……",
 | 
			
		||||
  "custom": "自定義",
 | 
			
		||||
@@ -537,6 +577,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -547,5 +588,33 @@
 | 
			
		||||
  "postCategoryKnowledge": "知識",
 | 
			
		||||
  "postCategoryLiterature": "文學",
 | 
			
		||||
  "postCategoryFunny": "搞笑",
 | 
			
		||||
  "postCategoryUncategorized": "未分類"
 | 
			
		||||
  "postCategoryUncategorized": "未分類",
 | 
			
		||||
  "newsAllSources": "所有新聞",
 | 
			
		||||
  "newsReadingProviderSwap": "切換",
 | 
			
		||||
  "newsReadingFromReader": "你正在從 HyperNet.Reader 閲讀文章",
 | 
			
		||||
  "newsReadingFromOriginal": "你正在閲讀原始文章",
 | 
			
		||||
  "newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
 | 
			
		||||
  "newsToday": "快訊",
 | 
			
		||||
  "totpPostSetup": "還有一件事",
 | 
			
		||||
  "totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
 | 
			
		||||
  "totpNeverShare": "永遠不要分享這個 QR Code",
 | 
			
		||||
  "needHelp": "需要幫助?",
 | 
			
		||||
  "needHelpLaunch": "查看我們的山羊維基!",
 | 
			
		||||
  "walletCreate": "創建錢包",
 | 
			
		||||
  "walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
 | 
			
		||||
  "walletCreatePassword": "在下方設置你的付款密碼",
 | 
			
		||||
  "walletCurrencyShort": "源點",
 | 
			
		||||
  "walletCurrency": {
 | 
			
		||||
    "one": "{} 源點",
 | 
			
		||||
    "other": "{} 源點"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程",
 | 
			
		||||
  "accountSettingsApplied": "帳號設置已應用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的問題",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的問題",
 | 
			
		||||
  "postQuestionAnswerTitle": "精選解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "選擇解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,10 @@
 | 
			
		||||
  "screenAccountProfileEdit": "編輯資料",
 | 
			
		||||
  "screenAbuseReport": "濫用檢舉",
 | 
			
		||||
  "screenSettings": "設置",
 | 
			
		||||
  "screenAccountSettings": "賬號設置",
 | 
			
		||||
  "screenFactorSettings": "驗證因子",
 | 
			
		||||
  "screenAccountWallet": "錢包",
 | 
			
		||||
  "screenNews": "新聞",
 | 
			
		||||
  "screenAlbum": "相冊",
 | 
			
		||||
  "screenChat": "聊天",
 | 
			
		||||
  "screenChatManage": "編輯聊天頻道",
 | 
			
		||||
@@ -87,8 +91,18 @@
 | 
			
		||||
  },
 | 
			
		||||
  "loginEnterPassword": "驗證代碼",
 | 
			
		||||
  "loginSuccess": "登錄為 {}",
 | 
			
		||||
  "authFactorDelete": "刪除驗證因子",
 | 
			
		||||
  "authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
 | 
			
		||||
  "authFactorPassword": "密碼",
 | 
			
		||||
  "authFactorPasswordDescription": "註冊時選擇設置的密碼。",
 | 
			
		||||
  "authFactorEmail": "電郵一次性驗證碼",
 | 
			
		||||
  "authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
 | 
			
		||||
  "authFactorTOTP": "時序驗證碼",
 | 
			
		||||
  "authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
 | 
			
		||||
  "authFactorInAppNotify": "應用內通知驗證碼",
 | 
			
		||||
  "authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
 | 
			
		||||
  "authFactorAdd": "添加新驗證因子",
 | 
			
		||||
  "authFactorAddSubtitle": "給你的帳戶登陸時提供另一個方案。",
 | 
			
		||||
  "accountIntroTitle": "喜歡您來!",
 | 
			
		||||
  "accountIntroSubtitle": "登陸以探索更廣大的世界。",
 | 
			
		||||
  "accountLogout": "退出登錄",
 | 
			
		||||
@@ -97,8 +111,14 @@
 | 
			
		||||
  "accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
 | 
			
		||||
  "accountPublishers": "你的發佈者",
 | 
			
		||||
  "accountPublishersSubtitle": "管理你的公共形象。",
 | 
			
		||||
  "accountSettings": "帳號設置",
 | 
			
		||||
  "accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
 | 
			
		||||
  "accountProfileEdit": "編輯資料",
 | 
			
		||||
  "accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
 | 
			
		||||
  "accountWallet": "錢包",
 | 
			
		||||
  "accountWalletSubtitle": "查看你的餘額和交易記錄。",
 | 
			
		||||
  "factorSettings": "驗證因子",
 | 
			
		||||
  "factorSettingsSubtitle": "管理你的登陸驗證方式。",
 | 
			
		||||
  "accountProfileEditApplied": "個人資料修改已被應用。",
 | 
			
		||||
  "publishersNew": "新發布者",
 | 
			
		||||
  "publisherNewSubtitle": "創建一個新的公共身份。",
 | 
			
		||||
@@ -118,9 +138,11 @@
 | 
			
		||||
  "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
 | 
			
		||||
  "writePostTypeStory": "發動態",
 | 
			
		||||
  "writePostTypeArticle": "寫文章",
 | 
			
		||||
  "writePostTypeQuestion": "提問題",
 | 
			
		||||
  "fieldPostPublisher": "帖子發佈者",
 | 
			
		||||
  "fieldPostContent": "發生什麼事了?!",
 | 
			
		||||
  "fieldPostTitle": "標題",
 | 
			
		||||
  "fieldPostQuestionReward": "回答獎勵源點",
 | 
			
		||||
  "fieldPostDescription": "描述",
 | 
			
		||||
  "fieldPostTags": "標籤",
 | 
			
		||||
  "fieldPostCategories": "分類",
 | 
			
		||||
@@ -177,6 +199,9 @@
 | 
			
		||||
    "other": "{} 條評論"
 | 
			
		||||
  },
 | 
			
		||||
  "settingsAppearance": "外觀",
 | 
			
		||||
  "settingsDisplayLanguage": "顯示語言",
 | 
			
		||||
  "settingsDisplayLanguageDescription": "設置應用程序使用的語言",
 | 
			
		||||
  "settingsDisplayLanguageSystem": "跟隨系統",
 | 
			
		||||
  "settingsBackgroundImage": "背景圖片",
 | 
			
		||||
  "settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
 | 
			
		||||
  "settingsBackgroundImageClear": "清除現存背景圖",
 | 
			
		||||
@@ -191,6 +216,13 @@
 | 
			
		||||
  "settingsColorSchemeDescription": "設置應用主題色。",
 | 
			
		||||
  "settingsColorSeed": "預設色彩主題",
 | 
			
		||||
  "settingsColorSeedDescription": "選擇一個預設色彩主題。",
 | 
			
		||||
  "settingsFeatures": "功能",
 | 
			
		||||
  "settingsNotifyWithHaptic": "新通知時振動",
 | 
			
		||||
  "settingsNotifyWithHapticDescription": "在應用在前臺時收到新通知出現時出發輕量的振動。",
 | 
			
		||||
  "settingsExpandPostLink": "展開帖子鏈接",
 | 
			
		||||
  "settingsExpandPostLinkDescription": "在帖子列表中展開顯示帖子中的鏈接。",
 | 
			
		||||
  "settingsExpandChatLink": "展開聊天鏈接",
 | 
			
		||||
  "settingsExpandChatLinkDescription": "在聊天信息中展開顯示內容中的鏈接。",
 | 
			
		||||
  "settingsNetwork": "網絡",
 | 
			
		||||
  "settingsNetworkServer": "HyperNet 服務器",
 | 
			
		||||
  "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
 | 
			
		||||
@@ -209,12 +241,15 @@
 | 
			
		||||
  "settingsMisc": "雜項",
 | 
			
		||||
  "settingsMiscAbout": "關於",
 | 
			
		||||
  "settingsMiscAboutDescription": "查看 Solian 的版本信息。",
 | 
			
		||||
  "settingsAccountLanguage": "帳號偏好語言",
 | 
			
		||||
  "settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
 | 
			
		||||
  "sensitiveContent": "敏感內容",
 | 
			
		||||
  "sensitiveContentCollapsed": "敏感內容已摺疊。",
 | 
			
		||||
  "sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
 | 
			
		||||
  "sensitiveContentReveal": "顯示內容",
 | 
			
		||||
  "serverConnecting": "正在連接服務器…",
 | 
			
		||||
  "serverDisconnected": "已與服務器斷開連接",
 | 
			
		||||
  "serverConnecting": "正在連接…",
 | 
			
		||||
  "serverDisconnected": "已斷開連接",
 | 
			
		||||
  "serverConnected": "已連接",
 | 
			
		||||
  "fieldChatAlias": "頻道別名",
 | 
			
		||||
  "fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
 | 
			
		||||
  "fieldChatName": "名稱",
 | 
			
		||||
@@ -292,6 +327,7 @@
 | 
			
		||||
  "addAttachmentFromCameraPhoto": "拍攝照片",
 | 
			
		||||
  "addAttachmentFromCameraVideo": "拍攝視頻",
 | 
			
		||||
  "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
 | 
			
		||||
  "attachmentDetailInfo": "附件詳細信息",
 | 
			
		||||
  "attachmentPastedImage": "粘貼的圖片",
 | 
			
		||||
  "attachmentInsertLink": "插入連接",
 | 
			
		||||
  "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
 | 
			
		||||
@@ -520,11 +556,15 @@
 | 
			
		||||
  "postImageShareAds": "來 Solar Network 探索更多有趣帖子",
 | 
			
		||||
  "postShare": "分享",
 | 
			
		||||
  "postShareImage": "分享帖圖",
 | 
			
		||||
  "postGetInsight": "獲取見解",
 | 
			
		||||
  "postGetInsightTitle": "AI 見解",
 | 
			
		||||
  "postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
 | 
			
		||||
  "appInitializing": "正在初始化",
 | 
			
		||||
  "poweredBy": "由 {} 提供支持",
 | 
			
		||||
  "shareIntent": "分享",
 | 
			
		||||
  "shareIntentDescription": "您想對您分享的內容做些什麼?",
 | 
			
		||||
  "shareIntentPostStory": "發佈動態",
 | 
			
		||||
  "shareIntentSendChannel": "分享到聊天頻道",
 | 
			
		||||
  "updateAvailable": "檢測到更新可用",
 | 
			
		||||
  "updateOngoing": "正在更新,請稍後……",
 | 
			
		||||
  "custom": "自定義",
 | 
			
		||||
@@ -537,6 +577,7 @@
 | 
			
		||||
  "colorSchemeWhite": "白色",
 | 
			
		||||
  "colorSchemeBlack": "黑色",
 | 
			
		||||
  "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
 | 
			
		||||
  "postFeaturedComment": "精選評論",
 | 
			
		||||
  "postCategoryTechnology": "技術",
 | 
			
		||||
  "postCategoryGaming": "遊戲",
 | 
			
		||||
  "postCategoryLife": "生活",
 | 
			
		||||
@@ -547,5 +588,33 @@
 | 
			
		||||
  "postCategoryKnowledge": "知識",
 | 
			
		||||
  "postCategoryLiterature": "文學",
 | 
			
		||||
  "postCategoryFunny": "搞笑",
 | 
			
		||||
  "postCategoryUncategorized": "未分類"
 | 
			
		||||
  "postCategoryUncategorized": "未分類",
 | 
			
		||||
  "newsAllSources": "所有新聞",
 | 
			
		||||
  "newsReadingProviderSwap": "切換",
 | 
			
		||||
  "newsReadingFromReader": "你正在從 HyperNet.Reader 閱讀文章",
 | 
			
		||||
  "newsReadingFromOriginal": "你正在閱讀原始文章",
 | 
			
		||||
  "newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
 | 
			
		||||
  "newsToday": "快訊",
 | 
			
		||||
  "totpPostSetup": "還有一件事",
 | 
			
		||||
  "totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
 | 
			
		||||
  "totpNeverShare": "永遠不要分享這個 QR Code",
 | 
			
		||||
  "needHelp": "需要幫助?",
 | 
			
		||||
  "needHelpLaunch": "查看我們的山羊維基!",
 | 
			
		||||
  "walletCreate": "創建錢包",
 | 
			
		||||
  "walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
 | 
			
		||||
  "walletCreatePassword": "在下方設置你的付款密碼",
 | 
			
		||||
  "walletCurrencyShort": "源點",
 | 
			
		||||
  "walletCurrency": {
 | 
			
		||||
    "one": "{} 源點",
 | 
			
		||||
    "other": "{} 源點"
 | 
			
		||||
  },
 | 
			
		||||
  "aiThinkingProcess": "AI 思考過程",
 | 
			
		||||
  "accountSettingsApplied": "帳號設置已應用。",
 | 
			
		||||
  "trayMenuExit": "退出",
 | 
			
		||||
  "postQuestionUnanswered": "未解答的問題",
 | 
			
		||||
  "postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
 | 
			
		||||
  "postQuestionAnswered": "已解答的問題",
 | 
			
		||||
  "postQuestionAnswerTitle": "精選解答",
 | 
			
		||||
  "postQuestionAnswerSelect": "選擇解答",
 | 
			
		||||
  "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										117
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							@@ -2,7 +2,6 @@ PODS:
 | 
			
		||||
  - Alamofire (5.10.2)
 | 
			
		||||
  - connectivity_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - croppy (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - device_info_plus (0.0.1):
 | 
			
		||||
@@ -43,58 +42,58 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - file_saver (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Firebase/Analytics (11.4.0):
 | 
			
		||||
  - Firebase/Analytics (11.7.0):
 | 
			
		||||
    - Firebase/Core
 | 
			
		||||
  - Firebase/Core (11.4.0):
 | 
			
		||||
  - Firebase/Core (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseAnalytics (~> 11.4.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.4.0):
 | 
			
		||||
    - FirebaseCore (= 11.4.0)
 | 
			
		||||
  - Firebase/Messaging (11.4.0):
 | 
			
		||||
    - FirebaseAnalytics (~> 11.7.0)
 | 
			
		||||
  - Firebase/CoreOnly (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
  - Firebase/Messaging (11.7.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseMessaging (~> 11.4.0)
 | 
			
		||||
  - firebase_analytics (11.3.6):
 | 
			
		||||
    - Firebase/Analytics (= 11.4.0)
 | 
			
		||||
    - FirebaseMessaging (~> 11.7.0)
 | 
			
		||||
  - firebase_analytics (11.4.2):
 | 
			
		||||
    - Firebase/Analytics (= 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_core (3.9.0):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.4.0)
 | 
			
		||||
  - firebase_core (3.11.0):
 | 
			
		||||
    - Firebase/CoreOnly (= 11.7.0)
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - firebase_messaging (15.1.6):
 | 
			
		||||
    - Firebase/Messaging (= 11.4.0)
 | 
			
		||||
  - firebase_messaging (15.2.2):
 | 
			
		||||
    - Firebase/Messaging (= 11.7.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - FirebaseAnalytics (11.4.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.4.0)
 | 
			
		||||
    - FirebaseCore (~> 11.0)
 | 
			
		||||
  - FirebaseAnalytics (11.7.0):
 | 
			
		||||
    - FirebaseAnalytics/AdIdSupport (= 11.7.0)
 | 
			
		||||
    - FirebaseCore (~> 11.7.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)
 | 
			
		||||
  - FirebaseAnalytics/AdIdSupport (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.4.0)
 | 
			
		||||
    - GoogleAppMeasurement (= 11.7.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)
 | 
			
		||||
  - FirebaseCore (11.7.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Logger (~> 8.0)
 | 
			
		||||
  - FirebaseCoreInternal (11.6.0):
 | 
			
		||||
  - FirebaseCoreInternal (11.7.0):
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.0)"
 | 
			
		||||
  - FirebaseInstallations (11.4.0):
 | 
			
		||||
    - FirebaseCore (~> 11.0)
 | 
			
		||||
  - FirebaseInstallations (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.0)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - FirebaseMessaging (11.4.0):
 | 
			
		||||
    - FirebaseCore (~> 11.0)
 | 
			
		||||
  - FirebaseMessaging (11.7.0):
 | 
			
		||||
    - FirebaseCore (~> 11.7.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleDataTransport (~> 10.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
@@ -105,32 +104,39 @@ PODS:
 | 
			
		||||
  - Flutter (1.0.0)
 | 
			
		||||
  - flutter_app_update (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_inappwebview_ios (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_inappwebview_ios/Core (= 0.0.1)
 | 
			
		||||
    - OrderedSet (~> 6.0.3)
 | 
			
		||||
  - flutter_inappwebview_ios/Core (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - OrderedSet (~> 6.0.3)
 | 
			
		||||
  - flutter_native_splash (2.4.3):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_udid (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
  - flutter_webrtc (0.12.2):
 | 
			
		||||
  - flutter_webrtc (0.12.6):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - GoogleAppMeasurement (11.4.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.4.0)
 | 
			
		||||
  - GoogleAppMeasurement (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/AdIdSupport (= 11.7.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)
 | 
			
		||||
  - GoogleAppMeasurement/AdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.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):
 | 
			
		||||
  - GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/MethodSwizzler (~> 8.0)
 | 
			
		||||
    - GoogleUtilities/Network (~> 8.0)
 | 
			
		||||
@@ -172,8 +178,8 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - in_app_review (2.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Kingfisher (8.1.3)
 | 
			
		||||
  - livekit_client (2.3.4):
 | 
			
		||||
  - Kingfisher (8.2.0)
 | 
			
		||||
  - livekit_client (2.3.6):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.06)
 | 
			
		||||
@@ -188,6 +194,7 @@ PODS:
 | 
			
		||||
    - nanopb/encode (= 3.30910.0)
 | 
			
		||||
  - nanopb/decode (3.30910.0)
 | 
			
		||||
  - nanopb/encode (3.30910.0)
 | 
			
		||||
  - OrderedSet (6.0.3)
 | 
			
		||||
  - package_info_plus (0.4.5):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - pasteboard (0.0.1):
 | 
			
		||||
@@ -229,7 +236,7 @@ PODS:
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - Alamofire
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
			
		||||
  - croppy (from `.symlinks/plugins/croppy/ios`)
 | 
			
		||||
  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
			
		||||
  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
			
		||||
@@ -239,6 +246,7 @@ DEPENDENCIES:
 | 
			
		||||
  - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
 | 
			
		||||
  - Flutter (from `Flutter`)
 | 
			
		||||
  - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
 | 
			
		||||
  - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
 | 
			
		||||
  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
			
		||||
  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
			
		||||
  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
 | 
			
		||||
@@ -282,6 +290,7 @@ SPEC REPOS:
 | 
			
		||||
    - GoogleUtilities
 | 
			
		||||
    - Kingfisher
 | 
			
		||||
    - nanopb
 | 
			
		||||
    - OrderedSet
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
    - SDWebImage
 | 
			
		||||
@@ -290,7 +299,7 @@ SPEC REPOS:
 | 
			
		||||
 | 
			
		||||
EXTERNAL SOURCES:
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/connectivity_plus/darwin"
 | 
			
		||||
    :path: ".symlinks/plugins/connectivity_plus/ios"
 | 
			
		||||
  croppy:
 | 
			
		||||
    :path: ".symlinks/plugins/croppy/ios"
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
@@ -309,6 +318,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: Flutter
 | 
			
		||||
  flutter_app_update:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_app_update/ios"
 | 
			
		||||
  flutter_inappwebview_ios:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
 | 
			
		||||
  flutter_native_splash:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
			
		||||
  flutter_udid:
 | 
			
		||||
@@ -362,40 +373,42 @@ EXTERNAL SOURCES:
 | 
			
		||||
 | 
			
		||||
SPEC CHECKSUMS:
 | 
			
		||||
  Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
 | 
			
		||||
  connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
 | 
			
		||||
  connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
 | 
			
		||||
  croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
 | 
			
		||||
  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
 | 
			
		||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
			
		||||
  DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
 | 
			
		||||
  file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
 | 
			
		||||
  file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
 | 
			
		||||
  file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
 | 
			
		||||
  Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99
 | 
			
		||||
  firebase_analytics: 2815af29d49c1a994652abd37a5b001a88bc7b75
 | 
			
		||||
  firebase_core: b62a5080210edad3f2934314a8b2c6f5124e8e10
 | 
			
		||||
  firebase_messaging: 98619a0572d82cfb3668e78859ba9f1110e268c9
 | 
			
		||||
  FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49
 | 
			
		||||
  FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771
 | 
			
		||||
  FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
 | 
			
		||||
  FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
 | 
			
		||||
  FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
 | 
			
		||||
  Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
 | 
			
		||||
  firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
 | 
			
		||||
  firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
 | 
			
		||||
  firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
 | 
			
		||||
  FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
 | 
			
		||||
  FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
 | 
			
		||||
  FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
 | 
			
		||||
  FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
 | 
			
		||||
  FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
 | 
			
		||||
  Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
 | 
			
		||||
  flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
 | 
			
		||||
  flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
 | 
			
		||||
  flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
 | 
			
		||||
  flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
 | 
			
		||||
  flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
 | 
			
		||||
  flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
 | 
			
		||||
  gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
 | 
			
		||||
  GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e
 | 
			
		||||
  GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
 | 
			
		||||
  home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
 | 
			
		||||
  image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
 | 
			
		||||
  in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
 | 
			
		||||
  Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
 | 
			
		||||
  livekit_client: 4eaa7a2968fc7e7c57888f43d90394547cc8d9e9
 | 
			
		||||
  Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
 | 
			
		||||
  livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
 | 
			
		||||
  media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
 | 
			
		||||
  media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
 | 
			
		||||
  media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
 | 
			
		||||
  nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
 | 
			
		||||
  OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
 | 
			
		||||
  package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
 | 
			
		||||
  pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
 | 
			
		||||
  path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
 | 
			
		||||
 
 | 
			
		||||
@@ -71,9 +71,10 @@ class ChatMessageController extends ChangeNotifier {
 | 
			
		||||
      resp.data as Map<String, dynamic>,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _wsSubscription = _ws.stream.stream.listen((event) {
 | 
			
		||||
    _wsSubscription = _ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
        case 'events.new':
 | 
			
		||||
          if (event.payload?['channel_id'] != channel?.id) break;
 | 
			
		||||
          final payload = SnChatMessage.fromJson(event.payload!);
 | 
			
		||||
          _addMessage(payload);
 | 
			
		||||
          break;
 | 
			
		||||
 
 | 
			
		||||
@@ -144,6 +144,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  static const Map<String, String> kTitleMap = {
 | 
			
		||||
    'stories': 'writePostTypeStory',
 | 
			
		||||
    'articles': 'writePostTypeArticle',
 | 
			
		||||
    'questions': 'writePostTypeQuestion',
 | 
			
		||||
    'videos': 'writePostTypeVideo',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static const kAttachmentProgressWeight = 0.9;
 | 
			
		||||
@@ -153,6 +155,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  final TextEditingController titleController = TextEditingController();
 | 
			
		||||
  final TextEditingController descriptionController = TextEditingController();
 | 
			
		||||
  final TextEditingController aliasController = TextEditingController();
 | 
			
		||||
  final TextEditingController rewardController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  bool _temporarySaveActive = false;
 | 
			
		||||
 | 
			
		||||
@@ -168,6 +171,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    });
 | 
			
		||||
    contentController.addListener(() {
 | 
			
		||||
      _temporaryPlanSave();
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
    });
 | 
			
		||||
    if (doLoadFromTemporary) _temporaryLoad();
 | 
			
		||||
  }
 | 
			
		||||
@@ -194,6 +198,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
  PostWriteMedia? thumbnail;
 | 
			
		||||
  List<PostWriteMedia> attachments = List.empty(growable: true);
 | 
			
		||||
  DateTime? publishedAt, publishedUntil;
 | 
			
		||||
  SnAttachment? videoAttachment;
 | 
			
		||||
 | 
			
		||||
  Future<void> fetchRelatedPost(
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
@@ -214,6 +219,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
        descriptionController.text = post.body['description'] ?? '';
 | 
			
		||||
        contentController.text = post.body['content'] ?? '';
 | 
			
		||||
        aliasController.text = post.alias ?? '';
 | 
			
		||||
        rewardController.text = post.body['reward']?.toString() ?? '';
 | 
			
		||||
        videoAttachment = post.preload?.video;
 | 
			
		||||
        publishedAt = post.publishedAt;
 | 
			
		||||
        publishedUntil = post.publishedUntil;
 | 
			
		||||
        visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
 | 
			
		||||
@@ -347,6 +354,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
 | 
			
		||||
          if (titleController.text.isNotEmpty) 'title': titleController.text,
 | 
			
		||||
          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
 | 
			
		||||
          if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
 | 
			
		||||
          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
 | 
			
		||||
          'attachments':
 | 
			
		||||
              attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
 | 
			
		||||
@@ -375,6 +383,7 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
      aliasController.text = data['alias'] ?? '';
 | 
			
		||||
      titleController.text = data['title'] ?? '';
 | 
			
		||||
      descriptionController.text = data['description'] ?? '';
 | 
			
		||||
      rewardController.text = data['reward']?.toString() ?? '';
 | 
			
		||||
      if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
 | 
			
		||||
      attachments
 | 
			
		||||
          .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
 | 
			
		||||
@@ -473,6 +482,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    progress = kAttachmentProgressWeight;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 | 
			
		||||
    final reward = double.tryParse(rewardController.text);
 | 
			
		||||
 | 
			
		||||
    // Posting the content
 | 
			
		||||
    try {
 | 
			
		||||
      final baseProgressVal = progress!;
 | 
			
		||||
@@ -498,6 +509,8 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
 | 
			
		||||
          if (replyingPost != null) 'reply_to': replyingPost!.id,
 | 
			
		||||
          if (repostingPost != null) 'repost_to': repostingPost!.id,
 | 
			
		||||
          if (reward != null) 'reward': reward,
 | 
			
		||||
          if (videoAttachment != null) 'video': videoAttachment!.rid,
 | 
			
		||||
        },
 | 
			
		||||
        onSendProgress: (count, total) {
 | 
			
		||||
          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
 | 
			
		||||
@@ -624,6 +637,11 @@ class PostWriteController extends ChangeNotifier {
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setVideoAttachment(SnAttachment? value) {
 | 
			
		||||
    videoAttachment = value;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void reset() {
 | 
			
		||||
    publishedAt = null;
 | 
			
		||||
    publishedUntil = null;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										107
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								lib/main.dart
									
									
									
									
									
								
							@@ -1,6 +1,7 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:croppy/croppy.dart';
 | 
			
		||||
@@ -10,8 +11,10 @@ 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:flutter/services.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
@@ -40,6 +43,7 @@ import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:tray_manager/tray_manager.dart';
 | 
			
		||||
import 'package:version/version.dart';
 | 
			
		||||
import 'package:workmanager/workmanager.dart';
 | 
			
		||||
import 'package:in_app_review/in_app_review.dart';
 | 
			
		||||
@@ -206,7 +210,7 @@ class _AppSplashScreen extends StatefulWidget {
 | 
			
		||||
  State<_AppSplashScreen> createState() => _AppSplashScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
 | 
			
		||||
  void _tryRequestRating() async {
 | 
			
		||||
    final prefs = await SharedPreferences.getInstance();
 | 
			
		||||
    if (prefs.containsKey('first_boot_time')) {
 | 
			
		||||
@@ -279,7 +283,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
      await ws.tryConnect();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final notify = context.read<NotificationProvider>();
 | 
			
		||||
      notify.listen();
 | 
			
		||||
      await notify.registerPushNotifications();
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
      await sticker.listStickerEagerly();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      await context.showErrorDialog(err);
 | 
			
		||||
@@ -290,9 +298,62 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
    await widgetUpdateRandomPost();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _hotkeyInitialization() async {
 | 
			
		||||
    if (kIsWeb) return;
 | 
			
		||||
 | 
			
		||||
    if (Platform.isMacOS) {
 | 
			
		||||
      HotKey quitHotKey = HotKey(
 | 
			
		||||
        key: PhysicalKeyboardKey.keyQ,
 | 
			
		||||
        modifiers: [HotKeyModifier.meta],
 | 
			
		||||
        scope: HotKeyScope.inapp,
 | 
			
		||||
      );
 | 
			
		||||
      await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _trayInitialization() async {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
 | 
			
		||||
    final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
 | 
			
		||||
    final appVersion = await PackageInfo.fromPlatform();
 | 
			
		||||
 | 
			
		||||
    trayManager.addListener(this);
 | 
			
		||||
    await trayManager.setIcon(icon);
 | 
			
		||||
 | 
			
		||||
    Menu menu = Menu(
 | 
			
		||||
      items: [
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'version_label',
 | 
			
		||||
          label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
 | 
			
		||||
          disabled: true,
 | 
			
		||||
        ),
 | 
			
		||||
        MenuItem.separator(),
 | 
			
		||||
        MenuItem(
 | 
			
		||||
          key: 'exit',
 | 
			
		||||
          label: 'trayMenuExit'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
    await trayManager.setContextMenu(menu);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  AppLifecycleListener? _appLifecycleListener;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
 | 
			
		||||
    if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
 | 
			
		||||
      _appLifecycleListener = AppLifecycleListener(
 | 
			
		||||
        onExitRequested: _onExitRequested,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _trayInitialization();
 | 
			
		||||
    _hotkeyInitialization();
 | 
			
		||||
    _initialize().then((_) {
 | 
			
		||||
      _postInitialization();
 | 
			
		||||
      _tryRequestRating();
 | 
			
		||||
@@ -300,6 +361,50 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<AppExitResponse> _onExitRequested() async {
 | 
			
		||||
    appWindow.hide();
 | 
			
		||||
    return AppExitResponse.cancel;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      context.read<NotificationProvider>().clearTray();
 | 
			
		||||
      appWindow.show();
 | 
			
		||||
    } else {
 | 
			
		||||
      trayManager.popUpContextMenu();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayIconRightMouseDown() {
 | 
			
		||||
    if (Platform.isWindows) {
 | 
			
		||||
      trayManager.popUpContextMenu();
 | 
			
		||||
    } else {
 | 
			
		||||
      context.read<NotificationProvider>().clearTray();
 | 
			
		||||
      appWindow.show();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onTrayMenuItemClick(MenuItem menuItem) {
 | 
			
		||||
    switch (menuItem.key) {
 | 
			
		||||
      case 'exit':
 | 
			
		||||
        _appLifecycleListener?.dispose();
 | 
			
		||||
        SystemChannels.platform.invokeMethod('SystemNavigator.pop');
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
      trayManager.removeListener(this);
 | 
			
		||||
      hotKeyManager.unregisterAll();
 | 
			
		||||
    }
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,9 @@ const kAppbarTransparentStoreKey = 'app_bar_transparent';
 | 
			
		||||
const kAppBackgroundStoreKey = 'app_has_background';
 | 
			
		||||
const kAppColorSchemeStoreKey = 'app_color_scheme';
 | 
			
		||||
const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
 | 
			
		||||
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
 | 
			
		||||
const kAppExpandPostLink = 'app_expand_post_link';
 | 
			
		||||
const kAppExpandChatLink = 'app_expand_chat_link';
 | 
			
		||||
 | 
			
		||||
const Map<String, FilterQuality> kImageQualityLevel = {
 | 
			
		||||
  'settingsImageQualityLowest': FilterQuality.none,
 | 
			
		||||
@@ -38,14 +41,22 @@ class ConfigProvider extends ChangeNotifier {
 | 
			
		||||
  bool drawerIsCollapsed = false;
 | 
			
		||||
  bool drawerIsExpanded = false;
 | 
			
		||||
 | 
			
		||||
  void calcDrawerSize(BuildContext context) {
 | 
			
		||||
  void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
 | 
			
		||||
    bool newDrawerIsCollapsed = false;
 | 
			
		||||
    bool newDrawerIsExpanded = false;
 | 
			
		||||
    if (withMediaQuery) {
 | 
			
		||||
      newDrawerIsCollapsed = MediaQuery.of(context).size.width < 450;
 | 
			
		||||
      newDrawerIsExpanded = MediaQuery.of(context).size.width >= 451;
 | 
			
		||||
    } else {
 | 
			
		||||
      final rpb = ResponsiveBreakpoints.of(context);
 | 
			
		||||
    final newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
 | 
			
		||||
    final newDrawerIsExpanded = rpb.largerThan(TABLET)
 | 
			
		||||
      newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
 | 
			
		||||
      newDrawerIsExpanded = rpb.largerThan(TABLET)
 | 
			
		||||
          ? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
 | 
			
		||||
              ? false
 | 
			
		||||
              : true
 | 
			
		||||
          : false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
 | 
			
		||||
      drawerIsExpanded = newDrawerIsExpanded;
 | 
			
		||||
      drawerIsCollapsed = newDrawerIsCollapsed;
 | 
			
		||||
 
 | 
			
		||||
@@ -58,6 +58,11 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
      screen: 'realm',
 | 
			
		||||
      label: 'screenRealm',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.newspaper, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'news',
 | 
			
		||||
      label: 'screenNews',
 | 
			
		||||
    ),
 | 
			
		||||
    AppNavDestination(
 | 
			
		||||
      icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
 | 
			
		||||
      screen: 'album',
 | 
			
		||||
@@ -83,8 +88,7 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
 | 
			
		||||
  List<AppNavDestination> destinations = [];
 | 
			
		||||
 | 
			
		||||
  int get pinnedDestinationCount =>
 | 
			
		||||
      destinations.where((ele) => ele.isPinned).length;
 | 
			
		||||
  int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length;
 | 
			
		||||
 | 
			
		||||
  NavigationProvider() {
 | 
			
		||||
    buildDestinations(kDefaultPinnedDestination);
 | 
			
		||||
@@ -113,17 +117,13 @@ class NavigationProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool isIndexInRange(int min, int max) {
 | 
			
		||||
    return _currentIndex != null &&
 | 
			
		||||
        _currentIndex! >= min &&
 | 
			
		||||
        _currentIndex! < max;
 | 
			
		||||
    return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void autoDetectIndex(GoRouter? state) {
 | 
			
		||||
    if (state == null) return;
 | 
			
		||||
    final idx = destinations.indexWhere(
 | 
			
		||||
      (ele) =>
 | 
			
		||||
          ele.screen ==
 | 
			
		||||
          state.routerDelegate.currentConfiguration.last.route.name,
 | 
			
		||||
      (ele) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name,
 | 
			
		||||
    );
 | 
			
		||||
    _currentIndex = idx == -1 ? null : idx;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
 
 | 
			
		||||
@@ -4,18 +4,27 @@ import 'dart:io';
 | 
			
		||||
import 'package:firebase_messaging/firebase_messaging.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_udid/flutter_udid.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:tray_manager/tray_manager.dart';
 | 
			
		||||
 | 
			
		||||
class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserProvider _ua;
 | 
			
		||||
  late final WebSocketProvider _ws;
 | 
			
		||||
  late final ConfigProvider _cfg;
 | 
			
		||||
 | 
			
		||||
  NotificationProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    _ua = context.read<UserProvider>();
 | 
			
		||||
    _ws = context.read<WebSocketProvider>();
 | 
			
		||||
    _cfg = context.read<ConfigProvider>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> registerPushNotifications() async {
 | 
			
		||||
@@ -62,4 +71,49 @@ class NotificationProvider extends ChangeNotifier {
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  int showingCount = 0;
 | 
			
		||||
  int showingTrayCount = 0;
 | 
			
		||||
  List<SnNotification> notifications = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  void listen() {
 | 
			
		||||
    _ws.pk.stream.listen((event) {
 | 
			
		||||
      if (event.method == 'notifications.new') {
 | 
			
		||||
        final notification = SnNotification.fromJson(event.payload!);
 | 
			
		||||
        if (showingCount < 0) showingCount = 0;
 | 
			
		||||
        showingCount++;
 | 
			
		||||
        showingTrayCount++;
 | 
			
		||||
        notifications.add(notification);
 | 
			
		||||
        Future.delayed(const Duration(seconds: 3), () {
 | 
			
		||||
          if (showingCount >= 0) showingCount--;
 | 
			
		||||
          notifyListeners();
 | 
			
		||||
        });
 | 
			
		||||
        notifyListeners();
 | 
			
		||||
        updateTray();
 | 
			
		||||
        final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
 | 
			
		||||
        if (doHaptic) HapticFeedback.mediumImpact();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void clearTray() {
 | 
			
		||||
    showingTrayCount = 0;
 | 
			
		||||
    updateTray();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void updateTray() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    if (showingTrayCount == 0) {
 | 
			
		||||
      trayManager.setTitle('');
 | 
			
		||||
    } else {
 | 
			
		||||
      trayManager.setTitle(' $showingTrayCount');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void clear() {
 | 
			
		||||
    showingCount = 0;
 | 
			
		||||
    notifications.clear();
 | 
			
		||||
    updateTray();
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,9 @@ class SnPostContentProvider {
 | 
			
		||||
      if (out[i].body['thumbnail'] != null) {
 | 
			
		||||
        rids.add(out[i].body['thumbnail']);
 | 
			
		||||
      }
 | 
			
		||||
      if (out[i].body['video'] != null) {
 | 
			
		||||
        rids.add(out[i].body['video']);
 | 
			
		||||
      }
 | 
			
		||||
      if (out[i].repostTo != null) {
 | 
			
		||||
        out[i] = out[i].copyWith(
 | 
			
		||||
          repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
 | 
			
		||||
@@ -36,6 +39,7 @@ class SnPostContentProvider {
 | 
			
		||||
        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(),
 | 
			
		||||
          video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@@ -53,6 +57,9 @@ class SnPostContentProvider {
 | 
			
		||||
    if (out.body['thumbnail'] != null) {
 | 
			
		||||
      rids.add(out.body['thumbnail']);
 | 
			
		||||
    }
 | 
			
		||||
    if (out.body['video'] != null) {
 | 
			
		||||
      rids.add(out.body['video']);
 | 
			
		||||
    }
 | 
			
		||||
    if (out.repostTo != null) {
 | 
			
		||||
      out = out.copyWith(
 | 
			
		||||
        repostTo: await _preloadRelatedDataSingle(out.repostTo!),
 | 
			
		||||
@@ -64,6 +71,7 @@ class SnPostContentProvider {
 | 
			
		||||
      preload: SnPostPreload(
 | 
			
		||||
        thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
 | 
			
		||||
        attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
 | 
			
		||||
        video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,10 @@ class SnStickerProvider {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  final Map<String, SnSticker?> _cache = {};
 | 
			
		||||
 | 
			
		||||
  final Map<int, List<SnSticker>> stickersByPack = {};
 | 
			
		||||
 | 
			
		||||
  List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
 | 
			
		||||
 | 
			
		||||
  SnStickerProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
  }
 | 
			
		||||
@@ -17,6 +21,12 @@ class SnStickerProvider {
 | 
			
		||||
    return _cache.containsKey(alias) && _cache[alias] == null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _cacheSticker(SnSticker sticker) {
 | 
			
		||||
    _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
 | 
			
		||||
    if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true);
 | 
			
		||||
    if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<SnSticker?> lookupSticker(String alias) async {
 | 
			
		||||
    if (_cache.containsKey(alias)) {
 | 
			
		||||
      return _cache[alias];
 | 
			
		||||
@@ -25,7 +35,7 @@ class SnStickerProvider {
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
 | 
			
		||||
      final sticker = SnSticker.fromJson(resp.data);
 | 
			
		||||
      _cache[alias] = sticker;
 | 
			
		||||
      _cacheSticker(sticker);
 | 
			
		||||
 | 
			
		||||
      return sticker;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
@@ -35,4 +45,30 @@ class SnStickerProvider {
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> listStickerEagerly() async {
 | 
			
		||||
    var count = await listSticker();
 | 
			
		||||
    for (var page = 1; count > 0; count -= 10) {
 | 
			
		||||
      await listSticker(page: page);
 | 
			
		||||
      page++;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<int> listSticker({int page = 0}) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': page * 10,
 | 
			
		||||
      });
 | 
			
		||||
      final data = resp.data;
 | 
			
		||||
      final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele));
 | 
			
		||||
      for (final sticker in stickers) {
 | 
			
		||||
        _cacheSticker(sticker);
 | 
			
		||||
      }
 | 
			
		||||
      return data['count'] as int;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      log('[Sticker] Failed to list stickers: $err');
 | 
			
		||||
      rethrow;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -53,4 +53,11 @@ class UserProvider extends ChangeNotifier {
 | 
			
		||||
    user = null;
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setLanguage(String? value) {
 | 
			
		||||
    if (value == null) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    user = user!.copyWith(language: value);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,8 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
  late final SnNetworkProvider _sn;
 | 
			
		||||
  late final UserProvider _ua;
 | 
			
		||||
 | 
			
		||||
  StreamController<WebSocketPackage> stream = StreamController.broadcast();
 | 
			
		||||
  StreamController<WebSocketPackage> pk = StreamController.broadcast();
 | 
			
		||||
  Stream<dynamic>? _wsStream;
 | 
			
		||||
 | 
			
		||||
  WebSocketProvider(BuildContext context) {
 | 
			
		||||
    _sn = context.read<SnNetworkProvider>();
 | 
			
		||||
@@ -33,7 +34,16 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    await connect();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Completer<void>? _connectCompleter;
 | 
			
		||||
 | 
			
		||||
  Future<void> connect({noRetry = false}) async {
 | 
			
		||||
    if (_connectCompleter != null) {
 | 
			
		||||
      await _connectCompleter!.future;
 | 
			
		||||
      _connectCompleter = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _connectCompleter = Completer<void>();
 | 
			
		||||
 | 
			
		||||
    if (!_ua.isAuthorized) return;
 | 
			
		||||
    if (isConnected || conn != null) {
 | 
			
		||||
      disconnect();
 | 
			
		||||
@@ -50,6 +60,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    try {
 | 
			
		||||
      conn = WebSocketChannel.connect(uri);
 | 
			
		||||
      await conn!.ready;
 | 
			
		||||
      _wsStream = conn!.stream.asBroadcastStream();
 | 
			
		||||
      listen();
 | 
			
		||||
      log('[WebSocket] Connected to server!');
 | 
			
		||||
      isConnected = true;
 | 
			
		||||
@@ -70,6 +81,7 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
    } finally {
 | 
			
		||||
      isBusy = false;
 | 
			
		||||
      notifyListeners();
 | 
			
		||||
      _connectCompleter!.complete();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -83,11 +95,12 @@ class WebSocketProvider extends ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void listen() {
 | 
			
		||||
    conn?.stream.listen(
 | 
			
		||||
    if (_wsStream == null) return;
 | 
			
		||||
    _wsStream!.listen(
 | 
			
		||||
      (event) {
 | 
			
		||||
        final packet = WebSocketPackage.fromJson(jsonDecode(event));
 | 
			
		||||
        log('Websocket incoming message: ${packet.method} ${packet.message}');
 | 
			
		||||
        stream.sink.add(packet);
 | 
			
		||||
        pk.sink.add(packet);
 | 
			
		||||
      },
 | 
			
		||||
      onDone: () {
 | 
			
		||||
        isConnected = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,7 @@ class HomeWidgetProvider {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Future<void> widgetUpdateRandomPost() async {
 | 
			
		||||
  if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
 | 
			
		||||
  final snc = await SnNetworkProvider.createOffContextClient();
 | 
			
		||||
  final resp = await snc.get('/cgi/co/recommendations/shuffle?take=1');
 | 
			
		||||
  final post = SnPost.fromJson(resp.data['data'][0]);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										249
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						
									
										249
									
								
								lib/router.dart
									
									
									
									
									
								
							@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:surface/screens/abuse_report.dart';
 | 
			
		||||
import 'package:surface/screens/account.dart';
 | 
			
		||||
import 'package:surface/screens/account/pfp.dart';
 | 
			
		||||
import 'package:surface/screens/account/account_settings.dart';
 | 
			
		||||
import 'package:surface/screens/account/factor_settings.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.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';
 | 
			
		||||
@@ -19,6 +21,8 @@ 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/news/news_detail.dart';
 | 
			
		||||
import 'package:surface/screens/news/news_list.dart';
 | 
			
		||||
import 'package:surface/screens/notification.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_detail.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_editor.dart';
 | 
			
		||||
@@ -29,37 +33,36 @@ import 'package:surface/screens/realm/manage.dart';
 | 
			
		||||
import 'package:surface/screens/realm/realm_detail.dart';
 | 
			
		||||
import 'package:surface/screens/settings.dart';
 | 
			
		||||
import 'package:surface/screens/sharing.dart';
 | 
			
		||||
import 'package:surface/screens/wallet.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/about.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_background.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
Widget _fadeThroughTransition(
 | 
			
		||||
    BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
 | 
			
		||||
  return FadeThroughTransition(
 | 
			
		||||
    animation: animation,
 | 
			
		||||
    secondaryAnimation: secondaryAnimation,
 | 
			
		||||
    fillColor: Colors.transparent,
 | 
			
		||||
    child: child,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final _appRoutes = [
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(
 | 
			
		||||
      body: child,
 | 
			
		||||
      showAppBar: false,
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/',
 | 
			
		||||
    name: 'home',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const HomeScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => const HomeScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/posts',
 | 
			
		||||
    name: 'explore',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const ExploreScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => const ExploreScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/write/:mode',
 | 
			
		||||
        name: 'postEditor',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: PostEditorScreen(
 | 
			
		||||
        builder: (context, state) => PostEditorScreen(
 | 
			
		||||
          mode: state.pathParameters['mode']!,
 | 
			
		||||
          postEditId: int.tryParse(
 | 
			
		||||
            state.uri.queryParameters['editing'] ?? '',
 | 
			
		||||
@@ -70,249 +73,187 @@ final _appRoutes = [
 | 
			
		||||
          postRepostId: int.tryParse(
 | 
			
		||||
            state.uri.queryParameters['reposting'] ?? '',
 | 
			
		||||
          ),
 | 
			
		||||
                extraProps: state.extra as PostEditorExtraProps?,
 | 
			
		||||
              ),
 | 
			
		||||
          extraProps: state.extra as PostEditorExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/search',
 | 
			
		||||
        name: 'postSearch',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: PostSearchScreen(
 | 
			
		||||
        builder: (context, state) => PostSearchScreen(
 | 
			
		||||
          initialTags: state.uri.queryParameters['tags']?.split(','),
 | 
			
		||||
          initialCategories: state.uri.queryParameters['categories']?.split(','),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
          ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/publishers/:name',
 | 
			
		||||
        name: 'postPublisher',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: PostPublisherScreen(name: state.pathParameters['name']!),
 | 
			
		||||
            ),
 | 
			
		||||
        builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:slug',
 | 
			
		||||
        name: 'postDetail',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: PostDetailScreen(
 | 
			
		||||
        builder: (context, state) => PostDetailScreen(
 | 
			
		||||
          slug: state.pathParameters['slug']!,
 | 
			
		||||
          preload: state.extra as SnPost?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
          ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
 | 
			
		||||
    GoRoute(
 | 
			
		||||
        path: '/account',
 | 
			
		||||
        name: 'account',
 | 
			
		||||
      path: '/wallet',
 | 
			
		||||
      name: 'accountWallet',
 | 
			
		||||
      builder: (context, state) => const WalletScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/settings',
 | 
			
		||||
      name: 'accountSettings',
 | 
			
		||||
      builder: (context, state) => AccountSettingsScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/settings/factors',
 | 
			
		||||
      name: 'factorSettings',
 | 
			
		||||
      builder: (context, state) => FactorSettingsScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/profile/edit',
 | 
			
		||||
      name: 'accountProfileEdit',
 | 
			
		||||
      builder: (context, state) => ProfileEditScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/publishers',
 | 
			
		||||
      name: 'accountPublishers',
 | 
			
		||||
      builder: (context, state) => PublisherScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/publishers/new',
 | 
			
		||||
      name: 'accountPublisherNew',
 | 
			
		||||
      builder: (context, state) => AccountPublisherNewScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/publishers/edit/:name',
 | 
			
		||||
      name: 'accountPublisherEdit',
 | 
			
		||||
      builder: (context, state) => AccountPublisherEditScreen(
 | 
			
		||||
        name: state.pathParameters['name']!,
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/:name',
 | 
			
		||||
      name: 'accountProfilePage',
 | 
			
		||||
      pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const AccountScreen(),
 | 
			
		||||
        child: UserScreen(name: state.pathParameters['name']!),
 | 
			
		||||
      ),
 | 
			
		||||
        routes: [],
 | 
			
		||||
    ),
 | 
			
		||||
  ]),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/chat',
 | 
			
		||||
    name: 'chat',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const ChatScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => const ChatScreen(),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias',
 | 
			
		||||
        name: 'chatRoom',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: ChatRoomScreen(
 | 
			
		||||
        builder: (context, state) => ChatRoomScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
              ),
 | 
			
		||||
          extra: state.extra as ChatRoomScreenExtra?,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias/call',
 | 
			
		||||
        name: 'chatCallRoom',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: CallRoomScreen(
 | 
			
		||||
        builder: (context, state) => CallRoomScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
          ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:scope/:alias/detail',
 | 
			
		||||
        name: 'channelDetail',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: ChannelDetailScreen(
 | 
			
		||||
        builder: (context, state) => ChannelDetailScreen(
 | 
			
		||||
          scope: state.pathParameters['scope']!,
 | 
			
		||||
          alias: state.pathParameters['alias']!,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
          ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'chatManage',
 | 
			
		||||
            pageBuilder: (context, state) => CustomTransitionPage(
 | 
			
		||||
              child: ChatManageScreen(
 | 
			
		||||
        builder: (context, state) => ChatManageScreen(
 | 
			
		||||
          editingChannelAlias: state.uri.queryParameters['editing'],
 | 
			
		||||
        ),
 | 
			
		||||
              transitionsBuilder: (context, animation, secondaryAnimation, child) {
 | 
			
		||||
                return FadeThroughTransition(
 | 
			
		||||
                  animation: animation,
 | 
			
		||||
                  secondaryAnimation: secondaryAnimation,
 | 
			
		||||
                  fillColor: Colors.transparent,
 | 
			
		||||
                  child: AppBackground(
 | 
			
		||||
                    child: child,
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          GoRoute(
 | 
			
		||||
            path: '/:alias',
 | 
			
		||||
            name: 'realmDetail',
 | 
			
		||||
            builder: (context, state) => AppBackground(
 | 
			
		||||
              child: RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
			
		||||
            ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/realm',
 | 
			
		||||
    name: 'realm',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
    pageBuilder: (context, state) => CustomTransitionPage(
 | 
			
		||||
      transitionsBuilder: _fadeThroughTransition,
 | 
			
		||||
      child: const RealmScreen(),
 | 
			
		||||
    ),
 | 
			
		||||
    routes: [
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/manage',
 | 
			
		||||
        name: 'realmManage',
 | 
			
		||||
            pageBuilder: (context, state) => CustomTransitionPage(
 | 
			
		||||
              child: RealmManageScreen(
 | 
			
		||||
        builder: (context, state) => RealmManageScreen(
 | 
			
		||||
          editingRealmAlias: state.uri.queryParameters['editing'],
 | 
			
		||||
        ),
 | 
			
		||||
              transitionsBuilder: (context, animation, secondaryAnimation, child) {
 | 
			
		||||
                return FadeThroughTransition(
 | 
			
		||||
                  animation: animation,
 | 
			
		||||
                  secondaryAnimation: secondaryAnimation,
 | 
			
		||||
                  fillColor: Colors.transparent,
 | 
			
		||||
                  child: AppBackground(
 | 
			
		||||
                    child: child,
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/:alias',
 | 
			
		||||
        name: 'realmDetail',
 | 
			
		||||
        builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
 | 
			
		||||
    GoRoute(
 | 
			
		||||
      path: '/:hash',
 | 
			
		||||
      name: 'newsDetail',
 | 
			
		||||
      builder: (context, state) => NewsDetailScreen(
 | 
			
		||||
        hash: state.pathParameters['hash']!,
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
  ]),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/album',
 | 
			
		||||
    name: 'album',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const AlbumScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => const AlbumScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/friend',
 | 
			
		||||
    name: 'friend',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const FriendScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => const FriendScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/notification',
 | 
			
		||||
    name: 'notification',
 | 
			
		||||
        pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
          child: const NotificationScreen(),
 | 
			
		||||
    builder: (context, state) => const NotificationScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/auth/login',
 | 
			
		||||
    name: 'authLogin',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: LoginScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => LoginScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/auth/register',
 | 
			
		||||
    name: 'authRegister',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: RegisterScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
    builder: (context, state) => RegisterScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/reports',
 | 
			
		||||
    name: 'abuseReport',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: AbuseReportScreen(),
 | 
			
		||||
    builder: (context, state) => AbuseReportScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/profile/edit',
 | 
			
		||||
        name: 'accountProfileEdit',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: ProfileEditScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers',
 | 
			
		||||
        name: 'accountPublishers',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: PublisherScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers/new',
 | 
			
		||||
        name: 'accountPublisherNew',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: AccountPublisherNewScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      GoRoute(
 | 
			
		||||
        path: '/account/publishers/edit/:name',
 | 
			
		||||
        name: 'accountPublisherEdit',
 | 
			
		||||
        builder: (context, state) => AppBackground(
 | 
			
		||||
          child: AccountPublisherEditScreen(
 | 
			
		||||
            name: state.pathParameters['name']!,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/account/:name',
 | 
			
		||||
    name: 'accountProfilePage',
 | 
			
		||||
    pageBuilder: (context, state) => NoTransitionPage(
 | 
			
		||||
      child: UserScreen(name: state.pathParameters['name']!),
 | 
			
		||||
    ),
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/settings',
 | 
			
		||||
    name: 'settings',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: SettingsScreen(),
 | 
			
		||||
    builder: (context, state) => SettingsScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
  ShellRoute(
 | 
			
		||||
    builder: (context, state, child) => AppPageScaffold(body: child),
 | 
			
		||||
    routes: [
 | 
			
		||||
  GoRoute(
 | 
			
		||||
    path: '/about',
 | 
			
		||||
    name: 'about',
 | 
			
		||||
        builder: (context, state) => const AppBackground(
 | 
			
		||||
          child: AboutScreen(),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
    builder: (context, state) => AboutScreen(),
 | 
			
		||||
  ),
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
import '../types/account.dart';
 | 
			
		||||
 | 
			
		||||
@@ -56,7 +57,11 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAbuseReport').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          ListTile(
 | 
			
		||||
@@ -73,6 +78,7 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
 | 
			
		||||
          else
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: ListView.builder(
 | 
			
		||||
                padding: EdgeInsets.only(top: 8),
 | 
			
		||||
                itemCount: _reports.length,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import 'dart:ui';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
@@ -12,6 +14,8 @@ import 'package:surface/providers/websocket.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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class AccountScreen extends StatelessWidget {
 | 
			
		||||
  const AccountScreen({super.key});
 | 
			
		||||
@@ -19,11 +23,51 @@ class AccountScreen extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text("screenAccount").tr(),
 | 
			
		||||
        title: Text(
 | 
			
		||||
          "screenAccount",
 | 
			
		||||
          style: TextStyle(
 | 
			
		||||
            color: Colors.white,
 | 
			
		||||
            shadows: [
 | 
			
		||||
              Shadow(
 | 
			
		||||
                offset: Offset(1, 1),
 | 
			
		||||
                blurRadius: 5.0,
 | 
			
		||||
                color: Color.fromARGB(255, 0, 0, 0),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ).tr(),
 | 
			
		||||
        flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
 | 
			
		||||
            ? Stack(
 | 
			
		||||
                fit: StackFit.expand,
 | 
			
		||||
                children: [
 | 
			
		||||
                  AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
 | 
			
		||||
                  Positioned(
 | 
			
		||||
                    top: 0,
 | 
			
		||||
                    left: 0,
 | 
			
		||||
                    right: 0,
 | 
			
		||||
                    height: 56 + MediaQuery.of(context).padding.top,
 | 
			
		||||
                    child: ClipRect(
 | 
			
		||||
                      child: BackdropFilter(
 | 
			
		||||
                        filter: ImageFilter.blur(
 | 
			
		||||
                          sigmaX: 10,
 | 
			
		||||
                          sigmaY: 10,
 | 
			
		||||
                        ),
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                          color: Colors.black.withOpacity(
 | 
			
		||||
                            clampDouble(10 * 0.1, 0, 0.5),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              )
 | 
			
		||||
            : null,
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.settings, fill: 1),
 | 
			
		||||
@@ -82,16 +126,6 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            );
 | 
			
		||||
          }).padding(all: 20),
 | 
			
		||||
        ).padding(horizontal: 8, top: 16, bottom: 4),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountProfileEdit').tr(),
 | 
			
		||||
          subtitle: Text('accountProfileEditSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.contact_page),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountProfileEdit');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountPublishers').tr(),
 | 
			
		||||
          subtitle: Text('accountPublishersSubtitle').tr(),
 | 
			
		||||
@@ -112,6 +146,36 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            GoRouter.of(context).pushNamed('abuseReport');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('factorSettings').tr(),
 | 
			
		||||
          subtitle: Text('factorSettingsSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.lock),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('factorSettings');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountWallet').tr(),
 | 
			
		||||
          subtitle: Text('accountWalletSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.wallet),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountWallet');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountSettings').tr(),
 | 
			
		||||
          subtitle: Text('accountSettingsSubtitle').tr(),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.manage_accounts),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            GoRouter.of(context).pushNamed('accountSettings');
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountLogout').tr(),
 | 
			
		||||
          subtitle: Text('accountLogoutSubtitle').tr(),
 | 
			
		||||
@@ -133,33 +197,6 @@ class _AuthorizedAccountScreen extends StatelessWidget {
 | 
			
		||||
            await Hive.initFlutter();
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          title: Text('accountDeletion'.tr()),
 | 
			
		||||
          subtitle: Text('accountDeletionActionDescription'.tr()),
 | 
			
		||||
          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
          leading: const Icon(Symbols.person_cancel),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            context
 | 
			
		||||
                .showConfirmDialog(
 | 
			
		||||
              'accountDeletion'.tr(),
 | 
			
		||||
              'accountDeletionDescription'.tr(),
 | 
			
		||||
            )
 | 
			
		||||
                .then((value) {
 | 
			
		||||
              if (!value || !context.mounted) return;
 | 
			
		||||
              final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
              sn.client.post('/cgi/id/users/me/deletion').then((value) {
 | 
			
		||||
                if (context.mounted) {
 | 
			
		||||
                  context.showSnackbar('accountDeletionSubmitted'.tr());
 | 
			
		||||
                }
 | 
			
		||||
              }).catchError((err) {
 | 
			
		||||
                if (context.mounted) {
 | 
			
		||||
                  context.showErrorDialog(err);
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										126
									
								
								lib/screens/account/account_settings.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								lib/screens/account/account_settings.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
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:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:intl/locale.dart';
 | 
			
		||||
 | 
			
		||||
class AccountSettingsScreen extends StatelessWidget {
 | 
			
		||||
  const AccountSettingsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  Future<void> _setAccountLanguage(BuildContext context, Locale? value) async {
 | 
			
		||||
    if (value == null) return;
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final ua = context.read<UserProvider>();
 | 
			
		||||
      await sn.client.put('/cgi/id/users/me/language', data: {
 | 
			
		||||
        'language': value.toString(),
 | 
			
		||||
      });
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showSnackbar('accountSettingsApplied'.tr());
 | 
			
		||||
      await ua.refreshUser();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountSettings').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('settingsAccountLanguage').tr(),
 | 
			
		||||
              subtitle: Text('settingsAccountLanguageDescription').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
              leading: const Icon(Symbols.translate),
 | 
			
		||||
              trailing: DropdownButtonHideUnderline(
 | 
			
		||||
                child: DropdownButton2<Locale?>(
 | 
			
		||||
                  isExpanded: true,
 | 
			
		||||
                  items: [
 | 
			
		||||
                    ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
			
		||||
                      return DropdownMenuItem<Locale?>(
 | 
			
		||||
                        value: Locale.parse(ele.toString()),
 | 
			
		||||
                        child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
 | 
			
		||||
                      );
 | 
			
		||||
                    }),
 | 
			
		||||
                  ],
 | 
			
		||||
                  value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
 | 
			
		||||
                  onChanged: (Locale? value) {
 | 
			
		||||
                    if (value == null) return;
 | 
			
		||||
                    _setAccountLanguage(context, value);
 | 
			
		||||
                    ua.setLanguage(value.toString());
 | 
			
		||||
                  },
 | 
			
		||||
                  buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                    padding: EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                      vertical: 5,
 | 
			
		||||
                    ),
 | 
			
		||||
                    height: 40,
 | 
			
		||||
                    width: 160,
 | 
			
		||||
                  ),
 | 
			
		||||
                  menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                    height: 40,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountProfileEdit').tr(),
 | 
			
		||||
              subtitle: Text('accountProfileEditSubtitle').tr(),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.contact_page),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                GoRouter.of(context).pushNamed('accountProfileEdit');
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              title: Text('accountDeletion'.tr()),
 | 
			
		||||
              subtitle: Text('accountDeletionActionDescription'.tr()),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              leading: const Icon(Symbols.person_cancel),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                context
 | 
			
		||||
                    .showConfirmDialog(
 | 
			
		||||
                  'accountDeletion'.tr(),
 | 
			
		||||
                  'accountDeletionDescription'.tr(),
 | 
			
		||||
                )
 | 
			
		||||
                    .then((value) {
 | 
			
		||||
                  if (!value || !context.mounted) return;
 | 
			
		||||
                  final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
                  sn.client.post('/cgi/id/users/me/deletion').then((value) {
 | 
			
		||||
                    if (context.mounted) {
 | 
			
		||||
                      context.showSnackbar('accountDeletionSubmitted'.tr());
 | 
			
		||||
                    }
 | 
			
		||||
                  }).catchError((err) {
 | 
			
		||||
                    if (context.mounted) {
 | 
			
		||||
                      context.showErrorDialog(err);
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                });
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										294
									
								
								lib/screens/account/factor_settings.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								lib/screens/account/factor_settings.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,294 @@
 | 
			
		||||
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:qr_flutter/qr_flutter.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/auth.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
final Map<int, (String, String, IconData)> kFactorTypes = {
 | 
			
		||||
  0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
 | 
			
		||||
  1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
 | 
			
		||||
  2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
 | 
			
		||||
  3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class FactorSettingsScreen extends StatefulWidget {
 | 
			
		||||
  const FactorSettingsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<FactorSettingsScreen> createState() => _FactorSettingsScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  List<SnAuthFactor>? _factors;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchFactors() async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/users/me/factors');
 | 
			
		||||
      _factors = List<SnAuthFactor>.from(
 | 
			
		||||
        resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchFactors();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenFactorSettings').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(
 | 
			
		||||
            isActive: _isBusy,
 | 
			
		||||
          ),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            title: Text('authFactorAdd').tr(),
 | 
			
		||||
            subtitle: Text('authFactorAddSubtitle').tr(),
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Symbols.add),
 | 
			
		||||
            trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              showDialog(
 | 
			
		||||
                context: context,
 | 
			
		||||
                builder: (context) => _FactorNewDialog(
 | 
			
		||||
                  currentlyHave: _factors!,
 | 
			
		||||
                ),
 | 
			
		||||
              ).then((val) {
 | 
			
		||||
                if (val == true) _fetchFactors();
 | 
			
		||||
              });
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: _fetchFactors,
 | 
			
		||||
                child: ListView.builder(
 | 
			
		||||
                  itemCount: _factors?.length ?? 0,
 | 
			
		||||
                  itemBuilder: (context, idx) {
 | 
			
		||||
                    final ele = _factors![idx];
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      title: Text(kFactorTypes[ele.type]!.$1).tr(),
 | 
			
		||||
                      subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
 | 
			
		||||
                      contentPadding: const EdgeInsets.only(left: 24, right: 12),
 | 
			
		||||
                      leading: Icon(kFactorTypes[ele.type]!.$3),
 | 
			
		||||
                      trailing: IconButton(
 | 
			
		||||
                        icon: const Icon(Symbols.close),
 | 
			
		||||
                        onPressed: ele.type > 0
 | 
			
		||||
                            ? () {
 | 
			
		||||
                                context
 | 
			
		||||
                                    .showConfirmDialog(
 | 
			
		||||
                                  'authFactorDelete'.tr(),
 | 
			
		||||
                                  'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
 | 
			
		||||
                                )
 | 
			
		||||
                                    .then((val) async {
 | 
			
		||||
                                  if (!val) return;
 | 
			
		||||
                                  try {
 | 
			
		||||
                                    if (!context.mounted) return;
 | 
			
		||||
                                    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
                                    await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
 | 
			
		||||
                                    _fetchFactors();
 | 
			
		||||
                                  } catch (err) {
 | 
			
		||||
                                    if (!context.mounted) return;
 | 
			
		||||
                                    context.showErrorDialog(err);
 | 
			
		||||
                                  }
 | 
			
		||||
                                });
 | 
			
		||||
                              }
 | 
			
		||||
                            : null,
 | 
			
		||||
                      ),
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FactorNewDialog extends StatefulWidget {
 | 
			
		||||
  final List<SnAuthFactor> currentlyHave;
 | 
			
		||||
 | 
			
		||||
  const _FactorNewDialog({required this.currentlyHave});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_FactorNewDialog> createState() => _FactorNewDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FactorNewDialogState extends State<_FactorNewDialog> {
 | 
			
		||||
  int? _factorType;
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _submit() async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.post('/cgi/id/users/me/factors', data: {
 | 
			
		||||
        'type': _factorType,
 | 
			
		||||
      });
 | 
			
		||||
      final factor = SnAuthFactor.fromJson(resp.data);
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      if (factor.type == 2) {
 | 
			
		||||
        await showModalBottomSheet(
 | 
			
		||||
          context: context,
 | 
			
		||||
          builder: (context) => _FactorTotpFactorDialog(factor: factor),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      Navigator.of(context).pop(true);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      title: Text('authFactorAdd').tr(),
 | 
			
		||||
      content: Column(
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonHideUnderline(
 | 
			
		||||
            child: DropdownButton2<int>(
 | 
			
		||||
              hint: Text(
 | 
			
		||||
                'Select Item',
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontSize: 14,
 | 
			
		||||
                ),
 | 
			
		||||
                overflow: TextOverflow.ellipsis,
 | 
			
		||||
              ),
 | 
			
		||||
              value: _factorType,
 | 
			
		||||
              items: kFactorTypes.entries.map(
 | 
			
		||||
                (ele) {
 | 
			
		||||
                  final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
 | 
			
		||||
                  return DropdownMenuItem<int>(
 | 
			
		||||
                    enabled: !contains,
 | 
			
		||||
                    value: ele.key,
 | 
			
		||||
                    child: Text(
 | 
			
		||||
                      ele.value.$1.tr(),
 | 
			
		||||
                      style: const TextStyle(
 | 
			
		||||
                        fontSize: 14,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ).opacity(contains ? 0.75 : 1),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ).toList(),
 | 
			
		||||
              onChanged: (val) => setState(() {
 | 
			
		||||
                _factorType = val;
 | 
			
		||||
              }),
 | 
			
		||||
              buttonStyleData: ButtonStyleData(
 | 
			
		||||
                height: 50,
 | 
			
		||||
                padding: const EdgeInsets.only(left: 14, right: 14),
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(14),
 | 
			
		||||
                  border: Border.all(
 | 
			
		||||
                    color: Theme.of(context).dividerColor,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
 | 
			
		||||
          child: Text('dialogCancel').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy ? null : () => _submit(),
 | 
			
		||||
          child: Text('dialogConfirm').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _FactorTotpFactorDialog extends StatelessWidget {
 | 
			
		||||
  final SnAuthFactor factor;
 | 
			
		||||
 | 
			
		||||
  const _FactorTotpFactorDialog({required this.factor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Center(
 | 
			
		||||
      child: Column(
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
        children: [
 | 
			
		||||
          Center(
 | 
			
		||||
            child: Text(
 | 
			
		||||
              'totpPostSetup',
 | 
			
		||||
              textAlign: TextAlign.center,
 | 
			
		||||
              style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
            ).tr().width(280),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(4),
 | 
			
		||||
          Center(
 | 
			
		||||
            child: Text(
 | 
			
		||||
              'totpPostSetupDescription',
 | 
			
		||||
              textAlign: TextAlign.center,
 | 
			
		||||
              style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
            ).tr().width(280),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          QrImageView(
 | 
			
		||||
            padding: EdgeInsets.zero,
 | 
			
		||||
            data: factor.config!['url'],
 | 
			
		||||
            errorCorrectionLevel: QrErrorCorrectLevel.H,
 | 
			
		||||
            version: QrVersions.auto,
 | 
			
		||||
            size: 160,
 | 
			
		||||
            gapless: true,
 | 
			
		||||
            eyeStyle: QrEyeStyle(
 | 
			
		||||
              eyeShape: QrEyeShape.circle,
 | 
			
		||||
              color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
            ),
 | 
			
		||||
            dataModuleStyle: QrDataModuleStyle(
 | 
			
		||||
              dataModuleShape: QrDataModuleShape.square,
 | 
			
		||||
              color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          Center(
 | 
			
		||||
            child: Text(
 | 
			
		||||
              'totpNeverShare',
 | 
			
		||||
              textAlign: TextAlign.center,
 | 
			
		||||
              style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
            ).tr().bold().width(280),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:surface/providers/userinfo.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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class ProfileEditScreen extends StatefulWidget {
 | 
			
		||||
@@ -81,8 +82,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
            onDateTimeChanged: (DateTime newDate) {
 | 
			
		||||
              setState(() {
 | 
			
		||||
                _birthday = newDate;
 | 
			
		||||
                _birthdayController.text =
 | 
			
		||||
                    DateFormat(_kDateFormat).format(_birthday!);
 | 
			
		||||
                _birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
 | 
			
		||||
              });
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
@@ -96,11 +96,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
    if (image == null) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    final ImageProvider imageProvider =
 | 
			
		||||
        kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
			
		||||
    final aspectRatios = place == 'banner'
 | 
			
		||||
        ? [CropAspectRatio(width: 16, height: 7)]
 | 
			
		||||
        : [CropAspectRatio(width: 1, height: 1)];
 | 
			
		||||
    final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
 | 
			
		||||
    final aspectRatios =
 | 
			
		||||
        place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
 | 
			
		||||
    final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
 | 
			
		||||
        ? await showCupertinoImageCropper(
 | 
			
		||||
            // ignore: use_build_context_synchronously
 | 
			
		||||
@@ -122,10 +120,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    final rawBytes =
 | 
			
		||||
        (await result.uiImage.toByteData(format: ImageByteFormat.png))!
 | 
			
		||||
            .buffer
 | 
			
		||||
            .asUint8List();
 | 
			
		||||
    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final attachment = await attach.directUploadOne(
 | 
			
		||||
@@ -212,7 +207,12 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return SingleChildScrollView(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountProfileEdit').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
@@ -229,8 +229,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
                      child: AspectRatio(
 | 
			
		||||
                        aspectRatio: 16 / 9,
 | 
			
		||||
                        child: Container(
 | 
			
		||||
                        color:
 | 
			
		||||
                            Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                          child: _banner != null
 | 
			
		||||
                              ? AutoResizeUniversalImage(
 | 
			
		||||
                                  sn.getAttachmentUrl(_banner!),
 | 
			
		||||
@@ -343,6 +342,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
 | 
			
		||||
            ).padding(horizontal: padding),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -241,6 +241,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      backgroundColor: Colors.transparent,
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
        controller: _scrollController,
 | 
			
		||||
        slivers: [
 | 
			
		||||
@@ -594,7 +595,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
 | 
			
		||||
                subtitle: Text('@${ele.name}'),
 | 
			
		||||
                trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                  GoRouter.of(context).goNamed(
 | 
			
		||||
                    'postPublisher',
 | 
			
		||||
                    pathParameters: {'name': ele.name},
 | 
			
		||||
                  );
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:surface/types/post.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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class AccountPublisherEditScreen extends StatefulWidget {
 | 
			
		||||
@@ -176,7 +177,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ 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:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class AccountPublisherNewScreen extends StatefulWidget {
 | 
			
		||||
  const AccountPublisherNewScreen({super.key});
 | 
			
		||||
@@ -24,7 +25,11 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return  AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountPublisherNew').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import 'package:surface/types/post.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/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class PublisherScreen extends StatefulWidget {
 | 
			
		||||
  const PublisherScreen({super.key});
 | 
			
		||||
@@ -32,8 +33,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/co/publishers/me');
 | 
			
		||||
      final List<SnPublisher> out = List<SnPublisher>.from(
 | 
			
		||||
          resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
			
		||||
      final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
@@ -53,7 +53,11 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountPublishers').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          ListTile(
 | 
			
		||||
@@ -62,9 +66,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            leading: const Icon(Symbols.add_circle),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              GoRouter.of(context)
 | 
			
		||||
                  .pushNamed('accountPublisherNew')
 | 
			
		||||
                  .then((value) {
 | 
			
		||||
              GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
			
		||||
                if (value == true) {
 | 
			
		||||
                  _publishers.clear();
 | 
			
		||||
                  _fetchPublishers();
 | 
			
		||||
@@ -75,6 +77,9 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () {
 | 
			
		||||
                  _publishers.clear();
 | 
			
		||||
@@ -120,6 +125,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ 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:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumScreen extends StatefulWidget {
 | 
			
		||||
@@ -82,7 +83,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
        controller: _scrollController,
 | 
			
		||||
        slivers: [
 | 
			
		||||
 
 | 
			
		||||
@@ -7,17 +7,14 @@ 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/screens/account/factor_settings.dart';
 | 
			
		||||
import 'package:surface/types/auth.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
import '../../providers/websocket.dart';
 | 
			
		||||
 | 
			
		||||
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
 | 
			
		||||
  0: ('authFactorPassword'.tr(), Symbols.password, false),
 | 
			
		||||
  1: ('authFactorEmail'.tr(), Symbols.email, true),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class LoginScreen extends StatefulWidget {
 | 
			
		||||
  const LoginScreen({super.key});
 | 
			
		||||
 | 
			
		||||
@@ -35,7 +32,12 @@ class _LoginScreenState extends State<LoginScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Theme(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAuthLogin').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Theme(
 | 
			
		||||
        data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
 | 
			
		||||
        child: SingleChildScrollView(
 | 
			
		||||
          child: PageTransitionSwitcher(
 | 
			
		||||
@@ -96,6 +98,7 @@ class _LoginScreenState extends State<LoginScreen> {
 | 
			
		||||
            },
 | 
			
		||||
          ).padding(all: 24),
 | 
			
		||||
        ).center(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -205,7 +208,9 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
 | 
			
		||||
          controller: _passwordController,
 | 
			
		||||
          obscureText: true,
 | 
			
		||||
          autofillHints: [
 | 
			
		||||
            (_factorLabelMap[widget.factor!.type]?.$3 ?? true) ? AutofillHints.password : AutofillHints.oneTimeCode
 | 
			
		||||
            widget.factor!.type == 0
 | 
			
		||||
                ? AutofillHints.password
 | 
			
		||||
                : AutofillHints.oneTimeCode
 | 
			
		||||
          ],
 | 
			
		||||
          decoration: InputDecoration(
 | 
			
		||||
            isDense: true,
 | 
			
		||||
@@ -260,7 +265,8 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  int? _factorPicked;
 | 
			
		||||
 | 
			
		||||
  Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
 | 
			
		||||
  Color get _unFocusColor =>
 | 
			
		||||
      Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
 | 
			
		||||
 | 
			
		||||
  void _performGetFactorCode() async {
 | 
			
		||||
    if (_factorPicked == null) return;
 | 
			
		||||
@@ -321,11 +327,11 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        secondary: Icon(
 | 
			
		||||
                          _factorLabelMap[x.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
                          kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
 | 
			
		||||
                        ),
 | 
			
		||||
                        title: Text(
 | 
			
		||||
                          _factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(),
 | 
			
		||||
                        ),
 | 
			
		||||
                          kFactorTypes[x.type]?.$1 ?? 'unknown',
 | 
			
		||||
                        ).tr(),
 | 
			
		||||
                        enabled: !widget.ticket!.factorTrail.contains(x.id),
 | 
			
		||||
                        value: _factorPicked == x.id,
 | 
			
		||||
                        onChanged: (value) {
 | 
			
		||||
@@ -401,11 +407,14 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final lookupResp = await sn.client.get('/cgi/id/users/lookup?probe=$username');
 | 
			
		||||
      final lookupResp =
 | 
			
		||||
          await sn.client.get('/cgi/id/users/lookup?probe=$username');
 | 
			
		||||
      await sn.client.post('/cgi/id/users/me/password-reset', data: {
 | 
			
		||||
        'user_id': lookupResp.data['id'],
 | 
			
		||||
      });
 | 
			
		||||
      if (mounted) context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
 | 
			
		||||
      if (mounted) {
 | 
			
		||||
        context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
@@ -430,7 +439,8 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
 | 
			
		||||
      widget.onTicket(result.ticket);
 | 
			
		||||
 | 
			
		||||
      // Pull factors
 | 
			
		||||
      final factorResp = await sn.client.get('/cgi/id/auth/factors', queryParameters: {
 | 
			
		||||
      final factorResp =
 | 
			
		||||
          await sn.client.get('/cgi/id/auth/factors', queryParameters: {
 | 
			
		||||
        'ticketId': result.ticket!.id.toString(),
 | 
			
		||||
      });
 | 
			
		||||
      widget.onFactor(
 | 
			
		||||
@@ -441,7 +451,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
 | 
			
		||||
 | 
			
		||||
      widget.onNext();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if(mounted) context.showErrorDialog(err);
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
      return;
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
@@ -524,7 +534,10 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
 | 
			
		||||
                    'termAcceptNextWithAgree'.tr(),
 | 
			
		||||
                    textAlign: TextAlign.end,
 | 
			
		||||
                    style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
 | 
			
		||||
                          color: Theme.of(context)
 | 
			
		||||
                              .colorScheme
 | 
			
		||||
                              .onSurface
 | 
			
		||||
                              .withAlpha((255 * 0.75).round()),
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  Material(
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class RegisterScreen extends StatefulWidget {
 | 
			
		||||
@@ -43,6 +44,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
        'nick': nickname,
 | 
			
		||||
        'email': email,
 | 
			
		||||
        'password': password,
 | 
			
		||||
        'language': EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
@@ -54,7 +56,12 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return StyledWidget(Container(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAuthRegister').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: StyledWidget(Container(
 | 
			
		||||
        constraints: const BoxConstraints(maxWidth: 380),
 | 
			
		||||
        child: SingleChildScrollView(
 | 
			
		||||
          child: Column(
 | 
			
		||||
@@ -180,10 +187,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
                          'termAcceptNextWithAgree'.tr(),
 | 
			
		||||
                          textAlign: TextAlign.end,
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                          color: Theme.of(context)
 | 
			
		||||
                              .colorScheme
 | 
			
		||||
                              .onSurface
 | 
			
		||||
                              .withAlpha((255 * 0.75).round()),
 | 
			
		||||
                                color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
 | 
			
		||||
                              ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        Material(
 | 
			
		||||
@@ -223,6 +227,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
    )).padding(all: 24).center();
 | 
			
		||||
      )).padding(all: 24).center(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
@@ -120,7 +121,7 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return Scaffold(
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          title: Text('screenChat').tr(),
 | 
			
		||||
@@ -131,7 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenChat').tr(),
 | 
			
		||||
@@ -195,6 +196,9 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
			
		||||
                child: ListView.builder(
 | 
			
		||||
@@ -236,7 +240,7 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
                              'alias': channel.alias,
 | 
			
		||||
                            },
 | 
			
		||||
                          ).then((value) {
 | 
			
		||||
                          if (value == true) _refreshChannels();
 | 
			
		||||
                            if (mounted) _refreshChannels();
 | 
			
		||||
                          });
 | 
			
		||||
                        },
 | 
			
		||||
                      );
 | 
			
		||||
@@ -276,6 +280,7 @@ class _ChatScreenState extends State<ChatScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -9,10 +9,12 @@ 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';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
class CallRoomScreen extends StatefulWidget {
 | 
			
		||||
  final String scope;
 | 
			
		||||
  final String alias;
 | 
			
		||||
 | 
			
		||||
  const CallRoomScreen({super.key, required this.scope, required this.alias});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -35,8 +37,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
    return Stack(
 | 
			
		||||
      children: [
 | 
			
		||||
        Container(
 | 
			
		||||
          color:
 | 
			
		||||
              Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
 | 
			
		||||
          color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
 | 
			
		||||
          child: call.focusTrack != null
 | 
			
		||||
              ? InteractiveParticipantWidget(
 | 
			
		||||
                  isFixedAvatar: false,
 | 
			
		||||
@@ -71,8 +72,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                      color: Theme.of(context).cardColor,
 | 
			
		||||
                      participant: track,
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        if (track.participant.sid !=
 | 
			
		||||
                            call.focusTrack?.participant.sid) {
 | 
			
		||||
                        if (track.participant.sid != call.focusTrack?.participant.sid) {
 | 
			
		||||
                          call.setFocusTrack(track);
 | 
			
		||||
                        }
 | 
			
		||||
                      },
 | 
			
		||||
@@ -114,14 +114,10 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
            child: ClipRRect(
 | 
			
		||||
              borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
              child: InteractiveParticipantWidget(
 | 
			
		||||
                color: Theme.of(context)
 | 
			
		||||
                    .colorScheme
 | 
			
		||||
                    .surfaceContainerHigh
 | 
			
		||||
                    .withOpacity(0.75),
 | 
			
		||||
                color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
 | 
			
		||||
                participant: track,
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  if (track.participant.sid !=
 | 
			
		||||
                      call.focusTrack?.participant.sid) {
 | 
			
		||||
                  if (track.participant.sid != call.focusTrack?.participant.sid) {
 | 
			
		||||
                    call.setFocusTrack(track);
 | 
			
		||||
                  }
 | 
			
		||||
                },
 | 
			
		||||
@@ -152,31 +148,28 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
        listenable: call,
 | 
			
		||||
        builder: (context, _) {
 | 
			
		||||
          return Scaffold(
 | 
			
		||||
          return AppScaffold(
 | 
			
		||||
            appBar: AppBar(
 | 
			
		||||
              title: RichText(
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                text: TextSpan(children: [
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: 'call'.tr(),
 | 
			
		||||
                    style: Theme.of(context)
 | 
			
		||||
                        .textTheme
 | 
			
		||||
                        .titleLarge!
 | 
			
		||||
                        .copyWith(color: Colors.white),
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor,
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const TextSpan(text: '\n'),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: call.lastDuration.toString(),
 | 
			
		||||
                    style: Theme.of(context)
 | 
			
		||||
                        .textTheme
 | 
			
		||||
                        .bodySmall!
 | 
			
		||||
                        .copyWith(color: Colors.white),
 | 
			
		||||
                    style: Theme.of(context).textTheme.bodySmall!.copyWith(
 | 
			
		||||
                          color: Theme.of(context).appBarTheme.foregroundColor,
 | 
			
		||||
                        ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ]),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            body: SafeArea(
 | 
			
		||||
              child: GestureDetector(
 | 
			
		||||
            body: GestureDetector(
 | 
			
		||||
              behavior: HitTestBehavior.translucent,
 | 
			
		||||
              child: Column(
 | 
			
		||||
                children: [
 | 
			
		||||
@@ -190,8 +183,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                        Builder(builder: (context) {
 | 
			
		||||
                          final call = context.read<ChatCallProvider>();
 | 
			
		||||
                          final connectionQuality =
 | 
			
		||||
                                call.room.localParticipant?.connectionQuality ??
 | 
			
		||||
                                    livekit.ConnectionQuality.unknown;
 | 
			
		||||
                              call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
 | 
			
		||||
                          return Expanded(
 | 
			
		||||
                            child: Column(
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
@@ -213,35 +205,24 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      {
 | 
			
		||||
                                          livekit.ConnectionState.disconnected:
 | 
			
		||||
                                              'callStatusDisconnected'.tr(),
 | 
			
		||||
                                          livekit.ConnectionState.connected:
 | 
			
		||||
                                              'callStatusConnected'.tr(),
 | 
			
		||||
                                          livekit.ConnectionState.connecting:
 | 
			
		||||
                                              'callStatusConnecting'.tr(),
 | 
			
		||||
                                          livekit.ConnectionState.reconnecting:
 | 
			
		||||
                                              'callStatusReconnecting'.tr(),
 | 
			
		||||
                                        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)
 | 
			
		||||
                                    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,
 | 
			
		||||
                                          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,
 | 
			
		||||
                                          livekit.ConnectionQuality.excellent: Colors.green,
 | 
			
		||||
                                          livekit.ConnectionQuality.good: Colors.orange,
 | 
			
		||||
                                          livekit.ConnectionQuality.poor: Colors.red,
 | 
			
		||||
                                        }[connectionQuality],
 | 
			
		||||
                                        size: 16,
 | 
			
		||||
                                      )
 | 
			
		||||
@@ -263,9 +244,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                        Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            IconButton(
 | 
			
		||||
                                icon: _layoutMode == 0
 | 
			
		||||
                                    ? const Icon(Icons.view_list)
 | 
			
		||||
                                    : const Icon(Icons.grid_view),
 | 
			
		||||
                              icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                _switchLayout();
 | 
			
		||||
                              },
 | 
			
		||||
@@ -277,8 +256,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
                  ),
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: Material(
 | 
			
		||||
                        color:
 | 
			
		||||
                            Theme.of(context).colorScheme.surfaceContainerLow,
 | 
			
		||||
                      color: Theme.of(context).colorScheme.surfaceContainerLow,
 | 
			
		||||
                      child: Builder(
 | 
			
		||||
                        builder: (context) {
 | 
			
		||||
                          switch (_layoutMode) {
 | 
			
		||||
@@ -303,7 +281,6 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
 | 
			
		||||
              ),
 | 
			
		||||
              onTap: () {},
 | 
			
		||||
            ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        });
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -10,15 +10,19 @@ 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/account.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.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,
 | 
			
		||||
@@ -54,8 +58,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client
 | 
			
		||||
          .get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
 | 
			
		||||
      _profile = SnChannelMember.fromJson(resp.data);
 | 
			
		||||
      _notifyLevel = _profile!.notify;
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -142,6 +145,25 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _addMember(SnAccount related) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/im/channels/${_channel!.keyPath}/members',
 | 
			
		||||
        data: {'related': related.name},
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('channelMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showChannelProfileDetail() {
 | 
			
		||||
    showDialog(
 | 
			
		||||
      context: context,
 | 
			
		||||
@@ -165,13 +187,16 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
  void _showMemberAdd() async {
 | 
			
		||||
    final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _NewChannelMemberWidget(
 | 
			
		||||
        channel: _channel!,
 | 
			
		||||
      builder: (context) => AccountSelect(
 | 
			
		||||
        title: 'channelMemberAdd'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    _addMember(user);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -189,7 +214,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
 | 
			
		||||
    final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
@@ -220,11 +245,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
              Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text('channelDetailPersonalRegion')
 | 
			
		||||
                      .bold()
 | 
			
		||||
                      .fontSize(17)
 | 
			
		||||
                      .tr()
 | 
			
		||||
                      .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: const Icon(Symbols.notifications),
 | 
			
		||||
                    trailing: DropdownButtonHideUnderline(
 | 
			
		||||
@@ -263,8 +284,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content:
 | 
			
		||||
                          ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
 | 
			
		||||
                      radius: 18,
 | 
			
		||||
                    ),
 | 
			
		||||
                    trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -283,8 +303,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
                      trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                      title: Text('channelActionLeave').tr(),
 | 
			
		||||
                      subtitle: Text('channelActionLeaveDescription').tr(),
 | 
			
		||||
                      contentPadding:
 | 
			
		||||
                          const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      onTap: _leaveChannel,
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
@@ -292,11 +311,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailMemberRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.group),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -318,11 +333,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('channelDetailAdminRegion')
 | 
			
		||||
                    .bold()
 | 
			
		||||
                    .fontSize(17)
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  leading: const Icon(Symbols.edit),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
@@ -361,18 +372,17 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
 | 
			
		||||
class _ChannelProfileDetailDialog extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
  final SnChannelMember current;
 | 
			
		||||
 | 
			
		||||
  const _ChannelProfileDetailDialog({
 | 
			
		||||
    required this.channel,
 | 
			
		||||
    required this.current,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() =>
 | 
			
		||||
      _ChannelProfileDetailDialogState();
 | 
			
		||||
  State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelProfileDetailDialogState
 | 
			
		||||
    extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  final TextEditingController _nickController = TextEditingController();
 | 
			
		||||
@@ -443,11 +453,11 @@ class _ChannelProfileDetailDialogState
 | 
			
		||||
 | 
			
		||||
class _ChannelMemberListWidget extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
 | 
			
		||||
  const _ChannelMemberListWidget({required this.channel});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() =>
 | 
			
		||||
      _ChannelMemberListWidgetState();
 | 
			
		||||
  State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
@@ -462,9 +472,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
    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: {
 | 
			
		||||
      final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': 0,
 | 
			
		||||
      });
 | 
			
		||||
@@ -525,9 +533,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.group, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('channelMemberManage')
 | 
			
		||||
                .tr()
 | 
			
		||||
                .textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
@@ -538,8 +544,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
            },
 | 
			
		||||
            child: InfiniteList(
 | 
			
		||||
              itemCount: _members.length,
 | 
			
		||||
              hasReachedMax:
 | 
			
		||||
                  _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
 | 
			
		||||
              isLoading: _isBusy,
 | 
			
		||||
              onFetchData: _fetchMembers,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
@@ -550,8 +555,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                    content: ud.getAccountFromCache(member.accountId)?.avatar,
 | 
			
		||||
                  ),
 | 
			
		||||
                  title: Text(
 | 
			
		||||
                    ud.getAccountFromCache(member.accountId)?.name ??
 | 
			
		||||
                        'unknown'.tr(),
 | 
			
		||||
                    ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  subtitle: Text(member.nick ?? 'unknown'.tr()),
 | 
			
		||||
                  trailing: SizedBox(
 | 
			
		||||
@@ -561,8 +565,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        IconButton(
 | 
			
		||||
                          onPressed:
 | 
			
		||||
                              _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          onPressed: _isUpdating ? null : () => _deleteMember(member),
 | 
			
		||||
                          icon: const Icon(Symbols.person_remove),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
@@ -577,83 +580,3 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewChannelMemberWidget extends StatefulWidget {
 | 
			
		||||
  final SnChannel channel;
 | 
			
		||||
  const _NewChannelMemberWidget({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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -12,10 +13,12 @@ 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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class ChatManageScreen extends StatefulWidget {
 | 
			
		||||
  final String? editingChannelAlias;
 | 
			
		||||
 | 
			
		||||
  const ChatManageScreen({super.key, this.editingChannelAlias});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -32,6 +35,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
  List<SnRealm>? _realms;
 | 
			
		||||
  SnRealm? _belongToRealm;
 | 
			
		||||
 | 
			
		||||
  SnChannel? _editingChannel;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchRealms() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
    try {
 | 
			
		||||
@@ -40,6 +45,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
      _realms = List<SnRealm>.from(
 | 
			
		||||
        resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
      if (_editingChannel != null) {
 | 
			
		||||
        _belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (mounted) context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
@@ -47,8 +55,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SnChannel? _editingChannel;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchChannel() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
@@ -121,11 +127,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.editingChannelAlias != null
 | 
			
		||||
            ? Text('screenChatManage').tr()
 | 
			
		||||
            : Text('screenChatNew').tr(),
 | 
			
		||||
        title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
@@ -137,8 +141,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                leadingPadding: const EdgeInsets.only(left: 10, right: 20),
 | 
			
		||||
                dividerColor: Colors.transparent,
 | 
			
		||||
                content: Text(
 | 
			
		||||
                  'channelEditingNotice'
 | 
			
		||||
                      .tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                  'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
 | 
			
		||||
                ),
 | 
			
		||||
                actions: [
 | 
			
		||||
                  TextButton(
 | 
			
		||||
@@ -161,6 +164,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                items: [
 | 
			
		||||
                  ...(_realms?.map(
 | 
			
		||||
                        (SnRealm item) => DropdownMenuItem<SnRealm>(
 | 
			
		||||
                          enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
 | 
			
		||||
                          value: item,
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            children: [
 | 
			
		||||
@@ -178,15 +182,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context)
 | 
			
		||||
                                        .textTheme
 | 
			
		||||
                                        .bodyMedium!),
 | 
			
		||||
                                    Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                    Text(
 | 
			
		||||
                                      item.description,
 | 
			
		||||
                                      maxLines: 1,
 | 
			
		||||
                                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                                    ).textStyle(
 | 
			
		||||
                                        Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                    ).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
@@ -196,14 +197,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                      ) ??
 | 
			
		||||
                      []),
 | 
			
		||||
                  DropdownMenuItem<SnRealm>(
 | 
			
		||||
                    enabled: _editingChannel == null,
 | 
			
		||||
                    value: null,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        CircleAvatar(
 | 
			
		||||
                          radius: 16,
 | 
			
		||||
                          backgroundColor: Colors.transparent,
 | 
			
		||||
                          foregroundColor:
 | 
			
		||||
                              Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                          child: const Icon(Symbols.clear),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(12),
 | 
			
		||||
@@ -212,9 +213,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset')
 | 
			
		||||
                                  .tr()
 | 
			
		||||
                                  .textStyle(
 | 
			
		||||
                              Text('fieldChatBelongToRealmUnset').tr().textStyle(
 | 
			
		||||
                                    Theme.of(context).textTheme.bodyMedium!,
 | 
			
		||||
                                  ),
 | 
			
		||||
                            ],
 | 
			
		||||
@@ -230,10 +229,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                },
 | 
			
		||||
                buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                  padding: EdgeInsets.only(right: 16),
 | 
			
		||||
                  height: 60,
 | 
			
		||||
                  height: 48,
 | 
			
		||||
                ),
 | 
			
		||||
                menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                  height: 60,
 | 
			
		||||
                  height: 48,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
@@ -249,8 +248,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    helperText: 'fieldChatAliasHint'.tr(),
 | 
			
		||||
                    helperMaxLines: 2,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -259,8 +257,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatName'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                TextField(
 | 
			
		||||
@@ -271,8 +268,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
 | 
			
		||||
                    border: const UnderlineInputBorder(),
 | 
			
		||||
                    labelText: 'fieldChatDescription'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) =>
 | 
			
		||||
                      FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(12),
 | 
			
		||||
                Row(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
@@ -9,9 +10,12 @@ 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/controllers/post_write_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/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
 | 
			
		||||
@@ -20,16 +24,22 @@ import 'package:surface/widgets/chat/chat_message_input.dart';
 | 
			
		||||
import 'package:surface/widgets/chat/chat_typing_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
import '../../providers/user_directory.dart';
 | 
			
		||||
import '../../providers/userinfo.dart';
 | 
			
		||||
class ChatRoomScreenExtra {
 | 
			
		||||
  final String? initialText;
 | 
			
		||||
  final List<PostWriteMedia>? initialAttachments;
 | 
			
		||||
 | 
			
		||||
  ChatRoomScreenExtra({this.initialText, this.initialAttachments});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ChatRoomScreen extends StatefulWidget {
 | 
			
		||||
  final String scope;
 | 
			
		||||
  final String alias;
 | 
			
		||||
  final ChatRoomScreenExtra? extra;
 | 
			
		||||
 | 
			
		||||
  const ChatRoomScreen({super.key, required this.scope, required this.alias});
 | 
			
		||||
  const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
 | 
			
		||||
@@ -176,12 +186,27 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    _messageController = ChatMessageController(context);
 | 
			
		||||
    _fetchChannel().then((_) async {
 | 
			
		||||
      await _messageController.initialize(_channel!);
 | 
			
		||||
      await _messageController.checkUpdate();
 | 
			
		||||
      await _fetchOngoingCall();
 | 
			
		||||
 | 
			
		||||
      if (widget.extra != null) {
 | 
			
		||||
        WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
          log('[ChatInput] Setting initial text and attachments...');
 | 
			
		||||
          if (widget.extra!.initialText != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
 | 
			
		||||
          }
 | 
			
		||||
          if (widget.extra!.initialAttachments != null) {
 | 
			
		||||
            _inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await Future.wait([
 | 
			
		||||
        _messageController.checkUpdate(),
 | 
			
		||||
        _fetchOngoingCall(),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    final ws = context.read<WebSocketProvider>();
 | 
			
		||||
    _wsSubscription = ws.stream.stream.listen((event) {
 | 
			
		||||
    _wsSubscription = ws.pk.stream.listen((event) {
 | 
			
		||||
      switch (event.method) {
 | 
			
		||||
        case 'calls.new':
 | 
			
		||||
          final payload = SnChatCall.fromJson(event.payload!);
 | 
			
		||||
@@ -211,7 +236,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
 | 
			
		||||
    final call = context.watch<ChatCallProvider>();
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text(
 | 
			
		||||
          _channel?.type == 1
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
@@ -93,7 +94,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      floatingActionButtonLocation: ExpandableFab.location,
 | 
			
		||||
      floatingActionButton: ExpandableFab(
 | 
			
		||||
        key: _fabKey,
 | 
			
		||||
@@ -160,6 +161,48 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeQuestion').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeQuestion'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'questions',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.question_answer),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              Text('writePostTypeVideo').tr(),
 | 
			
		||||
              const Gap(20),
 | 
			
		||||
              FloatingActionButton(
 | 
			
		||||
                heroTag: null,
 | 
			
		||||
                tooltip: 'writePostTypeVideo'.tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed('postEditor', pathParameters: {
 | 
			
		||||
                    'mode': 'videos',
 | 
			
		||||
                  }).then((value) {
 | 
			
		||||
                    if (value == true) {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    }
 | 
			
		||||
                  });
 | 
			
		||||
                  _fabKey.currentState!.toggle();
 | 
			
		||||
                },
 | 
			
		||||
                child: const Icon(Symbols.video_call),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: RefreshIndicator(
 | 
			
		||||
@@ -210,6 +253,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const SliverGap(12),
 | 
			
		||||
            SliverInfiniteList(
 | 
			
		||||
              itemCount: _posts.length,
 | 
			
		||||
              isLoading: _isBusy,
 | 
			
		||||
@@ -217,8 +261,8 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
              hasReachedMax: _postCount != null && _posts.length >= _postCount!,
 | 
			
		||||
              onFetchData: _fetchPosts,
 | 
			
		||||
              itemBuilder: (context, idx) {
 | 
			
		||||
                return GestureDetector(
 | 
			
		||||
                  child: PostItem(
 | 
			
		||||
                return Center(
 | 
			
		||||
                  child: OpenablePostItem(
 | 
			
		||||
                    data: _posts[idx],
 | 
			
		||||
                    maxWidth: 640,
 | 
			
		||||
                    onChanged: (data) {
 | 
			
		||||
@@ -228,16 +272,9 @@ class _ExploreScreenState extends State<ExploreScreen> {
 | 
			
		||||
                      _refreshPosts();
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    GoRouter.of(context).pushNamed(
 | 
			
		||||
                      'postDetail',
 | 
			
		||||
                      pathParameters: {'slug': _posts[idx].id.toString()},
 | 
			
		||||
                      extra: _posts[idx],
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
              separatorBuilder: (context, index) => const Divider(height: 1),
 | 
			
		||||
              separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,15 @@ 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/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
 | 
			
		||||
import '../providers/userinfo.dart';
 | 
			
		||||
import '../widgets/unauthorized_hint.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
			
		||||
 | 
			
		||||
const kFriendStatus = {
 | 
			
		||||
  0: 'friendStatusPending',
 | 
			
		||||
@@ -167,6 +168,24 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _sendRequest(SnAccount user) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/id/users/me/relations', data: {
 | 
			
		||||
        'related': user.name,
 | 
			
		||||
      });
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('friendRequestSent'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
@@ -180,7 +199,7 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return Scaffold(
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          title: Text('screenFriend').tr(),
 | 
			
		||||
@@ -191,18 +210,23 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenFriend').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      floatingActionButton: FloatingActionButton(
 | 
			
		||||
        child: const Icon(Symbols.add),
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          showModalBottomSheet(
 | 
			
		||||
        onPressed: () async {
 | 
			
		||||
          final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => _NewFriendWidget(),
 | 
			
		||||
            builder: (context) => AccountSelect(
 | 
			
		||||
              title: 'friendNew'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          if (!mounted) return;
 | 
			
		||||
          if (user == null) return;
 | 
			
		||||
          _sendRequest(user);
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
@@ -230,9 +254,11 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              onTap: _showBlocks,
 | 
			
		||||
            ),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty)
 | 
			
		||||
            const Divider(height: 1),
 | 
			
		||||
          if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: () => Future.wait([
 | 
			
		||||
                  _fetchRelations(),
 | 
			
		||||
@@ -260,16 +286,12 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
                              mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                onTap: _isUpdating
 | 
			
		||||
                                    ? null
 | 
			
		||||
                                    : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
 | 
			
		||||
                                  child: Text('friendBlock').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                                const Gap(8),
 | 
			
		||||
                                InkWell(
 | 
			
		||||
                                onTap: _isUpdating
 | 
			
		||||
                                    ? null
 | 
			
		||||
                                    : () => _deleteRelation(relation),
 | 
			
		||||
                                  onTap: _isUpdating ? null : () => _deleteRelation(relation),
 | 
			
		||||
                                  child: Text('friendDeleteAction').tr(),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
@@ -282,89 +304,16 @@ class _FriendScreenState extends State<FriendScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewFriendWidget extends StatefulWidget {
 | 
			
		||||
  const _NewFriendWidget();
 | 
			
		||||
 | 
			
		||||
  @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({required this.relations});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -471,9 +420,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
              mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown')
 | 
			
		||||
                    .tr()
 | 
			
		||||
                    .opacity(0.75),
 | 
			
		||||
                Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
 | 
			
		||||
                if (relation.status == 0)
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
@@ -494,8 +441,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        onTap:
 | 
			
		||||
                            _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        onTap: _isBusy ? null : () => _changeRelation(relation, 1),
 | 
			
		||||
                        child: Text('friendUnblock').tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(8),
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:html/parser.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:relative_time/relative_time.dart';
 | 
			
		||||
@@ -22,9 +23,11 @@ import 'package:surface/providers/special_day.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/widget.dart';
 | 
			
		||||
import 'package:surface/types/check_in.dart';
 | 
			
		||||
import 'package:surface/types/news.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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
 | 
			
		||||
class HomeScreenDashEntry {
 | 
			
		||||
@@ -48,12 +51,12 @@ class HomeScreen extends StatefulWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
  static const List<HomeScreenDashEntry> kCards = [
 | 
			
		||||
  late final List<HomeScreenDashEntry> kCards = [
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryRecommendation',
 | 
			
		||||
      cols: 2,
 | 
			
		||||
      rows: 2,
 | 
			
		||||
      child: _HomeDashRecommendationPostWidget(),
 | 
			
		||||
      rows: 2,
 | 
			
		||||
      cols: 2,
 | 
			
		||||
    ),
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryCheckIn',
 | 
			
		||||
@@ -63,11 +66,16 @@ class _HomeScreenState extends State<HomeScreen> {
 | 
			
		||||
      name: 'dashEntryNotification',
 | 
			
		||||
      child: _HomeDashNotificationWidget(),
 | 
			
		||||
    ),
 | 
			
		||||
    HomeScreenDashEntry(
 | 
			
		||||
      name: 'dashEntryTodayNews',
 | 
			
		||||
      child: _HomeDashTodayNews(),
 | 
			
		||||
      cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text("screenHome").tr(),
 | 
			
		||||
@@ -229,6 +237,106 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNews extends StatefulWidget {
 | 
			
		||||
  const _HomeDashTodayNews();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/today');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data['data']);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
      rethrow;
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchArticle();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Card(
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.newspaper),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(
 | 
			
		||||
                'newsToday',
 | 
			
		||||
                style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
              ).tr()
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 18, top: 12, bottom: 8),
 | 
			
		||||
          if (_article != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: InkWell(
 | 
			
		||||
                borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  spacing: 4,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
                      _article!.title,
 | 
			
		||||
                      style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
 | 
			
		||||
                      maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Text(
 | 
			
		||||
                      parse(_article!.description).children.map((e) => e.text.trim()).join(),
 | 
			
		||||
                      maxLines: 3,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Builder(builder: (context) {
 | 
			
		||||
                      final date = _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                      return Row(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        spacing: 2,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
			
		||||
                          Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ).opacity(0.75);
 | 
			
		||||
                    }),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                    'newsDetail',
 | 
			
		||||
                    pathParameters: {'hash': _article!.hash},
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Center(
 | 
			
		||||
                child: CircularProgressIndicator(),
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomeDashCheckInWidget extends StatefulWidget {
 | 
			
		||||
  const _HomeDashCheckInWidget();
 | 
			
		||||
 | 
			
		||||
@@ -387,6 +495,8 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'dailyCheckInNone',
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                          maxLines: 2,
 | 
			
		||||
                          overflow: TextOverflow.ellipsis,
 | 
			
		||||
                        ).tr(),
 | 
			
		||||
                      ],
 | 
			
		||||
                    )
 | 
			
		||||
@@ -404,6 +514,11 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
 | 
			
		||||
                          '+${_todayRecord!.resultExperience} EXP',
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                        ),
 | 
			
		||||
                        if (_todayRecord!.resultCoin >= 0)
 | 
			
		||||
                          Text(
 | 
			
		||||
                            '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
 | 
			
		||||
                            style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                          )
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
            ),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										241
									
								
								lib/screens/news/news_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								lib/screens/news/news_detail.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,241 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:html/dom.dart' as dom;
 | 
			
		||||
import 'package:html/parser.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/news.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class NewsDetailScreen extends StatefulWidget {
 | 
			
		||||
  final String hash;
 | 
			
		||||
 | 
			
		||||
  const NewsDetailScreen({super.key, required this.hash});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<NewsDetailScreen> createState() => _NewsDetailScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
 | 
			
		||||
  SnNewsArticle? _article;
 | 
			
		||||
  dom.Document? _articleFragment;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticle() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
 | 
			
		||||
      _article = SnNewsArticle.fromJson(resp.data);
 | 
			
		||||
      _articleFragment = parse(_article!.content);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err).then((_) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        Navigator.pop(context);
 | 
			
		||||
      });
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
 | 
			
		||||
    if (elements == null) return [];
 | 
			
		||||
 | 
			
		||||
    final List<Widget> widgets = [];
 | 
			
		||||
 | 
			
		||||
    for (final node in elements) {
 | 
			
		||||
      switch (node.localName) {
 | 
			
		||||
        case 'h1':
 | 
			
		||||
        case 'h2':
 | 
			
		||||
        case 'h3':
 | 
			
		||||
        case 'h4':
 | 
			
		||||
        case 'h5':
 | 
			
		||||
        case 'h6':
 | 
			
		||||
          widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'p':
 | 
			
		||||
          if (node.text.trim().isEmpty) continue;
 | 
			
		||||
          widgets.add(
 | 
			
		||||
            Text.rich(
 | 
			
		||||
              TextSpan(
 | 
			
		||||
                text: node.text.trim(),
 | 
			
		||||
                children: [
 | 
			
		||||
                  for (final child in node.children)
 | 
			
		||||
                    switch (child.localName) {
 | 
			
		||||
                      'a' => TextSpan(
 | 
			
		||||
                          text: child.text.trim(),
 | 
			
		||||
                          style: const TextStyle(decoration: TextDecoration.underline),
 | 
			
		||||
                          recognizer: TapGestureRecognizer()
 | 
			
		||||
                            ..onTap = () {
 | 
			
		||||
                              launchUrlString(child.attributes['href']!);
 | 
			
		||||
                            },
 | 
			
		||||
                        ),
 | 
			
		||||
                      _ => TextSpan(text: child.text.trim()),
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          break;
 | 
			
		||||
        case 'a':
 | 
			
		||||
          // drop single link
 | 
			
		||||
          break;
 | 
			
		||||
        case 'div':
 | 
			
		||||
          // ignore div text, normally it is not meaningful
 | 
			
		||||
          widgets.addAll(_parseHtmlToWidgets(node.children));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'hr':
 | 
			
		||||
          widgets.add(const Divider());
 | 
			
		||||
          break;
 | 
			
		||||
        case 'img':
 | 
			
		||||
          var src = node.attributes['src'];
 | 
			
		||||
          if (src == null) break;
 | 
			
		||||
          final width = double.tryParse(node.attributes['width'] ?? 'null');
 | 
			
		||||
          final height = double.tryParse(node.attributes['height'] ?? 'null');
 | 
			
		||||
          final ratio = width != null && height != null ? width / height : 1.0;
 | 
			
		||||
          if (src.startsWith('//')) {
 | 
			
		||||
            src = 'https:$src';
 | 
			
		||||
          } else if (!src.startsWith('http')) {
 | 
			
		||||
            final baseUri = Uri.parse(_article!.url);
 | 
			
		||||
            final baseUrl = '${baseUri.scheme}://${baseUri.host}';
 | 
			
		||||
            src = '$baseUrl/$src';
 | 
			
		||||
          }
 | 
			
		||||
          widgets.add(
 | 
			
		||||
            AspectRatio(
 | 
			
		||||
              aspectRatio: ratio,
 | 
			
		||||
              child: Container(
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                  border: Border.all(
 | 
			
		||||
                    color: Theme.of(context).dividerColor,
 | 
			
		||||
                    width: 1,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                height: height ?? double.infinity,
 | 
			
		||||
                child: ClipRRect(
 | 
			
		||||
                  borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                    child: AutoResizeUniversalImage(
 | 
			
		||||
                      src,
 | 
			
		||||
                      fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          widgets.addAll(_parseHtmlToWidgets(node.children));
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return widgets;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchArticle();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool _isReadingFromReader = true;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text(_article?.title ?? 'loading'.tr()),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          MaterialBanner(
 | 
			
		||||
            dividerColor: Colors.transparent,
 | 
			
		||||
            leading: const Icon(Icons.info),
 | 
			
		||||
            content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
 | 
			
		||||
            actions: [
 | 
			
		||||
              TextButton(
 | 
			
		||||
                child: Text('newsReadingProviderSwap').tr(),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  setState(() => _isReadingFromReader = !_isReadingFromReader);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          if (_articleFragment != null && _isReadingFromReader)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Container(
 | 
			
		||||
                constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
                child: SingleChildScrollView(
 | 
			
		||||
                  child: Column(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    spacing: 8,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final htmlDescription = parse(_article!.description);
 | 
			
		||||
                        return Text(
 | 
			
		||||
                          htmlDescription.children.map((ele) => ele.text.trim()).join(),
 | 
			
		||||
                          style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
                        );
 | 
			
		||||
                      }),
 | 
			
		||||
                      Builder(builder: (context) {
 | 
			
		||||
                        final date = _article!.publishedAt ?? _article!.createdAt;
 | 
			
		||||
                        return Row(
 | 
			
		||||
                          spacing: 2,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                            Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
			
		||||
                            Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.75);
 | 
			
		||||
                      }),
 | 
			
		||||
                      Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75),
 | 
			
		||||
                      const Divider(),
 | 
			
		||||
                      ..._parseHtmlToWidgets(_articleFragment!.children),
 | 
			
		||||
                      const Divider(),
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              'Reference from original website',
 | 
			
		||||
                              style: TextStyle(decoration: TextDecoration.underline),
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Icon(Icons.launch, size: 16),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.85),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          launchUrlString(_article!.url);
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                      Gap(MediaQuery.of(context).padding.bottom),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ).padding(horizontal: 12, vertical: 16),
 | 
			
		||||
                ),
 | 
			
		||||
              ).center(),
 | 
			
		||||
            )
 | 
			
		||||
          else if (_article != null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: InAppWebView(
 | 
			
		||||
                key: GlobalKey(),
 | 
			
		||||
                initialUrlRequest: URLRequest(url: WebUri(_article!.url)),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										239
									
								
								lib/screens/news/news_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								lib/screens/news/news_list.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,239 @@
 | 
			
		||||
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:html/parser.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/news.dart';
 | 
			
		||||
import 'package:surface/widgets/app_bar_leading.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
class NewsScreen extends StatefulWidget {
 | 
			
		||||
  const NewsScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<NewsScreen> createState() => _NewsScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewsScreenState extends State<NewsScreen> {
 | 
			
		||||
  List<SnNewsSource>? _sources;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchSources();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchSources() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/well-known/sources');
 | 
			
		||||
      _sources = List<SnNewsSource>.from(
 | 
			
		||||
        resp.data?.map((e) => SnNewsSource.fromJson(e)) ?? [],
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    if (_sources == null) {
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          title: Text('screenNews').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        body: Center(
 | 
			
		||||
          child: CircularProgressIndicator(),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return DefaultTabController(
 | 
			
		||||
      length: _sources!.length + 1,
 | 
			
		||||
      child: AppScaffold(
 | 
			
		||||
        body: NestedScrollView(
 | 
			
		||||
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
			
		||||
            return <Widget>[
 | 
			
		||||
              SliverOverlapAbsorber(
 | 
			
		||||
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
 | 
			
		||||
                sliver: SliverAppBar(
 | 
			
		||||
                  leading: AutoAppBarLeading(),
 | 
			
		||||
                  title: Text('screenNews').tr(),
 | 
			
		||||
                  floating: true,
 | 
			
		||||
                  snap: true,
 | 
			
		||||
                  bottom: TabBar(
 | 
			
		||||
                    isScrollable: true,
 | 
			
		||||
                    tabs: [
 | 
			
		||||
                      Tab(child: Text('newsAllSources'.tr()).textColor(Theme.of(context).appBarTheme.foregroundColor)),
 | 
			
		||||
                      for (final source in _sources!)
 | 
			
		||||
                        Tab(
 | 
			
		||||
                          child: Text(source.label).textColor(Theme.of(context).appBarTheme.foregroundColor),
 | 
			
		||||
                        ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ];
 | 
			
		||||
          },
 | 
			
		||||
          body: TabBarView(
 | 
			
		||||
            children: [
 | 
			
		||||
              _NewsArticleListWidget(allSources: _sources!),
 | 
			
		||||
              for (final source in _sources!)
 | 
			
		||||
                _NewsArticleListWidget(
 | 
			
		||||
                  source: source.id,
 | 
			
		||||
                  allSources: _sources!,
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewsArticleListWidget extends StatefulWidget {
 | 
			
		||||
  final String? source;
 | 
			
		||||
  final List<SnNewsSource> allSources;
 | 
			
		||||
 | 
			
		||||
  const _NewsArticleListWidget({this.source, required this.allSources});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_NewsArticleListWidget> createState() => _NewsArticleListWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewsArticleListWidgetState extends State<_NewsArticleListWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
  final List<SnNewsArticle> _articles = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchArticles() async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/re/news', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': _articles.length,
 | 
			
		||||
        if (widget.source != null) 'source': widget.source,
 | 
			
		||||
      });
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _articles.addAll(List<SnNewsArticle>.from(
 | 
			
		||||
        resp.data['data']?.map((e) => SnNewsArticle.fromJson(e)) ?? [],
 | 
			
		||||
      ));
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchArticles();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return MediaQuery.removePadding(
 | 
			
		||||
      context: context,
 | 
			
		||||
      removeTop: true,
 | 
			
		||||
      child: Center(
 | 
			
		||||
        child: Container(
 | 
			
		||||
          constraints: BoxConstraints(maxWidth: 640),
 | 
			
		||||
          child: RefreshIndicator(
 | 
			
		||||
            onRefresh: _fetchArticles,
 | 
			
		||||
            child: InfiniteList(
 | 
			
		||||
              isLoading: _isBusy,
 | 
			
		||||
              itemCount: _articles.length,
 | 
			
		||||
              hasReachedMax: _totalCount != null && _articles.length >= _totalCount!,
 | 
			
		||||
              onFetchData: () {
 | 
			
		||||
                _fetchArticles();
 | 
			
		||||
              },
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
                final article = _articles[index];
 | 
			
		||||
 | 
			
		||||
                final baseUri = Uri.parse(article.url);
 | 
			
		||||
                final baseUrl = '${baseUri.scheme}://${baseUri.host}';
 | 
			
		||||
 | 
			
		||||
                final htmlDescription = parse(article.description);
 | 
			
		||||
                final date = article.publishedAt ?? article.createdAt;
 | 
			
		||||
 | 
			
		||||
                return Card(
 | 
			
		||||
                  child: InkWell(
 | 
			
		||||
                    radius: 8,
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      GoRouter.of(context).pushNamed(
 | 
			
		||||
                        'newsDetail',
 | 
			
		||||
                        pathParameters: {'hash': article.hash},
 | 
			
		||||
                      );
 | 
			
		||||
                    },
 | 
			
		||||
                    child: Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (article.thumbnail.isNotEmpty && !article.thumbnail.endsWith('.svg'))
 | 
			
		||||
                          ClipRRect(
 | 
			
		||||
                            borderRadius: BorderRadius.only(
 | 
			
		||||
                              topRight: Radius.circular(8),
 | 
			
		||||
                              topLeft: Radius.circular(8),
 | 
			
		||||
                            ),
 | 
			
		||||
                            child: AspectRatio(
 | 
			
		||||
                              aspectRatio: 16 / 9,
 | 
			
		||||
                              child: Container(
 | 
			
		||||
                                color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                                child: AutoResizeUniversalImage(
 | 
			
		||||
                                  article.thumbnail.startsWith('http')
 | 
			
		||||
                                      ? article.thumbnail
 | 
			
		||||
                                      : '$baseUrl/${article.thumbnail}',
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                        Text(article.title).textStyle(Theme.of(context).textTheme.titleLarge!).padding(horizontal: 16),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Text(htmlDescription.children.map((ele) => ele.text.trim()).join())
 | 
			
		||||
                            .textStyle(Theme.of(context).textTheme.bodyMedium!)
 | 
			
		||||
                            .padding(horizontal: 16),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Row(
 | 
			
		||||
                          spacing: 2,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(widget.allSources.where((x) => x.id == article.source).first.label)
 | 
			
		||||
                                .textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.75).padding(horizontal: 16),
 | 
			
		||||
                        Row(
 | 
			
		||||
                          spacing: 2,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                            Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
 | 
			
		||||
                            Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).opacity(0.75).padding(horizontal: 16),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,6 +7,7 @@ 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/notification.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
@@ -14,12 +15,23 @@ 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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
import '../providers/userinfo.dart';
 | 
			
		||||
import '../widgets/unauthorized_hint.dart';
 | 
			
		||||
 | 
			
		||||
const Map<String, IconData> kNotificationTopicIcons = {
 | 
			
		||||
  'general': Symbols.notifications,
 | 
			
		||||
  'passport.security.alert': Symbols.gpp_maybe,
 | 
			
		||||
  'passport.security.otp': Symbols.password,
 | 
			
		||||
  'interactive.subscription': Symbols.subscriptions,
 | 
			
		||||
  'interactive.feedback': Symbols.add_reaction,
 | 
			
		||||
  'messaging.callStart': Symbols.call_received,
 | 
			
		||||
  'wallet.transaction.new': Symbols.receipt,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class NotificationScreen extends StatefulWidget {
 | 
			
		||||
  const NotificationScreen({super.key});
 | 
			
		||||
 | 
			
		||||
@@ -35,13 +47,6 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
  final List<SnNotification> _notifications = List.empty(growable: true);
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
 | 
			
		||||
  static const Map<String, IconData> kNotificationTopicIcons = {
 | 
			
		||||
    'passport.security.alert': Symbols.gpp_maybe,
 | 
			
		||||
    'interactive.subscription': Symbols.subscriptions,
 | 
			
		||||
    'interactive.feedback': Symbols.add_reaction,
 | 
			
		||||
    'messaging.callStart': Symbols.call_received,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchNotifications() async {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) return;
 | 
			
		||||
@@ -50,6 +55,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final nty = context.read<NotificationProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/id/notifications?take=10');
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _notifications.addAll(
 | 
			
		||||
@@ -58,6 +64,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
                .cast<SnNotification>() ??
 | 
			
		||||
            [],
 | 
			
		||||
      );
 | 
			
		||||
      nty.updateTray();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
@@ -84,9 +91,11 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final nty = context.read<NotificationProvider>();
 | 
			
		||||
      final resp = await sn.client.put('/cgi/id/notifications/read/all');
 | 
			
		||||
      _notifications.clear();
 | 
			
		||||
      _fetchNotifications();
 | 
			
		||||
      nty.clear();
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
@@ -137,7 +146,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return Scaffold(
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          title: Text('screenNotification').tr(),
 | 
			
		||||
@@ -148,7 +157,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenNotification').tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,8 @@ import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_background.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_comment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
			
		||||
@@ -20,12 +22,9 @@ import 'package:surface/widgets/post/post_mini_editor.dart';
 | 
			
		||||
class PostDetailScreen extends StatefulWidget {
 | 
			
		||||
  final String slug;
 | 
			
		||||
  final SnPost? preload;
 | 
			
		||||
  final Function? onBack;
 | 
			
		||||
 | 
			
		||||
  const PostDetailScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.slug,
 | 
			
		||||
    this.preload,
 | 
			
		||||
  });
 | 
			
		||||
  const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<PostDetailScreen> createState() => _PostDetailScreenState();
 | 
			
		||||
@@ -67,10 +66,15 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
    final ua = context.watch<UserProvider>();
 | 
			
		||||
    final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppBackground(
 | 
			
		||||
      isRoot: widget.onBack != null,
 | 
			
		||||
      child: AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: BackButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              if (widget.onBack != null) {
 | 
			
		||||
                widget.onBack!.call();
 | 
			
		||||
              }
 | 
			
		||||
              if (GoRouter.of(context).canPop()) {
 | 
			
		||||
                GoRouter.of(context).pop(context);
 | 
			
		||||
                return;
 | 
			
		||||
@@ -179,12 +183,13 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
 | 
			
		||||
            if (_data != null)
 | 
			
		||||
              PostCommentSliverList(
 | 
			
		||||
                key: _childListKey,
 | 
			
		||||
              parentPostId: _data!.id,
 | 
			
		||||
                parentPost: _data!,
 | 
			
		||||
                maxWidth: 640,
 | 
			
		||||
              ),
 | 
			
		||||
            SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,46 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dropdown_button2/dropdown_button2.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_item.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/pending_attachment_alt.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/pending_attachment_boost.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/markdown_content.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_meta_editor.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
class PostEditorExtraProps {
 | 
			
		||||
import '../../widgets/attachment/attachment_input.dart';
 | 
			
		||||
 | 
			
		||||
class PostEditorExtra {
 | 
			
		||||
  final String? text;
 | 
			
		||||
  final String? title;
 | 
			
		||||
  final String? description;
 | 
			
		||||
  final List<PostWriteMedia>? attachments;
 | 
			
		||||
 | 
			
		||||
  const PostEditorExtraProps({
 | 
			
		||||
  const PostEditorExtra({
 | 
			
		||||
    this.text,
 | 
			
		||||
    this.title,
 | 
			
		||||
    this.description,
 | 
			
		||||
@@ -38,7 +53,7 @@ class PostEditorScreen extends StatefulWidget {
 | 
			
		||||
  final int? postEditId;
 | 
			
		||||
  final int? postReplyId;
 | 
			
		||||
  final int? postRepostId;
 | 
			
		||||
  final PostEditorExtraProps? extraProps;
 | 
			
		||||
  final PostEditorExtra? extraProps;
 | 
			
		||||
 | 
			
		||||
  const PostEditorScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
@@ -93,15 +108,49 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final HotKey _pasteHotKey = HotKey(
 | 
			
		||||
    key: PhysicalKeyboardKey.keyV,
 | 
			
		||||
    modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _registerHotKey() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    hotKeyManager.register(_pasteHotKey, keyDownHandler: (_) async {
 | 
			
		||||
      final imageBytes = await Pasteboard.image;
 | 
			
		||||
      if (imageBytes == null) return;
 | 
			
		||||
      _writeController.addAttachments([
 | 
			
		||||
        PostWriteMedia.fromBytes(
 | 
			
		||||
          imageBytes,
 | 
			
		||||
          'attachmentPastedImage'.tr(),
 | 
			
		||||
          SnMediaType.image,
 | 
			
		||||
        ),
 | 
			
		||||
      ]);
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showPublisherPopup() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _PostPublisherPopup(
 | 
			
		||||
        controller: _writeController,
 | 
			
		||||
        publishers: _publishers,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _writeController.dispose();
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey);
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _registerHotKey();
 | 
			
		||||
    if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
 | 
			
		||||
      context.showErrorDialog('Unknown post type');
 | 
			
		||||
      Navigator.pop(context);
 | 
			
		||||
@@ -128,7 +177,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: _writeController,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
        return Scaffold(
 | 
			
		||||
        return AppScaffold(
 | 
			
		||||
          appBar: AppBar(
 | 
			
		||||
            leading: BackButton(
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
@@ -152,6 +201,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                      ),
 | 
			
		||||
                ),
 | 
			
		||||
              ]),
 | 
			
		||||
              maxLines: 2,
 | 
			
		||||
            ),
 | 
			
		||||
            actions: [
 | 
			
		||||
              IconButton(
 | 
			
		||||
@@ -163,173 +213,57 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
          ),
 | 
			
		||||
          body: Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              DropdownButtonHideUnderline(
 | 
			
		||||
                child: DropdownButton2<SnPublisher>(
 | 
			
		||||
                  isExpanded: true,
 | 
			
		||||
                  hint: Text(
 | 
			
		||||
                    'fieldPostPublisher',
 | 
			
		||||
                    style: TextStyle(
 | 
			
		||||
                      fontSize: 14,
 | 
			
		||||
                      color: Theme.of(context).hintColor,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ).tr(),
 | 
			
		||||
                  items: <DropdownMenuItem<SnPublisher>>[
 | 
			
		||||
                    ...(_publishers?.map(
 | 
			
		||||
                          (item) => DropdownMenuItem<SnPublisher>(
 | 
			
		||||
                            enabled: _writeController.editingPost == null,
 | 
			
		||||
                            value: item,
 | 
			
		||||
                            child: Row(
 | 
			
		||||
                              children: [
 | 
			
		||||
                                AccountImage(content: item.avatar, radius: 16),
 | 
			
		||||
                                const Gap(8),
 | 
			
		||||
                                Expanded(
 | 
			
		||||
                                  child: Column(
 | 
			
		||||
                                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                                      Text('@${item.name}')
 | 
			
		||||
                                          .textStyle(Theme.of(context).textTheme.bodySmall!)
 | 
			
		||||
                                          .fontSize(12),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ) ??
 | 
			
		||||
                        []),
 | 
			
		||||
                    DropdownMenuItem<SnPublisher>(
 | 
			
		||||
                      value: null,
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          CircleAvatar(
 | 
			
		||||
                            radius: 16,
 | 
			
		||||
                            backgroundColor: Colors.transparent,
 | 
			
		||||
                            foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                            child: const Icon(Symbols.add),
 | 
			
		||||
                          ),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          Expanded(
 | 
			
		||||
                            child: Column(
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                  value: _writeController.publisher,
 | 
			
		||||
                  onChanged: (SnPublisher? value) {
 | 
			
		||||
                    if (value == null) {
 | 
			
		||||
                      GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
 | 
			
		||||
                        if (value == true) {
 | 
			
		||||
                          _publishers = null;
 | 
			
		||||
                          _fetchPublishers();
 | 
			
		||||
                        }
 | 
			
		||||
                      });
 | 
			
		||||
                    } else {
 | 
			
		||||
                      _writeController.setPublisher(value);
 | 
			
		||||
                      final config = context.read<ConfigProvider>();
 | 
			
		||||
                      config.prefs.setInt('int_last_publisher_id', value.id);
 | 
			
		||||
                    }
 | 
			
		||||
                  },
 | 
			
		||||
                  buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                    padding: EdgeInsets.only(right: 16),
 | 
			
		||||
                    height: 48,
 | 
			
		||||
                  ),
 | 
			
		||||
                  menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                    height: 48,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const Divider(height: 1),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: SingleChildScrollView(
 | 
			
		||||
                  padding: EdgeInsets.only(bottom: 8),
 | 
			
		||||
                  child: Column(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      // Replying Notice
 | 
			
		||||
                      if (_writeController.replyingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.reply).padding(left: 4),
 | 
			
		||||
                              title: Text('postReplyingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[PostItem(data: _writeController.replyingPost!)],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      // Reposting Notice
 | 
			
		||||
                      if (_writeController.repostingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.forward).padding(left: 4),
 | 
			
		||||
                              title: Text('postRepostingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[
 | 
			
		||||
                                PostItem(
 | 
			
		||||
                                  data: _writeController.repostingPost!,
 | 
			
		||||
                                )
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      // Editing Notice
 | 
			
		||||
              if (_writeController.editingPost != null)
 | 
			
		||||
                        Column(
 | 
			
		||||
                Container(
 | 
			
		||||
                  padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    border: Border(
 | 
			
		||||
                      bottom: BorderSide(
 | 
			
		||||
                        color: Theme.of(context).dividerColor,
 | 
			
		||||
                        width: 1 / MediaQuery.of(context).devicePixelRatio,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                    children: [
 | 
			
		||||
                            ExpansionTile(
 | 
			
		||||
                              minTileHeight: 48,
 | 
			
		||||
                              leading: const Icon(Symbols.edit_note).padding(left: 4),
 | 
			
		||||
                              title: Text('postEditingNotice')
 | 
			
		||||
                                  .fontSize(15)
 | 
			
		||||
                                  .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
			
		||||
                              children: <Widget>[PostItem(data: _writeController.editingPost!)],
 | 
			
		||||
                            ),
 | 
			
		||||
                            const Divider(height: 1),
 | 
			
		||||
                      const Icon(Icons.edit, size: 16),
 | 
			
		||||
                      const Gap(10),
 | 
			
		||||
                      Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                      // Content Input Area
 | 
			
		||||
                      TextField(
 | 
			
		||||
                        controller: _writeController.contentController,
 | 
			
		||||
                        maxLines: null,
 | 
			
		||||
                        decoration: InputDecoration(
 | 
			
		||||
                          hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                          hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                          isCollapsed: true,
 | 
			
		||||
                          contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                            horizontal: 16,
 | 
			
		||||
                ),
 | 
			
		||||
                          border: InputBorder.none,
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Stack(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    SingleChildScrollView(
 | 
			
		||||
                      padding: EdgeInsets.only(bottom: 160),
 | 
			
		||||
                      child: switch (_writeController.mode) {
 | 
			
		||||
                        'stories' => _PostStoryEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                        'articles' => _PostArticleEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                    ]
 | 
			
		||||
                        .expandIndexed(
 | 
			
		||||
                          (idx, ele) => [
 | 
			
		||||
                            if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
 | 
			
		||||
                            ele,
 | 
			
		||||
                          ],
 | 
			
		||||
                        )
 | 
			
		||||
                        .toList(),
 | 
			
		||||
                        'questions' => _PostQuestionEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        'videos' => _PostVideoEditor(
 | 
			
		||||
                            controller: _writeController,
 | 
			
		||||
                            onTapPublisher: _showPublisherPopup,
 | 
			
		||||
                          ),
 | 
			
		||||
                        _ => const Placeholder(),
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                    if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
 | 
			
		||||
                PostMediaPendingList(
 | 
			
		||||
                      Positioned(
 | 
			
		||||
                        bottom: 0,
 | 
			
		||||
                        left: 0,
 | 
			
		||||
                        right: 0,
 | 
			
		||||
                        child: PostMediaPendingList(
 | 
			
		||||
                          thumbnail: _writeController.thumbnail,
 | 
			
		||||
                          attachments: _writeController.attachments,
 | 
			
		||||
                          isBusy: _writeController.isBusy,
 | 
			
		||||
@@ -361,11 +295,24 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                          },
 | 
			
		||||
                          onUpdateBusy: (state) => _writeController.setIsBusy(state),
 | 
			
		||||
                        ).padding(bottom: 8),
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Material(
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    LoadingIndicator(isActive: _isLoading),
 | 
			
		||||
                    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),
 | 
			
		||||
                      )
 | 
			
		||||
                    else if (_writeController.isBusy)
 | 
			
		||||
                      const LinearProgressIndicator(value: null, minHeight: 2),
 | 
			
		||||
                    Container(
 | 
			
		||||
                      child: _writeController.temporaryRestored
 | 
			
		||||
                          ? Container(
 | 
			
		||||
@@ -396,15 +343,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
 | 
			
		||||
                    )
 | 
			
		||||
                        .height(_writeController.temporaryRestored ? 32 : 0, animate: true)
 | 
			
		||||
                        .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
 | 
			
		||||
                    LoadingIndicator(isActive: _isLoading),
 | 
			
		||||
                    if (_writeController.isBusy && _writeController.progress != null)
 | 
			
		||||
                      TweenAnimationBuilder<double>(
 | 
			
		||||
                        tween: Tween(begin: 0, end: _writeController.progress),
 | 
			
		||||
                        duration: Duration(milliseconds: 300),
 | 
			
		||||
                        builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
 | 
			
		||||
                      )
 | 
			
		||||
                    else if (_writeController.isBusy)
 | 
			
		||||
                      const LinearProgressIndicator(value: null, minHeight: 2),
 | 
			
		||||
                    Row(
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                      children: [
 | 
			
		||||
@@ -462,3 +400,525 @@ class _PostEditorActionScrollBehavior extends MaterialScrollBehavior {
 | 
			
		||||
        PointerDeviceKind.mouse,
 | 
			
		||||
      };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostPublisherPopup extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final List<SnPublisher>? publishers;
 | 
			
		||||
 | 
			
		||||
  const _PostPublisherPopup({required this.controller, this.publishers});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.face, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: ListView.builder(
 | 
			
		||||
            itemCount: publishers?.length ?? 0,
 | 
			
		||||
            itemBuilder: (context, idx) {
 | 
			
		||||
              final publisher = publishers![idx];
 | 
			
		||||
              return ListTile(
 | 
			
		||||
                title: Text(publisher.nick),
 | 
			
		||||
                subtitle: Text('@${publisher.name}'),
 | 
			
		||||
                leading: AccountImage(content: publisher.avatar, radius: 18),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  controller.setPublisher(publisher);
 | 
			
		||||
                  Navigator.pop(context, true);
 | 
			
		||||
                },
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostStoryEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostStoryEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 2,
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(24)),
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                onTapPublisher?.call();
 | 
			
		||||
              },
 | 
			
		||||
              child: AccountImage(
 | 
			
		||||
                content: controller.publisher?.avatar,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                const Gap(6),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.titleController,
 | 
			
		||||
                  decoration: InputDecoration.collapsed(
 | 
			
		||||
                    hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.contentController,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                    hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                    ),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(bottom: 8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostArticleEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostArticleEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final editorWidgets = <Widget>[
 | 
			
		||||
      Material(
 | 
			
		||||
        color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
        child: InkWell(
 | 
			
		||||
          child: Row(
 | 
			
		||||
            children: [
 | 
			
		||||
              AccountImage(content: controller.publisher?.avatar, radius: 20),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(controller.publisher?.nick ?? 'loading'.tr()).bold(),
 | 
			
		||||
                    Text('@${controller.publisher?.name}'),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            onTapPublisher?.call();
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      const Gap(16),
 | 
			
		||||
      TextField(
 | 
			
		||||
        controller: controller.titleController,
 | 
			
		||||
        decoration: InputDecoration.collapsed(
 | 
			
		||||
          hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
          border: InputBorder.none,
 | 
			
		||||
        ),
 | 
			
		||||
        style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
      ).padding(horizontal: 16),
 | 
			
		||||
      const Gap(8),
 | 
			
		||||
      TextField(
 | 
			
		||||
        controller: controller.descriptionController,
 | 
			
		||||
        decoration: InputDecoration.collapsed(
 | 
			
		||||
          hintText: 'fieldPostDescription'.tr(),
 | 
			
		||||
          border: InputBorder.none,
 | 
			
		||||
        ),
 | 
			
		||||
        maxLines: null,
 | 
			
		||||
        keyboardType: TextInputType.multiline,
 | 
			
		||||
        style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
      ).padding(horizontal: 16),
 | 
			
		||||
      const Gap(4),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) {
 | 
			
		||||
      return Container(
 | 
			
		||||
        constraints: const BoxConstraints(maxWidth: 640 * 2 + 8),
 | 
			
		||||
        child: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
            ...editorWidgets,
 | 
			
		||||
            Row(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: TextField(
 | 
			
		||||
                    controller: controller.contentController,
 | 
			
		||||
                    maxLines: null,
 | 
			
		||||
                    decoration: InputDecoration(
 | 
			
		||||
                      hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                      hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                      isCollapsed: true,
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                        horizontal: 16,
 | 
			
		||||
                      ),
 | 
			
		||||
                      border: InputBorder.none,
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: MarkdownTextContent(
 | 
			
		||||
                    content: controller.contentController.text,
 | 
			
		||||
                  ).padding(horizontal: 24),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        ...editorWidgets,
 | 
			
		||||
        Container(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8),
 | 
			
		||||
          constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
          child: TextField(
 | 
			
		||||
            controller: controller.contentController,
 | 
			
		||||
            maxLines: null,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
              hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
              isCollapsed: true,
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                horizontal: 16,
 | 
			
		||||
              ),
 | 
			
		||||
              border: InputBorder.none,
 | 
			
		||||
            ),
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostQuestionEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostQuestionEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      padding: const EdgeInsets.symmetric(horizontal: 12),
 | 
			
		||||
      constraints: const BoxConstraints(maxWidth: 640),
 | 
			
		||||
      child: Row(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 1,
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(24)),
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                onTapPublisher?.call();
 | 
			
		||||
              },
 | 
			
		||||
              child: AccountImage(
 | 
			
		||||
                content: controller.publisher?.avatar,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                const Gap(6),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.titleController,
 | 
			
		||||
                  decoration: InputDecoration.collapsed(
 | 
			
		||||
                    hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.rewardController,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostQuestionReward'.tr(),
 | 
			
		||||
                    suffixText: 'walletCurrencyShort'.tr(),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ).padding(horizontal: 16),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                TextField(
 | 
			
		||||
                  controller: controller.contentController,
 | 
			
		||||
                  maxLines: null,
 | 
			
		||||
                  decoration: InputDecoration(
 | 
			
		||||
                    hintText: 'fieldPostContent'.tr(),
 | 
			
		||||
                    hintStyle: TextStyle(fontSize: 14),
 | 
			
		||||
                    isCollapsed: true,
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                      horizontal: 16,
 | 
			
		||||
                    ),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(top: 8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _PostVideoEditor extends StatelessWidget {
 | 
			
		||||
  final PostWriteController controller;
 | 
			
		||||
  final Function? onTapPublisher;
 | 
			
		||||
 | 
			
		||||
  const _PostVideoEditor({required this.controller, this.onTapPublisher});
 | 
			
		||||
 | 
			
		||||
  void _selectVideo(BuildContext context) async {
 | 
			
		||||
    final video = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AttachmentInputDialog(
 | 
			
		||||
        title: 'postVideoUpload'.tr(),
 | 
			
		||||
        pool: 'interactive',
 | 
			
		||||
        mediaType: SnMediaType.video,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!context.mounted) return;
 | 
			
		||||
    if (video == null) return;
 | 
			
		||||
    controller.setVideoAttachment(video);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setAlt(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final result = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)),
 | 
			
		||||
    );
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
 | 
			
		||||
    controller.setVideoAttachment(result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _createBoost(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final result = await showDialog<SnAttachmentBoost?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)),
 | 
			
		||||
    );
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
 | 
			
		||||
    final newAttach = controller.videoAttachment!.copyWith(
 | 
			
		||||
      boosts: [...controller.videoAttachment!.boosts, result],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    controller.setVideoAttachment(newAttach);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _setThumbnail(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    final thumbnail = await showDialog<SnAttachment?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => AttachmentInputDialog(
 | 
			
		||||
        title: 'attachmentSetThumbnail'.tr(),
 | 
			
		||||
        pool: 'interactive',
 | 
			
		||||
        analyzeNow: true,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (thumbnail == null) return;
 | 
			
		||||
    if (!context.mounted) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final attach = context.read<SnAttachmentProvider>();
 | 
			
		||||
      final newAttach = await attach.updateOne(
 | 
			
		||||
        controller.videoAttachment!,
 | 
			
		||||
        thumbnailId: thumbnail.id,
 | 
			
		||||
      );
 | 
			
		||||
      controller.setVideoAttachment(newAttach);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _deleteAttachment(BuildContext context) async {
 | 
			
		||||
    if (controller.videoAttachment == null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
 | 
			
		||||
      controller.setVideoAttachment(null);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!context.mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        Material(
 | 
			
		||||
          color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
          child: InkWell(
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                AccountImage(content: controller.publisher?.avatar, radius: 20),
 | 
			
		||||
                const Gap(8),
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: Column(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(controller.publisher?.nick ?? 'loading'.tr()).bold(),
 | 
			
		||||
                      Text('@${controller.publisher?.name}'),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 12, vertical: 8),
 | 
			
		||||
            onTap: () {
 | 
			
		||||
              onTapPublisher?.call();
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: controller.titleController,
 | 
			
		||||
          decoration: InputDecoration.collapsed(
 | 
			
		||||
            hintText: 'fieldPostTitle'.tr(),
 | 
			
		||||
            border: InputBorder.none,
 | 
			
		||||
          ),
 | 
			
		||||
          style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        TextField(
 | 
			
		||||
          controller: controller.descriptionController,
 | 
			
		||||
          decoration: InputDecoration.collapsed(
 | 
			
		||||
            hintText: 'fieldPostDescription'.tr(),
 | 
			
		||||
            border: InputBorder.none,
 | 
			
		||||
          ),
 | 
			
		||||
          maxLines: null,
 | 
			
		||||
          keyboardType: TextInputType.multiline,
 | 
			
		||||
          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        Container(
 | 
			
		||||
          margin: const EdgeInsets.only(left: 16, right: 16),
 | 
			
		||||
          decoration: BoxDecoration(
 | 
			
		||||
            borderRadius: BorderRadius.circular(16),
 | 
			
		||||
            border: Border.all(color: Theme.of(context).dividerColor),
 | 
			
		||||
          ),
 | 
			
		||||
          child: ContextMenuRegion(
 | 
			
		||||
            contextMenu: ContextMenu(
 | 
			
		||||
              entries: [
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentSetAlt'.tr(),
 | 
			
		||||
                  icon: Symbols.description,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _setAlt(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentBoost'.tr(),
 | 
			
		||||
                  icon: Symbols.bolt,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _createBoost(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentSetThumbnail'.tr(),
 | 
			
		||||
                  icon: Symbols.image,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    _setThumbnail(context);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'attachmentCopyRandomId'.tr(),
 | 
			
		||||
                  icon: Symbols.content_copy,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    Clipboard.setData(ClipboardData(text: controller.videoAttachment!.rid));
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'delete'.tr(),
 | 
			
		||||
                  icon: Symbols.delete,
 | 
			
		||||
                  onSelected: () => _deleteAttachment(context),
 | 
			
		||||
                ),
 | 
			
		||||
                MenuItem(
 | 
			
		||||
                  label: 'unlink'.tr(),
 | 
			
		||||
                  icon: Symbols.link_off,
 | 
			
		||||
                  onSelected: () {
 | 
			
		||||
                    controller.setVideoAttachment(null);
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            child: InkWell(
 | 
			
		||||
              borderRadius: BorderRadius.circular(16),
 | 
			
		||||
              onTap: controller.videoAttachment != null ? () => _selectVideo(context) : null,
 | 
			
		||||
              child: AspectRatio(
 | 
			
		||||
                aspectRatio: 16 / 9,
 | 
			
		||||
                child: controller.videoAttachment == null
 | 
			
		||||
                    ? Center(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Icon(Icons.add),
 | 
			
		||||
                            const Gap(4),
 | 
			
		||||
                            Text('postVideoUpload'.tr()),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      )
 | 
			
		||||
                    : ClipRRect(
 | 
			
		||||
                        borderRadius: BorderRadius.circular(16),
 | 
			
		||||
                        child: AttachmentItem(
 | 
			
		||||
                          data: controller.videoAttachment!,
 | 
			
		||||
                          heroTag: const Uuid().v4(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/post.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_tags_field.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
@@ -119,7 +119,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
      ),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('screenPostSearch').tr(),
 | 
			
		||||
        actions: [
 | 
			
		||||
@@ -133,7 +133,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
      body: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          InfiniteList(
 | 
			
		||||
            padding: const EdgeInsets.only(top: 100),
 | 
			
		||||
            padding: const EdgeInsets.only(top: 100 + 8),
 | 
			
		||||
            itemCount: _posts.length,
 | 
			
		||||
            isLoading: _isBusy,
 | 
			
		||||
            hasReachedMax: _postCount != null && _posts.length >= _postCount!,
 | 
			
		||||
@@ -141,8 +141,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
              _fetchPosts();
 | 
			
		||||
            },
 | 
			
		||||
            itemBuilder: (context, idx) {
 | 
			
		||||
              return GestureDetector(
 | 
			
		||||
                child: PostItem(
 | 
			
		||||
              return OpenablePostItem(
 | 
			
		||||
                data: _posts[idx],
 | 
			
		||||
                maxWidth: 640,
 | 
			
		||||
                onChanged: (data) {
 | 
			
		||||
@@ -151,17 +150,9 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
 | 
			
		||||
                onDeleted: () {
 | 
			
		||||
                  _refreshPosts();
 | 
			
		||||
                },
 | 
			
		||||
                ),
 | 
			
		||||
                onTap: () {
 | 
			
		||||
                  GoRouter.of(context).pushNamed(
 | 
			
		||||
                    'postDetail',
 | 
			
		||||
                    pathParameters: {'slug': _posts[idx].id.toString()},
 | 
			
		||||
                    extra: _posts[idx],
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            separatorBuilder: (context, index) => const Divider(height: 1),
 | 
			
		||||
            separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(
 | 
			
		||||
            top: 16,
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.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';
 | 
			
		||||
@@ -274,7 +275,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
 | 
			
		||||
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      body: NestedScrollView(
 | 
			
		||||
        controller: _scrollController,
 | 
			
		||||
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
 | 
			
		||||
@@ -596,25 +597,16 @@ class _PublisherPostList extends StatelessWidget {
 | 
			
		||||
      hasReachedMax: postCount != null && posts.length >= postCount!,
 | 
			
		||||
      onFetchData: fetchPosts,
 | 
			
		||||
      itemBuilder: (context, idx) {
 | 
			
		||||
        return GestureDetector(
 | 
			
		||||
          child: PostItem(
 | 
			
		||||
        return OpenablePostItem(
 | 
			
		||||
          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),
 | 
			
		||||
      separatorBuilder: (_, __) => const Gap(8),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ 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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/unauthorized_hint.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
@@ -83,7 +84,7 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      return Scaffold(
 | 
			
		||||
      return AppScaffold(
 | 
			
		||||
        appBar: AppBar(
 | 
			
		||||
          leading: AutoAppBarLeading(),
 | 
			
		||||
          title: Text('screenRealm').tr(),
 | 
			
		||||
@@ -94,7 +95,7 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: AutoAppBarLeading(),
 | 
			
		||||
        title: Text('screenRealm').tr(),
 | 
			
		||||
@@ -118,6 +119,9 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: MediaQuery.removePadding(
 | 
			
		||||
              context: context,
 | 
			
		||||
              removeTop: true,
 | 
			
		||||
              child: RefreshIndicator(
 | 
			
		||||
                onRefresh: _fetchRealms,
 | 
			
		||||
                child: ListView.builder(
 | 
			
		||||
@@ -196,7 +200,9 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
                                  clipBehavior: Clip.none,
 | 
			
		||||
                                  fit: StackFit.expand,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                  Container(
 | 
			
		||||
                                    ClipRRect(
 | 
			
		||||
                                      borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                      child: Container(
 | 
			
		||||
                                        color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                                        child: (realm.banner?.isEmpty ?? true)
 | 
			
		||||
                                            ? const SizedBox.shrink()
 | 
			
		||||
@@ -205,6 +211,7 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
                                                fit: BoxFit.cover,
 | 
			
		||||
                                              ),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    Positioned(
 | 
			
		||||
                                      bottom: -30,
 | 
			
		||||
                                      left: 18,
 | 
			
		||||
@@ -240,6 +247,7 @@ class _RealmScreenState extends State<RealmScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ 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/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:uuid/uuid.dart';
 | 
			
		||||
 | 
			
		||||
@@ -179,7 +180,7 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.editingRealmAlias != null
 | 
			
		||||
            ? Text('screenRealmManage').tr()
 | 
			
		||||
 
 | 
			
		||||
@@ -8,13 +8,15 @@ 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/account.dart';
 | 
			
		||||
import 'package:surface/types/post.dart';
 | 
			
		||||
import 'package:surface/types/realm.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_select.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
import '../../types/post.dart';
 | 
			
		||||
 | 
			
		||||
class RealmDetailScreen extends StatefulWidget {
 | 
			
		||||
  final String alias;
 | 
			
		||||
 | 
			
		||||
@@ -70,19 +72,11 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return DefaultTabController(
 | 
			
		||||
      length: 3,
 | 
			
		||||
      child: Scaffold(
 | 
			
		||||
      child: AppScaffold(
 | 
			
		||||
        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()),
 | 
			
		||||
@@ -237,13 +231,35 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() {
 | 
			
		||||
    showModalBottomSheet(
 | 
			
		||||
  Future<void> _addMember(SnAccount related) async {
 | 
			
		||||
    setState(() => _isBusy = true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post(
 | 
			
		||||
        '/cgi/id/realms/${widget.realm!.alias}/members',
 | 
			
		||||
        data: {'related': related.name},
 | 
			
		||||
      );
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar('realmMemberAdded'.tr());
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _showMemberAdd() async {
 | 
			
		||||
    final user = await showModalBottomSheet<SnAccount?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (context) => _NewRealmMemberWidget(
 | 
			
		||||
        realm: widget.realm!,
 | 
			
		||||
      builder: (context) => AccountSelect(
 | 
			
		||||
        title: 'realmMemberAdd'.tr(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    if (user == null) return;
 | 
			
		||||
    _addMember(user);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -301,85 +317,6 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NewRealmMemberWidget extends StatefulWidget {
 | 
			
		||||
  final SnRealm realm;
 | 
			
		||||
 | 
			
		||||
  const _NewRealmMemberWidget({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;
 | 
			
		||||
@@ -428,7 +365,7 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          leading: const Icon(Symbols.edit),
 | 
			
		||||
          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/theme.dart';
 | 
			
		||||
import 'package:surface/theme.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
 | 
			
		||||
const Map<String, Color> kColorSchemes = {
 | 
			
		||||
  'colorSchemeIndigo': Colors.indigo,
 | 
			
		||||
@@ -67,7 +68,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenSettings').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SingleChildScrollView(
 | 
			
		||||
        child: Column(
 | 
			
		||||
          spacing: 16,
 | 
			
		||||
@@ -77,6 +82,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  title: Text('settingsDisplayLanguage').tr(),
 | 
			
		||||
                  subtitle: Text('settingsDisplayLanguageDescription').tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
                  leading: const Icon(Symbols.translate),
 | 
			
		||||
                  trailing: DropdownButtonHideUnderline(
 | 
			
		||||
                    child: DropdownButton2<Locale?>(
 | 
			
		||||
                      isExpanded: true,
 | 
			
		||||
                      items: [
 | 
			
		||||
                        ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
 | 
			
		||||
                          return DropdownMenuItem<Locale?>(
 | 
			
		||||
                            value: ele,
 | 
			
		||||
                            child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
 | 
			
		||||
                          );
 | 
			
		||||
                        }),
 | 
			
		||||
                        DropdownMenuItem<Locale?>(
 | 
			
		||||
                          value: null,
 | 
			
		||||
                          child: Text('settingsDisplayLanguageSystem').tr().fontSize(14),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                      value: EasyLocalization.of(context)!.currentLocale,
 | 
			
		||||
                      onChanged: (Locale? value) {
 | 
			
		||||
                        if (value != null) {
 | 
			
		||||
                          EasyLocalization.of(context)!.setLocale(value);
 | 
			
		||||
                        } else {
 | 
			
		||||
                          EasyLocalization.of(context)!.resetLocale();
 | 
			
		||||
                        }
 | 
			
		||||
                      },
 | 
			
		||||
                      buttonStyleData: const ButtonStyleData(
 | 
			
		||||
                        padding: EdgeInsets.symmetric(
 | 
			
		||||
                          horizontal: 16,
 | 
			
		||||
                          vertical: 5,
 | 
			
		||||
                        ),
 | 
			
		||||
                        height: 40,
 | 
			
		||||
                        width: 160,
 | 
			
		||||
                      ),
 | 
			
		||||
                      menuItemStyleData: const MenuItemStyleData(
 | 
			
		||||
                        height: 40,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                if (!kIsWeb)
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    title: Text('settingsBackgroundImage').tr(),
 | 
			
		||||
@@ -120,7 +167,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                  subtitle: Text('settingsThemeMaterial3Description').tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
                  secondary: const Icon(Symbols.new_releases),
 | 
			
		||||
                  value: _prefs.getBool(kMaterialYouToggleStoreKey) ?? false,
 | 
			
		||||
                  value: _prefs.getBool(kMaterialYouToggleStoreKey) ?? true,
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _prefs.setBool(
 | 
			
		||||
@@ -142,7 +189,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                    Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
 | 
			
		||||
                    final color = await showDialog<Color?>(
 | 
			
		||||
                      context: context,
 | 
			
		||||
                      builder: (context) => AlertDialog(
 | 
			
		||||
                      builder: (context) =>
 | 
			
		||||
                          AlertDialog(
 | 
			
		||||
                            content: SingleChildScrollView(
 | 
			
		||||
                              child: ColorPicker(
 | 
			
		||||
                                pickerColor: pickerColor,
 | 
			
		||||
@@ -205,7 +253,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                          .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
 | 
			
		||||
                      onChanged: (int? value) {
 | 
			
		||||
                        if (value != null && value != -1) {
 | 
			
		||||
                          _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value);
 | 
			
		||||
                          _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values
 | 
			
		||||
                              .elementAt(value)
 | 
			
		||||
                              .value);
 | 
			
		||||
                          final th = context.read<ThemeProvider>();
 | 
			
		||||
                          th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
 | 
			
		||||
                          setState(() {});
 | 
			
		||||
@@ -255,6 +305,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
 | 
			
		||||
                CheckboxListTile(
 | 
			
		||||
                  secondary: const Icon(Symbols.vibration),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
                  title: Text('settingsNotifyWithHaptic').tr(),
 | 
			
		||||
                  subtitle: Text('settingsNotifyWithHapticDescription').tr(),
 | 
			
		||||
                  value: _prefs.getBool(kAppNotifyWithHaptic) ?? true,
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _prefs.setBool(kAppNotifyWithHaptic, value ?? false);
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                CheckboxListTile(
 | 
			
		||||
                  secondary: const Icon(Symbols.link),
 | 
			
		||||
                  title: Text('settingsExpandPostLink').tr(),
 | 
			
		||||
                  subtitle: Text('settingsExpandPostLinkDescription').tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
                  value: _prefs.getBool(kAppExpandPostLink) ?? true,
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _prefs.setBool(kAppExpandPostLink, value ?? false);
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
                CheckboxListTile(
 | 
			
		||||
                  secondary: const Icon(Symbols.chat),
 | 
			
		||||
                  title: Text('settingsExpandChatLink').tr(),
 | 
			
		||||
                  subtitle: Text('settingsExpandChatLinkDescription').tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
                  value: _prefs.getBool(kAppExpandChatLink) ?? true,
 | 
			
		||||
                  onChanged: (value) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _prefs.setBool(kAppExpandChatLink, value ?? false);
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
@@ -295,7 +387,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                          ('Custom', _serverUrlController.text),
 | 
			
		||||
                      ]
 | 
			
		||||
                          .map(
 | 
			
		||||
                            (item) => DropdownMenuItem<String>(
 | 
			
		||||
                            (item) =>
 | 
			
		||||
                            DropdownMenuItem<String>(
 | 
			
		||||
                              value: item.$2,
 | 
			
		||||
                              child: Column(
 | 
			
		||||
                                mainAxisSize: MainAxisSize.max,
 | 
			
		||||
@@ -362,7 +455,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
 | 
			
		||||
                      isExpanded: true,
 | 
			
		||||
                      items: kImageQualityLevel.entries
 | 
			
		||||
                          .map(
 | 
			
		||||
                            (item) => DropdownMenuItem<FilterQuality>(
 | 
			
		||||
                            (item) =>
 | 
			
		||||
                            DropdownMenuItem<FilterQuality>(
 | 
			
		||||
                              value: item.value,
 | 
			
		||||
                              child: Text(item.key).tr().fontSize(14),
 | 
			
		||||
                            ),
 | 
			
		||||
 
 | 
			
		||||
@@ -8,9 +8,20 @@ import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/channel.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/chat/room.dart';
 | 
			
		||||
import 'package:surface/screens/post/post_editor.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';
 | 
			
		||||
 | 
			
		||||
class AppSharingListener extends StatefulWidget {
 | 
			
		||||
  final Widget child;
 | 
			
		||||
@@ -51,20 +62,39 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                            'mode': 'stories',
 | 
			
		||||
                          },
 | 
			
		||||
                          extra: PostEditorExtraProps(
 | 
			
		||||
                          extra: PostEditorExtra(
 | 
			
		||||
                            text: value
 | 
			
		||||
                                .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
			
		||||
                                .map((e) => e.path).join('\n'),
 | 
			
		||||
                                .map((e) => e.path)
 | 
			
		||||
                                .join('\n'),
 | 
			
		||||
                            attachments: value
 | 
			
		||||
                                .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
 | 
			
		||||
                                .map((e) => PostWriteMedia.fromFile(XFile(e.path))).toList(),
 | 
			
		||||
                                .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
 | 
			
		||||
                                    .contains(e.type))
 | 
			
		||||
                                .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
			
		||||
                                .toList(),
 | 
			
		||||
                          ),
 | 
			
		||||
                        );
 | 
			
		||||
                        Navigator.pop(context);
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                    ListTile(
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
 | 
			
		||||
                      leading: Icon(Icons.chat_outlined),
 | 
			
		||||
                      trailing: const Icon(Icons.chevron_right),
 | 
			
		||||
                      title: Text('shareIntentSendChannel').tr(),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        showModalBottomSheet(
 | 
			
		||||
                          context: context,
 | 
			
		||||
                          builder: (context) => _ShareIntentChannelSelect(value: value),
 | 
			
		||||
                        ).then((val) {
 | 
			
		||||
                          if (!context.mounted) return;
 | 
			
		||||
                          if (val == true) Navigator.pop(context);
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).width(280),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
@@ -103,7 +133,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
    if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
			
		||||
      _initialize();
 | 
			
		||||
      _initialHandle();
 | 
			
		||||
    }
 | 
			
		||||
@@ -120,3 +150,193 @@ class _AppSharingListenerState extends State<AppSharingListener> {
 | 
			
		||||
    return widget.child;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ShareIntentChannelSelect extends StatefulWidget {
 | 
			
		||||
  final Iterable<SharedMediaFile> value;
 | 
			
		||||
 | 
			
		||||
  const _ShareIntentChannelSelect({required this.value});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
 | 
			
		||||
  bool _isBusy = true;
 | 
			
		||||
 | 
			
		||||
  List<SnChannel>? _channels;
 | 
			
		||||
  Map<int, SnChatMessage>? _lastMessages;
 | 
			
		||||
 | 
			
		||||
  void _refreshChannels() {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final chan = context.read<ChatChannelProvider>();
 | 
			
		||||
    chan.fetchChannels().listen((channels) async {
 | 
			
		||||
      final lastMessages = await chan.getLastMessages(channels);
 | 
			
		||||
      _lastMessages = {for (final val in lastMessages) val.channelId: val};
 | 
			
		||||
      channels.sort((a, b) {
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
 | 
			
		||||
          return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
 | 
			
		||||
        }
 | 
			
		||||
        if (_lastMessages!.containsKey(a.id)) return -1;
 | 
			
		||||
        if (_lastMessages!.containsKey(b.id)) return 1;
 | 
			
		||||
        return 0;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
      for (final channel in channels) {
 | 
			
		||||
        if (channel.type == 1) {
 | 
			
		||||
          await ud.listAccount(
 | 
			
		||||
            channel.members
 | 
			
		||||
                    ?.cast<SnChannelMember?>()
 | 
			
		||||
                    .map((ele) => ele?.accountId)
 | 
			
		||||
                    .where((ele) => ele != null)
 | 
			
		||||
                    .toSet() ??
 | 
			
		||||
                {},
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (mounted) setState(() => _channels = channels);
 | 
			
		||||
    })
 | 
			
		||||
      ..onError((err) {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        context.showErrorDialog(err);
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      })
 | 
			
		||||
      ..onDone(() {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        setState(() => _isBusy = false);
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _refreshChannels();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.chat, size: 24),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
        LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
        Expanded(
 | 
			
		||||
          child: MediaQuery.removePadding(
 | 
			
		||||
            context: context,
 | 
			
		||||
            removeTop: true,
 | 
			
		||||
            child: RefreshIndicator(
 | 
			
		||||
              onRefresh: () => Future.sync(() => _refreshChannels()),
 | 
			
		||||
              child: ListView.builder(
 | 
			
		||||
                itemCount: _channels?.length ?? 0,
 | 
			
		||||
                itemBuilder: (context, idx) {
 | 
			
		||||
                  final channel = _channels![idx];
 | 
			
		||||
                  final lastMessage = _lastMessages?[channel.id];
 | 
			
		||||
 | 
			
		||||
                  if (channel.type == 1) {
 | 
			
		||||
                    final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
 | 
			
		||||
                          (ele) => ele?.accountId != ua.user?.id,
 | 
			
		||||
                          orElse: () => null,
 | 
			
		||||
                        );
 | 
			
		||||
 | 
			
		||||
                    return ListTile(
 | 
			
		||||
                      title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
 | 
			
		||||
                      subtitle: lastMessage != null
 | 
			
		||||
                          ? Text(
 | 
			
		||||
                              '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            )
 | 
			
		||||
                          : Text(
 | 
			
		||||
                              'channelDirectMessageDescription'.tr(args: [
 | 
			
		||||
                                '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
 | 
			
		||||
                              ]),
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                            ),
 | 
			
		||||
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                      leading: AccountImage(
 | 
			
		||||
                        content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed(
 | 
			
		||||
                          'chatRoom',
 | 
			
		||||
                          pathParameters: {
 | 
			
		||||
                            'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
                            'alias': channel.alias,
 | 
			
		||||
                          },
 | 
			
		||||
                        ).then((value) {
 | 
			
		||||
                          if (mounted) _refreshChannels();
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  return ListTile(
 | 
			
		||||
                    title: Text(channel.name),
 | 
			
		||||
                    subtitle: lastMessage != null
 | 
			
		||||
                        ? Text(
 | 
			
		||||
                            '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                          )
 | 
			
		||||
                        : Text(
 | 
			
		||||
                            channel.description,
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                          ),
 | 
			
		||||
                    contentPadding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                    leading: AccountImage(
 | 
			
		||||
                      content: null,
 | 
			
		||||
                      fallbackWidget: const Icon(Symbols.chat, size: 20),
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      Navigator.pop(context, true);
 | 
			
		||||
                      GoRouter.of(context)
 | 
			
		||||
                          .pushNamed(
 | 
			
		||||
                        'chatRoom',
 | 
			
		||||
                        pathParameters: {
 | 
			
		||||
                          'scope': channel.realm?.alias ?? 'global',
 | 
			
		||||
                          'alias': channel.alias,
 | 
			
		||||
                        },
 | 
			
		||||
                        extra: ChatRoomScreenExtra(
 | 
			
		||||
                          initialText: widget.value
 | 
			
		||||
                              .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
 | 
			
		||||
                              .map((e) => e.path)
 | 
			
		||||
                              .join('\n'),
 | 
			
		||||
                          initialAttachments: widget.value
 | 
			
		||||
                              .where((e) =>
 | 
			
		||||
                                  [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
 | 
			
		||||
                              .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
 | 
			
		||||
                              .toList(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      )
 | 
			
		||||
                          .then((value) {
 | 
			
		||||
                        if (value == true) _refreshChannels();
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										279
									
								
								lib/screens/wallet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								lib/screens/wallet.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,279 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/types/wallet.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/loading_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
 | 
			
		||||
 | 
			
		||||
class WalletScreen extends StatefulWidget {
 | 
			
		||||
  const WalletScreen({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<WalletScreen> createState() => _WalletScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _WalletScreenState extends State<WalletScreen> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  SnWallet? _wallet;
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchWallet() async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/wa/wallets/me');
 | 
			
		||||
      _wallet = SnWallet.fromJson(resp.data);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchWallet();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: PageBackButton(),
 | 
			
		||||
        title: Text('screenAccountWallet').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          LoadingIndicator(isActive: _isBusy),
 | 
			
		||||
          if (_wallet == null)
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: _CreateWalletWidget(
 | 
			
		||||
                onCreate: () {
 | 
			
		||||
                  _fetchWallet();
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            Card(
 | 
			
		||||
              child: Column(
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  CircleAvatar(
 | 
			
		||||
                    radius: 28,
 | 
			
		||||
                    child: Icon(Symbols.wallet, size: 28),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  SizedBox(width: double.infinity),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    NumberFormat.compactCurrency(
 | 
			
		||||
                      locale: EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
                      symbol: '${'walletCurrencyShort'.tr()} ',
 | 
			
		||||
                      decimalDigits: 2,
 | 
			
		||||
                    ).format(double.parse(_wallet!.balance)),
 | 
			
		||||
                    style: Theme.of(context).textTheme.titleLarge,
 | 
			
		||||
                  ),
 | 
			
		||||
                  Text('walletCurrency'.plural(double.parse(_wallet!.balance))),
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
            ).padding(horizontal: 8, top: 16, bottom: 4),
 | 
			
		||||
          if (_wallet != null) Expanded(child: _WalletTransactionList(myself: _wallet!)),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _WalletTransactionList extends StatefulWidget {
 | 
			
		||||
  final SnWallet myself;
 | 
			
		||||
 | 
			
		||||
  const _WalletTransactionList({required this.myself});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_WalletTransactionList> createState() => _WalletTransactionListState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _WalletTransactionListState extends State<_WalletTransactionList> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
  int? _totalCount;
 | 
			
		||||
  final List<SnTransaction> _transactions = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  Future<void> _fetchTransactions() async {
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      final resp = await sn.client.get('/cgi/wa/transactions/me', queryParameters: {
 | 
			
		||||
        'take': 10,
 | 
			
		||||
        'offset': _transactions.length,
 | 
			
		||||
      });
 | 
			
		||||
      _totalCount = resp.data['count'];
 | 
			
		||||
      _transactions.addAll(
 | 
			
		||||
        resp.data['data']?.map((e) => SnTransaction.fromJson(e)).cast<SnTransaction>() ?? [],
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _fetchTransactions();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return MediaQuery.removePadding(
 | 
			
		||||
      context: context,
 | 
			
		||||
      removeTop: true,
 | 
			
		||||
      child: RefreshIndicator(
 | 
			
		||||
        onRefresh: _fetchTransactions,
 | 
			
		||||
        child: InfiniteList(
 | 
			
		||||
          itemCount: _transactions.length,
 | 
			
		||||
          isLoading: _isBusy,
 | 
			
		||||
          hasReachedMax: _totalCount != null && _transactions.length >= _totalCount!,
 | 
			
		||||
          onFetchData: () {
 | 
			
		||||
            _fetchTransactions();
 | 
			
		||||
          },
 | 
			
		||||
          itemBuilder: (context, idx) {
 | 
			
		||||
            final ele = _transactions[idx];
 | 
			
		||||
            final isIncoming = ele.payeeId == widget.myself.id;
 | 
			
		||||
            return ListTile(
 | 
			
		||||
              leading: isIncoming ? const Icon(Symbols.call_received) : const Icon(Symbols.call_made),
 | 
			
		||||
              title: Text(
 | 
			
		||||
                '${isIncoming ? '+' : '-'}${ele.amount} ${'walletCurrencyShort'.tr()}',
 | 
			
		||||
                style: TextStyle(color: isIncoming ? Colors.green : Colors.red),
 | 
			
		||||
              ),
 | 
			
		||||
              subtitle: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text(ele.remark),
 | 
			
		||||
                  const Gap(2),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    DateFormat(
 | 
			
		||||
                      null,
 | 
			
		||||
                      EasyLocalization.of(context)!.currentLocale.toString(),
 | 
			
		||||
                    ).format(ele.createdAt),
 | 
			
		||||
                    style: Theme.of(context).textTheme.labelSmall,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              contentPadding: const EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
            );
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CreateWalletWidget extends StatefulWidget {
 | 
			
		||||
  final Function()? onCreate;
 | 
			
		||||
 | 
			
		||||
  const _CreateWalletWidget({required this.onCreate});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<_CreateWalletWidget> createState() => _CreateWalletWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
 | 
			
		||||
  Future<void> _createWallet() async {
 | 
			
		||||
    final TextEditingController passwordController = TextEditingController();
 | 
			
		||||
    final password = await showDialog<String?>(
 | 
			
		||||
      context: context,
 | 
			
		||||
      builder: (ctx) => AlertDialog(
 | 
			
		||||
        title: Text('walletCreate').tr(),
 | 
			
		||||
        content: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
          children: [
 | 
			
		||||
            Text('walletCreatePassword').tr(),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            TextField(
 | 
			
		||||
              autofocus: true,
 | 
			
		||||
              obscureText: true,
 | 
			
		||||
              controller: passwordController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                labelText: 'fieldPassword'.tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        actions: [
 | 
			
		||||
          TextButton(
 | 
			
		||||
            onPressed: () => Navigator.of(ctx).pop(),
 | 
			
		||||
            child: Text('cancel').tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              Navigator.of(ctx).pop(passwordController.text);
 | 
			
		||||
            },
 | 
			
		||||
            child: Text('next').tr(),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
      passwordController.dispose();
 | 
			
		||||
    });
 | 
			
		||||
    if (password == null || password.isEmpty) return;
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setState(() => _isBusy = true);
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.post('/cgi/wa/wallets/me', data: {
 | 
			
		||||
        'password': password,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setState(() => _isBusy = false);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Center(
 | 
			
		||||
      child: Container(
 | 
			
		||||
        constraints: const BoxConstraints(maxWidth: 380),
 | 
			
		||||
        child: Card(
 | 
			
		||||
          child: Column(
 | 
			
		||||
            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            children: [
 | 
			
		||||
              CircleAvatar(
 | 
			
		||||
                radius: 28,
 | 
			
		||||
                child: Icon(Symbols.add, size: 28),
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(12),
 | 
			
		||||
              Text('walletCreate', style: Theme.of(context).textTheme.titleLarge).tr(),
 | 
			
		||||
              Text('walletCreateSubtitle', style: Theme.of(context).textTheme.bodyMedium).tr(),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Align(
 | 
			
		||||
                alignment: Alignment.centerRight,
 | 
			
		||||
                child: TextButton(
 | 
			
		||||
                  onPressed: _isBusy ? null : () => _createWallet(),
 | 
			
		||||
                  child: Text('next').tr(),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -33,10 +33,11 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
    brightness: brightness,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
			
		||||
  final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
 | 
			
		||||
  final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
 | 
			
		||||
 | 
			
		||||
  return ThemeData(
 | 
			
		||||
    useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
 | 
			
		||||
    useMaterial3: useM3,
 | 
			
		||||
    colorScheme: colorScheme,
 | 
			
		||||
    brightness: brightness,
 | 
			
		||||
    iconTheme: IconThemeData(
 | 
			
		||||
@@ -45,12 +46,24 @@ Future<ThemeData> createAppTheme(
 | 
			
		||||
      opticalSize: 20,
 | 
			
		||||
      color: colorScheme.onSurface,
 | 
			
		||||
    ),
 | 
			
		||||
    snackBarTheme: SnackBarThemeData(
 | 
			
		||||
      behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
 | 
			
		||||
    ),
 | 
			
		||||
    appBarTheme: AppBarTheme(
 | 
			
		||||
      centerTitle: true,
 | 
			
		||||
      elevation: hasAppBarBlurry ? 0 : null,
 | 
			
		||||
      backgroundColor: hasAppBarBlurry ? colorScheme.primary.withOpacity(0.3) : colorScheme.primary,
 | 
			
		||||
      foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
			
		||||
      elevation: hasAppBarTransparent ? 0 : null,
 | 
			
		||||
      backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
 | 
			
		||||
      foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
 | 
			
		||||
    ),
 | 
			
		||||
    pageTransitionsTheme: PageTransitionsTheme(
 | 
			
		||||
      builders: {
 | 
			
		||||
        TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
 | 
			
		||||
        TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
 | 
			
		||||
        TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
 | 
			
		||||
        TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(),
 | 
			
		||||
        TargetPlatform.linux: ZoomPageTransitionsBuilder(),
 | 
			
		||||
        TargetPlatform.windows: ZoomPageTransitionsBuilder(),
 | 
			
		||||
      },
 | 
			
		||||
    ),
 | 
			
		||||
    scaffoldBackgroundColor: Colors.transparent,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,12 +15,13 @@ class SnAccount with _$SnAccount {
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required DateTime? confirmedAt,
 | 
			
		||||
    required List<SnAccountContact>? contacts,
 | 
			
		||||
    required String avatar,
 | 
			
		||||
    required String banner,
 | 
			
		||||
    @Default("") String avatar,
 | 
			
		||||
    @Default("") String banner,
 | 
			
		||||
    required String description,
 | 
			
		||||
    required String name,
 | 
			
		||||
    required String nick,
 | 
			
		||||
    required Map<String, dynamic> permNodes,
 | 
			
		||||
    required String language,
 | 
			
		||||
    required SnAccountProfile? profile,
 | 
			
		||||
    @Default([]) List<SnAccountBadge> badges,
 | 
			
		||||
    required DateTime? suspendedAt,
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ mixin _$SnAccount {
 | 
			
		||||
  String get name => throw _privateConstructorUsedError;
 | 
			
		||||
  String get nick => throw _privateConstructorUsedError;
 | 
			
		||||
  Map<String, dynamic> get permNodes => throw _privateConstructorUsedError;
 | 
			
		||||
  String get language => throw _privateConstructorUsedError;
 | 
			
		||||
  SnAccountProfile? get profile => throw _privateConstructorUsedError;
 | 
			
		||||
  List<SnAccountBadge> get badges => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get suspendedAt => throw _privateConstructorUsedError;
 | 
			
		||||
@@ -69,6 +70,7 @@ abstract class $SnAccountCopyWith<$Res> {
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick,
 | 
			
		||||
      Map<String, dynamic> permNodes,
 | 
			
		||||
      String language,
 | 
			
		||||
      SnAccountProfile? profile,
 | 
			
		||||
      List<SnAccountBadge> badges,
 | 
			
		||||
      DateTime? suspendedAt,
 | 
			
		||||
@@ -107,6 +109,7 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
    Object? permNodes = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? profile = freezed,
 | 
			
		||||
    Object? badges = null,
 | 
			
		||||
    Object? suspendedAt = freezed,
 | 
			
		||||
@@ -164,6 +167,10 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
 | 
			
		||||
          ? _value.permNodes
 | 
			
		||||
          : permNodes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _value.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      profile: freezed == profile
 | 
			
		||||
          ? _value.profile
 | 
			
		||||
          : profile // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -231,6 +238,7 @@ abstract class _$$SnAccountImplCopyWith<$Res>
 | 
			
		||||
      String name,
 | 
			
		||||
      String nick,
 | 
			
		||||
      Map<String, dynamic> permNodes,
 | 
			
		||||
      String language,
 | 
			
		||||
      SnAccountProfile? profile,
 | 
			
		||||
      List<SnAccountBadge> badges,
 | 
			
		||||
      DateTime? suspendedAt,
 | 
			
		||||
@@ -268,6 +276,7 @@ class __$$SnAccountImplCopyWithImpl<$Res>
 | 
			
		||||
    Object? name = null,
 | 
			
		||||
    Object? nick = null,
 | 
			
		||||
    Object? permNodes = null,
 | 
			
		||||
    Object? language = null,
 | 
			
		||||
    Object? profile = freezed,
 | 
			
		||||
    Object? badges = null,
 | 
			
		||||
    Object? suspendedAt = freezed,
 | 
			
		||||
@@ -325,6 +334,10 @@ class __$$SnAccountImplCopyWithImpl<$Res>
 | 
			
		||||
          ? _value._permNodes
 | 
			
		||||
          : permNodes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as Map<String, dynamic>,
 | 
			
		||||
      language: null == language
 | 
			
		||||
          ? _value.language
 | 
			
		||||
          : language // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      profile: freezed == profile
 | 
			
		||||
          ? _value.profile
 | 
			
		||||
          : profile // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -367,12 +380,13 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.confirmedAt,
 | 
			
		||||
      required final List<SnAccountContact>? contacts,
 | 
			
		||||
      required this.avatar,
 | 
			
		||||
      required this.banner,
 | 
			
		||||
      this.avatar = "",
 | 
			
		||||
      this.banner = "",
 | 
			
		||||
      required this.description,
 | 
			
		||||
      required this.name,
 | 
			
		||||
      required this.nick,
 | 
			
		||||
      required final Map<String, dynamic> permNodes,
 | 
			
		||||
      required this.language,
 | 
			
		||||
      required this.profile,
 | 
			
		||||
      final List<SnAccountBadge> badges = const [],
 | 
			
		||||
      required this.suspendedAt,
 | 
			
		||||
@@ -410,8 +424,10 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey()
 | 
			
		||||
  final String avatar;
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey()
 | 
			
		||||
  final String banner;
 | 
			
		||||
  @override
 | 
			
		||||
  final String description;
 | 
			
		||||
@@ -427,6 +443,8 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
    return EqualUnmodifiableMapView(_permNodes);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final String language;
 | 
			
		||||
  @override
 | 
			
		||||
  final SnAccountProfile? profile;
 | 
			
		||||
  final List<SnAccountBadge> _badges;
 | 
			
		||||
@@ -451,7 +469,7 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
 | 
			
		||||
    return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, language: $language, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -477,6 +495,8 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
            (identical(other.nick, nick) || other.nick == nick) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._permNodes, _permNodes) &&
 | 
			
		||||
            (identical(other.language, language) ||
 | 
			
		||||
                other.language == language) &&
 | 
			
		||||
            (identical(other.profile, profile) || other.profile == profile) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other._badges, _badges) &&
 | 
			
		||||
            (identical(other.suspendedAt, suspendedAt) ||
 | 
			
		||||
@@ -507,6 +527,7 @@ class _$SnAccountImpl extends _SnAccount {
 | 
			
		||||
        name,
 | 
			
		||||
        nick,
 | 
			
		||||
        const DeepCollectionEquality().hash(_permNodes),
 | 
			
		||||
        language,
 | 
			
		||||
        profile,
 | 
			
		||||
        const DeepCollectionEquality().hash(_badges),
 | 
			
		||||
        suspendedAt,
 | 
			
		||||
@@ -540,12 +561,13 @@ abstract class _SnAccount extends SnAccount {
 | 
			
		||||
      required final DateTime? deletedAt,
 | 
			
		||||
      required final DateTime? confirmedAt,
 | 
			
		||||
      required final List<SnAccountContact>? contacts,
 | 
			
		||||
      required final String avatar,
 | 
			
		||||
      required final String banner,
 | 
			
		||||
      final String avatar,
 | 
			
		||||
      final String banner,
 | 
			
		||||
      required final String description,
 | 
			
		||||
      required final String name,
 | 
			
		||||
      required final String nick,
 | 
			
		||||
      required final Map<String, dynamic> permNodes,
 | 
			
		||||
      required final String language,
 | 
			
		||||
      required final SnAccountProfile? profile,
 | 
			
		||||
      final List<SnAccountBadge> badges,
 | 
			
		||||
      required final DateTime? suspendedAt,
 | 
			
		||||
@@ -584,6 +606,8 @@ abstract class _SnAccount extends SnAccount {
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> get permNodes;
 | 
			
		||||
  @override
 | 
			
		||||
  String get language;
 | 
			
		||||
  @override
 | 
			
		||||
  SnAccountProfile? get profile;
 | 
			
		||||
  @override
 | 
			
		||||
  List<SnAccountBadge> get badges;
 | 
			
		||||
 
 | 
			
		||||
@@ -20,12 +20,13 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      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,
 | 
			
		||||
      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,
 | 
			
		||||
      permNodes: json['perm_nodes'] as Map<String, dynamic>,
 | 
			
		||||
      language: json['language'] as String,
 | 
			
		||||
      profile: json['profile'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
 | 
			
		||||
@@ -56,6 +57,7 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
 | 
			
		||||
      'name': instance.name,
 | 
			
		||||
      'nick': instance.nick,
 | 
			
		||||
      'perm_nodes': instance.permNodes,
 | 
			
		||||
      'language': instance.language,
 | 
			
		||||
      'profile': instance.profile?.toJson(),
 | 
			
		||||
      'badges': instance.badges.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'suspended_at': instance.suspendedAt?.toIso8601String(),
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@ class SnCheckInRecord with _$SnCheckInRecord {
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required int resultTier,
 | 
			
		||||
    required int resultExperience,
 | 
			
		||||
    required double resultCoin,
 | 
			
		||||
    required List<int> resultModifiers,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnCheckInRecord;
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@ mixin _$SnCheckInRecord {
 | 
			
		||||
  DateTime? get deletedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  int get resultTier => throw _privateConstructorUsedError;
 | 
			
		||||
  int get resultExperience => throw _privateConstructorUsedError;
 | 
			
		||||
  double get resultCoin => throw _privateConstructorUsedError;
 | 
			
		||||
  List<int> get resultModifiers => throw _privateConstructorUsedError;
 | 
			
		||||
  int get accountId => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
@@ -52,6 +53,7 @@ abstract class $SnCheckInRecordCopyWith<$Res> {
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      double resultCoin,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
@@ -77,6 +79,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultCoin = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
@@ -105,6 +108,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
 | 
			
		||||
          ? _value.resultExperience
 | 
			
		||||
          : resultExperience // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultCoin: null == resultCoin
 | 
			
		||||
          ? _value.resultCoin
 | 
			
		||||
          : resultCoin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _value.resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -132,6 +139,7 @@ abstract class _$$SnCheckInRecordImplCopyWith<$Res>
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      int resultTier,
 | 
			
		||||
      int resultExperience,
 | 
			
		||||
      double resultCoin,
 | 
			
		||||
      List<int> resultModifiers,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
@@ -155,6 +163,7 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
 | 
			
		||||
    Object? deletedAt = freezed,
 | 
			
		||||
    Object? resultTier = null,
 | 
			
		||||
    Object? resultExperience = null,
 | 
			
		||||
    Object? resultCoin = null,
 | 
			
		||||
    Object? resultModifiers = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
@@ -183,6 +192,10 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
 | 
			
		||||
          ? _value.resultExperience
 | 
			
		||||
          : resultExperience // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      resultCoin: null == resultCoin
 | 
			
		||||
          ? _value.resultCoin
 | 
			
		||||
          : resultCoin // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as double,
 | 
			
		||||
      resultModifiers: null == resultModifiers
 | 
			
		||||
          ? _value._resultModifiers
 | 
			
		||||
          : resultModifiers // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -205,6 +218,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.resultTier,
 | 
			
		||||
      required this.resultExperience,
 | 
			
		||||
      required this.resultCoin,
 | 
			
		||||
      required final List<int> resultModifiers,
 | 
			
		||||
      required this.accountId})
 | 
			
		||||
      : _resultModifiers = resultModifiers,
 | 
			
		||||
@@ -225,6 +239,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
  final int resultTier;
 | 
			
		||||
  @override
 | 
			
		||||
  final int resultExperience;
 | 
			
		||||
  @override
 | 
			
		||||
  final double resultCoin;
 | 
			
		||||
  final List<int> _resultModifiers;
 | 
			
		||||
  @override
 | 
			
		||||
  List<int> get resultModifiers {
 | 
			
		||||
@@ -238,7 +254,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
    return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -257,6 +273,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
                other.resultTier == resultTier) &&
 | 
			
		||||
            (identical(other.resultExperience, resultExperience) ||
 | 
			
		||||
                other.resultExperience == resultExperience) &&
 | 
			
		||||
            (identical(other.resultCoin, resultCoin) ||
 | 
			
		||||
                other.resultCoin == resultCoin) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._resultModifiers, _resultModifiers) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
@@ -273,6 +291,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
 | 
			
		||||
      deletedAt,
 | 
			
		||||
      resultTier,
 | 
			
		||||
      resultExperience,
 | 
			
		||||
      resultCoin,
 | 
			
		||||
      const DeepCollectionEquality().hash(_resultModifiers),
 | 
			
		||||
      accountId);
 | 
			
		||||
 | 
			
		||||
@@ -301,6 +320,7 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
      required final DateTime? deletedAt,
 | 
			
		||||
      required final int resultTier,
 | 
			
		||||
      required final int resultExperience,
 | 
			
		||||
      required final double resultCoin,
 | 
			
		||||
      required final List<int> resultModifiers,
 | 
			
		||||
      required final int accountId}) = _$SnCheckInRecordImpl;
 | 
			
		||||
  const _SnCheckInRecord._() : super._();
 | 
			
		||||
@@ -321,6 +341,8 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
 | 
			
		||||
  @override
 | 
			
		||||
  int get resultExperience;
 | 
			
		||||
  @override
 | 
			
		||||
  double get resultCoin;
 | 
			
		||||
  @override
 | 
			
		||||
  List<int> get resultModifiers;
 | 
			
		||||
  @override
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ _$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
      resultTier: (json['result_tier'] as num).toInt(),
 | 
			
		||||
      resultExperience: (json['result_experience'] as num).toInt(),
 | 
			
		||||
      resultCoin: (json['result_coin'] as num).toDouble(),
 | 
			
		||||
      resultModifiers: (json['result_modifiers'] as List<dynamic>)
 | 
			
		||||
          .map((e) => (e as num).toInt())
 | 
			
		||||
          .toList(),
 | 
			
		||||
@@ -32,6 +33,7 @@ Map<String, dynamic> _$$SnCheckInRecordImplToJson(
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'result_tier': instance.resultTier,
 | 
			
		||||
      'result_experience': instance.resultExperience,
 | 
			
		||||
      'result_coin': instance.resultCoin,
 | 
			
		||||
      'result_modifiers': instance.resultModifiers,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										38
									
								
								lib/types/news.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								lib/types/news.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
 | 
			
		||||
part 'news.freezed.dart';
 | 
			
		||||
part 'news.g.dart';
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnNewsSource with _$SnNewsSource {
 | 
			
		||||
  const factory SnNewsSource({
 | 
			
		||||
    required String id,
 | 
			
		||||
    required String label,
 | 
			
		||||
    required String type,
 | 
			
		||||
    required String source,
 | 
			
		||||
    required int depth,
 | 
			
		||||
    required bool enabled,
 | 
			
		||||
  }) = _SnNewsSource;
 | 
			
		||||
 | 
			
		||||
  factory SnNewsSource.fromJson(Map<String, dynamic> json) => _$SnNewsSourceFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnNewsArticle with _$SnNewsArticle {
 | 
			
		||||
  const factory SnNewsArticle({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required dynamic deletedAt,
 | 
			
		||||
    required String thumbnail,
 | 
			
		||||
    required String title,
 | 
			
		||||
    required String description,
 | 
			
		||||
    required String content,
 | 
			
		||||
    required String url,
 | 
			
		||||
    required String hash,
 | 
			
		||||
    required String source,
 | 
			
		||||
    required DateTime? publishedAt,
 | 
			
		||||
  }) = _SnNewsArticle;
 | 
			
		||||
 | 
			
		||||
  factory SnNewsArticle.fromJson(Map<String, dynamic> json) => _$SnNewsArticleFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										660
									
								
								lib/types/news.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										660
									
								
								lib/types/news.freezed.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,660 @@
 | 
			
		||||
// 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 'news.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');
 | 
			
		||||
 | 
			
		||||
SnNewsSource _$SnNewsSourceFromJson(Map<String, dynamic> json) {
 | 
			
		||||
  return _SnNewsSource.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnNewsSource {
 | 
			
		||||
  String get id => throw _privateConstructorUsedError;
 | 
			
		||||
  String get label => throw _privateConstructorUsedError;
 | 
			
		||||
  String get type => throw _privateConstructorUsedError;
 | 
			
		||||
  String get source => throw _privateConstructorUsedError;
 | 
			
		||||
  int get depth => throw _privateConstructorUsedError;
 | 
			
		||||
  bool get enabled => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnNewsSource to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsSource
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  $SnNewsSourceCopyWith<SnNewsSource> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class $SnNewsSourceCopyWith<$Res> {
 | 
			
		||||
  factory $SnNewsSourceCopyWith(
 | 
			
		||||
          SnNewsSource value, $Res Function(SnNewsSource) then) =
 | 
			
		||||
      _$SnNewsSourceCopyWithImpl<$Res, SnNewsSource>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {String id,
 | 
			
		||||
      String label,
 | 
			
		||||
      String type,
 | 
			
		||||
      String source,
 | 
			
		||||
      int depth,
 | 
			
		||||
      bool enabled});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnNewsSourceCopyWithImpl<$Res, $Val extends SnNewsSource>
 | 
			
		||||
    implements $SnNewsSourceCopyWith<$Res> {
 | 
			
		||||
  _$SnNewsSourceCopyWithImpl(this._value, this._then);
 | 
			
		||||
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Val _value;
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Res Function($Val) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsSource
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? label = null,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? source = null,
 | 
			
		||||
    Object? depth = null,
 | 
			
		||||
    Object? enabled = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      label: null == label
 | 
			
		||||
          ? _value.label
 | 
			
		||||
          : label // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _value.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      source: null == source
 | 
			
		||||
          ? _value.source
 | 
			
		||||
          : source // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      depth: null == depth
 | 
			
		||||
          ? _value.depth
 | 
			
		||||
          : depth // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      enabled: null == enabled
 | 
			
		||||
          ? _value.enabled
 | 
			
		||||
          : enabled // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class _$$SnNewsSourceImplCopyWith<$Res>
 | 
			
		||||
    implements $SnNewsSourceCopyWith<$Res> {
 | 
			
		||||
  factory _$$SnNewsSourceImplCopyWith(
 | 
			
		||||
          _$SnNewsSourceImpl value, $Res Function(_$SnNewsSourceImpl) then) =
 | 
			
		||||
      __$$SnNewsSourceImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {String id,
 | 
			
		||||
      String label,
 | 
			
		||||
      String type,
 | 
			
		||||
      String source,
 | 
			
		||||
      int depth,
 | 
			
		||||
      bool enabled});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$$SnNewsSourceImplCopyWithImpl<$Res>
 | 
			
		||||
    extends _$SnNewsSourceCopyWithImpl<$Res, _$SnNewsSourceImpl>
 | 
			
		||||
    implements _$$SnNewsSourceImplCopyWith<$Res> {
 | 
			
		||||
  __$$SnNewsSourceImplCopyWithImpl(
 | 
			
		||||
      _$SnNewsSourceImpl _value, $Res Function(_$SnNewsSourceImpl) _then)
 | 
			
		||||
      : super(_value, _then);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsSource
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  @override
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? id = null,
 | 
			
		||||
    Object? label = null,
 | 
			
		||||
    Object? type = null,
 | 
			
		||||
    Object? source = null,
 | 
			
		||||
    Object? depth = null,
 | 
			
		||||
    Object? enabled = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnNewsSourceImpl(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      label: null == label
 | 
			
		||||
          ? _value.label
 | 
			
		||||
          : label // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      type: null == type
 | 
			
		||||
          ? _value.type
 | 
			
		||||
          : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      source: null == source
 | 
			
		||||
          ? _value.source
 | 
			
		||||
          : source // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      depth: null == depth
 | 
			
		||||
          ? _value.depth
 | 
			
		||||
          : depth // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      enabled: null == enabled
 | 
			
		||||
          ? _value.enabled
 | 
			
		||||
          : enabled // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as bool,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _$SnNewsSourceImpl implements _SnNewsSource {
 | 
			
		||||
  const _$SnNewsSourceImpl(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.label,
 | 
			
		||||
      required this.type,
 | 
			
		||||
      required this.source,
 | 
			
		||||
      required this.depth,
 | 
			
		||||
      required this.enabled});
 | 
			
		||||
 | 
			
		||||
  factory _$SnNewsSourceImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$$SnNewsSourceImplFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final String id;
 | 
			
		||||
  @override
 | 
			
		||||
  final String label;
 | 
			
		||||
  @override
 | 
			
		||||
  final String type;
 | 
			
		||||
  @override
 | 
			
		||||
  final String source;
 | 
			
		||||
  @override
 | 
			
		||||
  final int depth;
 | 
			
		||||
  @override
 | 
			
		||||
  final bool enabled;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnNewsSource(id: $id, label: $label, type: $type, source: $source, depth: $depth, enabled: $enabled)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _$SnNewsSourceImpl &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.label, label) || other.label == label) &&
 | 
			
		||||
            (identical(other.type, type) || other.type == type) &&
 | 
			
		||||
            (identical(other.source, source) || other.source == source) &&
 | 
			
		||||
            (identical(other.depth, depth) || other.depth == depth) &&
 | 
			
		||||
            (identical(other.enabled, enabled) || other.enabled == enabled));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
      Object.hash(runtimeType, id, label, type, source, depth, enabled);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsSource
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$$SnNewsSourceImplCopyWith<_$SnNewsSourceImpl> get copyWith =>
 | 
			
		||||
      __$$SnNewsSourceImplCopyWithImpl<_$SnNewsSourceImpl>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$$SnNewsSourceImplToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _SnNewsSource implements SnNewsSource {
 | 
			
		||||
  const factory _SnNewsSource(
 | 
			
		||||
      {required final String id,
 | 
			
		||||
      required final String label,
 | 
			
		||||
      required final String type,
 | 
			
		||||
      required final String source,
 | 
			
		||||
      required final int depth,
 | 
			
		||||
      required final bool enabled}) = _$SnNewsSourceImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnNewsSource.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnNewsSourceImpl.fromJson;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String get id;
 | 
			
		||||
  @override
 | 
			
		||||
  String get label;
 | 
			
		||||
  @override
 | 
			
		||||
  String get type;
 | 
			
		||||
  @override
 | 
			
		||||
  String get source;
 | 
			
		||||
  @override
 | 
			
		||||
  int get depth;
 | 
			
		||||
  @override
 | 
			
		||||
  bool get enabled;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsSource
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  _$$SnNewsSourceImplCopyWith<_$SnNewsSourceImpl> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
SnNewsArticle _$SnNewsArticleFromJson(Map<String, dynamic> json) {
 | 
			
		||||
  return _SnNewsArticle.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnNewsArticle {
 | 
			
		||||
  int get id => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get createdAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get updatedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  dynamic get deletedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  String get thumbnail => throw _privateConstructorUsedError;
 | 
			
		||||
  String get title => throw _privateConstructorUsedError;
 | 
			
		||||
  String get description => throw _privateConstructorUsedError;
 | 
			
		||||
  String get content => throw _privateConstructorUsedError;
 | 
			
		||||
  String get url => throw _privateConstructorUsedError;
 | 
			
		||||
  String get hash => throw _privateConstructorUsedError;
 | 
			
		||||
  String get source => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get publishedAt => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnNewsArticle to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsArticle
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  $SnNewsArticleCopyWith<SnNewsArticle> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class $SnNewsArticleCopyWith<$Res> {
 | 
			
		||||
  factory $SnNewsArticleCopyWith(
 | 
			
		||||
          SnNewsArticle value, $Res Function(SnNewsArticle) then) =
 | 
			
		||||
      _$SnNewsArticleCopyWithImpl<$Res, SnNewsArticle>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      dynamic deletedAt,
 | 
			
		||||
      String thumbnail,
 | 
			
		||||
      String title,
 | 
			
		||||
      String description,
 | 
			
		||||
      String content,
 | 
			
		||||
      String url,
 | 
			
		||||
      String hash,
 | 
			
		||||
      String source,
 | 
			
		||||
      DateTime? publishedAt});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnNewsArticleCopyWithImpl<$Res, $Val extends SnNewsArticle>
 | 
			
		||||
    implements $SnNewsArticleCopyWith<$Res> {
 | 
			
		||||
  _$SnNewsArticleCopyWithImpl(this._value, this._then);
 | 
			
		||||
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Val _value;
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Res Function($Val) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsArticle
 | 
			
		||||
  /// 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? thumbnail = null,
 | 
			
		||||
    Object? title = null,
 | 
			
		||||
    Object? description = null,
 | 
			
		||||
    Object? content = null,
 | 
			
		||||
    Object? url = null,
 | 
			
		||||
    Object? hash = null,
 | 
			
		||||
    Object? source = null,
 | 
			
		||||
    Object? publishedAt = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _value.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _value.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _value.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
      thumbnail: null == thumbnail
 | 
			
		||||
          ? _value.thumbnail
 | 
			
		||||
          : thumbnail // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      title: null == title
 | 
			
		||||
          ? _value.title
 | 
			
		||||
          : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      description: null == description
 | 
			
		||||
          ? _value.description
 | 
			
		||||
          : description // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      content: null == content
 | 
			
		||||
          ? _value.content
 | 
			
		||||
          : content // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      url: null == url
 | 
			
		||||
          ? _value.url
 | 
			
		||||
          : url // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      hash: null == hash
 | 
			
		||||
          ? _value.hash
 | 
			
		||||
          : hash // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      source: null == source
 | 
			
		||||
          ? _value.source
 | 
			
		||||
          : source // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      publishedAt: freezed == publishedAt
 | 
			
		||||
          ? _value.publishedAt
 | 
			
		||||
          : publishedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class _$$SnNewsArticleImplCopyWith<$Res>
 | 
			
		||||
    implements $SnNewsArticleCopyWith<$Res> {
 | 
			
		||||
  factory _$$SnNewsArticleImplCopyWith(
 | 
			
		||||
          _$SnNewsArticleImpl value, $Res Function(_$SnNewsArticleImpl) then) =
 | 
			
		||||
      __$$SnNewsArticleImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      dynamic deletedAt,
 | 
			
		||||
      String thumbnail,
 | 
			
		||||
      String title,
 | 
			
		||||
      String description,
 | 
			
		||||
      String content,
 | 
			
		||||
      String url,
 | 
			
		||||
      String hash,
 | 
			
		||||
      String source,
 | 
			
		||||
      DateTime? publishedAt});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$$SnNewsArticleImplCopyWithImpl<$Res>
 | 
			
		||||
    extends _$SnNewsArticleCopyWithImpl<$Res, _$SnNewsArticleImpl>
 | 
			
		||||
    implements _$$SnNewsArticleImplCopyWith<$Res> {
 | 
			
		||||
  __$$SnNewsArticleImplCopyWithImpl(
 | 
			
		||||
      _$SnNewsArticleImpl _value, $Res Function(_$SnNewsArticleImpl) _then)
 | 
			
		||||
      : super(_value, _then);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsArticle
 | 
			
		||||
  /// 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? thumbnail = null,
 | 
			
		||||
    Object? title = null,
 | 
			
		||||
    Object? description = null,
 | 
			
		||||
    Object? content = null,
 | 
			
		||||
    Object? url = null,
 | 
			
		||||
    Object? hash = null,
 | 
			
		||||
    Object? source = null,
 | 
			
		||||
    Object? publishedAt = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnNewsArticleImpl(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _value.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _value.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _value.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as dynamic,
 | 
			
		||||
      thumbnail: null == thumbnail
 | 
			
		||||
          ? _value.thumbnail
 | 
			
		||||
          : thumbnail // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      title: null == title
 | 
			
		||||
          ? _value.title
 | 
			
		||||
          : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      description: null == description
 | 
			
		||||
          ? _value.description
 | 
			
		||||
          : description // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      content: null == content
 | 
			
		||||
          ? _value.content
 | 
			
		||||
          : content // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      url: null == url
 | 
			
		||||
          ? _value.url
 | 
			
		||||
          : url // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      hash: null == hash
 | 
			
		||||
          ? _value.hash
 | 
			
		||||
          : hash // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      source: null == source
 | 
			
		||||
          ? _value.source
 | 
			
		||||
          : source // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      publishedAt: freezed == publishedAt
 | 
			
		||||
          ? _value.publishedAt
 | 
			
		||||
          : publishedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _$SnNewsArticleImpl implements _SnNewsArticle {
 | 
			
		||||
  const _$SnNewsArticleImpl(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.thumbnail,
 | 
			
		||||
      required this.title,
 | 
			
		||||
      required this.description,
 | 
			
		||||
      required this.content,
 | 
			
		||||
      required this.url,
 | 
			
		||||
      required this.hash,
 | 
			
		||||
      required this.source,
 | 
			
		||||
      required this.publishedAt});
 | 
			
		||||
 | 
			
		||||
  factory _$SnNewsArticleImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$$SnNewsArticleImplFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final dynamic deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  final String title;
 | 
			
		||||
  @override
 | 
			
		||||
  final String description;
 | 
			
		||||
  @override
 | 
			
		||||
  final String content;
 | 
			
		||||
  @override
 | 
			
		||||
  final String url;
 | 
			
		||||
  @override
 | 
			
		||||
  final String hash;
 | 
			
		||||
  @override
 | 
			
		||||
  final String source;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? publishedAt;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnNewsArticle(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, thumbnail: $thumbnail, title: $title, description: $description, content: $content, url: $url, hash: $hash, source: $source, publishedAt: $publishedAt)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _$SnNewsArticleImpl &&
 | 
			
		||||
            (identical(other.id, id) || other.id == id) &&
 | 
			
		||||
            (identical(other.createdAt, createdAt) ||
 | 
			
		||||
                other.createdAt == createdAt) &&
 | 
			
		||||
            (identical(other.updatedAt, updatedAt) ||
 | 
			
		||||
                other.updatedAt == updatedAt) &&
 | 
			
		||||
            const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
 | 
			
		||||
            (identical(other.thumbnail, thumbnail) ||
 | 
			
		||||
                other.thumbnail == thumbnail) &&
 | 
			
		||||
            (identical(other.title, title) || other.title == title) &&
 | 
			
		||||
            (identical(other.description, description) ||
 | 
			
		||||
                other.description == description) &&
 | 
			
		||||
            (identical(other.content, content) || other.content == content) &&
 | 
			
		||||
            (identical(other.url, url) || other.url == url) &&
 | 
			
		||||
            (identical(other.hash, hash) || other.hash == hash) &&
 | 
			
		||||
            (identical(other.source, source) || other.source == source) &&
 | 
			
		||||
            (identical(other.publishedAt, publishedAt) ||
 | 
			
		||||
                other.publishedAt == publishedAt));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(
 | 
			
		||||
      runtimeType,
 | 
			
		||||
      id,
 | 
			
		||||
      createdAt,
 | 
			
		||||
      updatedAt,
 | 
			
		||||
      const DeepCollectionEquality().hash(deletedAt),
 | 
			
		||||
      thumbnail,
 | 
			
		||||
      title,
 | 
			
		||||
      description,
 | 
			
		||||
      content,
 | 
			
		||||
      url,
 | 
			
		||||
      hash,
 | 
			
		||||
      source,
 | 
			
		||||
      publishedAt);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsArticle
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$$SnNewsArticleImplCopyWith<_$SnNewsArticleImpl> get copyWith =>
 | 
			
		||||
      __$$SnNewsArticleImplCopyWithImpl<_$SnNewsArticleImpl>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$$SnNewsArticleImplToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _SnNewsArticle implements SnNewsArticle {
 | 
			
		||||
  const factory _SnNewsArticle(
 | 
			
		||||
      {required final int id,
 | 
			
		||||
      required final DateTime createdAt,
 | 
			
		||||
      required final DateTime updatedAt,
 | 
			
		||||
      required final dynamic deletedAt,
 | 
			
		||||
      required final String thumbnail,
 | 
			
		||||
      required final String title,
 | 
			
		||||
      required final String description,
 | 
			
		||||
      required final String content,
 | 
			
		||||
      required final String url,
 | 
			
		||||
      required final String hash,
 | 
			
		||||
      required final String source,
 | 
			
		||||
      required final DateTime? publishedAt}) = _$SnNewsArticleImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnNewsArticle.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnNewsArticleImpl.fromJson;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get id;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  dynamic get deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  String get thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  String get title;
 | 
			
		||||
  @override
 | 
			
		||||
  String get description;
 | 
			
		||||
  @override
 | 
			
		||||
  String get content;
 | 
			
		||||
  @override
 | 
			
		||||
  String get url;
 | 
			
		||||
  @override
 | 
			
		||||
  String get hash;
 | 
			
		||||
  @override
 | 
			
		||||
  String get source;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime? get publishedAt;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnNewsArticle
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  _$$SnNewsArticleImplCopyWith<_$SnNewsArticleImpl> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								lib/types/news.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								lib/types/news.g.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
 | 
			
		||||
part of 'news.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// JsonSerializableGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
_$SnNewsSourceImpl _$$SnNewsSourceImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnNewsSourceImpl(
 | 
			
		||||
      id: json['id'] as String,
 | 
			
		||||
      label: json['label'] as String,
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      source: json['source'] as String,
 | 
			
		||||
      depth: (json['depth'] as num).toInt(),
 | 
			
		||||
      enabled: json['enabled'] as bool,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnNewsSourceImplToJson(_$SnNewsSourceImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'label': instance.label,
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'source': instance.source,
 | 
			
		||||
      'depth': instance.depth,
 | 
			
		||||
      'enabled': instance.enabled,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnNewsArticleImpl _$$SnNewsArticleImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnNewsArticleImpl(
 | 
			
		||||
      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'],
 | 
			
		||||
      thumbnail: json['thumbnail'] as String,
 | 
			
		||||
      title: json['title'] as String,
 | 
			
		||||
      description: json['description'] as String,
 | 
			
		||||
      content: json['content'] as String,
 | 
			
		||||
      url: json['url'] as String,
 | 
			
		||||
      hash: json['hash'] as String,
 | 
			
		||||
      source: json['source'] as String,
 | 
			
		||||
      publishedAt: json['published_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['published_at'] as String),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnNewsArticleImplToJson(_$SnNewsArticleImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt,
 | 
			
		||||
      'thumbnail': instance.thumbnail,
 | 
			
		||||
      'title': instance.title,
 | 
			
		||||
      'description': instance.description,
 | 
			
		||||
      'content': instance.content,
 | 
			
		||||
      'url': instance.url,
 | 
			
		||||
      'hash': instance.hash,
 | 
			
		||||
      'source': instance.source,
 | 
			
		||||
      'published_at': instance.publishedAt?.toIso8601String(),
 | 
			
		||||
    };
 | 
			
		||||
@@ -89,6 +89,7 @@ class SnPostPreload with _$SnPostPreload {
 | 
			
		||||
  const factory SnPostPreload({
 | 
			
		||||
    required SnAttachment? thumbnail,
 | 
			
		||||
    required List<SnAttachment?>? attachments,
 | 
			
		||||
    required SnAttachment? video,
 | 
			
		||||
  }) = _SnPostPreload;
 | 
			
		||||
 | 
			
		||||
  factory SnPostPreload.fromJson(Map<String, Object?> json) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -1567,6 +1567,7 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
 | 
			
		||||
mixin _$SnPostPreload {
 | 
			
		||||
  SnAttachment? get thumbnail => throw _privateConstructorUsedError;
 | 
			
		||||
  List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
 | 
			
		||||
  SnAttachment? get video => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnPostPreload to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
@@ -1584,9 +1585,13 @@ abstract class $SnPostPreloadCopyWith<$Res> {
 | 
			
		||||
          SnPostPreload value, $Res Function(SnPostPreload) then) =
 | 
			
		||||
      _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {SnAttachment? thumbnail,
 | 
			
		||||
      List<SnAttachment?>? attachments,
 | 
			
		||||
      SnAttachment? video});
 | 
			
		||||
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get thumbnail;
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1606,6 +1611,7 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? thumbnail = freezed,
 | 
			
		||||
    Object? attachments = freezed,
 | 
			
		||||
    Object? video = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      thumbnail: freezed == thumbnail
 | 
			
		||||
@@ -1616,6 +1622,10 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
          ? _value.attachments
 | 
			
		||||
          : attachments // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<SnAttachment?>?,
 | 
			
		||||
      video: freezed == video
 | 
			
		||||
          ? _value.video
 | 
			
		||||
          : video // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAttachment?,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -1632,6 +1642,20 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
 | 
			
		||||
      return _then(_value.copyWith(thumbnail: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video {
 | 
			
		||||
    if (_value.video == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnAttachmentCopyWith<$Res>(_value.video!, (value) {
 | 
			
		||||
      return _then(_value.copyWith(video: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1642,10 +1666,15 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
 | 
			
		||||
      __$$SnPostPreloadImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments});
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {SnAttachment? thumbnail,
 | 
			
		||||
      List<SnAttachment?>? attachments,
 | 
			
		||||
      SnAttachment? video});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  $SnAttachmentCopyWith<$Res>? get video;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@@ -1663,6 +1692,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
  $Res call({
 | 
			
		||||
    Object? thumbnail = freezed,
 | 
			
		||||
    Object? attachments = freezed,
 | 
			
		||||
    Object? video = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnPostPreloadImpl(
 | 
			
		||||
      thumbnail: freezed == thumbnail
 | 
			
		||||
@@ -1673,6 +1703,10 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
          ? _value._attachments
 | 
			
		||||
          : attachments // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as List<SnAttachment?>?,
 | 
			
		||||
      video: freezed == video
 | 
			
		||||
          ? _value.video
 | 
			
		||||
          : video // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnAttachment?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1682,7 +1716,8 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
 | 
			
		||||
class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
  const _$SnPostPreloadImpl(
 | 
			
		||||
      {required this.thumbnail,
 | 
			
		||||
      required final List<SnAttachment?>? attachments})
 | 
			
		||||
      required final List<SnAttachment?>? attachments,
 | 
			
		||||
      required this.video})
 | 
			
		||||
      : _attachments = attachments;
 | 
			
		||||
 | 
			
		||||
  factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
@@ -1700,9 +1735,12 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
    return EqualUnmodifiableListView(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final SnAttachment? video;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments)';
 | 
			
		||||
    return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments, video: $video)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@@ -1713,13 +1751,14 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
            (identical(other.thumbnail, thumbnail) ||
 | 
			
		||||
                other.thumbnail == thumbnail) &&
 | 
			
		||||
            const DeepCollectionEquality()
 | 
			
		||||
                .equals(other._attachments, _attachments));
 | 
			
		||||
                .equals(other._attachments, _attachments) &&
 | 
			
		||||
            (identical(other.video, video) || other.video == video));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, thumbnail,
 | 
			
		||||
      const DeepCollectionEquality().hash(_attachments));
 | 
			
		||||
      const DeepCollectionEquality().hash(_attachments), video);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@@ -1740,7 +1779,8 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
 | 
			
		||||
abstract class _SnPostPreload implements SnPostPreload {
 | 
			
		||||
  const factory _SnPostPreload(
 | 
			
		||||
      {required final SnAttachment? thumbnail,
 | 
			
		||||
      required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl;
 | 
			
		||||
      required final List<SnAttachment?>? attachments,
 | 
			
		||||
      required final SnAttachment? video}) = _$SnPostPreloadImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnPostPreloadImpl.fromJson;
 | 
			
		||||
@@ -1749,6 +1789,8 @@ abstract class _SnPostPreload implements SnPostPreload {
 | 
			
		||||
  SnAttachment? get thumbnail;
 | 
			
		||||
  @override
 | 
			
		||||
  List<SnAttachment?>? get attachments;
 | 
			
		||||
  @override
 | 
			
		||||
  SnAttachment? get video;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnPostPreload
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
 
 | 
			
		||||
@@ -165,12 +165,16 @@ _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
              ? null
 | 
			
		||||
              : SnAttachment.fromJson(e as Map<String, dynamic>))
 | 
			
		||||
          .toList(),
 | 
			
		||||
      video: json['video'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnAttachment.fromJson(json['video'] as Map<String, dynamic>),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'thumbnail': instance.thumbnail?.toJson(),
 | 
			
		||||
      'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
 | 
			
		||||
      'video': instance.video?.toJson(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								lib/types/wallet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/types/wallet.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
			
		||||
 | 
			
		||||
part 'wallet.freezed.dart';
 | 
			
		||||
part 'wallet.g.dart';
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnWallet with _$SnWallet {
 | 
			
		||||
  const factory SnWallet({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String balance,
 | 
			
		||||
    required String password,
 | 
			
		||||
    required int accountId,
 | 
			
		||||
  }) = _SnWallet;
 | 
			
		||||
 | 
			
		||||
  factory SnWallet.fromJson(Map<String, dynamic> json) => _$SnWalletFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
class SnTransaction with _$SnTransaction {
 | 
			
		||||
  const factory SnTransaction({
 | 
			
		||||
    required int id,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
    required String remark,
 | 
			
		||||
    required String amount,
 | 
			
		||||
    required SnWallet? payer,
 | 
			
		||||
    required SnWallet? payee,
 | 
			
		||||
    required int? payerId,
 | 
			
		||||
    required int? payeeId,
 | 
			
		||||
  }) = _SnTransaction;
 | 
			
		||||
 | 
			
		||||
  factory SnTransaction.fromJson(Map<String, dynamic> json) => _$SnTransactionFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										666
									
								
								lib/types/wallet.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										666
									
								
								lib/types/wallet.freezed.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,666 @@
 | 
			
		||||
// 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 'wallet.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');
 | 
			
		||||
 | 
			
		||||
SnWallet _$SnWalletFromJson(Map<String, dynamic> json) {
 | 
			
		||||
  return _SnWallet.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnWallet {
 | 
			
		||||
  int get id => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get createdAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get updatedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get deletedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  String get balance => throw _privateConstructorUsedError;
 | 
			
		||||
  String get password => throw _privateConstructorUsedError;
 | 
			
		||||
  int get accountId => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnWallet to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnWallet
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  $SnWalletCopyWith<SnWallet> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class $SnWalletCopyWith<$Res> {
 | 
			
		||||
  factory $SnWalletCopyWith(SnWallet value, $Res Function(SnWallet) then) =
 | 
			
		||||
      _$SnWalletCopyWithImpl<$Res, SnWallet>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String balance,
 | 
			
		||||
      String password,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnWalletCopyWithImpl<$Res, $Val extends SnWallet>
 | 
			
		||||
    implements $SnWalletCopyWith<$Res> {
 | 
			
		||||
  _$SnWalletCopyWithImpl(this._value, this._then);
 | 
			
		||||
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Val _value;
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Res Function($Val) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnWallet
 | 
			
		||||
  /// 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? balance = null,
 | 
			
		||||
    Object? password = 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?,
 | 
			
		||||
      balance: null == balance
 | 
			
		||||
          ? _value.balance
 | 
			
		||||
          : balance // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      password: null == password
 | 
			
		||||
          ? _value.password
 | 
			
		||||
          : password // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _value.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class _$$SnWalletImplCopyWith<$Res>
 | 
			
		||||
    implements $SnWalletCopyWith<$Res> {
 | 
			
		||||
  factory _$$SnWalletImplCopyWith(
 | 
			
		||||
          _$SnWalletImpl value, $Res Function(_$SnWalletImpl) then) =
 | 
			
		||||
      __$$SnWalletImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String balance,
 | 
			
		||||
      String password,
 | 
			
		||||
      int accountId});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$$SnWalletImplCopyWithImpl<$Res>
 | 
			
		||||
    extends _$SnWalletCopyWithImpl<$Res, _$SnWalletImpl>
 | 
			
		||||
    implements _$$SnWalletImplCopyWith<$Res> {
 | 
			
		||||
  __$$SnWalletImplCopyWithImpl(
 | 
			
		||||
      _$SnWalletImpl _value, $Res Function(_$SnWalletImpl) _then)
 | 
			
		||||
      : super(_value, _then);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnWallet
 | 
			
		||||
  /// 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? balance = null,
 | 
			
		||||
    Object? password = null,
 | 
			
		||||
    Object? accountId = null,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnWalletImpl(
 | 
			
		||||
      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?,
 | 
			
		||||
      balance: null == balance
 | 
			
		||||
          ? _value.balance
 | 
			
		||||
          : balance // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      password: null == password
 | 
			
		||||
          ? _value.password
 | 
			
		||||
          : password // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      accountId: null == accountId
 | 
			
		||||
          ? _value.accountId
 | 
			
		||||
          : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _$SnWalletImpl implements _SnWallet {
 | 
			
		||||
  const _$SnWalletImpl(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.balance,
 | 
			
		||||
      required this.password,
 | 
			
		||||
      required this.accountId});
 | 
			
		||||
 | 
			
		||||
  factory _$SnWalletImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$$SnWalletImplFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String balance;
 | 
			
		||||
  @override
 | 
			
		||||
  final String password;
 | 
			
		||||
  @override
 | 
			
		||||
  final int accountId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _$SnWalletImpl &&
 | 
			
		||||
            (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.balance, balance) || other.balance == balance) &&
 | 
			
		||||
            (identical(other.password, password) ||
 | 
			
		||||
                other.password == password) &&
 | 
			
		||||
            (identical(other.accountId, accountId) ||
 | 
			
		||||
                other.accountId == accountId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
 | 
			
		||||
      deletedAt, balance, password, accountId);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnWallet
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
 | 
			
		||||
      __$$SnWalletImplCopyWithImpl<_$SnWalletImpl>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$$SnWalletImplToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _SnWallet implements SnWallet {
 | 
			
		||||
  const factory _SnWallet(
 | 
			
		||||
      {required final int id,
 | 
			
		||||
      required final DateTime createdAt,
 | 
			
		||||
      required final DateTime updatedAt,
 | 
			
		||||
      required final DateTime? deletedAt,
 | 
			
		||||
      required final String balance,
 | 
			
		||||
      required final String password,
 | 
			
		||||
      required final int accountId}) = _$SnWalletImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnWallet.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnWalletImpl.fromJson;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get id;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  String get balance;
 | 
			
		||||
  @override
 | 
			
		||||
  String get password;
 | 
			
		||||
  @override
 | 
			
		||||
  int get accountId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnWallet
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  _$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) {
 | 
			
		||||
  return _SnTransaction.fromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnTransaction {
 | 
			
		||||
  int get id => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get createdAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime get updatedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  DateTime? get deletedAt => throw _privateConstructorUsedError;
 | 
			
		||||
  String get remark => throw _privateConstructorUsedError;
 | 
			
		||||
  String get amount => throw _privateConstructorUsedError;
 | 
			
		||||
  SnWallet? get payer => throw _privateConstructorUsedError;
 | 
			
		||||
  SnWallet? get payee => throw _privateConstructorUsedError;
 | 
			
		||||
  int? get payerId => throw _privateConstructorUsedError;
 | 
			
		||||
  int? get payeeId => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnTransaction to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  $SnTransactionCopyWith<SnTransaction> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class $SnTransactionCopyWith<$Res> {
 | 
			
		||||
  factory $SnTransactionCopyWith(
 | 
			
		||||
          SnTransaction value, $Res Function(SnTransaction) then) =
 | 
			
		||||
      _$SnTransactionCopyWithImpl<$Res, SnTransaction>;
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String remark,
 | 
			
		||||
      String amount,
 | 
			
		||||
      SnWallet? payer,
 | 
			
		||||
      SnWallet? payee,
 | 
			
		||||
      int? payerId,
 | 
			
		||||
      int? payeeId});
 | 
			
		||||
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payer;
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnTransactionCopyWithImpl<$Res, $Val extends SnTransaction>
 | 
			
		||||
    implements $SnTransactionCopyWith<$Res> {
 | 
			
		||||
  _$SnTransactionCopyWithImpl(this._value, this._then);
 | 
			
		||||
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Val _value;
 | 
			
		||||
  // ignore: unused_field
 | 
			
		||||
  final $Res Function($Val) _then;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// 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? remark = null,
 | 
			
		||||
    Object? amount = null,
 | 
			
		||||
    Object? payer = freezed,
 | 
			
		||||
    Object? payee = freezed,
 | 
			
		||||
    Object? payerId = freezed,
 | 
			
		||||
    Object? payeeId = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_value.copyWith(
 | 
			
		||||
      id: null == id
 | 
			
		||||
          ? _value.id
 | 
			
		||||
          : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int,
 | 
			
		||||
      createdAt: null == createdAt
 | 
			
		||||
          ? _value.createdAt
 | 
			
		||||
          : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      updatedAt: null == updatedAt
 | 
			
		||||
          ? _value.updatedAt
 | 
			
		||||
          : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime,
 | 
			
		||||
      deletedAt: freezed == deletedAt
 | 
			
		||||
          ? _value.deletedAt
 | 
			
		||||
          : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as DateTime?,
 | 
			
		||||
      remark: null == remark
 | 
			
		||||
          ? _value.remark
 | 
			
		||||
          : remark // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      amount: null == amount
 | 
			
		||||
          ? _value.amount
 | 
			
		||||
          : amount // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      payer: freezed == payer
 | 
			
		||||
          ? _value.payer
 | 
			
		||||
          : payer // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnWallet?,
 | 
			
		||||
      payee: freezed == payee
 | 
			
		||||
          ? _value.payee
 | 
			
		||||
          : payee // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnWallet?,
 | 
			
		||||
      payerId: freezed == payerId
 | 
			
		||||
          ? _value.payerId
 | 
			
		||||
          : payerId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int?,
 | 
			
		||||
      payeeId: freezed == payeeId
 | 
			
		||||
          ? _value.payeeId
 | 
			
		||||
          : payeeId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int?,
 | 
			
		||||
    ) as $Val);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payer {
 | 
			
		||||
    if (_value.payer == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnWalletCopyWith<$Res>(_value.payer!, (value) {
 | 
			
		||||
      return _then(_value.copyWith(payer: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payee {
 | 
			
		||||
    if (_value.payee == null) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return $SnWalletCopyWith<$Res>(_value.payee!, (value) {
 | 
			
		||||
      return _then(_value.copyWith(payee: value) as $Val);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract class _$$SnTransactionImplCopyWith<$Res>
 | 
			
		||||
    implements $SnTransactionCopyWith<$Res> {
 | 
			
		||||
  factory _$$SnTransactionImplCopyWith(
 | 
			
		||||
          _$SnTransactionImpl value, $Res Function(_$SnTransactionImpl) then) =
 | 
			
		||||
      __$$SnTransactionImplCopyWithImpl<$Res>;
 | 
			
		||||
  @override
 | 
			
		||||
  @useResult
 | 
			
		||||
  $Res call(
 | 
			
		||||
      {int id,
 | 
			
		||||
      DateTime createdAt,
 | 
			
		||||
      DateTime updatedAt,
 | 
			
		||||
      DateTime? deletedAt,
 | 
			
		||||
      String remark,
 | 
			
		||||
      String amount,
 | 
			
		||||
      SnWallet? payer,
 | 
			
		||||
      SnWallet? payee,
 | 
			
		||||
      int? payerId,
 | 
			
		||||
      int? payeeId});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payer;
 | 
			
		||||
  @override
 | 
			
		||||
  $SnWalletCopyWith<$Res>? get payee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$$SnTransactionImplCopyWithImpl<$Res>
 | 
			
		||||
    extends _$SnTransactionCopyWithImpl<$Res, _$SnTransactionImpl>
 | 
			
		||||
    implements _$$SnTransactionImplCopyWith<$Res> {
 | 
			
		||||
  __$$SnTransactionImplCopyWithImpl(
 | 
			
		||||
      _$SnTransactionImpl _value, $Res Function(_$SnTransactionImpl) _then)
 | 
			
		||||
      : super(_value, _then);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// 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? remark = null,
 | 
			
		||||
    Object? amount = null,
 | 
			
		||||
    Object? payer = freezed,
 | 
			
		||||
    Object? payee = freezed,
 | 
			
		||||
    Object? payerId = freezed,
 | 
			
		||||
    Object? payeeId = freezed,
 | 
			
		||||
  }) {
 | 
			
		||||
    return _then(_$SnTransactionImpl(
 | 
			
		||||
      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?,
 | 
			
		||||
      remark: null == remark
 | 
			
		||||
          ? _value.remark
 | 
			
		||||
          : remark // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      amount: null == amount
 | 
			
		||||
          ? _value.amount
 | 
			
		||||
          : amount // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as String,
 | 
			
		||||
      payer: freezed == payer
 | 
			
		||||
          ? _value.payer
 | 
			
		||||
          : payer // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnWallet?,
 | 
			
		||||
      payee: freezed == payee
 | 
			
		||||
          ? _value.payee
 | 
			
		||||
          : payee // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as SnWallet?,
 | 
			
		||||
      payerId: freezed == payerId
 | 
			
		||||
          ? _value.payerId
 | 
			
		||||
          : payerId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int?,
 | 
			
		||||
      payeeId: freezed == payeeId
 | 
			
		||||
          ? _value.payeeId
 | 
			
		||||
          : payeeId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
              as int?,
 | 
			
		||||
    ));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
class _$SnTransactionImpl implements _SnTransaction {
 | 
			
		||||
  const _$SnTransactionImpl(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.createdAt,
 | 
			
		||||
      required this.updatedAt,
 | 
			
		||||
      required this.deletedAt,
 | 
			
		||||
      required this.remark,
 | 
			
		||||
      required this.amount,
 | 
			
		||||
      required this.payer,
 | 
			
		||||
      required this.payee,
 | 
			
		||||
      required this.payerId,
 | 
			
		||||
      required this.payeeId});
 | 
			
		||||
 | 
			
		||||
  factory _$SnTransactionImpl.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$$SnTransactionImplFromJson(json);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  final int id;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final DateTime? deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  final String remark;
 | 
			
		||||
  @override
 | 
			
		||||
  final String amount;
 | 
			
		||||
  @override
 | 
			
		||||
  final SnWallet? payer;
 | 
			
		||||
  @override
 | 
			
		||||
  final SnWallet? payee;
 | 
			
		||||
  @override
 | 
			
		||||
  final int? payerId;
 | 
			
		||||
  @override
 | 
			
		||||
  final int? payeeId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    return identical(this, other) ||
 | 
			
		||||
        (other.runtimeType == runtimeType &&
 | 
			
		||||
            other is _$SnTransactionImpl &&
 | 
			
		||||
            (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.remark, remark) || other.remark == remark) &&
 | 
			
		||||
            (identical(other.amount, amount) || other.amount == amount) &&
 | 
			
		||||
            (identical(other.payer, payer) || other.payer == payer) &&
 | 
			
		||||
            (identical(other.payee, payee) || other.payee == payee) &&
 | 
			
		||||
            (identical(other.payerId, payerId) || other.payerId == payerId) &&
 | 
			
		||||
            (identical(other.payeeId, payeeId) || other.payeeId == payeeId));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
 | 
			
		||||
      deletedAt, remark, amount, payer, payee, payerId, payeeId);
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  @override
 | 
			
		||||
  @pragma('vm:prefer-inline')
 | 
			
		||||
  _$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
 | 
			
		||||
      __$$SnTransactionImplCopyWithImpl<_$SnTransactionImpl>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    return _$$SnTransactionImplToJson(
 | 
			
		||||
      this,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class _SnTransaction implements SnTransaction {
 | 
			
		||||
  const factory _SnTransaction(
 | 
			
		||||
      {required final int id,
 | 
			
		||||
      required final DateTime createdAt,
 | 
			
		||||
      required final DateTime updatedAt,
 | 
			
		||||
      required final DateTime? deletedAt,
 | 
			
		||||
      required final String remark,
 | 
			
		||||
      required final String amount,
 | 
			
		||||
      required final SnWallet? payer,
 | 
			
		||||
      required final SnWallet? payee,
 | 
			
		||||
      required final int? payerId,
 | 
			
		||||
      required final int? payeeId}) = _$SnTransactionImpl;
 | 
			
		||||
 | 
			
		||||
  factory _SnTransaction.fromJson(Map<String, dynamic> json) =
 | 
			
		||||
      _$SnTransactionImpl.fromJson;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get id;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get createdAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime get updatedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  DateTime? get deletedAt;
 | 
			
		||||
  @override
 | 
			
		||||
  String get remark;
 | 
			
		||||
  @override
 | 
			
		||||
  String get amount;
 | 
			
		||||
  @override
 | 
			
		||||
  SnWallet? get payer;
 | 
			
		||||
  @override
 | 
			
		||||
  SnWallet? get payee;
 | 
			
		||||
  @override
 | 
			
		||||
  int? get payerId;
 | 
			
		||||
  @override
 | 
			
		||||
  int? get payeeId;
 | 
			
		||||
 | 
			
		||||
  /// Create a copy of SnTransaction
 | 
			
		||||
  /// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
  @override
 | 
			
		||||
  @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
  _$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
 | 
			
		||||
      throw _privateConstructorUsedError;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								lib/types/wallet.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								lib/types/wallet.g.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
 | 
			
		||||
part of 'wallet.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// JsonSerializableGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
_$SnWalletImpl _$$SnWalletImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnWalletImpl(
 | 
			
		||||
      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),
 | 
			
		||||
      balance: json['balance'] as String,
 | 
			
		||||
      password: json['password'] as String,
 | 
			
		||||
      accountId: (json['account_id'] as num).toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnWalletImplToJson(_$SnWalletImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'balance': instance.balance,
 | 
			
		||||
      'password': instance.password,
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_$SnTransactionImpl _$$SnTransactionImplFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _$SnTransactionImpl(
 | 
			
		||||
      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),
 | 
			
		||||
      remark: json['remark'] as String,
 | 
			
		||||
      amount: json['amount'] as String,
 | 
			
		||||
      payer: json['payer'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnWallet.fromJson(json['payer'] as Map<String, dynamic>),
 | 
			
		||||
      payee: json['payee'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : SnWallet.fromJson(json['payee'] as Map<String, dynamic>),
 | 
			
		||||
      payerId: (json['payer_id'] as num?)?.toInt(),
 | 
			
		||||
      payeeId: (json['payee_id'] as num?)?.toInt(),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$$SnTransactionImplToJson(_$SnTransactionImpl instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
      'remark': instance.remark,
 | 
			
		||||
      'amount': instance.amount,
 | 
			
		||||
      'payer': instance.payer?.toJson(),
 | 
			
		||||
      'payee': instance.payee?.toJson(),
 | 
			
		||||
      'payer_id': instance.payerId,
 | 
			
		||||
      'payee_id': instance.payeeId,
 | 
			
		||||
    };
 | 
			
		||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
class AboutScreen extends StatelessWidget {
 | 
			
		||||
@@ -12,7 +13,12 @@ class AboutScreen extends StatelessWidget {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    const denseButtonStyle = ButtonStyle(visualDensity: VisualDensity(vertical: -4));
 | 
			
		||||
 | 
			
		||||
    return SizedBox(
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title: Text('screenAbout').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: SizedBox(
 | 
			
		||||
        width: double.infinity,
 | 
			
		||||
        child: Column(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
@@ -104,6 +110,7 @@ class AboutScreen extends StatelessWidget {
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										164
									
								
								lib/widgets/account/account_popover.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								lib/widgets/account/account_popover.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
			
		||||
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:relative_time/relative_time.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/experience.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/screens/account/profile_page.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class AccountPopoverCard extends StatelessWidget {
 | 
			
		||||
  final SnAccount data;
 | 
			
		||||
 | 
			
		||||
  const AccountPopoverCard({super.key, required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
      children: [
 | 
			
		||||
        if (data.banner.isNotEmpty)
 | 
			
		||||
          Container(
 | 
			
		||||
            color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
            child: AspectRatio(
 | 
			
		||||
              aspectRatio: 16 / 7,
 | 
			
		||||
              child: AutoResizeUniversalImage(
 | 
			
		||||
                sn.getAttachmentUrl(data.banner),
 | 
			
		||||
                fit: BoxFit.cover,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        // Top padding
 | 
			
		||||
        Gap(16),
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            AccountImage(
 | 
			
		||||
              content: data.avatar,
 | 
			
		||||
              radius: 20,
 | 
			
		||||
            ),
 | 
			
		||||
            Gap(16),
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text(data.nick).bold(),
 | 
			
		||||
                  Text('@${data.name}').fontSize(13).opacity(0.75),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            IconButton(
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                Navigator.pop(context);
 | 
			
		||||
                GoRouter.of(context).pushNamed(
 | 
			
		||||
                  'accountProfilePage',
 | 
			
		||||
                  pathParameters: {'name': data.name},
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
              icon: const Icon(Symbols.chevron_right),
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(8)
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        Wrap(
 | 
			
		||||
          children: data.badges
 | 
			
		||||
              .map(
 | 
			
		||||
                (ele) => Tooltip(
 | 
			
		||||
              richMessage: TextSpan(
 | 
			
		||||
                children: [
 | 
			
		||||
                  TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
 | 
			
		||||
                  if (ele.metadata['title'] != null)
 | 
			
		||||
                    TextSpan(
 | 
			
		||||
                      text: '\n${ele.metadata['title']}',
 | 
			
		||||
                      style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                    ),
 | 
			
		||||
                  TextSpan(text: '\n'),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: DateFormat.yMEd().format(ele.createdAt),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              child: Icon(
 | 
			
		||||
                kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
 | 
			
		||||
                color: kBadgesMeta[ele.type]?.$3,
 | 
			
		||||
                fill: 1,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
              .toList(),
 | 
			
		||||
        ).padding(horizontal: 24),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        Row(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Icon(Symbols.star),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Container(
 | 
			
		||||
              width: double.infinity,
 | 
			
		||||
              constraints: const BoxConstraints(maxWidth: 160),
 | 
			
		||||
              child: LinearProgressIndicator(
 | 
			
		||||
                value: calcLevelUpProgress(data.profile?.experience ?? 0),
 | 
			
		||||
                borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
              ).alignment(Alignment.centerLeft),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 24),
 | 
			
		||||
        FutureBuilder(
 | 
			
		||||
          future: sn.client.get('/cgi/id/users/${data.name}/status'),
 | 
			
		||||
          builder: (context, snapshot) {
 | 
			
		||||
            final SnAccountStatusInfo? status =
 | 
			
		||||
                snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null;
 | 
			
		||||
            return Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  Icon(
 | 
			
		||||
                    Symbols.circle,
 | 
			
		||||
                    fill: 1,
 | 
			
		||||
                    size: 16,
 | 
			
		||||
                    color: (status?.isOnline ?? false) ? Colors.green : Colors.grey,
 | 
			
		||||
                  ).padding(all: 4),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    status != null
 | 
			
		||||
                        ? status.isOnline
 | 
			
		||||
                            ? 'accountStatusOnline'.tr()
 | 
			
		||||
                            : 'accountStatusOffline'.tr()
 | 
			
		||||
                        : 'loading'.tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  if (status != null && !status.isOnline && status.lastSeenAt != null)
 | 
			
		||||
                    Text(
 | 
			
		||||
                      'accountStatusLastSeen'.tr(args: [
 | 
			
		||||
                        status.lastSeenAt != null
 | 
			
		||||
                            ? RelativeTime(context).format(
 | 
			
		||||
                                status.lastSeenAt!.toLocal(),
 | 
			
		||||
                              )
 | 
			
		||||
                            : 'unknown',
 | 
			
		||||
                      ]),
 | 
			
		||||
                    ).padding(left: 6).opacity(0.75),
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(horizontal: 24);
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        // Bottom padding
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
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/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/types/account.dart';
 | 
			
		||||
import 'package:surface/widgets/account/account_image.dart';
 | 
			
		||||
 | 
			
		||||
@@ -47,10 +50,19 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
  Future<void> _getFriends() async {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
 | 
			
		||||
    if (!mounted) return;
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _relativeUsers.addAll(
 | 
			
		||||
        resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
 | 
			
		||||
        resp.data?.map((e) {
 | 
			
		||||
          final rel = SnRelationship.fromJson(e);
 | 
			
		||||
          if (rel.relatedId == ua.user?.id) {
 | 
			
		||||
            return rel.account!;
 | 
			
		||||
          } else {
 | 
			
		||||
            return rel.related!;
 | 
			
		||||
          }
 | 
			
		||||
        }).cast<SnAccount>(),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@@ -96,10 +108,14 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Text(
 | 
			
		||||
            widget.title,
 | 
			
		||||
            style: Theme.of(context).textTheme.headlineSmall,
 | 
			
		||||
          ).padding(left: 24, right: 24, top: 16, bottom: 16),
 | 
			
		||||
          Row(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.group, size: 24),
 | 
			
		||||
              const Gap(16),
 | 
			
		||||
              Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
          Container(
 | 
			
		||||
            color: Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
 | 
			
		||||
@@ -117,13 +133,9 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
          ),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: ListView.builder(
 | 
			
		||||
              itemCount: _pendingUsers.isEmpty
 | 
			
		||||
                  ? _relativeUsers.length
 | 
			
		||||
                  : _pendingUsers.length,
 | 
			
		||||
              itemCount: _pendingUsers.isEmpty ? _relativeUsers.length : _pendingUsers.length,
 | 
			
		||||
              itemBuilder: (context, index) {
 | 
			
		||||
                var user = _pendingUsers.isEmpty
 | 
			
		||||
                    ? _relativeUsers[index]
 | 
			
		||||
                    : _pendingUsers[index];
 | 
			
		||||
                var user = _pendingUsers.isEmpty ? _relativeUsers[index] : _pendingUsers[index];
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  title: Text(user.nick),
 | 
			
		||||
                  subtitle: Text(user.name),
 | 
			
		||||
@@ -142,8 +154,7 @@ class _AccountSelectState extends State<AccountSelect> {
 | 
			
		||||
                          }
 | 
			
		||||
 | 
			
		||||
                          setState(() {
 | 
			
		||||
                            final idx = _selectedUsers
 | 
			
		||||
                                .indexWhere((x) => x.id == user.id);
 | 
			
		||||
                            final idx = _selectedUsers.indexWhere((x) => x.id == user.id);
 | 
			
		||||
                            if (idx != -1) {
 | 
			
		||||
                              _selectedUsers.removeAt(idx);
 | 
			
		||||
                            } else {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,12 +6,22 @@ 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/types/attachment.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
 | 
			
		||||
class AttachmentInputDialog extends StatefulWidget {
 | 
			
		||||
  final String? title;
 | 
			
		||||
final bool? analyzeNow;
 | 
			
		||||
  const AttachmentInputDialog({super.key, required this.title, this.analyzeNow = false});
 | 
			
		||||
  final bool? analyzeNow;
 | 
			
		||||
  final SnMediaType? mediaType;
 | 
			
		||||
  final String pool;
 | 
			
		||||
 | 
			
		||||
  const AttachmentInputDialog({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.title,
 | 
			
		||||
    required this.pool,
 | 
			
		||||
    this.analyzeNow = false,
 | 
			
		||||
    this.mediaType = SnMediaType.image,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<AttachmentInputDialog> createState() => _AttachmentInputDialogState();
 | 
			
		||||
@@ -20,13 +30,18 @@ final bool? analyzeNow;
 | 
			
		||||
class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
  final _randomIdController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  XFile? _thumbnailFile;
 | 
			
		||||
  XFile? _file;
 | 
			
		||||
  double? _progress;
 | 
			
		||||
 | 
			
		||||
  void _pickImage() async {
 | 
			
		||||
  void _pickMedia() async {
 | 
			
		||||
    final picker = ImagePicker();
 | 
			
		||||
    final result = await picker.pickImage(source: ImageSource.gallery);
 | 
			
		||||
    final result = switch (widget.mediaType) {
 | 
			
		||||
      SnMediaType.image => await picker.pickImage(source: ImageSource.gallery),
 | 
			
		||||
      SnMediaType.video => await picker.pickVideo(source: ImageSource.gallery),
 | 
			
		||||
      _ => await picker.pickMedia(),
 | 
			
		||||
    };
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
    setState(() => _thumbnailFile = result);
 | 
			
		||||
    setState(() => _file = result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool _isBusy = false;
 | 
			
		||||
@@ -46,15 +61,20 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        context.showErrorDialog(err);
 | 
			
		||||
      }
 | 
			
		||||
    } else if (_thumbnailFile != null) {
 | 
			
		||||
    } else if (_file != null) {
 | 
			
		||||
      try {
 | 
			
		||||
        final attachment = await attach.directUploadOne(
 | 
			
		||||
          (await _thumbnailFile!.readAsBytes()).buffer.asUint8List(),
 | 
			
		||||
          _thumbnailFile!.path,
 | 
			
		||||
          'interactive',
 | 
			
		||||
          null,
 | 
			
		||||
        final place = await attach.chunkedUploadInitialize(await _file!.length(), _file!.name, widget.pool, null);
 | 
			
		||||
 | 
			
		||||
        final attachment = await attach.chunkedUploadParts(
 | 
			
		||||
          _file!,
 | 
			
		||||
          place.$1,
 | 
			
		||||
          place.$2,
 | 
			
		||||
          analyzeNow: widget.analyzeNow ?? false,
 | 
			
		||||
          onProgress: (value) {
 | 
			
		||||
            setState(() => _progress = value);
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!mounted) return;
 | 
			
		||||
        Navigator.pop(context, attachment);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
@@ -67,7 +87,7 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      title: Text(widget.title ?? 'attachmentInputDialog').tr(),
 | 
			
		||||
      title: Text(widget.title ?? 'attachmentInputDialog'.tr()),
 | 
			
		||||
      content: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
@@ -86,22 +106,33 @@ class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
 | 
			
		||||
          const Gap(24),
 | 
			
		||||
          Text('attachmentInputNew').tr().fontSize(14),
 | 
			
		||||
          Card(
 | 
			
		||||
            child: ListTile(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                ListTile(
 | 
			
		||||
                  contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
			
		||||
                  leading: const Icon(Symbols.add_photo_alternate),
 | 
			
		||||
                  trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                  title: Text('addAttachmentFromAlbum').tr(),
 | 
			
		||||
              subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
 | 
			
		||||
                  subtitle: _file == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                _pickImage();
 | 
			
		||||
                    _pickMedia();
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (_isBusy)
 | 
			
		||||
            LinearProgressIndicator(
 | 
			
		||||
              value: _progress,
 | 
			
		||||
              borderRadius: BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
            ).padding(top: 16),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
          onPressed: _isBusy ? null : () {
 | 
			
		||||
          onPressed: _isBusy
 | 
			
		||||
              ? null
 | 
			
		||||
              : () {
 | 
			
		||||
                  Navigator.pop(context);
 | 
			
		||||
                },
 | 
			
		||||
          child: Text('dialogDismiss').tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ class AttachmentList extends StatefulWidget {
 | 
			
		||||
  final List<SnAttachment?> data;
 | 
			
		||||
  final bool bordered;
 | 
			
		||||
  final bool gridded;
 | 
			
		||||
  final bool columned;
 | 
			
		||||
  final BoxFit fit;
 | 
			
		||||
  final double? maxHeight;
 | 
			
		||||
  final double? minWidth;
 | 
			
		||||
@@ -26,6 +27,7 @@ class AttachmentList extends StatefulWidget {
 | 
			
		||||
    required this.data,
 | 
			
		||||
    this.bordered = false,
 | 
			
		||||
    this.gridded = false,
 | 
			
		||||
    this.columned = false,
 | 
			
		||||
    this.fit = BoxFit.cover,
 | 
			
		||||
    this.maxHeight,
 | 
			
		||||
    this.minWidth,
 | 
			
		||||
@@ -105,7 +107,10 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (widget.gridded) {
 | 
			
		||||
        final fullOfImage =
 | 
			
		||||
            widget.data.where((ele) => ele?.mediaType == SnMediaType.image).length == widget.data.length;
 | 
			
		||||
 | 
			
		||||
        if (widget.gridded && fullOfImage) {
 | 
			
		||||
          return Container(
 | 
			
		||||
            margin: widget.padding ?? EdgeInsets.zero,
 | 
			
		||||
            decoration: BoxDecoration(
 | 
			
		||||
@@ -153,7 +158,47 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ((!fullOfImage && widget.gridded) || widget.columned) {
 | 
			
		||||
          return Container(
 | 
			
		||||
            margin: widget.padding ?? EdgeInsets.zero,
 | 
			
		||||
            decoration: BoxDecoration(
 | 
			
		||||
              color: backgroundColor,
 | 
			
		||||
              border: Border(
 | 
			
		||||
                top: borderSide,
 | 
			
		||||
                bottom: borderSide,
 | 
			
		||||
              ),
 | 
			
		||||
              borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
            ),
 | 
			
		||||
            child: ClipRRect(
 | 
			
		||||
              borderRadius: AttachmentList.kDefaultRadius,
 | 
			
		||||
              child: Column(
 | 
			
		||||
                children: widget.data
 | 
			
		||||
                    .mapIndexed(
 | 
			
		||||
                      (idx, ele) => GestureDetector(
 | 
			
		||||
                        child: AspectRatio(
 | 
			
		||||
                          aspectRatio: ele?.data['ratio']?.toDouble() ?? 1,
 | 
			
		||||
                          child: Container(
 | 
			
		||||
                            constraints: constraints,
 | 
			
		||||
                            child: AttachmentItem(
 | 
			
		||||
                              data: ele,
 | 
			
		||||
                              heroTag: heroTags[idx],
 | 
			
		||||
                              fit: BoxFit.cover,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    )
 | 
			
		||||
                    .expand((ele) => [ele, const Divider(height: 1)])
 | 
			
		||||
                    .toList()
 | 
			
		||||
                  ..removeLast(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return AspectRatio(
 | 
			
		||||
          aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1,
 | 
			
		||||
          child: Container(
 | 
			
		||||
            constraints: BoxConstraints(maxHeight: constraints.maxHeight),
 | 
			
		||||
            child: ScrollConfiguration(
 | 
			
		||||
              behavior: _AttachmentListScrollBehavior(),
 | 
			
		||||
@@ -217,6 +262,7 @@ class _AttachmentListState extends State<AttachmentList> {
 | 
			
		||||
                scrollDirection: Axis.horizontal,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -129,6 +129,8 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
 | 
			
		||||
  Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
 | 
			
		||||
 | 
			
		||||
  bool _showDetail = false;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
@@ -144,10 +146,13 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
      onDismissed: () {
 | 
			
		||||
        Navigator.of(context).pop();
 | 
			
		||||
      },
 | 
			
		||||
      direction: DismissiblePageDismissDirection.down,
 | 
			
		||||
      direction: DismissiblePageDismissDirection.none,
 | 
			
		||||
      backgroundColor: Colors.transparent,
 | 
			
		||||
      isFullScreen: true,
 | 
			
		||||
      child: GestureDetector(
 | 
			
		||||
        behavior: HitTestBehavior.translucent,
 | 
			
		||||
        child: Scaffold(
 | 
			
		||||
          backgroundColor: Colors.transparent,
 | 
			
		||||
          body: Stack(
 | 
			
		||||
            children: [
 | 
			
		||||
              Builder(builder: (context) {
 | 
			
		||||
@@ -264,7 +269,8 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
                                borderRadius: const BorderRadius.all(Radius.circular(16)),
 | 
			
		||||
                                onTap: _isDownloading
 | 
			
		||||
                                    ? null
 | 
			
		||||
                                  : () => _saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
 | 
			
		||||
                                    : () =>
 | 
			
		||||
                                        _saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
 | 
			
		||||
                                child: Container(
 | 
			
		||||
                                  padding: const EdgeInsets.all(6),
 | 
			
		||||
                                  child: !_isDownloading
 | 
			
		||||
@@ -322,7 +328,8 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
                                  'f/${item.metadata['exif']?['Aperture']}',
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
                                ).padding(right: 2),
 | 
			
		||||
                            if (item.metadata['exif']?['Megapixels'] != null && item.metadata['exif']?['Model'] != null)
 | 
			
		||||
                              if (item.metadata['exif']?['Megapixels'] != null &&
 | 
			
		||||
                                  item.metadata['exif']?['Model'] != null)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  '${item.metadata['exif']?['Megapixels']}MP',
 | 
			
		||||
                                  style: metaTextStyle,
 | 
			
		||||
@@ -357,6 +364,134 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        onVerticalDragUpdate: (details) {
 | 
			
		||||
          if (_showDetail) return;
 | 
			
		||||
          if (details.delta.dy <= -40) {
 | 
			
		||||
            _showDetail = true;
 | 
			
		||||
            showModalBottomSheet(
 | 
			
		||||
              context: context,
 | 
			
		||||
              builder: (context) => _AttachmentZoomDetailPopup(
 | 
			
		||||
                data: widget.data.elementAt(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
 | 
			
		||||
              ),
 | 
			
		||||
            ).then((_) {
 | 
			
		||||
              _showDetail = false;
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          Navigator.of(context).pop();
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AttachmentZoomDetailPopup extends StatelessWidget {
 | 
			
		||||
  final SnAttachment data;
 | 
			
		||||
 | 
			
		||||
  const _AttachmentZoomDetailPopup({required this.data});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ud = context.read<UserDirectoryProvider>();
 | 
			
		||||
    final account = ud.getAccountFromCache(data.accountId);
 | 
			
		||||
 | 
			
		||||
    const tableGap = TableRow(
 | 
			
		||||
      children: [
 | 
			
		||||
        TableCell(child: SizedBox(height: 16)),
 | 
			
		||||
        TableCell(child: SizedBox(height: 16)),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return SizedBox(
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Row(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              const Icon(Symbols.info, size: 24),
 | 
			
		||||
              const Gap(16),
 | 
			
		||||
              Text('attachmentDetailInfo').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(horizontal: 20, top: 16, bottom: 12),
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: SingleChildScrollView(
 | 
			
		||||
              child: Table(
 | 
			
		||||
                columnWidths: {
 | 
			
		||||
                  0: IntrinsicColumnWidth(),
 | 
			
		||||
                  1: FlexColumnWidth(),
 | 
			
		||||
                },
 | 
			
		||||
                children: [
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                        child: Text('attachmentUploadBy').tr().padding(right: 16),
 | 
			
		||||
                      ),
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                        child: Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            if (data.accountId > 0)
 | 
			
		||||
                              AccountImage(
 | 
			
		||||
                                content: account?.avatar,
 | 
			
		||||
                                radius: 8,
 | 
			
		||||
                              ),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Text(data.accountId > 0 ? account?.nick ?? 'unknown'.tr() : 'unknown'.tr()),
 | 
			
		||||
                            const Gap(8),
 | 
			
		||||
                            Text('#${data.accountId}', style: GoogleFonts.robotoMono()).opacity(0.75),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  tableGap,
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(child: Text('Mimetype').padding(right: 16)),
 | 
			
		||||
                      TableCell(child: Text(data.mimetype)),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(child: Text('Size').padding(right: 16)),
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Text(data.size.formatBytes()),
 | 
			
		||||
                          const Gap(12),
 | 
			
		||||
                          Text('${data.size} Bytes', style: GoogleFonts.robotoMono()).opacity(0.75),
 | 
			
		||||
                        ],
 | 
			
		||||
                      )),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(child: Text('Name').padding(right: 16)),
 | 
			
		||||
                      TableCell(child: Text(data.name)),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  if (data.hash.isNotEmpty)
 | 
			
		||||
                    TableRow(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        TableCell(child: Text('Hash').padding(right: 16)),
 | 
			
		||||
                        TableCell(child: Text(data.hash, style: GoogleFonts.robotoMono(fontSize: 11)).opacity(0.9)),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  tableGap,
 | 
			
		||||
                  ...(data.metadata['exif']?.keys.map((k) => TableRow(
 | 
			
		||||
                        children: [
 | 
			
		||||
                          TableCell(child: Text(k).padding(right: 16)),
 | 
			
		||||
                          TableCell(child: Text(data.metadata['exif'][k].toString())),
 | 
			
		||||
                        ],
 | 
			
		||||
                      )) ??
 | 
			
		||||
                      []),
 | 
			
		||||
                ],
 | 
			
		||||
              ).padding(horizontal: 20, vertical: 8),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,19 @@
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:popover/popover.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.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/account/account_popover.dart';
 | 
			
		||||
import 'package:surface/widgets/attachment/attachment_list.dart';
 | 
			
		||||
import 'package:surface/widgets/context_menu.dart';
 | 
			
		||||
import 'package:surface/widgets/link_preview.dart';
 | 
			
		||||
@@ -49,6 +54,8 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
    final dateFormatter = DateFormat('MM/dd HH:mm');
 | 
			
		||||
 | 
			
		||||
    final cfg = context.read<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    return SwipeTo(
 | 
			
		||||
      key: Key('chat-message-${data.id}'),
 | 
			
		||||
      iconOnLeftSwipe: Symbols.reply,
 | 
			
		||||
@@ -95,8 +102,28 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  if (!isMerged && !isCompact)
 | 
			
		||||
                    AccountImage(
 | 
			
		||||
                    GestureDetector(
 | 
			
		||||
                      child: AccountImage(
 | 
			
		||||
                        content: user?.avatar,
 | 
			
		||||
                      ),
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        if (user == null) return;
 | 
			
		||||
                        showPopover(
 | 
			
		||||
                          backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                          context: context,
 | 
			
		||||
                          transition: PopoverTransition.other,
 | 
			
		||||
                          bodyBuilder: (context) => SizedBox(
 | 
			
		||||
                            width: math.min(400, MediaQuery.of(context).size.width - 10),
 | 
			
		||||
                            child: AccountPopoverCard(
 | 
			
		||||
                              data: user,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          direction: PopoverDirection.bottom,
 | 
			
		||||
                          arrowHeight: 5,
 | 
			
		||||
                          arrowWidth: 15,
 | 
			
		||||
                          arrowDxOffset: -190,
 | 
			
		||||
                        );
 | 
			
		||||
                      },
 | 
			
		||||
                    )
 | 
			
		||||
                  else if (isMerged)
 | 
			
		||||
                    const Gap(40),
 | 
			
		||||
@@ -128,6 +155,9 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
                          if (isCompact) const Gap(8),
 | 
			
		||||
                          if (data.preload?.quoteEvent != null)
 | 
			
		||||
                            StyledWidget(Container(
 | 
			
		||||
                              constraints: BoxConstraints(
 | 
			
		||||
                                maxWidth: 480,
 | 
			
		||||
                              ),
 | 
			
		||||
                              decoration: BoxDecoration(
 | 
			
		||||
                                borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                border: Border.all(
 | 
			
		||||
@@ -165,7 +195,10 @@ class ChatMessage extends StatelessWidget {
 | 
			
		||||
                ],
 | 
			
		||||
              ).opacity(isPending ? 0.5 : 1),
 | 
			
		||||
            ),
 | 
			
		||||
            if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false))
 | 
			
		||||
            if (data.body['text'] != null &&
 | 
			
		||||
                data.type == 'messages.new' &&
 | 
			
		||||
                (data.body['text']?.isNotEmpty ?? false) &&
 | 
			
		||||
                (cfg.prefs.getBool(kAppExpandChatLink) ?? true))
 | 
			
		||||
              LinkPreviewWidget(text: data.body['text']!),
 | 
			
		||||
            if (data.preload?.attachments?.isNotEmpty ?? false)
 | 
			
		||||
              AttachmentList(
 | 
			
		||||
@@ -248,11 +281,14 @@ class _ChatMessageText extends StatelessWidget {
 | 
			
		||||
                buttonItems: items,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            child: Container(
 | 
			
		||||
              constraints: const BoxConstraints(maxWidth: 480),
 | 
			
		||||
              child: MarkdownTextContent(
 | 
			
		||||
                content: data.body['text'],
 | 
			
		||||
                isAutoWarp: true,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (data.updatedAt != data.createdAt)
 | 
			
		||||
            Text(
 | 
			
		||||
              'messageEditedHint'.tr(),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,28 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:math' show min;
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:google_fonts/google_fonts.dart';
 | 
			
		||||
import 'package:hotkey_manager/hotkey_manager.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/controllers/chat_message_controller.dart';
 | 
			
		||||
import 'package:surface/controllers/post_write_controller.dart';
 | 
			
		||||
import 'package:surface/providers/sn_attachment.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/sn_sticker.dart';
 | 
			
		||||
import 'package:surface/providers/user_directory.dart';
 | 
			
		||||
import 'package:surface/types/attachment.dart';
 | 
			
		||||
import 'package:surface/types/chat.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
class ChatMessageInput extends StatefulWidget {
 | 
			
		||||
  final ChatMessageController controller;
 | 
			
		||||
@@ -32,9 +43,30 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
  final TextEditingController _contentController = TextEditingController();
 | 
			
		||||
  final FocusNode _focusNode = FocusNode();
 | 
			
		||||
 | 
			
		||||
  final HotKey _pasteHotKey = HotKey(
 | 
			
		||||
    key: PhysicalKeyboardKey.keyV,
 | 
			
		||||
    modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control],
 | 
			
		||||
    scope: HotKeyScope.inapp,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _registerHotKey() {
 | 
			
		||||
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
 | 
			
		||||
    hotKeyManager.register(_pasteHotKey, keyDownHandler: (_) async {
 | 
			
		||||
      final imageBytes = await Pasteboard.image;
 | 
			
		||||
      if (imageBytes == null) return;
 | 
			
		||||
      _attachments.add(PostWriteMedia.fromBytes(
 | 
			
		||||
        imageBytes,
 | 
			
		||||
        'attachmentPastedImage'.tr(),
 | 
			
		||||
        SnMediaType.image,
 | 
			
		||||
      ));
 | 
			
		||||
      setState(() {});
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _registerHotKey();
 | 
			
		||||
    _contentController.addListener(() {
 | 
			
		||||
      if (_contentController.text.isNotEmpty) {
 | 
			
		||||
        widget.controller.pingTypingStatus();
 | 
			
		||||
@@ -46,8 +78,20 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
    setState(() => _replyingMessage = value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setInitialText(String? value) {
 | 
			
		||||
    _contentController.text = value ?? '';
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setInitialAttachments(List<PostWriteMedia>? value) {
 | 
			
		||||
    _attachments.addAll(value ?? []);
 | 
			
		||||
    setState(() {});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setEdit(SnChatMessage? value) {
 | 
			
		||||
    _contentController.text = value?.body['text'] ?? '';
 | 
			
		||||
    _attachments.clear();
 | 
			
		||||
    _attachments.addAll(value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []);
 | 
			
		||||
    setState(() => _editingMessage = value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -101,7 +145,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setState(() {
 | 
			
		||||
          _attachments[i] = PostWriteMedia(item);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
@@ -113,7 +159,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
    // Send the message
 | 
			
		||||
    // NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type
 | 
			
		||||
    widget.controller.sendMessage(
 | 
			
		||||
      'messages.new',
 | 
			
		||||
      _editingMessage != null ? 'messages.edit' : 'messages.new',
 | 
			
		||||
      _contentController.text,
 | 
			
		||||
      attachments: _attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
 | 
			
		||||
      relatedId: _editingMessage?.id,
 | 
			
		||||
@@ -130,10 +176,35 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
 | 
			
		||||
  final List<PostWriteMedia> _attachments = List.empty(growable: true);
 | 
			
		||||
 | 
			
		||||
  OverlayEntry? _overlayEntry;
 | 
			
		||||
 | 
			
		||||
  void _showEmojiPicker(BuildContext context) {
 | 
			
		||||
    final overlay = Overlay.of(context);
 | 
			
		||||
    _overlayEntry = OverlayEntry(
 | 
			
		||||
      builder: (context) => Positioned(
 | 
			
		||||
        bottom: 16 + MediaQuery.of(context).padding.bottom,
 | 
			
		||||
        right: 16,
 | 
			
		||||
        child: _StickerPicker(
 | 
			
		||||
          originalText: _contentController.text,
 | 
			
		||||
          onDismiss: () => _dismissEmojiPicker(),
 | 
			
		||||
          onInsert: (str) => _contentController.text = str,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    overlay.insert(_overlayEntry!);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _dismissEmojiPicker() {
 | 
			
		||||
    _overlayEntry?.remove();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _contentController.dispose();
 | 
			
		||||
    _focusNode.dispose();
 | 
			
		||||
    _dismissEmojiPicker();
 | 
			
		||||
    if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey);
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -197,6 +268,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        child: Text('cancel'.tr()),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          _attachments.clear();
 | 
			
		||||
                          setState(() => _replyingMessage = null);
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
@@ -236,6 +308,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                      InkWell(
 | 
			
		||||
                        child: Text('cancel'.tr()),
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          _attachments.clear();
 | 
			
		||||
                          _contentController.clear();
 | 
			
		||||
                          setState(() => _editingMessage = null);
 | 
			
		||||
                        },
 | 
			
		||||
@@ -264,6 +337,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                        : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]),
 | 
			
		||||
                    border: InputBorder.none,
 | 
			
		||||
                  ),
 | 
			
		||||
                  textInputAction: TextInputAction.send,
 | 
			
		||||
                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                  onSubmitted: (_) {
 | 
			
		||||
                    if (_isBusy) return;
 | 
			
		||||
@@ -273,6 +347,19 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              IconButton(
 | 
			
		||||
                icon: Icon(
 | 
			
		||||
                  Symbols.mood,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
                visualDensity: const VisualDensity(
 | 
			
		||||
                  horizontal: -4,
 | 
			
		||||
                  vertical: -4,
 | 
			
		||||
                ),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  _showEmojiPicker(context);
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
              AddPostMediaButton(
 | 
			
		||||
                onAdd: (items) {
 | 
			
		||||
                  setState(() {
 | 
			
		||||
@@ -298,3 +385,107 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _StickerPicker extends StatelessWidget {
 | 
			
		||||
  final String originalText;
 | 
			
		||||
  final Function? onDismiss;
 | 
			
		||||
  final Function(String)? onInsert;
 | 
			
		||||
 | 
			
		||||
  const _StickerPicker({this.onDismiss, required this.originalText, this.onInsert});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sticker = context.read<SnStickerProvider>();
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        onDismiss?.call();
 | 
			
		||||
      },
 | 
			
		||||
      child: Container(
 | 
			
		||||
        constraints: BoxConstraints(maxWidth: min(360, MediaQuery.of(context).size.width), maxHeight: 240),
 | 
			
		||||
        child: Material(
 | 
			
		||||
          elevation: 8,
 | 
			
		||||
          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
          child: ClipRRect(
 | 
			
		||||
            borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
            child: ListView(
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              children: sticker.stickersByPack.entries
 | 
			
		||||
                  .map((e) {
 | 
			
		||||
                    return <Widget>[
 | 
			
		||||
                      Container(
 | 
			
		||||
                        margin: EdgeInsets.only(bottom: 8),
 | 
			
		||||
                        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
 | 
			
		||||
                        color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                        child: Column(
 | 
			
		||||
                          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(e.value.first.pack.name).bold(),
 | 
			
		||||
                            Text(e.value.first.pack.description),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      GridView.builder(
 | 
			
		||||
                        physics: const NeverScrollableScrollPhysics(),
 | 
			
		||||
                        padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
 | 
			
		||||
                        shrinkWrap: true,
 | 
			
		||||
                        gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
 | 
			
		||||
                          maxCrossAxisExtent: 48,
 | 
			
		||||
                          childAspectRatio: 1.0,
 | 
			
		||||
                          mainAxisSpacing: 8,
 | 
			
		||||
                          crossAxisSpacing: 8,
 | 
			
		||||
                        ),
 | 
			
		||||
                        itemCount: e.value.length,
 | 
			
		||||
                        itemBuilder: (context, index) {
 | 
			
		||||
                          final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
                          final element = e.value[index];
 | 
			
		||||
                          return GestureDetector(
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              final withSpace = originalText.isNotEmpty;
 | 
			
		||||
                              onInsert?.call(
 | 
			
		||||
                                  '$originalText${withSpace ? ' ' : ''}:${element.pack.prefix}${element.alias}:');
 | 
			
		||||
                              onDismiss?.call();
 | 
			
		||||
                            },
 | 
			
		||||
                            child: Tooltip(
 | 
			
		||||
                              richMessage: TextSpan(
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  TextSpan(
 | 
			
		||||
                                      text: ':${element.pack.prefix}${element.alias}:\n',
 | 
			
		||||
                                      style: GoogleFonts.robotoMono()),
 | 
			
		||||
                                  TextSpan(text: element.name).bold(),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: Container(
 | 
			
		||||
                                width: 48,
 | 
			
		||||
                                height: 48,
 | 
			
		||||
                                decoration: BoxDecoration(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                                ),
 | 
			
		||||
                                child: ClipRRect(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  child: UniversalImage(
 | 
			
		||||
                                    sn.getAttachmentUrl(element.attachment.rid),
 | 
			
		||||
                                    width: 48,
 | 
			
		||||
                                    height: 48,
 | 
			
		||||
                                    cacheHeight: 48,
 | 
			
		||||
                                    cacheWidth: 48,
 | 
			
		||||
                                    fit: BoxFit.contain,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
                    ];
 | 
			
		||||
                  })
 | 
			
		||||
                  .expand((ele) => ele)
 | 
			
		||||
                  .toList(),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,10 @@
 | 
			
		||||
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/config.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/providers/websocket.dart';
 | 
			
		||||
 | 
			
		||||
@@ -11,42 +14,52 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final ws = context.watch<WebSocketProvider>();
 | 
			
		||||
    final cfg = context.watch<ConfigProvider>();
 | 
			
		||||
 | 
			
		||||
    final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0;
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: ws,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
        final ua = context.read<UserProvider>();
 | 
			
		||||
        final show = (ws.isBusy || !ws.isConnected) && ua.isAuthorized;
 | 
			
		||||
 | 
			
		||||
        return GestureDetector(
 | 
			
		||||
          child: Container(
 | 
			
		||||
            padding: EdgeInsets.only(
 | 
			
		||||
              bottom: 8,
 | 
			
		||||
              top: MediaQuery.of(context).padding.top + 8,
 | 
			
		||||
              left: 24,
 | 
			
		||||
              right: 24,
 | 
			
		||||
            ),
 | 
			
		||||
        return IgnorePointer(
 | 
			
		||||
          ignoring: !show,
 | 
			
		||||
          child: Center(
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              child: Material(
 | 
			
		||||
                elevation: 2,
 | 
			
		||||
                shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondaryContainer,
 | 
			
		||||
                child: ua.isAuthorized
 | 
			
		||||
                    ? Row(
 | 
			
		||||
                        mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                        mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          if (ws.isBusy)
 | 
			
		||||
                        Text('serverConnecting').tr().textColor(
 | 
			
		||||
                            Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                            Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                          else if (!ws.isConnected)
 | 
			
		||||
                        Text('serverDisconnected').tr().textColor(
 | 
			
		||||
                            Theme.of(context).colorScheme.onSecondaryContainer),
 | 
			
		||||
                            Text('serverDisconnected')
 | 
			
		||||
                                .tr()
 | 
			
		||||
                                .textColor(Theme.of(context).colorScheme.onSecondaryContainer)
 | 
			
		||||
                          else
 | 
			
		||||
                            Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
 | 
			
		||||
                          const Gap(8),
 | 
			
		||||
                          if (ws.isBusy)
 | 
			
		||||
                            const CircularProgressIndicator(strokeWidth: 2.5)
 | 
			
		||||
                                .width(12)
 | 
			
		||||
                                .height(12)
 | 
			
		||||
                                .padding(horizontal: 4, right: 4)
 | 
			
		||||
                          else if (!ws.isConnected)
 | 
			
		||||
                            const Icon(Symbols.power_off, size: 18)
 | 
			
		||||
                          else
 | 
			
		||||
                            const Icon(Symbols.power, size: 18),
 | 
			
		||||
                        ],
 | 
			
		||||
                  )
 | 
			
		||||
                      ).padding(horizontal: 8, vertical: 4)
 | 
			
		||||
                    : const SizedBox.shrink(),
 | 
			
		||||
          )
 | 
			
		||||
              .height(
 | 
			
		||||
                  (ws.isBusy || !ws.isConnected) && ua.isAuthorized
 | 
			
		||||
                      ? MediaQuery.of(context).padding.top + 36
 | 
			
		||||
                      : 0,
 | 
			
		||||
                  animate: true)
 | 
			
		||||
              .animate(
 | 
			
		||||
              ).opacity(show ? 1 : 0, animate: true).animate(
 | 
			
		||||
                    const Duration(milliseconds: 300),
 | 
			
		||||
                    Curves.easeInOut,
 | 
			
		||||
                  ),
 | 
			
		||||
@@ -55,6 +68,8 @@ class ConnectionIndicator extends StatelessWidget {
 | 
			
		||||
                  ws.connect();
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ).padding(left: marginLeft),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@ class ContextMenuArea extends StatelessWidget {
 | 
			
		||||
          // Leave padding for side navigation
 | 
			
		||||
          mousePosition = cfg.drawerIsExpanded
 | 
			
		||||
              ? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
 | 
			
		||||
              : mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
 | 
			
		||||
              : mousePosition.copyWith(dx: mousePosition.dx - 80 * 2);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: GestureDetector(
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,9 @@ import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/gestures.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
extension AppPromptExtension on BuildContext {
 | 
			
		||||
  void showSnackbar(String content, {SnackBarAction? action}) {
 | 
			
		||||
@@ -111,7 +113,34 @@ extension AppPromptExtension on BuildContext {
 | 
			
		||||
      context: this,
 | 
			
		||||
      builder: (ctx) => AlertDialog(
 | 
			
		||||
        title: Text('dialogError').tr(),
 | 
			
		||||
        content: content,
 | 
			
		||||
        content: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
          spacing: 20,
 | 
			
		||||
          children: [
 | 
			
		||||
            content,
 | 
			
		||||
            Text.rich(
 | 
			
		||||
              TextSpan(
 | 
			
		||||
                text: 'needHelp'.tr(),
 | 
			
		||||
                children: [
 | 
			
		||||
                  TextSpan(text: ' '),
 | 
			
		||||
                  TextSpan(
 | 
			
		||||
                    text: 'needHelpLaunch'.tr(),
 | 
			
		||||
                    style: TextStyle(
 | 
			
		||||
                      color: Theme.of(ctx).colorScheme.primary,
 | 
			
		||||
                      decoration: TextDecoration.underline,
 | 
			
		||||
                      decorationColor: Theme.of(ctx).colorScheme.primary,
 | 
			
		||||
                    ),
 | 
			
		||||
                    recognizer: TapGestureRecognizer()
 | 
			
		||||
                      ..onTap = () {
 | 
			
		||||
                        launchUrlString('https://kb.solsynth.dev/solar-network');
 | 
			
		||||
                      },
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        actions: [
 | 
			
		||||
          TextButton(
 | 
			
		||||
            onPressed: () => Navigator.pop(ctx),
 | 
			
		||||
@@ -128,17 +157,7 @@ extension ByteFormatter on int {
 | 
			
		||||
    if (this == 0) return '0 Bytes';
 | 
			
		||||
    const k = 1024;
 | 
			
		||||
    final dm = decimals < 0 ? 0 : decimals;
 | 
			
		||||
    final sizes = [
 | 
			
		||||
      'Bytes',
 | 
			
		||||
      'KiB',
 | 
			
		||||
      'MiB',
 | 
			
		||||
      'GiB',
 | 
			
		||||
      'TiB',
 | 
			
		||||
      'PiB',
 | 
			
		||||
      'EiB',
 | 
			
		||||
      'ZiB',
 | 
			
		||||
      'YiB'
 | 
			
		||||
    ];
 | 
			
		||||
    final sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
 | 
			
		||||
    final i = (math.log(this) / math.log(k)).floor().toInt();
 | 
			
		||||
    return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -7,12 +7,11 @@ import 'package:marquee/marquee.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/link_preview.dart';
 | 
			
		||||
import 'package:surface/types/link.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
import '../providers/link_preview.dart';
 | 
			
		||||
 | 
			
		||||
class LinkPreviewWidget extends StatefulWidget {
 | 
			
		||||
  final String text;
 | 
			
		||||
 | 
			
		||||
@@ -81,8 +80,9 @@ class _LinkPreviewEntry extends StatelessWidget {
 | 
			
		||||
                  child: AspectRatio(
 | 
			
		||||
                    aspectRatio: 16 / 9,
 | 
			
		||||
                    child: ClipRRect(
 | 
			
		||||
                      borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                      child: AutoResizeUniversalImage(
 | 
			
		||||
                        meta.image!,
 | 
			
		||||
                        meta.image!.startsWith('//') ? 'https:${meta.image}' : meta.image!,
 | 
			
		||||
                        fit: BoxFit.contain,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
@@ -94,11 +94,14 @@ class _LinkPreviewEntry extends StatelessWidget {
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    if (meta.icon?.isNotEmpty ?? false)
 | 
			
		||||
                      StyledWidget(
 | 
			
		||||
                        meta.icon!.endsWith('.svg')
 | 
			
		||||
                            ? SvgPicture.network(meta.icon!)
 | 
			
		||||
                      SizedBox(
 | 
			
		||||
                        width: 36,
 | 
			
		||||
                        height: 36,
 | 
			
		||||
                        child: meta.icon!.endsWith('.svg')
 | 
			
		||||
                            ? SvgPicture.network(meta.icon!, width: 36, height: 36)
 | 
			
		||||
                            : UniversalImage(
 | 
			
		||||
                                meta.icon!,
 | 
			
		||||
                                noErrorWidget: true,
 | 
			
		||||
                                width: 36,
 | 
			
		||||
                                height: 36,
 | 
			
		||||
                                cacheHeight: 36,
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
  final bool isAutoWarp;
 | 
			
		||||
  final bool isEnlargeSticker;
 | 
			
		||||
  final TextScaler? textScaler;
 | 
			
		||||
  final Color? textColor;
 | 
			
		||||
  final List<SnAttachment?>? attachments;
 | 
			
		||||
 | 
			
		||||
  const MarkdownTextContent({
 | 
			
		||||
@@ -28,6 +29,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
    this.isAutoWarp = false,
 | 
			
		||||
    this.isEnlargeSticker = false,
 | 
			
		||||
    this.textScaler,
 | 
			
		||||
    this.textColor,
 | 
			
		||||
    this.attachments,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -42,6 +44,7 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
        Theme.of(context),
 | 
			
		||||
      ).copyWith(
 | 
			
		||||
        textScaler: textScaler,
 | 
			
		||||
        p: textColor != null ? Theme.of(context).textTheme.bodyMedium!.copyWith(color: textColor) : null,
 | 
			
		||||
        blockquote: TextStyle(
 | 
			
		||||
          color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
        ),
 | 
			
		||||
@@ -126,14 +129,27 @@ class MarkdownTextContent extends StatelessWidget {
 | 
			
		||||
                    future: st.lookupSticker(alias),
 | 
			
		||||
                    builder: (context, snapshot) {
 | 
			
		||||
                      if (snapshot.hasData) {
 | 
			
		||||
                        return UniversalImage(
 | 
			
		||||
                        return GestureDetector(
 | 
			
		||||
                            child: UniversalImage(
 | 
			
		||||
                              sn.getAttachmentUrl(snapshot.data!.attachment.rid),
 | 
			
		||||
                          fit: BoxFit.cover,
 | 
			
		||||
                              fit: BoxFit.contain,
 | 
			
		||||
                              width: size,
 | 
			
		||||
                              height: size,
 | 
			
		||||
                              cacheHeight: size,
 | 
			
		||||
                              cacheWidth: size,
 | 
			
		||||
                            ),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              if (snapshot.data == null) return;
 | 
			
		||||
                              context.pushTransparentRoute(
 | 
			
		||||
                                AttachmentZoomView(
 | 
			
		||||
                                  data: [snapshot.data!.attachment],
 | 
			
		||||
                                  initialIndex: 0,
 | 
			
		||||
                                  heroTags: [const Uuid().v4()],
 | 
			
		||||
                                ),
 | 
			
		||||
                                backgroundColor: Colors.black.withOpacity(0.7),
 | 
			
		||||
                                rootNavigator: true,
 | 
			
		||||
                              );
 | 
			
		||||
                            });
 | 
			
		||||
                      }
 | 
			
		||||
                      return const SizedBox.shrink();
 | 
			
		||||
                    },
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
 | 
			
		||||
          backgroundColor: backgroundColor,
 | 
			
		||||
          selectedIndex: nav.currentIndex,
 | 
			
		||||
          children: [
 | 
			
		||||
            if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS))
 | 
			
		||||
            if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) && !cfg.drawerIsExpanded)
 | 
			
		||||
              Container(
 | 
			
		||||
                decoration: BoxDecoration(
 | 
			
		||||
                  border: Border(
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,7 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    WidgetsBinding.instance.addPostFrameCallback((_) {
 | 
			
		||||
      context
 | 
			
		||||
          .read<NavigationProvider>()
 | 
			
		||||
          .autoDetectIndex(GoRouter.maybeOf(context));
 | 
			
		||||
      context.read<NavigationProvider>().autoDetectIndex(GoRouter.maybeOf(context));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -31,11 +29,13 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
      listenable: nav,
 | 
			
		||||
      builder: (context, _) {
 | 
			
		||||
        final destinations =
 | 
			
		||||
            nav.destinations.where((ele) => ele.isPinned).toList();
 | 
			
		||||
        final destinations = nav.destinations.where((ele) => ele.isPinned).toList();
 | 
			
		||||
 | 
			
		||||
        return NavigationRail(
 | 
			
		||||
          selectedIndex: nav.currentIndex,
 | 
			
		||||
        return SizedBox(
 | 
			
		||||
          width: 80,
 | 
			
		||||
          child: NavigationRail(
 | 
			
		||||
            selectedIndex:
 | 
			
		||||
                nav.currentIndex != null && nav.currentIndex! < nav.pinnedDestinationCount ? nav.currentIndex : null,
 | 
			
		||||
            destinations: [
 | 
			
		||||
              ...destinations.where((ele) => ele.isPinned).map((ele) {
 | 
			
		||||
                return NavigationRailDestination(
 | 
			
		||||
@@ -61,6 +61,7 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
 | 
			
		||||
              nav.setIndex(idx);
 | 
			
		||||
              GoRouter.of(context).goNamed(destinations[idx].screen);
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:go_router/go_router.dart';
 | 
			
		||||
@@ -12,42 +11,84 @@ import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/config.dart';
 | 
			
		||||
import 'package:surface/providers/navigation.dart';
 | 
			
		||||
import 'package:surface/widgets/connection_indicator.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_background.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_drawer_navigation.dart';
 | 
			
		||||
import 'package:surface/widgets/navigation/app_rail_navigation.dart';
 | 
			
		||||
import 'package:surface/widgets/notify_indicator.dart';
 | 
			
		||||
 | 
			
		||||
final globalRootScaffoldKey = GlobalKey<ScaffoldState>();
 | 
			
		||||
 | 
			
		||||
class AppPageScaffold extends StatelessWidget {
 | 
			
		||||
  final String? title;
 | 
			
		||||
class AppScaffold extends StatelessWidget {
 | 
			
		||||
  final Widget? body;
 | 
			
		||||
  final bool showAppBar;
 | 
			
		||||
  final bool showBottomNavigation;
 | 
			
		||||
  final PreferredSizeWidget? bottomNavigationBar;
 | 
			
		||||
  final PreferredSizeWidget? bottomSheet;
 | 
			
		||||
  final Drawer? drawer;
 | 
			
		||||
  final Widget? endDrawer;
 | 
			
		||||
  final FloatingActionButtonAnimator? floatingActionButtonAnimator;
 | 
			
		||||
  final FloatingActionButtonLocation? floatingActionButtonLocation;
 | 
			
		||||
  final Widget? floatingActionButton;
 | 
			
		||||
  final AppBar? appBar;
 | 
			
		||||
  final DrawerCallback? onDrawerChanged;
 | 
			
		||||
  final DrawerCallback? onEndDrawerChanged;
 | 
			
		||||
 | 
			
		||||
  const AppPageScaffold({
 | 
			
		||||
  const AppScaffold({
 | 
			
		||||
    super.key,
 | 
			
		||||
    this.title,
 | 
			
		||||
    this.appBar,
 | 
			
		||||
    this.body,
 | 
			
		||||
    this.showAppBar = true,
 | 
			
		||||
    this.showBottomNavigation = false,
 | 
			
		||||
    this.floatingActionButton,
 | 
			
		||||
    this.floatingActionButtonLocation,
 | 
			
		||||
    this.floatingActionButtonAnimator,
 | 
			
		||||
    this.bottomNavigationBar,
 | 
			
		||||
    this.bottomSheet,
 | 
			
		||||
    this.drawer,
 | 
			
		||||
    this.endDrawer,
 | 
			
		||||
    this.onDrawerChanged,
 | 
			
		||||
    this.onEndDrawerChanged,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final state = GoRouter.maybeOf(context);
 | 
			
		||||
    final routeName = state?.routerDelegate.currentConfiguration.last.route.name;
 | 
			
		||||
 | 
			
		||||
    final autoTitle = state != null ? 'screen${routeName?.capitalize()}' : 'screen';
 | 
			
		||||
    final appBarHeight = appBar?.preferredSize.height ?? 0;
 | 
			
		||||
    final safeTop = MediaQuery.of(context).padding.top;
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: showAppBar
 | 
			
		||||
          ? AppBar(
 | 
			
		||||
              title: Text(title ?? autoTitle.tr()),
 | 
			
		||||
            )
 | 
			
		||||
          : null,
 | 
			
		||||
      body: body,
 | 
			
		||||
      extendBody: true,
 | 
			
		||||
      extendBodyBehindAppBar: true,
 | 
			
		||||
      backgroundColor: Theme.of(context).scaffoldBackgroundColor,
 | 
			
		||||
      body: SizedBox.expand(
 | 
			
		||||
        child: AppBackground(
 | 
			
		||||
          child: Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              IgnorePointer(child: SizedBox(height: appBar != null ? appBarHeight + safeTop : 0)),
 | 
			
		||||
              if (body != null) Expanded(child: body!),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      appBar: appBar,
 | 
			
		||||
      bottomNavigationBar: bottomNavigationBar,
 | 
			
		||||
      bottomSheet: bottomSheet,
 | 
			
		||||
      drawer: drawer,
 | 
			
		||||
      endDrawer: endDrawer,
 | 
			
		||||
      floatingActionButton: floatingActionButton,
 | 
			
		||||
      floatingActionButtonAnimator: floatingActionButtonAnimator,
 | 
			
		||||
      floatingActionButtonLocation: floatingActionButtonLocation,
 | 
			
		||||
      onDrawerChanged: onDrawerChanged,
 | 
			
		||||
      onEndDrawerChanged: onEndDrawerChanged,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PageBackButton extends StatelessWidget {
 | 
			
		||||
  const PageBackButton({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return BackButton(
 | 
			
		||||
      onPressed: () {
 | 
			
		||||
        GoRouter.of(context).pop();
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -98,14 +139,19 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
      iconMouseDown: Theme.of(context).colorScheme.primary,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return AppBackground(
 | 
			
		||||
      isRoot: true,
 | 
			
		||||
      child: Scaffold(
 | 
			
		||||
    final safeTop = MediaQuery.of(context).padding.top;
 | 
			
		||||
    final safeBottom = MediaQuery.of(context).padding.bottom;
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      key: globalRootScaffoldKey,
 | 
			
		||||
        body: Column(
 | 
			
		||||
      backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
      body: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS))
 | 
			
		||||
              Container(
 | 
			
		||||
                WindowTitleBarBox(
 | 
			
		||||
                  child: Container(
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      border: Border(
 | 
			
		||||
                        bottom: BorderSide(
 | 
			
		||||
@@ -114,22 +160,18 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    child: MoveWindow(
 | 
			
		||||
                      child: Row(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        mainAxisAlignment: Platform.isMacOS ? MainAxisAlignment.center : MainAxisAlignment.start,
 | 
			
		||||
                        children: [
 | 
			
		||||
                    WindowTitleBarBox(
 | 
			
		||||
                      child: MoveWindow(
 | 
			
		||||
                        child: Text(
 | 
			
		||||
                          Text(
 | 
			
		||||
                            'Solar Network',
 | 
			
		||||
                            style: GoogleFonts.spaceGrotesk(),
 | 
			
		||||
                          ).padding(horizontal: 12, vertical: 5),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                          if (!Platform.isMacOS)
 | 
			
		||||
                      Expanded(
 | 
			
		||||
                        child: WindowTitleBarBox(
 | 
			
		||||
                          child: Row(
 | 
			
		||||
                            Row(
 | 
			
		||||
                              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                Expanded(child: MoveWindow()),
 | 
			
		||||
                                Row(
 | 
			
		||||
@@ -141,19 +183,24 @@ class AppRootScaffold extends StatelessWidget {
 | 
			
		||||
                                ),
 | 
			
		||||
                              ],
 | 
			
		||||
                            ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
            ConnectionIndicator(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              Expanded(child: innerWidget),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(top: safeTop > 0 ? safeTop : 16, right: 8, child: NotifyIndicator()),
 | 
			
		||||
          if (ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE))
 | 
			
		||||
            Positioned(bottom: safeBottom > 0 ? safeBottom : 16, left: 0, right: 0, child: ConnectionIndicator())
 | 
			
		||||
          else
 | 
			
		||||
            Positioned(top: safeTop > 0 ? safeTop : 16, left: 0, right: 0, child: ConnectionIndicator()),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      drawer: !isExpandedDrawer ? AppNavigationDrawer() : null,
 | 
			
		||||
      drawerEdgeDragWidth: isPopable ? 0 : null,
 | 
			
		||||
      bottomNavigationBar: isShowBottomNavigation ? AppBottomNavigationBar() : null,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										184
									
								
								lib/widgets/notify_indicator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								lib/widgets/notify_indicator.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,184 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:responsive_framework/responsive_framework.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:surface/providers/notification.dart';
 | 
			
		||||
import 'package:surface/providers/sn_network.dart';
 | 
			
		||||
import 'package:surface/providers/userinfo.dart';
 | 
			
		||||
import 'package:surface/screens/notification.dart';
 | 
			
		||||
import 'package:surface/types/notification.dart';
 | 
			
		||||
import 'package:surface/widgets/dialog.dart';
 | 
			
		||||
import 'package:surface/widgets/universal_image.dart';
 | 
			
		||||
 | 
			
		||||
import 'markdown_content.dart';
 | 
			
		||||
 | 
			
		||||
class NotifyIndicator extends StatefulWidget {
 | 
			
		||||
  const NotifyIndicator({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<NotifyIndicator> createState() => _NotifyIndicatorState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NotifyIndicatorState extends State<NotifyIndicator> with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final AnimationController _animationController = AnimationController(
 | 
			
		||||
    vsync: this,
 | 
			
		||||
    duration: const Duration(milliseconds: 300),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  void _markOneAsRead(SnNotification notification) async {
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    if (!ua.isAuthorized) return;
 | 
			
		||||
 | 
			
		||||
    if (notification.id == 0) return;
 | 
			
		||||
    if (notification.readAt != null) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
      await sn.client.put('/cgi/id/notifications/read/${notification.id}');
 | 
			
		||||
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showSnackbar(
 | 
			
		||||
        'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (!mounted) return;
 | 
			
		||||
      context.showErrorDialog(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _animationController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final sn = context.read<SnNetworkProvider>();
 | 
			
		||||
    final ua = context.read<UserProvider>();
 | 
			
		||||
    final nty = context.watch<NotificationProvider>();
 | 
			
		||||
 | 
			
		||||
    final isMobile = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
 | 
			
		||||
 | 
			
		||||
    final show = nty.showingCount > 0 && ua.isAuthorized;
 | 
			
		||||
 | 
			
		||||
    if (show) {
 | 
			
		||||
      _animationController.animateTo(1);
 | 
			
		||||
    } else {
 | 
			
		||||
      _animationController.animateTo(0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ListenableBuilder(
 | 
			
		||||
        listenable: nty,
 | 
			
		||||
        builder: (context, _) {
 | 
			
		||||
          final current = nty.notifications.lastOrNull;
 | 
			
		||||
 | 
			
		||||
          return IgnorePointer(
 | 
			
		||||
            ignoring: !show,
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              child: Animate(
 | 
			
		||||
                autoPlay: false,
 | 
			
		||||
                controller: _animationController,
 | 
			
		||||
                effects: [
 | 
			
		||||
                  SlideEffect(
 | 
			
		||||
                    begin: isMobile ? Offset(0, -1) : Offset(1, 0),
 | 
			
		||||
                    end: Offset(0, 0),
 | 
			
		||||
                    duration: Duration(milliseconds: 300),
 | 
			
		||||
                    curve: Curves.fastEaseInToSlowEaseOut,
 | 
			
		||||
                  ),
 | 
			
		||||
                  FadeEffect(
 | 
			
		||||
                    begin: 0.0,
 | 
			
		||||
                    end: 1.0,
 | 
			
		||||
                    duration: Duration(milliseconds: 300),
 | 
			
		||||
                    curve: Curves.easeInOut,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
                child: Container(
 | 
			
		||||
                  padding: const EdgeInsets.symmetric(vertical: 16),
 | 
			
		||||
                  width: double.infinity,
 | 
			
		||||
                  constraints: BoxConstraints(
 | 
			
		||||
                    maxWidth: isMobile ? MediaQuery.of(context).size.width - 16 : 360,
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: Material(
 | 
			
		||||
                    elevation: 2,
 | 
			
		||||
                    borderRadius: BorderRadius.circular(8),
 | 
			
		||||
                    color: Theme.of(context).colorScheme.surfaceContainer,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (current?.metadata['avatar'] != null)
 | 
			
		||||
                          CircleAvatar(
 | 
			
		||||
                            radius: 14,
 | 
			
		||||
                            backgroundImage: UniversalImage.provider(
 | 
			
		||||
                              sn.getAttachmentUrl(current!.metadata['avatar']),
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        else
 | 
			
		||||
                          Icon(kNotificationTopicIcons[current?.topic] ?? Symbols.notifications),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                        Expanded(
 | 
			
		||||
                          child: Column(
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text(
 | 
			
		||||
                                current?.title ?? 'Notification',
 | 
			
		||||
                                style: Theme.of(context).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                                      fontWeight: FontWeight.bold,
 | 
			
		||||
                                    ),
 | 
			
		||||
                              ),
 | 
			
		||||
                              if (current?.subtitle?.isNotEmpty ?? false)
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  current!.subtitle!,
 | 
			
		||||
                                  style: Theme.of(context).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                                        fontWeight: FontWeight.bold,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              MarkdownTextContent(
 | 
			
		||||
                                content: current?.body ?? '',
 | 
			
		||||
                                isAutoWarp: true,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(16),
 | 
			
		||||
                        Column(
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(DateFormat('HH:mm').format(current?.createdAt.toLocal() ?? DateTime.now()))
 | 
			
		||||
                                .fontSize(12)
 | 
			
		||||
                                .padding(right: 2),
 | 
			
		||||
                            const Gap(6),
 | 
			
		||||
                            if (current?.metadata['image'] != null)
 | 
			
		||||
                              SizedBox(
 | 
			
		||||
                                width: 40,
 | 
			
		||||
                                height: 40,
 | 
			
		||||
                                child: ClipRRect(
 | 
			
		||||
                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
			
		||||
                                  child: AutoResizeUniversalImage(
 | 
			
		||||
                                    sn.getAttachmentUrl(current?.metadata['image']),
 | 
			
		||||
                                    fit: BoxFit.cover,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ).padding(horizontal: 16, vertical: 12),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                nty.clear();
 | 
			
		||||
                if (current != null) {
 | 
			
		||||
                  _markOneAsRead(current);
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user