Compare commits
	
		
			19 Commits
		
	
	
		
			217a0c0a54
			...
			3.0.0+106
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 91c5a2e1b6 | |||
| cb991d1574 | |||
| 75097ab6fc | |||
| 8d855867c1 | |||
| 89fd80bcb8 | |||
| ab4f4faafe | |||
| 52111c4b95 | |||
| 15a5848785 | |||
| e29a2fc054 | |||
| 7f4e489f51 | |||
| eb4d2c2e2f | |||
| 9b67d58ee4 | |||
| 4dbee27718 | |||
| 4b9c9aec92 | |||
| 00b3dc7be6 | |||
| 7f26196e85 | |||
| 3e5669780f | |||
| 484ded03b1 | |||
| b3786827ef | 
@@ -18,9 +18,7 @@ android {
 | 
			
		||||
        targetCompatibility = JavaVersion.VERSION_17
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    kotlinOptions {
 | 
			
		||||
        jvmTarget = JavaVersion.VERSION_17.toString()
 | 
			
		||||
    }
 | 
			
		||||
    kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }
 | 
			
		||||
 | 
			
		||||
    defaultConfig {
 | 
			
		||||
        applicationId = "dev.solsynth.solian"
 | 
			
		||||
@@ -32,11 +30,20 @@ android {
 | 
			
		||||
        versionName = flutter.versionName
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    signingConfigs {
 | 
			
		||||
        release {
 | 
			
		||||
            keyAlias = keystoreProperties['keyAlias']
 | 
			
		||||
            keyPassword = keystoreProperties['keyPassword']
 | 
			
		||||
            storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
 | 
			
		||||
            storePassword = keystoreProperties['storePassword']
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildTypes {
 | 
			
		||||
        release {
 | 
			
		||||
            // TODO: Add your own signing config for the release build.
 | 
			
		||||
            // Signing with the debug keys for now, so `flutter run --release` works.
 | 
			
		||||
            signingConfig = signingConfigs.getByName("debug")
 | 
			
		||||
            signingConfig = signingConfigs.getByName("release")
 | 
			
		||||
            minifyEnabled = true
 | 
			
		||||
            shrinkResources = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -42,6 +42,22 @@
 | 
			
		||||
                <category android:name="android.intent.category.LAUNCHER" />
 | 
			
		||||
            </intent-filter>
 | 
			
		||||
        </activity>
 | 
			
		||||
 | 
			
		||||
        <!-- Sign in with Apple -->
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
 | 
			
		||||
            android:exported="true"
 | 
			
		||||
        >
 | 
			
		||||
            <intent-filter>
 | 
			
		||||
                <action android:name="android.intent.action.VIEW" />
 | 
			
		||||
                <category android:name="android.intent.category.DEFAULT" />
 | 
			
		||||
                <category android:name="android.intent.category.BROWSABLE" />
 | 
			
		||||
 | 
			
		||||
                <data android:scheme="signinwithapple" />
 | 
			
		||||
                <data android:path="callback" />
 | 
			
		||||
            </intent-filter>
 | 
			
		||||
        </activity>
 | 
			
		||||
 | 
			
		||||
        <provider
 | 
			
		||||
            android:name="androidx.core.content.FileProvider"
 | 
			
		||||
            android:authorities="dev.solsynth.solian.provider"
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,8 @@
 | 
			
		||||
  "loginEnterPassword": "Enter the code",
 | 
			
		||||
  "loginSuccess": "Logged in as {}",
 | 
			
		||||
  "loginGreeting": "Welcome back!",
 | 
			
		||||
  "loginOr": "Or login with\nthird parties",
 | 
			
		||||
  "loginInProgress": "Logging you in...",
 | 
			
		||||
  "username": "Username",
 | 
			
		||||
  "usernameCannotChangeHint": "Username cannot be updated after created.",
 | 
			
		||||
  "usernameLookupHint": "We also take your email address.",
 | 
			
		||||
@@ -27,7 +29,7 @@
 | 
			
		||||
  "fieldCannotBeEmpty": "This field cannot be empty.",
 | 
			
		||||
  "fieldEmailAddressMustBeValid": "The email address must be valid.",
 | 
			
		||||
  "logout": "Logout",
 | 
			
		||||
  "updateYourProfile": "Edit Profile",
 | 
			
		||||
  "updateYourProfile": "Profile Settings",
 | 
			
		||||
  "accountBasicInfo": "Basic Info",
 | 
			
		||||
  "accountProfile": "Your Profile",
 | 
			
		||||
  "saveChanges": "Save Changes",
 | 
			
		||||
@@ -98,6 +100,11 @@
 | 
			
		||||
  "permissionModerator": "Moderator",
 | 
			
		||||
  "permissionMember": "Member",
 | 
			
		||||
  "reply": "Reply",
 | 
			
		||||
  "repliesCount": {
 | 
			
		||||
    "zero": "No reply",
 | 
			
		||||
    "one": "{} reply",
 | 
			
		||||
    "other": "{} replies"
 | 
			
		||||
  },
 | 
			
		||||
  "forward": "Forward",
 | 
			
		||||
  "repliedTo": "Replied to",
 | 
			
		||||
  "forwarded": "Forwarded",
 | 
			
		||||
@@ -127,6 +134,24 @@
 | 
			
		||||
  "connectionConnected": "Connected",
 | 
			
		||||
  "connectionDisconnected": "Disconnected",
 | 
			
		||||
  "connectionReconnecting": "Reconnecting",
 | 
			
		||||
  "accountConnections": "Account Connections",
 | 
			
		||||
  "accountConnectionsDescription": "Manage your external account connections",
 | 
			
		||||
  "accountConnectionAdd": "Add Connection",
 | 
			
		||||
  "accountConnectionDelete": "Delete Connection",
 | 
			
		||||
  "accountConnectionDeleteHint": "Are you sure you want to delete this connection? This action cannot be undone.",
 | 
			
		||||
  "accountConnectionsEmpty": "No connections found. Add a connection to get started.",
 | 
			
		||||
  "accountConnectionProvider": "Provider",
 | 
			
		||||
  "accountConnectionProviderHint": "Enter provider name",
 | 
			
		||||
  "accountConnectionIdentifier": "Identifier",
 | 
			
		||||
  "accountConnectionIdentifierHint": "Enter your identifier for this provider",
 | 
			
		||||
  "accountConnectionDescription": "Add a connection to link your account with external services.",
 | 
			
		||||
  "accountConnectionAddSuccess": "Connection added successfully.",
 | 
			
		||||
  "accountConnectionAddError": "Unable to setup connection.",
 | 
			
		||||
  "accountConnectionProviderApple": "Apple",
 | 
			
		||||
  "accountConnectionProviderMicrosoft": "Microsoft",
 | 
			
		||||
  "accountConnectionProviderGoogle": "Google",
 | 
			
		||||
  "accountConnectionProviderGithub": "GitHub",
 | 
			
		||||
  "accountConnectionProviderDiscord": "Discord",
 | 
			
		||||
  "checkIn": "Check In",
 | 
			
		||||
  "checkInNone": "Not checked-in yet",
 | 
			
		||||
  "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
 | 
			
		||||
@@ -317,6 +342,9 @@
 | 
			
		||||
  "unauthorized": "Unauthorized",
 | 
			
		||||
  "unauthorizedHint": "You're not signed in or session expired, please sign in again.",
 | 
			
		||||
  "publisherBelongsTo": "Belongs to {}",
 | 
			
		||||
  "postContent": "Content",
 | 
			
		||||
  "postSettings": "Settings",
 | 
			
		||||
  "postPublisherUnselected": "Publisher Unspecified",
 | 
			
		||||
  "postVisibility": "Visibility",
 | 
			
		||||
  "postVisibilityPublic": "Public",
 | 
			
		||||
  "postVisibilityFriends": "Friends Only",
 | 
			
		||||
@@ -429,5 +457,7 @@
 | 
			
		||||
  "checkInResultT4": "Best",
 | 
			
		||||
  "accountProfileView": "View Profile",
 | 
			
		||||
  "unspecified": "Unspecified",
 | 
			
		||||
  "added": "Added"
 | 
			
		||||
  "added": "Added",
 | 
			
		||||
  "preview": "Preview",
 | 
			
		||||
  "togglePreview": "Toggle Preview"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								assets/images/oidc/apple.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								assets/images/oidc/apple.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
 | 
			
		||||
  <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 660 B  | 
							
								
								
									
										1
									
								
								assets/images/oidc/discord.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/discord.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Discord-Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.644 96"><path id="Discord-Symbol-Black" d="M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.1 KiB  | 
							
								
								
									
										1
									
								
								assets/images/oidc/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/github.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 963 B  | 
							
								
								
									
										104
									
								
								assets/images/oidc/google.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								assets/images/oidc/google.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,104 @@
 | 
			
		||||
<svg version="1.1" viewBox="0 0 268.1522 273.8827" overflow="hidden" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
 | 
			
		||||
  <defs>
 | 
			
		||||
    <linearGradient id="a">
 | 
			
		||||
      <stop offset="0" stop-color="#0fbc5c"/>
 | 
			
		||||
      <stop offset="1" stop-color="#0cba65"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="g">
 | 
			
		||||
      <stop offset=".2312727" stop-color="#0fbc5f"/>
 | 
			
		||||
      <stop offset=".3115468" stop-color="#0fbc5f"/>
 | 
			
		||||
      <stop offset=".3660131" stop-color="#0fbc5e"/>
 | 
			
		||||
      <stop offset=".4575163" stop-color="#0fbc5d"/>
 | 
			
		||||
      <stop offset=".540305" stop-color="#12bc58"/>
 | 
			
		||||
      <stop offset=".6993464" stop-color="#28bf3c"/>
 | 
			
		||||
      <stop offset=".7712418" stop-color="#38c02b"/>
 | 
			
		||||
      <stop offset=".8605665" stop-color="#52c218"/>
 | 
			
		||||
      <stop offset=".9150327" stop-color="#67c30f"/>
 | 
			
		||||
      <stop offset="1" stop-color="#86c504"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="h">
 | 
			
		||||
      <stop offset=".1416122" stop-color="#1abd4d"/>
 | 
			
		||||
      <stop offset=".2475151" stop-color="#6ec30d"/>
 | 
			
		||||
      <stop offset=".3115468" stop-color="#8ac502"/>
 | 
			
		||||
      <stop offset=".3660131" stop-color="#a2c600"/>
 | 
			
		||||
      <stop offset=".4456735" stop-color="#c8c903"/>
 | 
			
		||||
      <stop offset=".540305" stop-color="#ebcb03"/>
 | 
			
		||||
      <stop offset=".6156363" stop-color="#f7cd07"/>
 | 
			
		||||
      <stop offset=".6993454" stop-color="#fdcd04"/>
 | 
			
		||||
      <stop offset=".7712418" stop-color="#fdce05"/>
 | 
			
		||||
      <stop offset=".8605661" stop-color="#ffce0a"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="f">
 | 
			
		||||
      <stop offset=".3159041" stop-color="#ff4c3c"/>
 | 
			
		||||
      <stop offset=".6038179" stop-color="#ff692c"/>
 | 
			
		||||
      <stop offset=".7268366" stop-color="#ff7825"/>
 | 
			
		||||
      <stop offset=".884534" stop-color="#ff8d1b"/>
 | 
			
		||||
      <stop offset="1" stop-color="#ff9f13"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="b">
 | 
			
		||||
      <stop offset=".2312727" stop-color="#ff4541"/>
 | 
			
		||||
      <stop offset=".3115468" stop-color="#ff4540"/>
 | 
			
		||||
      <stop offset=".4575163" stop-color="#ff4640"/>
 | 
			
		||||
      <stop offset=".540305" stop-color="#ff473f"/>
 | 
			
		||||
      <stop offset=".6993464" stop-color="#ff5138"/>
 | 
			
		||||
      <stop offset=".7712418" stop-color="#ff5b33"/>
 | 
			
		||||
      <stop offset=".8605665" stop-color="#ff6c29"/>
 | 
			
		||||
      <stop offset="1" stop-color="#ff8c18"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="d">
 | 
			
		||||
      <stop offset=".4084578" stop-color="#fb4e5a"/>
 | 
			
		||||
      <stop offset="1" stop-color="#ff4540"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="c">
 | 
			
		||||
      <stop offset=".1315461" stop-color="#0cba65"/>
 | 
			
		||||
      <stop offset=".2097843" stop-color="#0bb86d"/>
 | 
			
		||||
      <stop offset=".2972969" stop-color="#09b479"/>
 | 
			
		||||
      <stop offset=".3962575" stop-color="#08ad93"/>
 | 
			
		||||
      <stop offset=".4771242" stop-color="#0aa6a9"/>
 | 
			
		||||
      <stop offset=".5684245" stop-color="#0d9cc6"/>
 | 
			
		||||
      <stop offset=".667385" stop-color="#1893dd"/>
 | 
			
		||||
      <stop offset=".7687273" stop-color="#258bf1"/>
 | 
			
		||||
      <stop offset=".8585063" stop-color="#3086ff"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient id="e">
 | 
			
		||||
      <stop offset=".3660131" stop-color="#ff4e3a"/>
 | 
			
		||||
      <stop offset=".4575163" stop-color="#ff8a1b"/>
 | 
			
		||||
      <stop offset=".540305" stop-color="#ffa312"/>
 | 
			
		||||
      <stop offset=".6156363" stop-color="#ffb60c"/>
 | 
			
		||||
      <stop offset=".7712418" stop-color="#ffcd0a"/>
 | 
			
		||||
      <stop offset=".8605665" stop-color="#fecf0a"/>
 | 
			
		||||
      <stop offset=".9150327" stop-color="#fecf08"/>
 | 
			
		||||
      <stop offset="1" stop-color="#fdcd01"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient xlink:href="#a" id="s" x1="219.6997" y1="329.5351" x2="254.4673" y2="329.5351" gradientUnits="userSpaceOnUse"/>
 | 
			
		||||
    <radialGradient xlink:href="#b" id="m" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,1.043001,1.455731,2.555422,290.5254,-400.6338)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/>
 | 
			
		||||
    <radialGradient xlink:href="#c" id="n" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.512595,-4.45809,-1.692547,1.260616,870.8006,191.554)" cx="45.25866" cy="279.2738" fx="45.25866" fy="279.2738" r="71.46001"/>
 | 
			
		||||
    <radialGradient xlink:href="#d" id="l" cx="304.0166" cy="118.0089" fx="304.0166" fy="118.0089" r="47.85445" gradientTransform="matrix(2.064353,-4.926832e-6,-2.901531e-6,2.592041,-297.6788,-151.7469)" gradientUnits="userSpaceOnUse"/>
 | 
			
		||||
    <radialGradient xlink:href="#e" id="o" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.2485783,2.083138,2.962486,0.3341668,-255.1463,-331.1636)" cx="181.001" cy="177.2013" fx="181.001" fy="177.2013" r="71.46001"/>
 | 
			
		||||
    <radialGradient xlink:href="#f" id="p" cx="207.6733" cy="108.0972" fx="207.6733" fy="108.0972" r="41.1025" gradientTransform="matrix(-1.249206,1.343263,-3.896837,-3.425693,880.5011,194.9051)" gradientUnits="userSpaceOnUse"/>
 | 
			
		||||
    <radialGradient xlink:href="#g" id="r" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-1.936885,-1.043001,1.455731,-2.555422,290.5254,838.6834)" cx="109.6267" cy="135.8619" fx="109.6267" fy="135.8619" r="71.46001"/>
 | 
			
		||||
    <radialGradient xlink:href="#h" id="j" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.081402,-1.93722,2.926737,-0.1162508,-215.1345,632.8606)" cx="154.8697" cy="145.9691" fx="154.8697" fy="145.9691" r="71.46001"/>
 | 
			
		||||
    <filter id="q" x="-.04842873" y="-.0582241" width="1.096857" height="1.116448" color-interpolation-filters="sRGB">
 | 
			
		||||
      <feGaussianBlur stdDeviation="1.700914"/>
 | 
			
		||||
    </filter>
 | 
			
		||||
    <filter id="k" x="-.01670084" y="-.01009856" width="1.033402" height="1.020197" color-interpolation-filters="sRGB">
 | 
			
		||||
      <feGaussianBlur stdDeviation=".2419367"/>
 | 
			
		||||
    </filter>
 | 
			
		||||
    <clipPath clipPathUnits="userSpaceOnUse" id="i">
 | 
			
		||||
      <path d="M371.3784 193.2406H237.0825v53.4375h77.167c-1.2405 7.5627-4.0259 15.0024-8.1049 21.7862-4.6734 7.7723-10.4511 13.6895-16.373 18.1957-17.7389 13.4983-38.42 16.2584-52.7828 16.2584-36.2824 0-67.2833-23.2865-79.2844-54.9287-.4843-1.1482-.8059-2.3344-1.1975-3.5068-2.652-8.0533-4.101-16.5825-4.101-25.4474 0-9.226 1.5691-18.0575 4.4301-26.3985 11.2851-32.8967 42.9849-57.4674 80.1789-57.4674 7.4811 0 14.6854.8843 21.5173 2.6481 15.6135 4.0309 26.6578 11.9698 33.4252 18.2494l40.834-39.7111c-24.839-22.616-57.2194-36.3201-95.8444-36.3201-30.8782-.00066-59.3863 9.55308-82.7477 25.6992-18.9454 13.0941-34.4833 30.6254-44.9695 50.9861-9.75366 18.8785-15.09441 39.7994-15.09441 62.2934 0 22.495 5.34891 43.6334 15.10261 62.3374v.126c10.3023 19.8567 25.3678 36.9537 43.6783 49.9878 15.9962 11.3866 44.6789 26.5516 84.0307 26.5516 22.6301 0 42.6867-4.0517 60.3748-11.6447 12.76-5.4775 24.0655-12.6217 34.3012-21.8036 13.5247-12.1323 24.1168-27.1388 31.3465-44.4041 7.2297-17.2654 11.097-36.7895 11.097-57.957 0-9.858-.9971-19.8694-2.6881-28.9684Z" fill="#000"/>
 | 
			
		||||
    </clipPath>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <g transform="matrix(0.957922,0,0,0.985255,-90.17436,-78.85577)">
 | 
			
		||||
    <g clip-path="url(#i)">
 | 
			
		||||
      <path d="M92.07563 219.9585c.14844 22.14 6.5014 44.983 16.11767 63.4234v.1269c6.9482 13.3919 16.4444 23.9704 27.2604 34.4518l65.326-23.67c-12.3593-6.2344-14.2452-10.0546-23.1048-17.0253-9.0537-9.0658-15.8015-19.4735-20.0038-31.677h-.1693l.1693-.1269c-2.7646-8.0587-3.0373-16.6129-3.1393-25.5029Z" fill="url(#j)" filter="url(#k)"/>
 | 
			
		||||
      <path d="M237.0835 79.02491c-6.4568 22.52569-3.988 44.42139 0 57.16129 7.4561.0055 14.6388.8881 21.4494 2.6464 15.6135 4.0309 26.6566 11.97 33.424 18.2496l41.8794-40.7256c-24.8094-22.58904-54.6663-37.2961-96.7528-37.33169Z" fill="url(#l)" filter="url(#k)"/>
 | 
			
		||||
      <path d="M236.9434 78.84678c-31.6709-.00068-60.9107 9.79833-84.8718 26.35902-8.8968 6.149-17.0612 13.2521-24.3311 21.1509-1.9045 17.7429 14.2569 39.5507 46.2615 39.3702 15.5284-17.9373 38.4946-29.5427 64.0561-29.5427.0233 0 .046.0019.0693.002l-1.0439-57.33536c-.0472-.00003-.0929-.00406-.1401-.00406Z" fill="url(#m)" filter="url(#k)"/>
 | 
			
		||||
      <path d="m341.4751 226.3788-28.2685 19.2848c-1.2405 7.5627-4.0278 15.0023-8.1068 21.7861-4.6734 7.7723-10.4506 13.6898-16.3725 18.196-17.7022 13.4704-38.3286 16.2439-52.6877 16.2553-14.8415 25.1018-17.4435 37.6749 1.0439 57.9342 22.8762-.0167 43.157-4.1174 61.0458-11.7965 12.9312-5.551 24.3879-12.7913 34.7609-22.0964 13.7061-12.295 24.4421-27.5034 31.7688-45.0003 7.3267-17.497 11.2446-37.2822 11.2446-58.7336Z" fill="url(#n)" filter="url(#k)"/>
 | 
			
		||||
      <path d="M234.9956 191.2104v57.4981h136.0062c1.1962-7.8745 5.1523-18.0644 5.1523-26.5001 0-9.858-.9963-21.899-2.6873-30.998Z" fill="#3086ff" filter="url(#k)"/>
 | 
			
		||||
      <path d="M128.3894 124.3268c-8.393 9.1191-15.5632 19.326-21.2483 30.3646-9.75351 18.8785-15.09402 41.8295-15.09402 64.3235 0 .317.02642.6271.02855.9436 4.31953 8.2244 59.66647 6.6495 62.45617 0-.0035-.3103-.0387-.6128-.0387-.9238 0-9.226 1.5696-16.0262 4.4306-24.3672 3.5294-10.2885 9.0557-19.7628 16.1223-27.9257 1.6019-2.0309 5.8748-6.3969 7.1214-9.0157.4749-.9975-.8621-1.5574-.9369-1.9085-.0836-.3927-1.8762-.0769-2.2778-.3694-1.2751-.9288-3.8001-1.4138-5.3334-1.8449-3.2772-.9215-8.7085-2.9536-11.7252-5.0601-9.5357-6.6586-24.417-14.6122-33.5047-24.2164Z" fill="url(#o)" filter="url(#k)"/>
 | 
			
		||||
      <path d="M162.0989 155.8569c22.1123 13.3013 28.4714-6.7139 43.173-12.9771L179.698 90.21568c-9.4075 3.92642-18.2957 8.80465-26.5426 14.50442-12.316 8.5122-23.192 18.8995-32.1763 30.7204Z" fill="url(#p)" filter="url(#q)"/>
 | 
			
		||||
      <path d="M171.0987 290.222c-29.6829 10.6413-34.3299 11.023-37.0622 29.2903 5.2213 5.0597 10.8312 9.74 16.7926 13.9835 15.9962 11.3867 46.766 26.5517 86.1178 26.5517.0462 0 .0904-.004.1366-.004v-59.1574c-.0298.0001-.064.002-.0938.002-14.7359 0-26.5113-3.8435-38.5848-10.5273-2.9768-1.6479-8.3775 2.7772-11.1229.799-3.7865-2.7284-12.8991 2.3508-16.1833-.9378Z" fill="url(#r)" filter="url(#k)"/>
 | 
			
		||||
      <path d="M219.6997 299.0227v59.9959c5.506.6402 11.2361 1.0289 17.2472 1.0289 6.0259 0 11.8556-.3073 17.5204-.8723v-59.7481c-6.3482 1.0777-12.3272 1.461-17.4776 1.461-5.9318 0-11.7005-.6858-17.29-1.8654Z" opacity=".5" fill="url(#s)" filter="url(#k)"/>
 | 
			
		||||
    </g>
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 9.6 KiB  | 
							
								
								
									
										1
									
								
								assets/images/oidc/microsoft.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/images/oidc/microsoft.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21"><path fill="#f35325" d="M0 0h10v10H0z"/><path fill="#81bc06" d="M11 0h10v10H11z"/><path fill="#05a6f0" d="M0 11h10v10H0z"/><path fill="#ffba08" d="M11 11h10v10H11z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 232 B  | 
@@ -140,6 +140,8 @@ PODS:
 | 
			
		||||
    - nanopb/encode (= 3.30910.0)
 | 
			
		||||
  - nanopb/decode (3.30910.0)
 | 
			
		||||
  - nanopb/encode (3.30910.0)
 | 
			
		||||
  - native_exif (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - OrderedSet (6.0.3)
 | 
			
		||||
  - package_info_plus (0.4.5):
 | 
			
		||||
    - Flutter
 | 
			
		||||
@@ -158,6 +160,8 @@ PODS:
 | 
			
		||||
  - shared_preferences_foundation (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - sign_in_with_apple (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - sqflite_darwin (0.0.4):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
@@ -216,11 +220,13 @@ DEPENDENCIES:
 | 
			
		||||
  - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
 | 
			
		||||
  - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
 | 
			
		||||
  - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
 | 
			
		||||
  - native_exif (from `.symlinks/plugins/native_exif/ios`)
 | 
			
		||||
  - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
 | 
			
		||||
  - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
 | 
			
		||||
  - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
 | 
			
		||||
  - record_ios (from `.symlinks/plugins/record_ios/ios`)
 | 
			
		||||
  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
			
		||||
  - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
 | 
			
		||||
  - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
 | 
			
		||||
  - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
 | 
			
		||||
  - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
 | 
			
		||||
@@ -289,6 +295,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
 | 
			
		||||
  media_kit_video:
 | 
			
		||||
    :path: ".symlinks/plugins/media_kit_video/ios"
 | 
			
		||||
  native_exif:
 | 
			
		||||
    :path: ".symlinks/plugins/native_exif/ios"
 | 
			
		||||
  package_info_plus:
 | 
			
		||||
    :path: ".symlinks/plugins/package_info_plus/ios"
 | 
			
		||||
  pasteboard:
 | 
			
		||||
@@ -299,6 +307,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/record_ios/ios"
 | 
			
		||||
  shared_preferences_foundation:
 | 
			
		||||
    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 | 
			
		||||
  sign_in_with_apple:
 | 
			
		||||
    :path: ".symlinks/plugins/sign_in_with_apple/ios"
 | 
			
		||||
  sqflite_darwin:
 | 
			
		||||
    :path: ".symlinks/plugins/sqflite_darwin/darwin"
 | 
			
		||||
  sqlite3_flutter_libs:
 | 
			
		||||
@@ -344,6 +354,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
 | 
			
		||||
  media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
 | 
			
		||||
  nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
 | 
			
		||||
  native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c
 | 
			
		||||
  OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
 | 
			
		||||
  package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
 | 
			
		||||
  pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
 | 
			
		||||
@@ -353,6 +364,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
			
		||||
  SDWebImage: f29024626962457f3470184232766516dee8dfea
 | 
			
		||||
  shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
 | 
			
		||||
  sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
 | 
			
		||||
  sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
 | 
			
		||||
  sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
 | 
			
		||||
  sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,14 @@
 | 
			
		||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>CLIENT_ID</key>
 | 
			
		||||
	<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string>
 | 
			
		||||
	<key>REVERSED_CLIENT_ID</key>
 | 
			
		||||
	<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string>
 | 
			
		||||
	<key>PLIST_VERSION</key>
 | 
			
		||||
	<string>1</string>
 | 
			
		||||
	<key>BUNDLE_ID</key>
 | 
			
		||||
	<string>dev.solsynth.solian</string>
 | 
			
		||||
	<key>ITSAppUsesNonExemptEncryption</key>
 | 
			
		||||
	<false/>
 | 
			
		||||
	<key>CADisableMinimumFrameDurationOnPhone</key>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,14 @@
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>aps-environment</key>
 | 
			
		||||
	<string>development</string>
 | 
			
		||||
	<key>com.apple.developer.applesignin</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>Default</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>com.apple.developer.associated-domains</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>webcredentials:solian.app</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>com.apple.developer.device-information.user-assigned-device-name</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.developer.usernotifications.communication</key>
 | 
			
		||||
 
 | 
			
		||||
@@ -91,3 +91,21 @@ sealed class SnAuthDevice with _$SnAuthDevice {
 | 
			
		||||
  factory SnAuthDevice.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnAuthDeviceFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
sealed class SnAccountConnection with _$SnAccountConnection {
 | 
			
		||||
  const factory SnAccountConnection({
 | 
			
		||||
    required String id,
 | 
			
		||||
    required String accountId,
 | 
			
		||||
    required String provider,
 | 
			
		||||
    required String providedIdentifier,
 | 
			
		||||
    @Default({}) Map<String, dynamic> meta,
 | 
			
		||||
    required DateTime lastUsedAt,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
  }) = _SnAccountConnection;
 | 
			
		||||
 | 
			
		||||
  factory SnAccountConnection.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnAccountConnectionFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -847,6 +847,169 @@ as bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnAccountConnection {
 | 
			
		||||
 | 
			
		||||
 String get id; String get accountId; String get provider; String get providedIdentifier; Map<String, dynamic> get meta; DateTime get lastUsedAt; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 | 
			
		||||
/// Create a copy of SnAccountConnection
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
$SnAccountConnectionCopyWith<SnAccountConnection> get copyWith => _$SnAccountConnectionCopyWithImpl<SnAccountConnection>(this as SnAccountConnection, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnAccountConnection to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(meta),lastUsedAt,createdAt,updatedAt,deletedAt);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnAccountConnectionCopyWith<$Res>  {
 | 
			
		||||
  factory $SnAccountConnectionCopyWith(SnAccountConnection value, $Res Function(SnAccountConnection) _then) = _$SnAccountConnectionCopyWithImpl;
 | 
			
		||||
@useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String accountId, String provider, String providedIdentifier, Map<String, dynamic> meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnAccountConnectionCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnAccountConnectionCopyWith<$Res> {
 | 
			
		||||
  _$SnAccountConnectionCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnAccountConnection _self;
 | 
			
		||||
  final $Res Function(SnAccountConnection) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnAccountConnection
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_self.copyWith(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as Map<String, dynamic>,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime?,
 | 
			
		||||
  ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
 | 
			
		||||
class _SnAccountConnection implements SnAccountConnection {
 | 
			
		||||
  const _SnAccountConnection({required this.id, required this.accountId, required this.provider, required this.providedIdentifier, final  Map<String, dynamic> meta = const {}, required this.lastUsedAt, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
 | 
			
		||||
  factory _SnAccountConnection.fromJson(Map<String, dynamic> json) => _$SnAccountConnectionFromJson(json);
 | 
			
		||||
 | 
			
		||||
@override final  String id;
 | 
			
		||||
@override final  String accountId;
 | 
			
		||||
@override final  String provider;
 | 
			
		||||
@override final  String providedIdentifier;
 | 
			
		||||
 final  Map<String, dynamic> _meta;
 | 
			
		||||
@override@JsonKey() Map<String, dynamic> get meta {
 | 
			
		||||
  if (_meta is EqualUnmodifiableMapView) return _meta;
 | 
			
		||||
  // ignore: implicit_dynamic_type
 | 
			
		||||
  return EqualUnmodifiableMapView(_meta);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@override final  DateTime lastUsedAt;
 | 
			
		||||
@override final  DateTime createdAt;
 | 
			
		||||
@override final  DateTime updatedAt;
 | 
			
		||||
@override final  DateTime? deletedAt;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnAccountConnection
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
_$SnAccountConnectionCopyWith<_SnAccountConnection> get copyWith => __$SnAccountConnectionCopyWithImpl<_SnAccountConnection>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
Map<String, dynamic> toJson() {
 | 
			
		||||
  return _$SnAccountConnectionToJson(this, );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountConnection&&(identical(other.id, id) || other.id == id)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.providedIdentifier, providedIdentifier) || other.providedIdentifier == providedIdentifier)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.lastUsedAt, lastUsedAt) || other.lastUsedAt == lastUsedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hash(runtimeType,id,accountId,provider,providedIdentifier,const DeepCollectionEquality().hash(_meta),lastUsedAt,createdAt,updatedAt,deletedAt);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnAccountConnection(id: $id, accountId: $accountId, provider: $provider, providedIdentifier: $providedIdentifier, meta: $meta, lastUsedAt: $lastUsedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnAccountConnectionCopyWith<$Res> implements $SnAccountConnectionCopyWith<$Res> {
 | 
			
		||||
  factory _$SnAccountConnectionCopyWith(_SnAccountConnection value, $Res Function(_SnAccountConnection) _then) = __$SnAccountConnectionCopyWithImpl;
 | 
			
		||||
@override @useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String accountId, String provider, String providedIdentifier, Map<String, dynamic> meta, DateTime lastUsedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnAccountConnectionCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnAccountConnectionCopyWith<$Res> {
 | 
			
		||||
  __$SnAccountConnectionCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnAccountConnection _self;
 | 
			
		||||
  final $Res Function(_SnAccountConnection) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnAccountConnection
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? accountId = null,Object? provider = null,Object? providedIdentifier = null,Object? meta = null,Object? lastUsedAt = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_SnAccountConnection(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,providedIdentifier: null == providedIdentifier ? _self.providedIdentifier : providedIdentifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as Map<String, dynamic>,lastUsedAt: null == lastUsedAt ? _self.lastUsedAt : lastUsedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime?,
 | 
			
		||||
  ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// dart format on
 | 
			
		||||
 
 | 
			
		||||
@@ -155,3 +155,33 @@ Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) =>
 | 
			
		||||
      'sessions': instance.sessions.map((e) => e.toJson()).toList(),
 | 
			
		||||
      'is_current': instance.isCurrent,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnAccountConnection(
 | 
			
		||||
      id: json['id'] as String,
 | 
			
		||||
      accountId: json['account_id'] as String,
 | 
			
		||||
      provider: json['provider'] as String,
 | 
			
		||||
      providedIdentifier: json['provided_identifier'] as String,
 | 
			
		||||
      meta: json['meta'] as Map<String, dynamic>? ?? const {},
 | 
			
		||||
      lastUsedAt: DateTime.parse(json['last_used_at'] as String),
 | 
			
		||||
      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),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnAccountConnectionToJson(
 | 
			
		||||
  _SnAccountConnection instance,
 | 
			
		||||
) => <String, dynamic>{
 | 
			
		||||
  'id': instance.id,
 | 
			
		||||
  'account_id': instance.accountId,
 | 
			
		||||
  'provider': instance.provider,
 | 
			
		||||
  'provided_identifier': instance.providedIdentifier,
 | 
			
		||||
  'meta': instance.meta,
 | 
			
		||||
  'last_used_at': instance.lastUsedAt.toIso8601String(),
 | 
			
		||||
  'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
  'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
  'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ sealed class SnPost with _$SnPost {
 | 
			
		||||
    required int viewsTotal,
 | 
			
		||||
    required int upvotes,
 | 
			
		||||
    required int downvotes,
 | 
			
		||||
    required int repliesCount,
 | 
			
		||||
    required String? threadedPostId,
 | 
			
		||||
    required SnPost? threadedPost,
 | 
			
		||||
    required String? repliedPostId,
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnPost {
 | 
			
		||||
 | 
			
		||||
 String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 | 
			
		||||
 String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 | 
			
		||||
/// Create a copy of SnPost
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@@ -29,16 +29,16 @@ $SnPostCopyWith<SnPost> get copyWith => _$SnPostCopyWithImpl<SnPost>(this as SnP
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other.reactionsCount, reactionsCount)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.collections, collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other.reactionsCount, reactionsCount)&&const DeepCollectionEquality().equals(other.reactions, reactions)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.categories, categories)&&const DeepCollectionEquality().equals(other.collections, collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(meta),viewsUnique,viewsTotal,upvotes,downvotes,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(attachments),publisher,const DeepCollectionEquality().hash(reactionsCount),const DeepCollectionEquality().hash(reactions),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(collections),createdAt,updatedAt,deletedAt]);
 | 
			
		||||
int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(attachments),publisher,const DeepCollectionEquality().hash(reactionsCount),const DeepCollectionEquality().hash(reactions),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(categories),const DeepCollectionEquality().hash(collections),createdAt,updatedAt,deletedAt]);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
  return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -49,7 +49,7 @@ abstract mixin class $SnPostCopyWith<$Res>  {
 | 
			
		||||
  factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl;
 | 
			
		||||
@useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
 String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -66,7 +66,7 @@ class _$SnPostCopyWithImpl<$Res>
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnPost
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_self.copyWith(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -82,6 +82,7 @@ as Map<String, dynamic>?,viewsUnique: null == viewsUnique ? _self.viewsUnique :
 | 
			
		||||
as int,viewsTotal: null == viewsTotal ? _self.viewsTotal : viewsTotal // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,upvotes: null == upvotes ? _self.upvotes : upvotes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,downvotes: null == downvotes ? _self.downvotes : downvotes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,repliesCount: null == repliesCount ? _self.repliesCount : repliesCount // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,threadedPostId: freezed == threadedPostId ? _self.threadedPostId : threadedPostId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,threadedPost: freezed == threadedPost ? _self.threadedPost : threadedPost // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as SnPost?,repliedPostId: freezed == repliedPostId ? _self.repliedPostId : repliedPostId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -154,7 +155,7 @@ $SnPublisherCopyWith<$Res> get publisher {
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
 | 
			
		||||
class _SnPost implements SnPost {
 | 
			
		||||
  const _SnPost({required this.id, required this.title, required this.description, required this.language, required this.editedAt, required this.publishedAt, required this.visibility, required this.content, required this.type, required final  Map<String, dynamic>? meta, required this.viewsUnique, required this.viewsTotal, required this.upvotes, required this.downvotes, required this.threadedPostId, required this.threadedPost, required this.repliedPostId, required this.repliedPost, required this.forwardedPostId, required this.forwardedPost, required final  List<SnCloudFile> attachments, required this.publisher, final  Map<String, int> reactionsCount = const {}, required final  List<dynamic> reactions, required final  List<dynamic> tags, required final  List<dynamic> categories, required final  List<dynamic> collections, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections;
 | 
			
		||||
  const _SnPost({required this.id, required this.title, required this.description, required this.language, required this.editedAt, required this.publishedAt, required this.visibility, required this.content, required this.type, required final  Map<String, dynamic>? meta, required this.viewsUnique, required this.viewsTotal, required this.upvotes, required this.downvotes, required this.repliesCount, required this.threadedPostId, required this.threadedPost, required this.repliedPostId, required this.repliedPost, required this.forwardedPostId, required this.forwardedPost, required final  List<SnCloudFile> attachments, required this.publisher, final  Map<String, int> reactionsCount = const {}, required final  List<dynamic> reactions, required final  List<dynamic> tags, required final  List<dynamic> categories, required final  List<dynamic> collections, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections;
 | 
			
		||||
  factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json);
 | 
			
		||||
 | 
			
		||||
@override final  String id;
 | 
			
		||||
@@ -179,6 +180,7 @@ class _SnPost implements SnPost {
 | 
			
		||||
@override final  int viewsTotal;
 | 
			
		||||
@override final  int upvotes;
 | 
			
		||||
@override final  int downvotes;
 | 
			
		||||
@override final  int repliesCount;
 | 
			
		||||
@override final  String? threadedPostId;
 | 
			
		||||
@override final  SnPost? threadedPost;
 | 
			
		||||
@override final  String? repliedPostId;
 | 
			
		||||
@@ -245,16 +247,16 @@ Map<String, dynamic> toJson() {
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other._reactionsCount, _reactionsCount)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._collections, _collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.language, language) || other.language == language)&&(identical(other.editedAt, editedAt) || other.editedAt == editedAt)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.content, content) || other.content == content)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.viewsUnique, viewsUnique) || other.viewsUnique == viewsUnique)&&(identical(other.viewsTotal, viewsTotal) || other.viewsTotal == viewsTotal)&&(identical(other.upvotes, upvotes) || other.upvotes == upvotes)&&(identical(other.downvotes, downvotes) || other.downvotes == downvotes)&&(identical(other.repliesCount, repliesCount) || other.repliesCount == repliesCount)&&(identical(other.threadedPostId, threadedPostId) || other.threadedPostId == threadedPostId)&&(identical(other.threadedPost, threadedPost) || other.threadedPost == threadedPost)&&(identical(other.repliedPostId, repliedPostId) || other.repliedPostId == repliedPostId)&&(identical(other.repliedPost, repliedPost) || other.repliedPost == repliedPost)&&(identical(other.forwardedPostId, forwardedPostId) || other.forwardedPostId == forwardedPostId)&&(identical(other.forwardedPost, forwardedPost) || other.forwardedPost == forwardedPost)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.publisher, publisher) || other.publisher == publisher)&&const DeepCollectionEquality().equals(other._reactionsCount, _reactionsCount)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._categories, _categories)&&const DeepCollectionEquality().equals(other._collections, _collections)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(_meta),viewsUnique,viewsTotal,upvotes,downvotes,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(_attachments),publisher,const DeepCollectionEquality().hash(_reactionsCount),const DeepCollectionEquality().hash(_reactions),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_collections),createdAt,updatedAt,deletedAt]);
 | 
			
		||||
int get hashCode => Object.hashAll([runtimeType,id,title,description,language,editedAt,publishedAt,visibility,content,type,const DeepCollectionEquality().hash(_meta),viewsUnique,viewsTotal,upvotes,downvotes,repliesCount,threadedPostId,threadedPost,repliedPostId,repliedPost,forwardedPostId,forwardedPost,const DeepCollectionEquality().hash(_attachments),publisher,const DeepCollectionEquality().hash(_reactionsCount),const DeepCollectionEquality().hash(_reactions),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_categories),const DeepCollectionEquality().hash(_collections),createdAt,updatedAt,deletedAt]);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
  return 'SnPost(id: $id, title: $title, description: $description, language: $language, editedAt: $editedAt, publishedAt: $publishedAt, visibility: $visibility, content: $content, type: $type, meta: $meta, viewsUnique: $viewsUnique, viewsTotal: $viewsTotal, upvotes: $upvotes, downvotes: $downvotes, repliesCount: $repliesCount, threadedPostId: $threadedPostId, threadedPost: $threadedPost, repliedPostId: $repliedPostId, repliedPost: $repliedPost, forwardedPostId: $forwardedPostId, forwardedPost: $forwardedPost, attachments: $attachments, publisher: $publisher, reactionsCount: $reactionsCount, reactions: $reactions, tags: $tags, categories: $categories, collections: $collections, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -265,7 +267,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> {
 | 
			
		||||
  factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl;
 | 
			
		||||
@override @useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
 String id, String? title, String? description, String? language, DateTime? editedAt, DateTime publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -282,7 +284,7 @@ class __$SnPostCopyWithImpl<$Res>
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnPost
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? description = freezed,Object? language = freezed,Object? editedAt = freezed,Object? publishedAt = null,Object? visibility = null,Object? content = freezed,Object? type = null,Object? meta = freezed,Object? viewsUnique = null,Object? viewsTotal = null,Object? upvotes = null,Object? downvotes = null,Object? repliesCount = null,Object? threadedPostId = freezed,Object? threadedPost = freezed,Object? repliedPostId = freezed,Object? repliedPost = freezed,Object? forwardedPostId = freezed,Object? forwardedPost = freezed,Object? attachments = null,Object? publisher = null,Object? reactionsCount = null,Object? reactions = null,Object? tags = null,Object? categories = null,Object? collections = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_SnPost(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -298,6 +300,7 @@ as Map<String, dynamic>?,viewsUnique: null == viewsUnique ? _self.viewsUnique :
 | 
			
		||||
as int,viewsTotal: null == viewsTotal ? _self.viewsTotal : viewsTotal // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,upvotes: null == upvotes ? _self.upvotes : upvotes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,downvotes: null == downvotes ? _self.downvotes : downvotes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,repliesCount: null == repliesCount ? _self.repliesCount : repliesCount // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,threadedPostId: freezed == threadedPostId ? _self.threadedPostId : threadedPostId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,threadedPost: freezed == threadedPost ? _self.threadedPost : threadedPost // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as SnPost?,repliedPostId: freezed == repliedPostId ? _self.repliedPostId : repliedPostId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
 | 
			
		||||
  viewsTotal: (json['views_total'] as num).toInt(),
 | 
			
		||||
  upvotes: (json['upvotes'] as num).toInt(),
 | 
			
		||||
  downvotes: (json['downvotes'] as num).toInt(),
 | 
			
		||||
  repliesCount: (json['replies_count'] as num).toInt(),
 | 
			
		||||
  threadedPostId: json['threaded_post_id'] as String?,
 | 
			
		||||
  threadedPost:
 | 
			
		||||
      json['threaded_post'] == null
 | 
			
		||||
@@ -76,6 +77,7 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
 | 
			
		||||
  'views_total': instance.viewsTotal,
 | 
			
		||||
  'upvotes': instance.upvotes,
 | 
			
		||||
  'downvotes': instance.downvotes,
 | 
			
		||||
  'replies_count': instance.repliesCount,
 | 
			
		||||
  'threaded_post_id': instance.threadedPostId,
 | 
			
		||||
  'threaded_post': instance.threadedPost?.toJson(),
 | 
			
		||||
  'replied_post_id': instance.repliedPostId,
 | 
			
		||||
 
 | 
			
		||||
@@ -16,27 +16,42 @@ import 'config.dart';
 | 
			
		||||
final imagePickerProvider = Provider((ref) => ImagePicker());
 | 
			
		||||
 | 
			
		||||
final userAgentProvider = FutureProvider<String>((ref) async {
 | 
			
		||||
  // Helper function to sanitize strings for HTTP headers
 | 
			
		||||
  String sanitizeForHeader(String input) {
 | 
			
		||||
    // Remove or replace characters that are not allowed in HTTP headers
 | 
			
		||||
    // Keep only ASCII printable characters (32-126) and replace others with underscore
 | 
			
		||||
    return input.runes.map((rune) {
 | 
			
		||||
      if (rune >= 32 && rune <= 126) {
 | 
			
		||||
        return String.fromCharCode(rune);
 | 
			
		||||
      } else {
 | 
			
		||||
        return '_';
 | 
			
		||||
      }
 | 
			
		||||
    }).join();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final String platformInfo;
 | 
			
		||||
  if (kIsWeb) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
 | 
			
		||||
    platformInfo = 'Web; ${deviceInfo.vendor}';
 | 
			
		||||
    platformInfo = 'Web; ${sanitizeForHeader(deviceInfo.vendor ?? 'Unknown')}';
 | 
			
		||||
  } else if (Platform.isAndroid) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().androidInfo;
 | 
			
		||||
    platformInfo =
 | 
			
		||||
        'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
 | 
			
		||||
        'Android; ${sanitizeForHeader(deviceInfo.brand)} ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.id)}';
 | 
			
		||||
  } else if (Platform.isIOS) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().iosInfo;
 | 
			
		||||
    platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
 | 
			
		||||
    platformInfo =
 | 
			
		||||
        'iOS; ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.name)}';
 | 
			
		||||
  } else if (Platform.isMacOS) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().macOsInfo;
 | 
			
		||||
    platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
 | 
			
		||||
    platformInfo =
 | 
			
		||||
        'MacOS; ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.hostName)}';
 | 
			
		||||
  } else if (Platform.isWindows) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().windowsInfo;
 | 
			
		||||
    platformInfo =
 | 
			
		||||
        'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
 | 
			
		||||
        'Windows NT; ${sanitizeForHeader(deviceInfo.productName)}; ${sanitizeForHeader(deviceInfo.computerName)}';
 | 
			
		||||
  } else if (Platform.isLinux) {
 | 
			
		||||
    final deviceInfo = await DeviceInfoPlugin().linuxInfo;
 | 
			
		||||
    platformInfo = 'Linux; ${deviceInfo.prettyName}';
 | 
			
		||||
    platformInfo = 'Linux; ${sanitizeForHeader(deviceInfo.prettyName)}';
 | 
			
		||||
  } else {
 | 
			
		||||
    platformInfo = 'Unknown';
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
 | 
			
		||||
    final prefs = _ref.read(sharedPreferencesProvider);
 | 
			
		||||
    await prefs.remove(kTokenPairStoreKey);
 | 
			
		||||
    _ref.invalidate(userInfoProvider);
 | 
			
		||||
    _ref.invalidate(tokenProvider);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,14 +8,14 @@ class AppRouter extends RootStackRouter {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  List<AutoRoute> get routes => [
 | 
			
		||||
    AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'),
 | 
			
		||||
    AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'),
 | 
			
		||||
    AutoRoute(
 | 
			
		||||
      page: ExploreShellRoute.page,
 | 
			
		||||
      path: '/',
 | 
			
		||||
      children: [
 | 
			
		||||
        AutoRoute(page: ExploreRoute.page, path: ''),
 | 
			
		||||
        AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'),
 | 
			
		||||
        AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'),
 | 
			
		||||
        AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'),
 | 
			
		||||
        AutoRoute(page: PublisherProfileRoute.page, path: 'publishers/:name'),
 | 
			
		||||
      ],
 | 
			
		||||
    ),
 | 
			
		||||
@@ -51,6 +51,7 @@ class AppRouter extends RootStackRouter {
 | 
			
		||||
      path: '/creators',
 | 
			
		||||
      children: [
 | 
			
		||||
        AutoRoute(page: CreatorHubRoute.page, path: ''),
 | 
			
		||||
        AutoRoute(page: CreatorPostListRoute.page, path: ':name/posts'),
 | 
			
		||||
        AutoRoute(page: StickersRoute.page, path: ':name/stickers'),
 | 
			
		||||
        AutoRoute(page: NewStickerPacksRoute.page, path: ':name/stickers/new'),
 | 
			
		||||
        AutoRoute(
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -221,16 +221,6 @@ class AccountScreen extends HookConsumerWidget {
 | 
			
		||||
                context.router.push(RelationshipRoute());
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              minTileHeight: 48,
 | 
			
		||||
              leading: const Icon(Symbols.edit),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              title: Text('updateYourProfile').tr(),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                context.router.push(UpdateProfileRoute());
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            const Divider(height: 1).padding(vertical: 8),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              minTileHeight: 48,
 | 
			
		||||
@@ -242,6 +232,16 @@ class AccountScreen extends HookConsumerWidget {
 | 
			
		||||
                context.router.push(SettingsRoute());
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              minTileHeight: 48,
 | 
			
		||||
              leading: const Icon(Symbols.person_edit),
 | 
			
		||||
              trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 24),
 | 
			
		||||
              title: Text('updateYourProfile').tr(),
 | 
			
		||||
              onTap: () {
 | 
			
		||||
                context.router.push(UpdateProfileRoute());
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            ListTile(
 | 
			
		||||
              minTileHeight: 48,
 | 
			
		||||
              leading: const Icon(Symbols.manage_accounts),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:auto_route/annotations.dart';
 | 
			
		||||
@@ -6,24 +5,22 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/auth.dart';
 | 
			
		||||
import 'package:island/models/user.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/pods/userinfo.dart';
 | 
			
		||||
import 'package:island/screens/account/me/settings_auth_factors.dart';
 | 
			
		||||
import 'package:island/screens/account/me/settings_connections.dart';
 | 
			
		||||
import 'package:island/screens/account/me/settings_contacts.dart';
 | 
			
		||||
import 'package:island/screens/auth/captcha.dart';
 | 
			
		||||
import 'package:island/screens/auth/login.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
import 'package:island/widgets/account/account_session_sheet.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:island/widgets/response.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:qr_flutter/qr_flutter.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +42,15 @@ Future<List<SnContactMethod>> contactMethods(Ref ref) async {
 | 
			
		||||
      .toList();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@riverpod
 | 
			
		||||
Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
 | 
			
		||||
  final client = ref.read(apiClientProvider);
 | 
			
		||||
  final resp = await client.get('/accounts/me/connections');
 | 
			
		||||
  return resp.data
 | 
			
		||||
      .map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e))
 | 
			
		||||
      .toList();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
  const AccountSettingsScreen({super.key});
 | 
			
		||||
@@ -122,6 +128,96 @@ class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      ExpansionTile(
 | 
			
		||||
        leading: const Icon(
 | 
			
		||||
          Symbols.link,
 | 
			
		||||
        ).alignment(Alignment.centerLeft).width(48),
 | 
			
		||||
        title: Text('accountConnections').tr(),
 | 
			
		||||
        subtitle: Text('accountConnectionsDescription').tr().fontSize(12),
 | 
			
		||||
        tilePadding: const EdgeInsets.only(left: 24, right: 17),
 | 
			
		||||
        children: [
 | 
			
		||||
          ref
 | 
			
		||||
              .watch(accountConnectionsProvider)
 | 
			
		||||
              .when(
 | 
			
		||||
                data:
 | 
			
		||||
                    (connections) => Column(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        for (final connection in connections)
 | 
			
		||||
                          ListTile(
 | 
			
		||||
                            minLeadingWidth: 48,
 | 
			
		||||
                            contentPadding: const EdgeInsets.only(
 | 
			
		||||
                              left: 16,
 | 
			
		||||
                              right: 17,
 | 
			
		||||
                              top: 2,
 | 
			
		||||
                              bottom: 4,
 | 
			
		||||
                            ),
 | 
			
		||||
                            title:
 | 
			
		||||
                                Text(
 | 
			
		||||
                                  getLocalizedProviderName(connection.provider),
 | 
			
		||||
                                ).tr(),
 | 
			
		||||
                            subtitle:
 | 
			
		||||
                                connection.meta['email'] != null
 | 
			
		||||
                                    ? Text(connection.meta['email'])
 | 
			
		||||
                                    : Text(connection.providedIdentifier),
 | 
			
		||||
                            leading: CircleAvatar(
 | 
			
		||||
                              child: getProviderIcon(
 | 
			
		||||
                                connection.provider,
 | 
			
		||||
                                size: 16,
 | 
			
		||||
                                color:
 | 
			
		||||
                                    Theme.of(
 | 
			
		||||
                                      context,
 | 
			
		||||
                                    ).colorScheme.onPrimaryContainer,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ).padding(top: 4),
 | 
			
		||||
                            trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              showModalBottomSheet(
 | 
			
		||||
                                context: context,
 | 
			
		||||
                                builder:
 | 
			
		||||
                                    (context) => AccountConnectionSheet(
 | 
			
		||||
                                      connection: connection,
 | 
			
		||||
                                    ),
 | 
			
		||||
                              ).then((value) {
 | 
			
		||||
                                if (value == true) {
 | 
			
		||||
                                  ref.invalidate(accountConnectionsProvider);
 | 
			
		||||
                                }
 | 
			
		||||
                              });
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                        if (connections.isNotEmpty) const Divider(height: 1),
 | 
			
		||||
                        ListTile(
 | 
			
		||||
                          minLeadingWidth: 48,
 | 
			
		||||
                          contentPadding: const EdgeInsets.only(
 | 
			
		||||
                            left: 24,
 | 
			
		||||
                            right: 17,
 | 
			
		||||
                          ),
 | 
			
		||||
                          title: Text('accountConnectionAdd').tr(),
 | 
			
		||||
                          leading: const Icon(Symbols.add),
 | 
			
		||||
                          trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                          onTap: () {
 | 
			
		||||
                            showModalBottomSheet(
 | 
			
		||||
                              context: context,
 | 
			
		||||
                              builder:
 | 
			
		||||
                                  (context) =>
 | 
			
		||||
                                      const AccountConnectionNewSheet(),
 | 
			
		||||
                            ).then((value) {
 | 
			
		||||
                              if (value == true) {
 | 
			
		||||
                                ref.invalidate(accountConnectionsProvider);
 | 
			
		||||
                              }
 | 
			
		||||
                            });
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                error:
 | 
			
		||||
                    (err, _) => ResponseErrorWidget(
 | 
			
		||||
                      error: err,
 | 
			
		||||
                      onRetry: () => ref.invalidate(accountConnectionsProvider),
 | 
			
		||||
                    ),
 | 
			
		||||
                loading: () => const ResponseLoadingWidget(),
 | 
			
		||||
              ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      ExpansionTile(
 | 
			
		||||
        leading: const Icon(
 | 
			
		||||
          Symbols.security,
 | 
			
		||||
@@ -184,7 +280,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
                          showModalBottomSheet(
 | 
			
		||||
                            context: context,
 | 
			
		||||
                            builder:
 | 
			
		||||
                                (context) => _AuthFactorSheet(factor: factor),
 | 
			
		||||
                                (context) => AuthFactorSheet(factor: factor),
 | 
			
		||||
                          ).then((value) {
 | 
			
		||||
                            if (value == true) {
 | 
			
		||||
                              ref.invalidate(authFactorsProvider);
 | 
			
		||||
@@ -205,7 +301,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
                      onTap: () {
 | 
			
		||||
                        showModalBottomSheet(
 | 
			
		||||
                          context: context,
 | 
			
		||||
                          builder: (context) => const _AuthFactorNewSheet(),
 | 
			
		||||
                          builder: (context) => const AuthFactorNewSheet(),
 | 
			
		||||
                        ).then((value) {
 | 
			
		||||
                          if (value == true) {
 | 
			
		||||
                            ref.invalidate(authFactorsProvider);
 | 
			
		||||
@@ -289,7 +385,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
                                context: context,
 | 
			
		||||
                                builder:
 | 
			
		||||
                                    (context) =>
 | 
			
		||||
                                        _ContactMethodSheet(contact: contact),
 | 
			
		||||
                                        ContactMethodSheet(contact: contact),
 | 
			
		||||
                              ).then((value) {
 | 
			
		||||
                                if (value == true) {
 | 
			
		||||
                                  ref.invalidate(contactMethodsProvider);
 | 
			
		||||
@@ -311,7 +407,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
 | 
			
		||||
                            showModalBottomSheet(
 | 
			
		||||
                              context: context,
 | 
			
		||||
                              builder:
 | 
			
		||||
                                  (context) => const _ContactMethodNewSheet(),
 | 
			
		||||
                                  (context) => const ContactMethodNewSheet(),
 | 
			
		||||
                            ).then((value) {
 | 
			
		||||
                              if (value == true) {
 | 
			
		||||
                                ref.invalidate(contactMethodsProvider);
 | 
			
		||||
@@ -471,599 +567,3 @@ class _SettingsSection extends StatelessWidget {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AuthFactorSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnAuthFactor factor;
 | 
			
		||||
  const _AuthFactorSheet({required this.factor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    Future<void> deleteFactor() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'authFactorDeleteHint'.tr(),
 | 
			
		||||
        'authFactorDelete'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.delete('/accounts/me/factors/${factor.id}');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> disableFactor() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'authFactorDisableHint'.tr(),
 | 
			
		||||
        'authFactorDisable'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/factors/${factor.id}/disable');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> enableFactor() async {
 | 
			
		||||
      String? password;
 | 
			
		||||
      if ([3].contains(factor.type)) {
 | 
			
		||||
        final confirmed = await showDialog<bool>(
 | 
			
		||||
          context: context,
 | 
			
		||||
          builder:
 | 
			
		||||
              (context) => AlertDialog(
 | 
			
		||||
                title: Text('authFactorEnable').tr(),
 | 
			
		||||
                content: Column(
 | 
			
		||||
                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text('authFactorEnableHint').tr(),
 | 
			
		||||
                    const SizedBox(height: 16),
 | 
			
		||||
                    OtpTextField(
 | 
			
		||||
                      showCursor: false,
 | 
			
		||||
                      numberOfFields: 6,
 | 
			
		||||
                      obscureText: false,
 | 
			
		||||
                      showFieldAsBox: true,
 | 
			
		||||
                      focusedBorderColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                      onSubmit: (String verificationCode) {
 | 
			
		||||
                        password = verificationCode;
 | 
			
		||||
                      },
 | 
			
		||||
                      textStyle: Theme.of(context).textTheme.titleLarge!,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
                actions: [
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                    onPressed: () => Navigator.of(context).pop(false),
 | 
			
		||||
                    child: Text('cancel').tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                    onPressed: () => Navigator.of(context).pop(true),
 | 
			
		||||
                    child: Text('confirm').tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
        );
 | 
			
		||||
        if (confirmed == false ||
 | 
			
		||||
            (password?.isEmpty ?? true) ||
 | 
			
		||||
            !context.mounted) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post(
 | 
			
		||||
          '/accounts/me/factors/${factor.id}/enable',
 | 
			
		||||
          data: jsonEncode(password),
 | 
			
		||||
        );
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactor'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              Icon(kFactorTypes[factor.type]!.$3, size: 32),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(kFactorTypes[factor.type]!.$1).tr(),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              Text(
 | 
			
		||||
                kFactorTypes[factor.type]!.$2,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ).tr(),
 | 
			
		||||
              const Gap(10),
 | 
			
		||||
              Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  if (factor.enabledAt == null)
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('authFactorDisabled'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onSecondary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                    )
 | 
			
		||||
                  else
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('authFactorEnabled'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onPrimary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(all: 20),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          if (factor.enabledAt != null)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.disabled_by_default),
 | 
			
		||||
              title: Text('authFactorDisable').tr(),
 | 
			
		||||
              onTap: disableFactor,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.check_circle),
 | 
			
		||||
              title: Text('authFactorEnable').tr(),
 | 
			
		||||
              onTap: enableFactor,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.delete),
 | 
			
		||||
            title: Text('authFactorDelete').tr(),
 | 
			
		||||
            onTap: deleteFactor,
 | 
			
		||||
            contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AuthFactorNewSheet extends HookConsumerWidget {
 | 
			
		||||
  const _AuthFactorNewSheet();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final factorType = useState<int>(0);
 | 
			
		||||
    final secretController = useTextEditingController();
 | 
			
		||||
 | 
			
		||||
    Future<void> addFactor() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final apiClient = ref.read(apiClientProvider);
 | 
			
		||||
        final resp = await apiClient.post(
 | 
			
		||||
          '/accounts/me/factors',
 | 
			
		||||
          data: {'type': factorType.value, 'secret': secretController.text},
 | 
			
		||||
        );
 | 
			
		||||
        final factor = SnAuthFactor.fromJson(resp.data);
 | 
			
		||||
        if (!context.mounted) return;
 | 
			
		||||
        hideLoadingModal(context);
 | 
			
		||||
        if (factor.type == 3) {
 | 
			
		||||
          showModalBottomSheet(
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => _AuthFactorNewAdditonalSheet(factor: factor),
 | 
			
		||||
          ).then((_) {
 | 
			
		||||
            if (context.mounted) {
 | 
			
		||||
              showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
 | 
			
		||||
            }
 | 
			
		||||
            if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          Navigator.pop(context, true);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactorNew'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        spacing: 16,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonFormField<int>(
 | 
			
		||||
            value: factorType.value,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              labelText: 'authFactor'.tr(),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            items:
 | 
			
		||||
                kFactorTypes.entries.map((entry) {
 | 
			
		||||
                  return DropdownMenuItem<int>(
 | 
			
		||||
                    value: entry.key,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Icon(entry.value.$3),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Text(entry.value.$1).tr(),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                }).toList(),
 | 
			
		||||
            onChanged: (value) {
 | 
			
		||||
              if (value != null) {
 | 
			
		||||
                factorType.value = value;
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          if (factorType.value == 0)
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: secretController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                prefixIcon: const Icon(Symbols.password_2),
 | 
			
		||||
                labelText: 'authFactorSecret'.tr(),
 | 
			
		||||
                hintText: 'authFactorSecretHint'.tr(),
 | 
			
		||||
                border: const OutlineInputBorder(),
 | 
			
		||||
              ),
 | 
			
		||||
              onTapOutside:
 | 
			
		||||
                  (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child: Text(kFactorTypes[factorType.value]!.$2).tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              TextButton.icon(
 | 
			
		||||
                onPressed: addFactor,
 | 
			
		||||
                icon: Icon(Symbols.add),
 | 
			
		||||
                label: Text('create').tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _AuthFactorNewAdditonalSheet extends StatelessWidget {
 | 
			
		||||
  final SnAuthFactor factor;
 | 
			
		||||
  const _AuthFactorNewAdditonalSheet({required this.factor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final uri = factor.createdResponse?['uri'];
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactorAdditional'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          if (uri != null) ...[
 | 
			
		||||
            const SizedBox(height: 16),
 | 
			
		||||
            Center(
 | 
			
		||||
              child: ClipRRect(
 | 
			
		||||
                borderRadius: BorderRadius.circular(16),
 | 
			
		||||
                child: QrImageView(
 | 
			
		||||
                  data: uri,
 | 
			
		||||
                  version: QrVersions.auto,
 | 
			
		||||
                  size: 200,
 | 
			
		||||
                  backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                  foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                'authFactorQrCodeScan'.tr(),
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ] else ...[
 | 
			
		||||
            const SizedBox(height: 16),
 | 
			
		||||
            Center(
 | 
			
		||||
              child: Text(
 | 
			
		||||
                'authFactorNoQrCode'.tr(),
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
            child: TextButton.icon(
 | 
			
		||||
              onPressed: () => Navigator.of(context).pop(),
 | 
			
		||||
              icon: const Icon(Symbols.check),
 | 
			
		||||
              label: Text('next'.tr()),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ContactMethodSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnContactMethod contact;
 | 
			
		||||
  const _ContactMethodSheet({required this.contact});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    Future<void> deleteContactMethod() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'contactMethodDeleteHint'.tr(),
 | 
			
		||||
        'contactMethodDelete'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.delete('/accounts/me/contacts/${contact.id}');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> verifyContactMethod() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/contacts/${contact.id}/verify');
 | 
			
		||||
        if (context.mounted) {
 | 
			
		||||
          showSnackBar(context, 'contactMethodVerificationSent'.tr());
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> setContactMethodAsPrimary() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/contacts/${contact.id}/primary');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'contactMethod'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              Icon(switch (contact.type) {
 | 
			
		||||
                0 => Symbols.mail,
 | 
			
		||||
                1 => Symbols.phone,
 | 
			
		||||
                _ => Symbols.home,
 | 
			
		||||
              }, size: 32),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(switch (contact.type) {
 | 
			
		||||
                0 => 'contactMethodTypeEmail'.tr(),
 | 
			
		||||
                1 => 'contactMethodTypePhone'.tr(),
 | 
			
		||||
                _ => 'contactMethodTypeAddress'.tr(),
 | 
			
		||||
              }),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              Text(
 | 
			
		||||
                contact.content,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(10),
 | 
			
		||||
              Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  if (contact.verifiedAt == null)
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('contactMethodUnverified'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onSecondary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                    )
 | 
			
		||||
                  else
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('contactMethodVerified'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onPrimary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                    ),
 | 
			
		||||
                  if (contact.isPrimary)
 | 
			
		||||
                    Padding(
 | 
			
		||||
                      padding: const EdgeInsets.only(left: 8.0),
 | 
			
		||||
                      child: Badge(
 | 
			
		||||
                        label: Text('contactMethodPrimary'.tr()),
 | 
			
		||||
                        textColor: Theme.of(context).colorScheme.onTertiary,
 | 
			
		||||
                        backgroundColor: Theme.of(context).colorScheme.tertiary,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(all: 20),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          if (contact.verifiedAt == null)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.verified),
 | 
			
		||||
              title: Text('contactMethodVerify').tr(),
 | 
			
		||||
              onTap: verifyContactMethod,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          if (contact.verifiedAt != null && !contact.isPrimary)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.star),
 | 
			
		||||
              title: Text('contactMethodSetPrimary').tr(),
 | 
			
		||||
              onTap: setContactMethodAsPrimary,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.delete),
 | 
			
		||||
            title: Text('contactMethodDelete').tr(),
 | 
			
		||||
            onTap: deleteContactMethod,
 | 
			
		||||
            contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ContactMethodNewSheet extends HookConsumerWidget {
 | 
			
		||||
  const _ContactMethodNewSheet();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final contactType = useState<int>(0);
 | 
			
		||||
    final contentController = useTextEditingController();
 | 
			
		||||
 | 
			
		||||
    Future<void> addContactMethod() async {
 | 
			
		||||
      if (contentController.text.isEmpty) {
 | 
			
		||||
        showSnackBar(context, 'contactMethodContentEmpty'.tr());
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final apiClient = ref.read(apiClientProvider);
 | 
			
		||||
        await apiClient.post(
 | 
			
		||||
          '/accounts/me/contacts',
 | 
			
		||||
          data: {'type': contactType.value, 'content': contentController.text},
 | 
			
		||||
        );
 | 
			
		||||
        if (context.mounted) {
 | 
			
		||||
          showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
 | 
			
		||||
          Navigator.pop(context, true);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'contactMethodNew'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        spacing: 16,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonFormField<int>(
 | 
			
		||||
            value: contactType.value,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              labelText: 'contactMethodType'.tr(),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            items: [
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 0,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.mail),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypeEmail'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 1,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.phone),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypePhone'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 2,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.home),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypeAddress'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
            onChanged: (value) {
 | 
			
		||||
              if (value != null) {
 | 
			
		||||
                contactType.value = value;
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          TextField(
 | 
			
		||||
            controller: contentController,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              prefixIcon: Icon(switch (contactType.value) {
 | 
			
		||||
                0 => Symbols.mail,
 | 
			
		||||
                1 => Symbols.phone,
 | 
			
		||||
                _ => Symbols.home,
 | 
			
		||||
              }),
 | 
			
		||||
              labelText: switch (contactType.value) {
 | 
			
		||||
                0 => 'contactMethodTypeEmail'.tr(),
 | 
			
		||||
                1 => 'contactMethodTypePhone'.tr(),
 | 
			
		||||
                _ => 'contactMethodTypeAddress'.tr(),
 | 
			
		||||
              },
 | 
			
		||||
              hintText: switch (contactType.value) {
 | 
			
		||||
                0 => 'contactMethodEmailHint'.tr(),
 | 
			
		||||
                1 => 'contactMethodPhoneHint'.tr(),
 | 
			
		||||
                _ => 'contactMethodAddressHint'.tr(),
 | 
			
		||||
              },
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            keyboardType: switch (contactType.value) {
 | 
			
		||||
              0 => TextInputType.emailAddress,
 | 
			
		||||
              1 => TextInputType.phone,
 | 
			
		||||
              _ => TextInputType.multiline,
 | 
			
		||||
            },
 | 
			
		||||
            maxLines: switch (contactType.value) {
 | 
			
		||||
              2 => 3,
 | 
			
		||||
              _ => 1,
 | 
			
		||||
            },
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child:
 | 
			
		||||
                Text(switch (contactType.value) {
 | 
			
		||||
                  0 => 'contactMethodEmailDescription',
 | 
			
		||||
                  1 => 'contactMethodPhoneDescription',
 | 
			
		||||
                  _ => 'contactMethodAddressDescription',
 | 
			
		||||
                }).tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              TextButton.icon(
 | 
			
		||||
                onPressed: addContactMethod,
 | 
			
		||||
                icon: Icon(Symbols.add),
 | 
			
		||||
                label: Text('create').tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -44,5 +44,26 @@ final contactMethodsProvider =
 | 
			
		||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
 | 
			
		||||
// ignore: unused_element
 | 
			
		||||
typedef ContactMethodsRef = AutoDisposeFutureProviderRef<List<SnContactMethod>>;
 | 
			
		||||
String _$accountConnectionsHash() =>
 | 
			
		||||
    r'38a309d596e0ea2539cd92ea86984e1e4fb346e4';
 | 
			
		||||
 | 
			
		||||
/// See also [accountConnections].
 | 
			
		||||
@ProviderFor(accountConnections)
 | 
			
		||||
final accountConnectionsProvider =
 | 
			
		||||
    AutoDisposeFutureProvider<List<SnAccountConnection>>.internal(
 | 
			
		||||
      accountConnections,
 | 
			
		||||
      name: r'accountConnectionsProvider',
 | 
			
		||||
      debugGetCreateSourceHash:
 | 
			
		||||
          const bool.fromEnvironment('dart.vm.product')
 | 
			
		||||
              ? null
 | 
			
		||||
              : _$accountConnectionsHash,
 | 
			
		||||
      dependencies: null,
 | 
			
		||||
      allTransitiveDependencies: null,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
 | 
			
		||||
// ignore: unused_element
 | 
			
		||||
typedef AccountConnectionsRef =
 | 
			
		||||
    AutoDisposeFutureProviderRef<List<SnAccountConnection>>;
 | 
			
		||||
// ignore_for_file: type=lint
 | 
			
		||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										342
									
								
								lib/screens/account/me/settings_auth_factors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										342
									
								
								lib/screens/account/me/settings_auth_factors.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,342 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/auth.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/screens/auth/login.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:qr_flutter/qr_flutter.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
class AuthFactorSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnAuthFactor factor;
 | 
			
		||||
  const AuthFactorSheet({super.key, required this.factor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    Future<void> deleteFactor() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'authFactorDeleteHint'.tr(),
 | 
			
		||||
        'authFactorDelete'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.delete('/accounts/me/factors/${factor.id}');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> disableFactor() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'authFactorDisableHint'.tr(),
 | 
			
		||||
        'authFactorDisable'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/factors/${factor.id}/disable');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> enableFactor() async {
 | 
			
		||||
      String? password;
 | 
			
		||||
      if ([3].contains(factor.type)) {
 | 
			
		||||
        final confirmed = await showDialog<bool>(
 | 
			
		||||
          context: context,
 | 
			
		||||
          builder:
 | 
			
		||||
              (context) => AlertDialog(
 | 
			
		||||
                title: Text('authFactorEnable').tr(),
 | 
			
		||||
                content: Column(
 | 
			
		||||
                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text('authFactorEnableHint').tr(),
 | 
			
		||||
                    const SizedBox(height: 16),
 | 
			
		||||
                    OtpTextField(
 | 
			
		||||
                      showCursor: false,
 | 
			
		||||
                      numberOfFields: 6,
 | 
			
		||||
                      obscureText: false,
 | 
			
		||||
                      showFieldAsBox: true,
 | 
			
		||||
                      focusedBorderColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                      onSubmit: (String verificationCode) {
 | 
			
		||||
                        password = verificationCode;
 | 
			
		||||
                      },
 | 
			
		||||
                      textStyle: Theme.of(context).textTheme.titleLarge!,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
                actions: [
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                    onPressed: () => Navigator.of(context).pop(false),
 | 
			
		||||
                    child: Text('cancel').tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                    onPressed: () => Navigator.of(context).pop(true),
 | 
			
		||||
                    child: Text('confirm').tr(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
        );
 | 
			
		||||
        if (confirmed == false ||
 | 
			
		||||
            (password?.isEmpty ?? true) ||
 | 
			
		||||
            !context.mounted) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post(
 | 
			
		||||
          '/accounts/me/factors/${factor.id}/enable',
 | 
			
		||||
          data: jsonEncode(password),
 | 
			
		||||
        );
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactor'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              Icon(kFactorTypes[factor.type]!.$3, size: 32),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(kFactorTypes[factor.type]!.$1).tr(),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              Text(
 | 
			
		||||
                kFactorTypes[factor.type]!.$2,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ).tr(),
 | 
			
		||||
              const Gap(10),
 | 
			
		||||
              Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  if (factor.enabledAt == null)
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('authFactorDisabled'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onSecondary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                    )
 | 
			
		||||
                  else
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('authFactorEnabled'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onPrimary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(all: 20),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          if (factor.enabledAt != null)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.disabled_by_default),
 | 
			
		||||
              title: Text('authFactorDisable').tr(),
 | 
			
		||||
              onTap: disableFactor,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            )
 | 
			
		||||
          else
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.check_circle),
 | 
			
		||||
              title: Text('authFactorEnable').tr(),
 | 
			
		||||
              onTap: enableFactor,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.delete),
 | 
			
		||||
            title: Text('authFactorDelete').tr(),
 | 
			
		||||
            onTap: deleteFactor,
 | 
			
		||||
            contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AuthFactorNewSheet extends HookConsumerWidget {
 | 
			
		||||
  const AuthFactorNewSheet({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final factorType = useState<int>(0);
 | 
			
		||||
    final secretController = useTextEditingController();
 | 
			
		||||
 | 
			
		||||
    Future<void> addFactor() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final apiClient = ref.read(apiClientProvider);
 | 
			
		||||
        final resp = await apiClient.post(
 | 
			
		||||
          '/accounts/me/factors',
 | 
			
		||||
          data: {'type': factorType.value, 'secret': secretController.text},
 | 
			
		||||
        );
 | 
			
		||||
        final factor = SnAuthFactor.fromJson(resp.data);
 | 
			
		||||
        if (!context.mounted) return;
 | 
			
		||||
        hideLoadingModal(context);
 | 
			
		||||
        if (factor.type == 3) {
 | 
			
		||||
          showModalBottomSheet(
 | 
			
		||||
            context: context,
 | 
			
		||||
            builder: (context) => AuthFactorNewAdditonalSheet(factor: factor),
 | 
			
		||||
          ).then((_) {
 | 
			
		||||
            if (context.mounted) {
 | 
			
		||||
              showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
 | 
			
		||||
            }
 | 
			
		||||
            if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          Navigator.pop(context, true);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactorNew'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        spacing: 16,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonFormField<int>(
 | 
			
		||||
            value: factorType.value,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              labelText: 'authFactor'.tr(),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            items:
 | 
			
		||||
                kFactorTypes.entries.map((entry) {
 | 
			
		||||
                  return DropdownMenuItem<int>(
 | 
			
		||||
                    value: entry.key,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Icon(entry.value.$3),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        Text(entry.value.$1).tr(),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                }).toList(),
 | 
			
		||||
            onChanged: (value) {
 | 
			
		||||
              if (value != null) {
 | 
			
		||||
                factorType.value = value;
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          if (factorType.value == 0)
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: secretController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                prefixIcon: const Icon(Symbols.password_2),
 | 
			
		||||
                labelText: 'authFactorSecret'.tr(),
 | 
			
		||||
                hintText: 'authFactorSecretHint'.tr(),
 | 
			
		||||
                border: const OutlineInputBorder(),
 | 
			
		||||
              ),
 | 
			
		||||
              onTapOutside:
 | 
			
		||||
                  (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child: Text(kFactorTypes[factorType.value]!.$2).tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              TextButton.icon(
 | 
			
		||||
                onPressed: addFactor,
 | 
			
		||||
                icon: Icon(Symbols.add),
 | 
			
		||||
                label: Text('create').tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AuthFactorNewAdditonalSheet extends StatelessWidget {
 | 
			
		||||
  final SnAuthFactor factor;
 | 
			
		||||
  const AuthFactorNewAdditonalSheet({super.key, required this.factor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final uri = factor.createdResponse?['uri'];
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'authFactorAdditional'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          if (uri != null) ...[
 | 
			
		||||
            const SizedBox(height: 16),
 | 
			
		||||
            Center(
 | 
			
		||||
              child: ClipRRect(
 | 
			
		||||
                borderRadius: BorderRadius.circular(16),
 | 
			
		||||
                child: QrImageView(
 | 
			
		||||
                  data: uri,
 | 
			
		||||
                  version: QrVersions.auto,
 | 
			
		||||
                  size: 200,
 | 
			
		||||
                  backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                  foregroundColor: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                'authFactorQrCodeScan'.tr(),
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ] else ...[
 | 
			
		||||
            const SizedBox(height: 16),
 | 
			
		||||
            Center(
 | 
			
		||||
              child: Text(
 | 
			
		||||
                'authFactorNoQrCode'.tr(),
 | 
			
		||||
                textAlign: TextAlign.center,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodyMedium,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
            child: TextButton.icon(
 | 
			
		||||
              onPressed: () => Navigator.of(context).pop(),
 | 
			
		||||
              icon: const Icon(Symbols.check),
 | 
			
		||||
              label: Text('next'.tr()),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										381
									
								
								lib/screens/account/me/settings_connections.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								lib/screens/account/me/settings_connections.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,381 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:flutter_svg/flutter_svg.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/auth.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/screens/account/me/settings.dart';
 | 
			
		||||
import 'package:island/screens/auth/oidc.native.dart';
 | 
			
		||||
import 'package:island/services/text.dart';
 | 
			
		||||
import 'package:island/services/time.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:island/widgets/response.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
// Helper function to get provider icon and localized name
 | 
			
		||||
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
 | 
			
		||||
  final providerLower = provider.toLowerCase();
 | 
			
		||||
 | 
			
		||||
  // Check if we have an SVG for this provider
 | 
			
		||||
  switch (providerLower) {
 | 
			
		||||
    case 'apple':
 | 
			
		||||
    case 'microsoft':
 | 
			
		||||
    case 'google':
 | 
			
		||||
    case 'github':
 | 
			
		||||
    case 'discord':
 | 
			
		||||
      return SvgPicture.asset(
 | 
			
		||||
        'assets/images/oidc/$providerLower.svg',
 | 
			
		||||
        width: size,
 | 
			
		||||
        height: size,
 | 
			
		||||
        color: color,
 | 
			
		||||
      );
 | 
			
		||||
    default:
 | 
			
		||||
      return Icon(Symbols.link, size: size);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
String getLocalizedProviderName(String provider) {
 | 
			
		||||
  switch (provider.toLowerCase()) {
 | 
			
		||||
    case 'apple':
 | 
			
		||||
      return 'accountConnectionProviderApple'.tr();
 | 
			
		||||
    case 'microsoft':
 | 
			
		||||
      return 'accountConnectionProviderMicrosoft'.tr();
 | 
			
		||||
    case 'google':
 | 
			
		||||
      return 'accountConnectionProviderGoogle'.tr();
 | 
			
		||||
    case 'github':
 | 
			
		||||
      return 'accountConnectionProviderGithub'.tr();
 | 
			
		||||
    case 'discord':
 | 
			
		||||
      return 'accountConnectionProviderDiscord'.tr();
 | 
			
		||||
    default:
 | 
			
		||||
      return provider;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AccountConnectionSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnAccountConnection connection;
 | 
			
		||||
  const AccountConnectionSheet({super.key, required this.connection});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    Future<void> deleteConnection() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'accountConnectionDeleteHint'.tr(),
 | 
			
		||||
        'accountConnectionDelete'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.delete('/accounts/me/connections/${connection.id}');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'accountConnections'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              getProviderIcon(
 | 
			
		||||
                connection.provider,
 | 
			
		||||
                size: 32,
 | 
			
		||||
                color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(getLocalizedProviderName(connection.provider)).tr(),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              if (connection.meta.isNotEmpty)
 | 
			
		||||
                Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                  mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    for (final meta in connection.meta.entries)
 | 
			
		||||
                      Text(
 | 
			
		||||
                        '${meta.key.replaceAll('_', ' ').capitalizeEachWord()}: ${meta.value}',
 | 
			
		||||
                        style: const TextStyle(fontSize: 12),
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              Text(
 | 
			
		||||
                connection.providedIdentifier,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(
 | 
			
		||||
                connection.lastUsedAt.formatSystem(),
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ).opacity(0.85),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(all: 20),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.delete),
 | 
			
		||||
            title: Text('accountConnectionDelete').tr(),
 | 
			
		||||
            onTap: deleteConnection,
 | 
			
		||||
            contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AccountConnectionNewSheet extends HookConsumerWidget {
 | 
			
		||||
  const AccountConnectionNewSheet({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final selectedProvider = useState<String>('apple');
 | 
			
		||||
 | 
			
		||||
    // List of available providers
 | 
			
		||||
    final providers = ['apple', 'microsoft', 'google', 'github', 'discord'];
 | 
			
		||||
 | 
			
		||||
    Future<void> addConnection() async {
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
 | 
			
		||||
      switch (selectedProvider.value.toLowerCase()) {
 | 
			
		||||
        case 'apple':
 | 
			
		||||
          try {
 | 
			
		||||
            final credential = await SignInWithApple.getAppleIDCredential(
 | 
			
		||||
              scopes: [AppleIDAuthorizationScopes.email],
 | 
			
		||||
              webAuthenticationOptions: WebAuthenticationOptions(
 | 
			
		||||
                clientId: 'dev.solsynth.solarpass',
 | 
			
		||||
                redirectUri: Uri.parse(
 | 
			
		||||
                  'https://nt.solian.app/auth/callback/apple',
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (context.mounted) showLoadingModal(context);
 | 
			
		||||
 | 
			
		||||
            await client.post(
 | 
			
		||||
              '/auth/connect/apple/mobile',
 | 
			
		||||
              data: {
 | 
			
		||||
                'identity_token': credential.identityToken!,
 | 
			
		||||
                'authorization_code': credential.authorizationCode,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            if (context.mounted) {
 | 
			
		||||
              showSnackBar(context, 'accountConnectionAddSuccess'.tr());
 | 
			
		||||
              Navigator.pop(context, true);
 | 
			
		||||
            }
 | 
			
		||||
          } catch (err) {
 | 
			
		||||
            if (err is SignInWithAppleAuthorizationException) return;
 | 
			
		||||
            showErrorAlert(err);
 | 
			
		||||
          } finally {
 | 
			
		||||
            if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
          }
 | 
			
		||||
        case 'microsoft':
 | 
			
		||||
        case 'google':
 | 
			
		||||
        case 'github':
 | 
			
		||||
        case 'discord':
 | 
			
		||||
          await Navigator.of(context).push(
 | 
			
		||||
            MaterialPageRoute(
 | 
			
		||||
              builder:
 | 
			
		||||
                  (context) => OidcScreen(
 | 
			
		||||
                    provider: selectedProvider.value.toLowerCase(),
 | 
			
		||||
                    title:
 | 
			
		||||
                        'Connect with ${selectedProvider.value.capitalizeEachWord()}',
 | 
			
		||||
                  ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
          if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          showSnackBar(context, 'accountConnectionAddError'.tr());
 | 
			
		||||
          return;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'accountConnectionAdd'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        spacing: 16,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonFormField<String>(
 | 
			
		||||
            value: selectedProvider.value,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              prefixIcon: getProviderIcon(
 | 
			
		||||
                selectedProvider.value,
 | 
			
		||||
                size: 16,
 | 
			
		||||
                color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
              ).padding(all: 16),
 | 
			
		||||
              labelText: 'accountConnectionProvider'.tr(),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            items:
 | 
			
		||||
                providers.map((String provider) {
 | 
			
		||||
                  return DropdownMenuItem<String>(
 | 
			
		||||
                    value: provider,
 | 
			
		||||
                    child: Row(
 | 
			
		||||
                      children: [Text(getLocalizedProviderName(provider)).tr()],
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                }).toList(),
 | 
			
		||||
            onChanged: (String? newValue) {
 | 
			
		||||
              if (newValue != null) {
 | 
			
		||||
                selectedProvider.value = newValue;
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child: Text('accountConnectionDescription'.tr()),
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              TextButton.icon(
 | 
			
		||||
                onPressed: addConnection,
 | 
			
		||||
                icon: const Icon(Symbols.add),
 | 
			
		||||
                label: Text('next').tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AccountConnectionsSheet extends HookConsumerWidget {
 | 
			
		||||
  const AccountConnectionsSheet({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final connections = ref.watch(accountConnectionsProvider);
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'accountConnections'.tr(),
 | 
			
		||||
      actions: [
 | 
			
		||||
        IconButton(
 | 
			
		||||
          icon: const Icon(Symbols.add),
 | 
			
		||||
          onPressed: () async {
 | 
			
		||||
            final result = await showModalBottomSheet<bool>(
 | 
			
		||||
              context: context,
 | 
			
		||||
              isScrollControlled: true,
 | 
			
		||||
              builder: (context) => const AccountConnectionNewSheet(),
 | 
			
		||||
            );
 | 
			
		||||
            if (result == true) {
 | 
			
		||||
              ref.invalidate(accountConnectionsProvider);
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
      child: connections.when(
 | 
			
		||||
        data:
 | 
			
		||||
            (data) => RefreshIndicator(
 | 
			
		||||
              onRefresh:
 | 
			
		||||
                  () => Future.sync(
 | 
			
		||||
                    () => ref.invalidate(accountConnectionsProvider),
 | 
			
		||||
                  ),
 | 
			
		||||
              child:
 | 
			
		||||
                  data.isEmpty
 | 
			
		||||
                      ? Center(
 | 
			
		||||
                        child: Text(
 | 
			
		||||
                          'accountConnectionsEmpty'.tr(),
 | 
			
		||||
                          textAlign: TextAlign.center,
 | 
			
		||||
                        ).padding(horizontal: 32),
 | 
			
		||||
                      )
 | 
			
		||||
                      : ListView.builder(
 | 
			
		||||
                        padding: EdgeInsets.zero,
 | 
			
		||||
                        itemCount: data.length,
 | 
			
		||||
                        itemBuilder: (context, index) {
 | 
			
		||||
                          final connection = data[index];
 | 
			
		||||
                          return Dismissible(
 | 
			
		||||
                            key: Key('connection-${connection.id}'),
 | 
			
		||||
                            direction: DismissDirection.endToStart,
 | 
			
		||||
                            background: Container(
 | 
			
		||||
                              color: Colors.red,
 | 
			
		||||
                              alignment: Alignment.centerRight,
 | 
			
		||||
                              padding: const EdgeInsets.symmetric(
 | 
			
		||||
                                horizontal: 20,
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: const Icon(
 | 
			
		||||
                                Icons.delete,
 | 
			
		||||
                                color: Colors.white,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            confirmDismiss: (direction) async {
 | 
			
		||||
                              final confirm = await showConfirmAlert(
 | 
			
		||||
                                'accountConnectionDeleteHint'.tr(),
 | 
			
		||||
                                'accountConnectionDelete'.tr(),
 | 
			
		||||
                              );
 | 
			
		||||
                              if (confirm && context.mounted) {
 | 
			
		||||
                                try {
 | 
			
		||||
                                  final client = ref.read(apiClientProvider);
 | 
			
		||||
                                  await client.delete(
 | 
			
		||||
                                    '/accounts/me/connections/${connection.id}',
 | 
			
		||||
                                  );
 | 
			
		||||
                                  ref.invalidate(accountConnectionsProvider);
 | 
			
		||||
                                  return true;
 | 
			
		||||
                                } catch (err) {
 | 
			
		||||
                                  showErrorAlert(err);
 | 
			
		||||
                                  return false;
 | 
			
		||||
                                }
 | 
			
		||||
                              }
 | 
			
		||||
                              return false;
 | 
			
		||||
                            },
 | 
			
		||||
                            child: ListTile(
 | 
			
		||||
                              leading: getProviderIcon(
 | 
			
		||||
                                connection.provider,
 | 
			
		||||
                                color: Theme.of(context).colorScheme.onSurface,
 | 
			
		||||
                              ),
 | 
			
		||||
                              title:
 | 
			
		||||
                                  Text(
 | 
			
		||||
                                    getLocalizedProviderName(
 | 
			
		||||
                                      connection.provider,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ).tr(),
 | 
			
		||||
                              subtitle:
 | 
			
		||||
                                  connection.meta['email'] != null
 | 
			
		||||
                                      ? Text(connection.meta['email'])
 | 
			
		||||
                                      : Text(connection.providedIdentifier),
 | 
			
		||||
                              trailing: Text(
 | 
			
		||||
                                DateFormat.yMd().format(
 | 
			
		||||
                                  connection.lastUsedAt.toLocal(),
 | 
			
		||||
                                ),
 | 
			
		||||
                                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                              ),
 | 
			
		||||
                              onTap: () async {
 | 
			
		||||
                                final result = await showModalBottomSheet<bool>(
 | 
			
		||||
                                  context: context,
 | 
			
		||||
                                  isScrollControlled: true,
 | 
			
		||||
                                  builder:
 | 
			
		||||
                                      (context) => AccountConnectionSheet(
 | 
			
		||||
                                        connection: connection,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                );
 | 
			
		||||
                                if (result == true) {
 | 
			
		||||
                                  ref.invalidate(accountConnectionsProvider);
 | 
			
		||||
                                }
 | 
			
		||||
                              },
 | 
			
		||||
                            ),
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      ),
 | 
			
		||||
            ),
 | 
			
		||||
        error:
 | 
			
		||||
            (err, _) => ResponseErrorWidget(
 | 
			
		||||
              error: err,
 | 
			
		||||
              onRetry: () => ref.invalidate(accountConnectionsProvider),
 | 
			
		||||
            ),
 | 
			
		||||
        loading: () => const ResponseLoadingWidget(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										281
									
								
								lib/screens/account/me/settings_contacts.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								lib/screens/account/me/settings_contacts.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,281 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/user.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
class ContactMethodSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnContactMethod contact;
 | 
			
		||||
  const ContactMethodSheet({super.key, required this.contact});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    Future<void> deleteContactMethod() async {
 | 
			
		||||
      final confirm = await showConfirmAlert(
 | 
			
		||||
        'contactMethodDeleteHint'.tr(),
 | 
			
		||||
        'contactMethodDelete'.tr(),
 | 
			
		||||
      );
 | 
			
		||||
      if (!confirm || !context.mounted) return;
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.delete('/accounts/me/contacts/${contact.id}');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> verifyContactMethod() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/contacts/${contact.id}/verify');
 | 
			
		||||
        if (context.mounted) {
 | 
			
		||||
          showSnackBar(context, 'contactMethodVerificationSent'.tr());
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> setContactMethodAsPrimary() async {
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final client = ref.read(apiClientProvider);
 | 
			
		||||
        await client.post('/accounts/me/contacts/${contact.id}/primary');
 | 
			
		||||
        if (context.mounted) Navigator.pop(context, true);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'contactMethod'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
            children: [
 | 
			
		||||
              Icon(switch (contact.type) {
 | 
			
		||||
                0 => Symbols.mail,
 | 
			
		||||
                1 => Symbols.phone,
 | 
			
		||||
                _ => Symbols.home,
 | 
			
		||||
              }, size: 32),
 | 
			
		||||
              const Gap(8),
 | 
			
		||||
              Text(switch (contact.type) {
 | 
			
		||||
                0 => 'contactMethodTypeEmail'.tr(),
 | 
			
		||||
                1 => 'contactMethodTypePhone'.tr(),
 | 
			
		||||
                _ => 'contactMethodTypeAddress'.tr(),
 | 
			
		||||
              }),
 | 
			
		||||
              const Gap(4),
 | 
			
		||||
              Text(
 | 
			
		||||
                contact.content,
 | 
			
		||||
                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
              ),
 | 
			
		||||
              const Gap(10),
 | 
			
		||||
              Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  if (contact.verifiedAt == null)
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('contactMethodUnverified'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onSecondary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                    )
 | 
			
		||||
                  else
 | 
			
		||||
                    Badge(
 | 
			
		||||
                      label: Text('contactMethodVerified'.tr()),
 | 
			
		||||
                      textColor: Theme.of(context).colorScheme.onPrimary,
 | 
			
		||||
                      backgroundColor: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                    ),
 | 
			
		||||
                  if (contact.isPrimary)
 | 
			
		||||
                    Padding(
 | 
			
		||||
                      padding: const EdgeInsets.only(left: 8.0),
 | 
			
		||||
                      child: Badge(
 | 
			
		||||
                        label: Text('contactMethodPrimary'.tr()),
 | 
			
		||||
                        textColor: Theme.of(context).colorScheme.onTertiary,
 | 
			
		||||
                        backgroundColor: Theme.of(context).colorScheme.tertiary,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ).padding(all: 20),
 | 
			
		||||
          const Divider(height: 1),
 | 
			
		||||
          if (contact.verifiedAt == null)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.verified),
 | 
			
		||||
              title: Text('contactMethodVerify').tr(),
 | 
			
		||||
              onTap: verifyContactMethod,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          if (contact.verifiedAt != null && !contact.isPrimary)
 | 
			
		||||
            ListTile(
 | 
			
		||||
              leading: const Icon(Symbols.star),
 | 
			
		||||
              title: Text('contactMethodSetPrimary').tr(),
 | 
			
		||||
              onTap: setContactMethodAsPrimary,
 | 
			
		||||
              contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
            ),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Symbols.delete),
 | 
			
		||||
            title: Text('contactMethodDelete').tr(),
 | 
			
		||||
            onTap: deleteContactMethod,
 | 
			
		||||
            contentPadding: EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ContactMethodNewSheet extends HookConsumerWidget {
 | 
			
		||||
  const ContactMethodNewSheet({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final contactType = useState<int>(0);
 | 
			
		||||
    final contentController = useTextEditingController();
 | 
			
		||||
 | 
			
		||||
    Future<void> addContactMethod() async {
 | 
			
		||||
      if (contentController.text.isEmpty) {
 | 
			
		||||
        showSnackBar(context, 'contactMethodContentEmpty'.tr());
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        showLoadingModal(context);
 | 
			
		||||
        final apiClient = ref.read(apiClientProvider);
 | 
			
		||||
        await apiClient.post(
 | 
			
		||||
          '/accounts/me/contacts',
 | 
			
		||||
          data: {'type': contactType.value, 'content': contentController.text},
 | 
			
		||||
        );
 | 
			
		||||
        if (context.mounted) {
 | 
			
		||||
          showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
 | 
			
		||||
          Navigator.pop(context, true);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'contactMethodNew'.tr(),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        spacing: 16,
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
        children: [
 | 
			
		||||
          DropdownButtonFormField<int>(
 | 
			
		||||
            value: contactType.value,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              labelText: 'contactMethodType'.tr(),
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            items: [
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 0,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.mail),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypeEmail'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 1,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.phone),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypePhone'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              DropdownMenuItem<int>(
 | 
			
		||||
                value: 2,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Icon(Symbols.home),
 | 
			
		||||
                    const Gap(8),
 | 
			
		||||
                    Text('contactMethodTypeAddress'.tr()),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
            onChanged: (value) {
 | 
			
		||||
              if (value != null) {
 | 
			
		||||
                contactType.value = value;
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          TextField(
 | 
			
		||||
            controller: contentController,
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              prefixIcon: Icon(switch (contactType.value) {
 | 
			
		||||
                0 => Symbols.mail,
 | 
			
		||||
                1 => Symbols.phone,
 | 
			
		||||
                _ => Symbols.home,
 | 
			
		||||
              }),
 | 
			
		||||
              labelText: switch (contactType.value) {
 | 
			
		||||
                0 => 'contactMethodTypeEmail'.tr(),
 | 
			
		||||
                1 => 'contactMethodTypePhone'.tr(),
 | 
			
		||||
                _ => 'contactMethodTypeAddress'.tr(),
 | 
			
		||||
              },
 | 
			
		||||
              hintText: switch (contactType.value) {
 | 
			
		||||
                0 => 'contactMethodEmailHint'.tr(),
 | 
			
		||||
                1 => 'contactMethodPhoneHint'.tr(),
 | 
			
		||||
                _ => 'contactMethodAddressHint'.tr(),
 | 
			
		||||
              },
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
            ),
 | 
			
		||||
            keyboardType: switch (contactType.value) {
 | 
			
		||||
              0 => TextInputType.emailAddress,
 | 
			
		||||
              1 => TextInputType.phone,
 | 
			
		||||
              _ => TextInputType.multiline,
 | 
			
		||||
            },
 | 
			
		||||
            maxLines: switch (contactType.value) {
 | 
			
		||||
              2 => 3,
 | 
			
		||||
              _ => 1,
 | 
			
		||||
            },
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          ),
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child:
 | 
			
		||||
                Text(switch (contactType.value) {
 | 
			
		||||
                  0 => 'contactMethodEmailDescription',
 | 
			
		||||
                  1 => 'contactMethodPhoneDescription',
 | 
			
		||||
                  _ => 'contactMethodAddressDescription',
 | 
			
		||||
                }).tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          Row(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
            children: [
 | 
			
		||||
              TextButton.icon(
 | 
			
		||||
                onPressed: addContactMethod,
 | 
			
		||||
                icon: Icon(Symbols.add),
 | 
			
		||||
                label: Text('create').tr(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ).padding(horizontal: 20, vertical: 24),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -18,11 +18,14 @@ import 'package:island/pods/config.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/pods/userinfo.dart';
 | 
			
		||||
import 'package:island/pods/websocket.dart';
 | 
			
		||||
import 'package:island/screens/account/me/settings_connections.dart';
 | 
			
		||||
import 'package:island/screens/auth/oidc.dart';
 | 
			
		||||
import 'package:island/services/notify.dart';
 | 
			
		||||
import 'package:island/services/udid.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
 | 
			
		||||
@@ -104,7 +107,7 @@ class LoginScreen extends HookConsumerWidget {
 | 
			
		||||
                      child: switch (period.value % 3) {
 | 
			
		||||
                        1 => _LoginPickerScreen(
 | 
			
		||||
                          key: const ValueKey(1),
 | 
			
		||||
                          ticket: currentTicket.value,
 | 
			
		||||
                          challenge: currentTicket.value,
 | 
			
		||||
                          factors: factors.value,
 | 
			
		||||
                          onChallenge:
 | 
			
		||||
                              (SnAuthChallenge? p0) => currentTicket.value = p0,
 | 
			
		||||
@@ -172,28 +175,15 @@ class _LoginCheckScreen extends HookConsumerWidget {
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [isBusy]);
 | 
			
		||||
 | 
			
		||||
    Future<void> performCheckTicket() async {
 | 
			
		||||
      final pwd = passwordController.value.text;
 | 
			
		||||
      if (pwd.isEmpty) return;
 | 
			
		||||
      isBusy.value = true;
 | 
			
		||||
      try {
 | 
			
		||||
        // Pass challenge
 | 
			
		||||
        final client = ref.watch(apiClientProvider);
 | 
			
		||||
        final resp = await client.patch(
 | 
			
		||||
          '/auth/challenge/${challenge!.id}',
 | 
			
		||||
          data: {'factor_id': factor!.id, 'password': pwd},
 | 
			
		||||
        );
 | 
			
		||||
        final result = SnAuthChallenge.fromJson(resp.data);
 | 
			
		||||
        onChallenge(result);
 | 
			
		||||
        if (result.stepRemain > 0) {
 | 
			
		||||
          onNext();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    Future<void> getToken({String? code}) async {
 | 
			
		||||
      // Get token if challenge is completed
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
      final tokenResp = await client.post(
 | 
			
		||||
        '/auth/token',
 | 
			
		||||
          data: {'grant_type': 'authorization_code', 'code': result.id},
 | 
			
		||||
        data: {
 | 
			
		||||
          'grant_type': 'authorization_code',
 | 
			
		||||
          'code': code ?? challenge!.id,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      final token = tokenResp.data['token'];
 | 
			
		||||
      setToken(ref.watch(sharedPreferencesProvider), token);
 | 
			
		||||
@@ -231,6 +221,62 @@ class _LoginCheckScreen extends HookConsumerWidget {
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (challenge != null && challenge?.stepRemain == 0) {
 | 
			
		||||
        Future(() {
 | 
			
		||||
          isBusy.value = true;
 | 
			
		||||
          getToken().catchError((err) {
 | 
			
		||||
            showErrorAlert(err);
 | 
			
		||||
            isBusy.value = false;
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [challenge]);
 | 
			
		||||
 | 
			
		||||
    if (factor == null) {
 | 
			
		||||
      // Logging in by third parties
 | 
			
		||||
      return Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          Align(
 | 
			
		||||
            alignment: Alignment.centerLeft,
 | 
			
		||||
            child: CircleAvatar(
 | 
			
		||||
              radius: 26,
 | 
			
		||||
              child: const Icon(Symbols.asterisk, size: 28),
 | 
			
		||||
            ).padding(bottom: 8),
 | 
			
		||||
          ),
 | 
			
		||||
          Text(
 | 
			
		||||
            'loginInProgress'.tr(),
 | 
			
		||||
            style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
 | 
			
		||||
          ).padding(left: 4, bottom: 16),
 | 
			
		||||
          const Gap(16),
 | 
			
		||||
          CircularProgressIndicator().alignment(Alignment.centerLeft),
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> performCheckTicket() async {
 | 
			
		||||
      final pwd = passwordController.value.text;
 | 
			
		||||
      if (pwd.isEmpty) return;
 | 
			
		||||
      isBusy.value = true;
 | 
			
		||||
      try {
 | 
			
		||||
        // Pass challenge
 | 
			
		||||
        final client = ref.watch(apiClientProvider);
 | 
			
		||||
        final resp = await client.patch(
 | 
			
		||||
          '/auth/challenge/${challenge!.id}',
 | 
			
		||||
          data: {'factor_id': factor!.id, 'password': pwd},
 | 
			
		||||
        );
 | 
			
		||||
        final result = SnAuthChallenge.fromJson(resp.data);
 | 
			
		||||
        onChallenge(result);
 | 
			
		||||
        if (result.stepRemain > 0) {
 | 
			
		||||
          onNext();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await getToken(code: result.id);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
        return;
 | 
			
		||||
@@ -268,7 +314,6 @@ class _LoginCheckScreen extends HookConsumerWidget {
 | 
			
		||||
            ],
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              isDense: true,
 | 
			
		||||
              border: const OutlineInputBorder(),
 | 
			
		||||
              labelText: 'password'.tr(),
 | 
			
		||||
            ),
 | 
			
		||||
            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
@@ -289,15 +334,13 @@ class _LoginCheckScreen extends HookConsumerWidget {
 | 
			
		||||
            textStyle: Theme.of(context).textTheme.titleLarge!,
 | 
			
		||||
          ),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        Card(
 | 
			
		||||
          child: ListTile(
 | 
			
		||||
        ListTile(
 | 
			
		||||
          leading: Icon(
 | 
			
		||||
            kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
 | 
			
		||||
          ),
 | 
			
		||||
          title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
 | 
			
		||||
          subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        Row(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.end,
 | 
			
		||||
@@ -320,7 +363,7 @@ class _LoginCheckScreen extends HookConsumerWidget {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
  final SnAuthChallenge? ticket;
 | 
			
		||||
  final SnAuthChallenge? challenge;
 | 
			
		||||
  final List<SnAuthFactor>? factors;
 | 
			
		||||
  final Function(SnAuthChallenge?) onChallenge;
 | 
			
		||||
  final Function(SnAuthFactor) onPickFactor;
 | 
			
		||||
@@ -329,7 +372,7 @@ class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  const _LoginPickerScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.ticket,
 | 
			
		||||
    required this.challenge,
 | 
			
		||||
    required this.factors,
 | 
			
		||||
    required this.onChallenge,
 | 
			
		||||
    required this.onPickFactor,
 | 
			
		||||
@@ -347,6 +390,15 @@ class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [isBusy]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (challenge != null && challenge?.stepRemain == 0) {
 | 
			
		||||
        Future(() {
 | 
			
		||||
          onNext();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [challenge]);
 | 
			
		||||
 | 
			
		||||
    final unfocusColor = Theme.of(
 | 
			
		||||
      context,
 | 
			
		||||
    ).colorScheme.onSurface.withAlpha((255 * 0.75).round());
 | 
			
		||||
@@ -361,7 +413,7 @@ class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await client.post(
 | 
			
		||||
          '/auth/challenge/${ticket!.id}/factors/${factorPicked.value!.id}',
 | 
			
		||||
          '/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
 | 
			
		||||
          data:
 | 
			
		||||
              hintController.text.isNotEmpty
 | 
			
		||||
                  ? jsonEncode(hintController.text)
 | 
			
		||||
@@ -415,7 +467,7 @@ class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
                          kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
 | 
			
		||||
                        ),
 | 
			
		||||
                        title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
 | 
			
		||||
                        enabled: !ticket!.blacklistFactors.contains(x.id),
 | 
			
		||||
                        enabled: !challenge!.blacklistFactors.contains(x.id),
 | 
			
		||||
                        value: factorPicked.value == x,
 | 
			
		||||
                        onChanged: (value) {
 | 
			
		||||
                          if (value == true) {
 | 
			
		||||
@@ -440,7 +492,7 @@ class _LoginPickerScreen extends HookConsumerWidget {
 | 
			
		||||
          ).padding(top: 12, bottom: 4, horizontal: 4),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        Text(
 | 
			
		||||
          'loginMultiFactor'.plural(ticket!.stepRemain),
 | 
			
		||||
          'loginMultiFactor'.plural(challenge!.stepRemain),
 | 
			
		||||
          style: TextStyle(color: unfocusColor, fontSize: 13),
 | 
			
		||||
        ).padding(horizontal: 16),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
@@ -558,6 +610,72 @@ class _LoginLookupScreen extends HookConsumerWidget {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> withApple() async {
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
      try {
 | 
			
		||||
        final credential = await SignInWithApple.getAppleIDCredential(
 | 
			
		||||
          scopes: [AppleIDAuthorizationScopes.email],
 | 
			
		||||
          webAuthenticationOptions: WebAuthenticationOptions(
 | 
			
		||||
            clientId: 'dev.solsynth.solarpass',
 | 
			
		||||
            redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (context.mounted) showLoadingModal(context);
 | 
			
		||||
        final resp = await client.post(
 | 
			
		||||
          '/auth/login/apple/mobile',
 | 
			
		||||
          data: {
 | 
			
		||||
            'identity_token': credential.identityToken!,
 | 
			
		||||
            'authorization_code': credential.authorizationCode,
 | 
			
		||||
            'device_id': await getUdid(),
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        final challenge = SnAuthChallenge.fromJson(resp.data);
 | 
			
		||||
        onChallenge(challenge);
 | 
			
		||||
        final factorResp = await client.get(
 | 
			
		||||
          '/auth/challenge/${challenge.id}/factors',
 | 
			
		||||
        );
 | 
			
		||||
        onFactor(
 | 
			
		||||
          List<SnAuthFactor>.from(
 | 
			
		||||
            factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        onNext();
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        if (err is SignInWithAppleAuthorizationException) return;
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        if (context.mounted) hideLoadingModal(context);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> withOidc(String provider) async {
 | 
			
		||||
      final challengeId = await Navigator.of(context).push(
 | 
			
		||||
        MaterialPageRoute(
 | 
			
		||||
          builder: (context) => OidcScreen(provider: provider.toLowerCase()),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
      try {
 | 
			
		||||
        final resp = await client.get('/auth/challenge/$challengeId');
 | 
			
		||||
        final challenge = SnAuthChallenge.fromJson(resp.data);
 | 
			
		||||
        onChallenge(challenge);
 | 
			
		||||
        final factorResp = await client.get(
 | 
			
		||||
          '/auth/challenge/${challenge.id}/factors',
 | 
			
		||||
        );
 | 
			
		||||
        onFactor(
 | 
			
		||||
          List<SnAuthFactor>.from(
 | 
			
		||||
            factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        onNext();
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
@@ -586,7 +704,45 @@ class _LoginLookupScreen extends HookConsumerWidget {
 | 
			
		||||
          onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
          onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
 | 
			
		||||
        ).padding(horizontal: 7),
 | 
			
		||||
        const Gap(12),
 | 
			
		||||
        Row(
 | 
			
		||||
          spacing: 6,
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          children: <Widget>[
 | 
			
		||||
            Text("loginOr").tr().fontSize(11).opacity(0.85),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Spacer(),
 | 
			
		||||
            IconButton.filledTonal(
 | 
			
		||||
              onPressed: () => withOidc('github'),
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              icon: getProviderIcon(
 | 
			
		||||
                "github",
 | 
			
		||||
                size: 16,
 | 
			
		||||
                color: Theme.of(context).colorScheme.onPrimaryContainer,
 | 
			
		||||
              ),
 | 
			
		||||
              tooltip: 'GitHub',
 | 
			
		||||
            ),
 | 
			
		||||
            IconButton.filledTonal(
 | 
			
		||||
              onPressed: () => withOidc('google'),
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              icon: getProviderIcon(
 | 
			
		||||
                "google",
 | 
			
		||||
                size: 16,
 | 
			
		||||
                color: Theme.of(context).colorScheme.onPrimaryContainer,
 | 
			
		||||
              ),
 | 
			
		||||
              tooltip: 'Google',
 | 
			
		||||
            ),
 | 
			
		||||
            IconButton.filledTonal(
 | 
			
		||||
              onPressed: withApple,
 | 
			
		||||
              padding: EdgeInsets.zero,
 | 
			
		||||
              icon: getProviderIcon(
 | 
			
		||||
                "apple",
 | 
			
		||||
                size: 16,
 | 
			
		||||
                color: Theme.of(context).colorScheme.onPrimaryContainer,
 | 
			
		||||
              ),
 | 
			
		||||
              tooltip: 'Apple Account',
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(horizontal: 8, vertical: 8),
 | 
			
		||||
        Row(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
          children: [
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								lib/screens/auth/oidc.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/screens/auth/oidc.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export 'oidc.native.dart' if (dart.library.html) 'oidc.web.dart';
 | 
			
		||||
							
								
								
									
										225
									
								
								lib/screens/auth/oidc.native.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								lib/screens/auth/oidc.native.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,225 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
 | 
			
		||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:island/pods/config.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/services/udid.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
class OidcScreen extends ConsumerStatefulWidget {
 | 
			
		||||
  final String provider;
 | 
			
		||||
  final String? title;
 | 
			
		||||
 | 
			
		||||
  const OidcScreen({super.key, required this.provider, this.title});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  ConsumerState<OidcScreen> createState() => _OidcScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _OidcScreenState extends ConsumerState<OidcScreen> {
 | 
			
		||||
  String? authToken;
 | 
			
		||||
  String? currentUrl;
 | 
			
		||||
  final TextEditingController _urlController = TextEditingController();
 | 
			
		||||
  bool _isLoading = true;
 | 
			
		||||
  late Future<String> _deviceIdFuture;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _deviceIdFuture = getUdid();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _urlController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final serverUrl = ref.watch(serverUrlProvider);
 | 
			
		||||
    final token = ref.watch(tokenProvider);
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body: FutureBuilder<String>(
 | 
			
		||||
        future: _deviceIdFuture,
 | 
			
		||||
        builder: (context, snapshot) {
 | 
			
		||||
          if (snapshot.connectionState == ConnectionState.waiting) {
 | 
			
		||||
            return const Center(child: CircularProgressIndicator());
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (snapshot.hasError) {
 | 
			
		||||
            return Center(child: Text('somethingWentWrong').tr());
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          final deviceId = snapshot.data!;
 | 
			
		||||
 | 
			
		||||
          return Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: InAppWebView(
 | 
			
		||||
                  initialSettings: InAppWebViewSettings(
 | 
			
		||||
                    userAgent:
 | 
			
		||||
                        kIsWeb
 | 
			
		||||
                            ? null
 | 
			
		||||
                            : Platform.isIOS
 | 
			
		||||
                            ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
 | 
			
		||||
                            : Platform.isAndroid
 | 
			
		||||
                            ? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
 | 
			
		||||
                            : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
 | 
			
		||||
                  ),
 | 
			
		||||
                  initialUrlRequest: URLRequest(
 | 
			
		||||
                    url: WebUri('$serverUrl/auth/login/${widget.provider}'),
 | 
			
		||||
                    headers: {
 | 
			
		||||
                      if (token?.token.isNotEmpty ?? false)
 | 
			
		||||
                        'Authorization': 'AtField ${token!.token}',
 | 
			
		||||
                      'X-Device-Id': deviceId,
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  onWebViewCreated: (controller) {
 | 
			
		||||
                    // Register a handler to receive the token from JavaScript
 | 
			
		||||
                    controller.addJavaScriptHandler(
 | 
			
		||||
                      handlerName: 'tokenHandler',
 | 
			
		||||
                      callback: (args) {
 | 
			
		||||
                        // args[0] will be the token string
 | 
			
		||||
                        if (args.isNotEmpty && args[0] is String) {
 | 
			
		||||
                          setState(() {
 | 
			
		||||
                            authToken = args[0];
 | 
			
		||||
                          });
 | 
			
		||||
 | 
			
		||||
                          // Return the token and close the webview
 | 
			
		||||
                          Navigator.of(context).pop(authToken);
 | 
			
		||||
                        }
 | 
			
		||||
                      },
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                  shouldOverrideUrlLoading: (
 | 
			
		||||
                    controller,
 | 
			
		||||
                    navigationAction,
 | 
			
		||||
                  ) async {
 | 
			
		||||
                    final url = navigationAction.request.url;
 | 
			
		||||
                    if (url != null) {
 | 
			
		||||
                      setState(() {
 | 
			
		||||
                        currentUrl = url.toString();
 | 
			
		||||
                        _urlController.text = currentUrl ?? '';
 | 
			
		||||
                        _isLoading = true;
 | 
			
		||||
                      });
 | 
			
		||||
 | 
			
		||||
                      final path = url.path;
 | 
			
		||||
                      final queryParams = url.queryParameters;
 | 
			
		||||
 | 
			
		||||
                      // Check if we're on the token page
 | 
			
		||||
                      if (path.endsWith('/auth/callback')) {
 | 
			
		||||
                        // Extract token from URL
 | 
			
		||||
                        final challenge = queryParams['challenge'];
 | 
			
		||||
                        // Return the token and close the webview
 | 
			
		||||
                        Navigator.of(context).pop(challenge);
 | 
			
		||||
                        return NavigationActionPolicy.CANCEL;
 | 
			
		||||
                      }
 | 
			
		||||
                    }
 | 
			
		||||
                    return NavigationActionPolicy.ALLOW;
 | 
			
		||||
                  },
 | 
			
		||||
                  onUpdateVisitedHistory: (controller, url, androidIsReload) {
 | 
			
		||||
                    if (url != null) {
 | 
			
		||||
                      setState(() {
 | 
			
		||||
                        currentUrl = url.toString();
 | 
			
		||||
                        _urlController.text = currentUrl ?? '';
 | 
			
		||||
                      });
 | 
			
		||||
                    }
 | 
			
		||||
                  },
 | 
			
		||||
                  onLoadStop: (controller, url) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _isLoading = false;
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                  onLoadStart: (controller, url) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _isLoading = true;
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                  onLoadError: (controller, url, code, message) {
 | 
			
		||||
                    setState(() {
 | 
			
		||||
                      _isLoading = false;
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              // Loading progress indicator
 | 
			
		||||
              if (_isLoading)
 | 
			
		||||
                LinearProgressIndicator(
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                  backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
 | 
			
		||||
                  borderRadius: BorderRadius.zero,
 | 
			
		||||
                  stopIndicatorRadius: 0,
 | 
			
		||||
                  minHeight: 2,
 | 
			
		||||
                )
 | 
			
		||||
              else
 | 
			
		||||
                ColoredBox(
 | 
			
		||||
                  color: Theme.of(context).colorScheme.surfaceVariant,
 | 
			
		||||
                ).height(2),
 | 
			
		||||
              // Debug location bar (only visible in debug mode)
 | 
			
		||||
              Container(
 | 
			
		||||
                padding: EdgeInsets.only(
 | 
			
		||||
                  left: 16,
 | 
			
		||||
                  right: 0,
 | 
			
		||||
                  bottom: MediaQuery.of(context).padding.bottom + 8,
 | 
			
		||||
                  top: 8,
 | 
			
		||||
                ),
 | 
			
		||||
                color: Theme.of(context).colorScheme.surface,
 | 
			
		||||
                child: Row(
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Expanded(
 | 
			
		||||
                      child: TextField(
 | 
			
		||||
                        controller: _urlController,
 | 
			
		||||
                        decoration: InputDecoration(
 | 
			
		||||
                          isDense: true,
 | 
			
		||||
                          contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                            horizontal: 8,
 | 
			
		||||
                            vertical: 8,
 | 
			
		||||
                          ),
 | 
			
		||||
                          border: OutlineInputBorder(
 | 
			
		||||
                            borderRadius: BorderRadius.circular(4),
 | 
			
		||||
                          ),
 | 
			
		||||
                          hintText: 'URL',
 | 
			
		||||
                        ),
 | 
			
		||||
                        style: const TextStyle(fontSize: 12),
 | 
			
		||||
                        readOnly: true,
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    const Gap(4),
 | 
			
		||||
                    IconButton(
 | 
			
		||||
                      icon: const Icon(Icons.copy, size: 20),
 | 
			
		||||
                      padding: const EdgeInsets.all(4),
 | 
			
		||||
                      constraints: const BoxConstraints(),
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                        if (currentUrl != null) {
 | 
			
		||||
                          Clipboard.setData(ClipboardData(text: currentUrl!));
 | 
			
		||||
                          ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
                            SnackBar(
 | 
			
		||||
                              content: Text('copyToClipboard').tr(),
 | 
			
		||||
                              duration: const Duration(seconds: 1),
 | 
			
		||||
                            ),
 | 
			
		||||
                          );
 | 
			
		||||
                        }
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										86
									
								
								lib/screens/auth/oidc.web.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								lib/screens/auth/oidc.web.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
// ignore_for_file: invalid_runtime_check_with_js_interop_types
 | 
			
		||||
 | 
			
		||||
import 'dart:ui_web' as ui;
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:island/pods/config.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:web/web.dart' as web;
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
 | 
			
		||||
class OidcScreen extends ConsumerStatefulWidget {
 | 
			
		||||
  final String provider;
 | 
			
		||||
  final String? title;
 | 
			
		||||
 | 
			
		||||
  const OidcScreen({super.key, required this.provider, this.title});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  ConsumerState<OidcScreen> createState() => _OidcScreenState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _OidcScreenState extends ConsumerState<OidcScreen> {
 | 
			
		||||
  bool _isInitialized = false;
 | 
			
		||||
  final String _viewType = 'oidc-iframe';
 | 
			
		||||
 | 
			
		||||
  void _setupWebListener(String serverUrl) {
 | 
			
		||||
    // Listen for messages from the iframe
 | 
			
		||||
    web.window.onMessage.listen((event) {
 | 
			
		||||
      if (event.data != null && event.data is String) {
 | 
			
		||||
        final message = event.data as String;
 | 
			
		||||
        if (message.startsWith("token=")) {
 | 
			
		||||
          String token = message.replaceFirst("token=", "");
 | 
			
		||||
          // Return the token and close the screen
 | 
			
		||||
          if (mounted) Navigator.pop(context, token);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Create the iframe for the OIDC login
 | 
			
		||||
    final token = ref.watch(tokenProvider);
 | 
			
		||||
    final iframe =
 | 
			
		||||
        web.HTMLIFrameElement()
 | 
			
		||||
          ..src =
 | 
			
		||||
              (token?.token.isNotEmpty ?? false)
 | 
			
		||||
                  ? '$serverUrl/auth/login/${widget.provider}?tk=${token!.token}'
 | 
			
		||||
                  : '$serverUrl/auth/login/${widget.provider}'
 | 
			
		||||
          ..style.border = 'none'
 | 
			
		||||
          ..width = '100%'
 | 
			
		||||
          ..height = '100%';
 | 
			
		||||
 | 
			
		||||
    // Add the iframe to the document body
 | 
			
		||||
    web.document.body!.append(iframe);
 | 
			
		||||
 | 
			
		||||
    // Register the iframe as a platform view
 | 
			
		||||
    ui.platformViewRegistry.registerViewFactory(
 | 
			
		||||
      _viewType,
 | 
			
		||||
      (int viewId) => iframe,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _isInitialized = true;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    Future.delayed(Duration.zero, () {
 | 
			
		||||
      final serverUrl = ref.watch(serverUrlProvider);
 | 
			
		||||
      _setupWebListener(serverUrl);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
 | 
			
		||||
      ),
 | 
			
		||||
      body:
 | 
			
		||||
          _isInitialized
 | 
			
		||||
              ? HtmlElementView(viewType: _viewType)
 | 
			
		||||
              : Center(child: CircularProgressIndicator()),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -263,6 +263,13 @@ class CreatorHubScreen extends HookConsumerWidget {
 | 
			
		||||
                            contentPadding: EdgeInsets.symmetric(
 | 
			
		||||
                              horizontal: 24,
 | 
			
		||||
                            ),
 | 
			
		||||
                            onTap: () {
 | 
			
		||||
                              context.router.push(
 | 
			
		||||
                                CreatorPostListRoute(
 | 
			
		||||
                                  pubName: currentPublisher.value!.name,
 | 
			
		||||
                                ),
 | 
			
		||||
                              );
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                          Divider(height: 1).padding(vertical: 8),
 | 
			
		||||
                          ListTile(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										79
									
								
								lib/screens/creators/posts/list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lib/screens/creators/posts/list.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_list.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
class CreatorPostListScreen extends HookConsumerWidget {
 | 
			
		||||
  final String pubName;
 | 
			
		||||
  const CreatorPostListScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    @PathParam('name') required this.pubName,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final refreshKey = useState(0);
 | 
			
		||||
 | 
			
		||||
    void showCreatePostSheet() {
 | 
			
		||||
      showModalBottomSheet(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder:
 | 
			
		||||
            (context) => SheetScaffold(
 | 
			
		||||
              titleText: 'create'.tr(),
 | 
			
		||||
              child: Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: const Icon(Symbols.edit),
 | 
			
		||||
                    title: Text('postContent'.tr()),
 | 
			
		||||
                    subtitle: Text('Create a regular post'),
 | 
			
		||||
                    onTap: () async {
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
                      final result = await context.router.pushPath(
 | 
			
		||||
                        '/posts/compose?type=0',
 | 
			
		||||
                      );
 | 
			
		||||
                      if (result == true) {
 | 
			
		||||
                        refreshKey.value++;
 | 
			
		||||
                      }
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: const Icon(Symbols.article),
 | 
			
		||||
                    title: Text('Article'),
 | 
			
		||||
                    subtitle: Text('Create a detailed article'),
 | 
			
		||||
                    onTap: () async {
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
                      final result = await context.router.pushPath(
 | 
			
		||||
                        '/posts/compose?type=1',
 | 
			
		||||
                      );
 | 
			
		||||
                      if (result == true) {
 | 
			
		||||
                        refreshKey.value++;
 | 
			
		||||
                      }
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(title: Text('posts').tr()),
 | 
			
		||||
      body: CustomScrollView(
 | 
			
		||||
        key: ValueKey(refreshKey.value),
 | 
			
		||||
        slivers: [
 | 
			
		||||
          SliverPostList(pubName: pubName, itemType: PostItemType.creator),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      floatingActionButton: FloatingActionButton(
 | 
			
		||||
        onPressed: showCreatePostSheet,
 | 
			
		||||
        child: const Icon(Symbols.add),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -47,8 +47,9 @@ class NotificationUnreadCountNotifier
 | 
			
		||||
  void _subscribeToWebSocket() {
 | 
			
		||||
    final webSocketService = ref.read(websocketProvider);
 | 
			
		||||
    _subscription = webSocketService.dataStream.listen((packet) {
 | 
			
		||||
      if (packet.type == 'notifications.new') {
 | 
			
		||||
        _incrementCounter();
 | 
			
		||||
      if (packet.type == 'notifications.new' && packet.data != null) {
 | 
			
		||||
        final notification = SnNotification.fromJson(packet.data!);
 | 
			
		||||
        if (notification.topic != 'messages.new') _incrementCounter();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,22 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:image_picker/image_picker.dart';
 | 
			
		||||
import 'package:island/models/file.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/config.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/screens/creators/publishers.dart';
 | 
			
		||||
import 'package:island/screens/posts/detail.dart';
 | 
			
		||||
import 'package:island/services/file.dart';
 | 
			
		||||
import 'package:island/screens/posts/compose_article.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_files.dart';
 | 
			
		||||
import 'package:island/widgets/content/attachment_preview.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_files.dart';
 | 
			
		||||
import 'package:island/widgets/post/compose_shared.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:island/widgets/post/publishers_modal.dart';
 | 
			
		||||
import 'package:island/screens/posts/detail.dart';
 | 
			
		||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
@@ -54,296 +48,70 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
  final SnPost? originalPost;
 | 
			
		||||
  final SnPost? repliedPost;
 | 
			
		||||
  final SnPost? forwardedPost;
 | 
			
		||||
  final int? type;
 | 
			
		||||
  const PostComposeScreen({
 | 
			
		||||
    super.key,
 | 
			
		||||
    this.originalPost,
 | 
			
		||||
    this.repliedPost,
 | 
			
		||||
    this.forwardedPost,
 | 
			
		||||
    @QueryParam('type') this.type,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    // Determine the compose type: auto-detect from edited post or use query parameter
 | 
			
		||||
    final composeType = originalPost?.type ?? type ?? 0;
 | 
			
		||||
 | 
			
		||||
    // If type is 1 (article), return ArticleComposeScreen
 | 
			
		||||
    if (composeType == 1) {
 | 
			
		||||
      return ArticleComposeScreen(originalPost: originalPost);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Otherwise, continue with regular post compose
 | 
			
		||||
    final theme = Theme.of(context);
 | 
			
		||||
    final colorScheme = theme.colorScheme;
 | 
			
		||||
 | 
			
		||||
    final publishers = ref.watch(publishersManagedProvider);
 | 
			
		||||
    final state = useMemoized(
 | 
			
		||||
      () => ComposeLogic.createState(
 | 
			
		||||
        originalPost: originalPost,
 | 
			
		||||
        forwardedPost: forwardedPost,
 | 
			
		||||
      ),
 | 
			
		||||
      [originalPost, forwardedPost],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final currentPublisher = useState<SnPublisher?>(null);
 | 
			
		||||
 | 
			
		||||
    // Initialize publisher once when data is available
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (publishers.value?.isNotEmpty ?? false) {
 | 
			
		||||
        currentPublisher.value = publishers.value!.first;
 | 
			
		||||
        state.currentPublisher.value = publishers.value!.first;
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [publishers]);
 | 
			
		||||
 | 
			
		||||
    // Contains the XFile, ByteData, or SnCloudFile
 | 
			
		||||
    final attachments = useState<List<UniversalFile>>(
 | 
			
		||||
      originalPost?.attachments
 | 
			
		||||
              .map(
 | 
			
		||||
                (e) => UniversalFile(
 | 
			
		||||
                  data: e,
 | 
			
		||||
                  type: switch (e.mimeType?.split('/').firstOrNull) {
 | 
			
		||||
                    'image' => UniversalFileType.image,
 | 
			
		||||
                    'video' => UniversalFileType.video,
 | 
			
		||||
                    'audio' => UniversalFileType.audio,
 | 
			
		||||
                    _ => UniversalFileType.file,
 | 
			
		||||
                  },
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
              .toList() ??
 | 
			
		||||
          [],
 | 
			
		||||
    );
 | 
			
		||||
    final titleController = useTextEditingController(text: originalPost?.title);
 | 
			
		||||
    final descriptionController = useTextEditingController(
 | 
			
		||||
      text: originalPost?.description,
 | 
			
		||||
    );
 | 
			
		||||
    final contentController = useTextEditingController(
 | 
			
		||||
      text:
 | 
			
		||||
          originalPost?.content ??
 | 
			
		||||
          (forwardedPost != null ? '> ${forwardedPost!.content}\n\n' : null),
 | 
			
		||||
    );
 | 
			
		||||
    // Dispose state when widget is disposed
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      return () => ComposeLogic.dispose(state);
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    // Add visibility state with default value from original post or 0 (public)
 | 
			
		||||
    final visibility = useState<int>(originalPost?.visibility ?? 0);
 | 
			
		||||
    // Helper methods
 | 
			
		||||
 | 
			
		||||
    final submitting = useState(false);
 | 
			
		||||
 | 
			
		||||
    Future<void> pickPhotoMedia() async {
 | 
			
		||||
      final result = await ref
 | 
			
		||||
          .watch(imagePickerProvider)
 | 
			
		||||
          .pickMultiImage(requestFullMetadata: true);
 | 
			
		||||
      if (result.isEmpty) return;
 | 
			
		||||
      attachments.value = [
 | 
			
		||||
        ...attachments.value,
 | 
			
		||||
        ...result.map(
 | 
			
		||||
          (e) => UniversalFile(data: e, type: UniversalFileType.image),
 | 
			
		||||
        ),
 | 
			
		||||
      ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> pickVideoMedia() async {
 | 
			
		||||
      final result = await ref
 | 
			
		||||
          .watch(imagePickerProvider)
 | 
			
		||||
          .pickVideo(source: ImageSource.gallery);
 | 
			
		||||
      if (result == null) return;
 | 
			
		||||
      attachments.value = [
 | 
			
		||||
        ...attachments.value,
 | 
			
		||||
        UniversalFile(data: result, type: UniversalFileType.video),
 | 
			
		||||
      ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final attachmentProgress = useState<Map<int, double>>({});
 | 
			
		||||
 | 
			
		||||
    Future<void> uploadAttachment(int index) async {
 | 
			
		||||
      final attachment = attachments.value[index];
 | 
			
		||||
      if (attachment is SnCloudFile) return;
 | 
			
		||||
      final baseUrl = ref.watch(serverUrlProvider);
 | 
			
		||||
      final token = await getToken(ref.watch(tokenProvider));
 | 
			
		||||
      if (token == null) throw ArgumentError('Token is null');
 | 
			
		||||
      try {
 | 
			
		||||
        attachmentProgress.value = {...attachmentProgress.value, index: 0};
 | 
			
		||||
        final cloudFile =
 | 
			
		||||
            await putMediaToCloud(
 | 
			
		||||
              fileData: attachment,
 | 
			
		||||
              atk: token,
 | 
			
		||||
              baseUrl: baseUrl,
 | 
			
		||||
              filename: attachment.data.name ?? 'Post media',
 | 
			
		||||
              mimetype:
 | 
			
		||||
                  attachment.data.mimeType ??
 | 
			
		||||
                  switch (attachment.type) {
 | 
			
		||||
                    UniversalFileType.image => 'image/unknown',
 | 
			
		||||
                    UniversalFileType.video => 'video/unknown',
 | 
			
		||||
                    UniversalFileType.audio => 'audio/unknown',
 | 
			
		||||
                    UniversalFileType.file => 'application/octet-stream',
 | 
			
		||||
                  },
 | 
			
		||||
              onProgress: (progress, estimate) {
 | 
			
		||||
                attachmentProgress.value = {
 | 
			
		||||
                  ...attachmentProgress.value,
 | 
			
		||||
                  index: progress,
 | 
			
		||||
                };
 | 
			
		||||
              },
 | 
			
		||||
            ).future;
 | 
			
		||||
        if (cloudFile == null) {
 | 
			
		||||
          throw ArgumentError('Failed to upload the file...');
 | 
			
		||||
        }
 | 
			
		||||
        final clone = List.of(attachments.value);
 | 
			
		||||
        clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
 | 
			
		||||
        attachments.value = clone;
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        attachmentProgress.value = attachmentProgress.value..remove(index);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> deleteAttachment(int index) async {
 | 
			
		||||
      final attachment = attachments.value[index];
 | 
			
		||||
      if (attachment.isOnCloud) {
 | 
			
		||||
        final client = ref.watch(apiClientProvider);
 | 
			
		||||
        await client.delete('/files/${attachment.data.id}');
 | 
			
		||||
      }
 | 
			
		||||
      final clone = List.of(attachments.value);
 | 
			
		||||
      clone.removeAt(index);
 | 
			
		||||
      attachments.value = clone;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> performAction() async {
 | 
			
		||||
      try {
 | 
			
		||||
        submitting.value = true;
 | 
			
		||||
 | 
			
		||||
        await Future.wait(
 | 
			
		||||
          attachments.value
 | 
			
		||||
              .where((e) => e.isOnDevice)
 | 
			
		||||
              .mapIndexed((idx, e) => uploadAttachment(idx)),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        final client = ref.watch(apiClientProvider);
 | 
			
		||||
        await client.request(
 | 
			
		||||
          originalPost == null ? '/posts' : '/posts/${originalPost!.id}',
 | 
			
		||||
          data: {
 | 
			
		||||
            'title': titleController.text,
 | 
			
		||||
            'description': descriptionController.text,
 | 
			
		||||
            'content': contentController.text,
 | 
			
		||||
            'visibility':
 | 
			
		||||
                visibility.value, // Add visibility field to API request
 | 
			
		||||
            'attachments':
 | 
			
		||||
                attachments.value
 | 
			
		||||
                    .where((e) => e.isOnCloud)
 | 
			
		||||
                    .map((e) => e.data.id)
 | 
			
		||||
                    .toList(),
 | 
			
		||||
            if (repliedPost != null) 'replied_post_id': repliedPost!.id,
 | 
			
		||||
            if (forwardedPost != null) 'forwarded_post_id': forwardedPost!.id,
 | 
			
		||||
          },
 | 
			
		||||
          options: Options(
 | 
			
		||||
            headers: {'X-Pub': currentPublisher.value?.name},
 | 
			
		||||
            method: originalPost == null ? 'POST' : 'PATCH',
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
        if (context.mounted) {
 | 
			
		||||
          context.maybePop(true);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        showErrorAlert(err);
 | 
			
		||||
      } finally {
 | 
			
		||||
        submitting.value = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Future<void> handlePaste() async {
 | 
			
		||||
      final clipboard = await Pasteboard.image;
 | 
			
		||||
      if (clipboard == null) return;
 | 
			
		||||
 | 
			
		||||
      attachments.value = [
 | 
			
		||||
        ...attachments.value,
 | 
			
		||||
        UniversalFile(
 | 
			
		||||
          data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
 | 
			
		||||
          type: UniversalFileType.image,
 | 
			
		||||
        ),
 | 
			
		||||
      ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void handleKeyPress(RawKeyEvent event) {
 | 
			
		||||
      if (event is! RawKeyDownEvent) return;
 | 
			
		||||
 | 
			
		||||
      final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
 | 
			
		||||
      final isModifierPressed = event.isMetaPressed || event.isControlPressed;
 | 
			
		||||
 | 
			
		||||
      if (isPaste && isModifierPressed) {
 | 
			
		||||
        handlePaste();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void showVisibilityModal() {
 | 
			
		||||
      showDialog(
 | 
			
		||||
    void showSettingsSheet() {
 | 
			
		||||
      showModalBottomSheet(
 | 
			
		||||
        context: context,
 | 
			
		||||
        isScrollControlled: true,
 | 
			
		||||
        builder:
 | 
			
		||||
            (context) => AlertDialog(
 | 
			
		||||
              title: Text('postVisibility'.tr()),
 | 
			
		||||
              content: Column(
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                children: [
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: Icon(Symbols.public),
 | 
			
		||||
                    title: Text('postVisibilityPublic'.tr()),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      visibility.value = 0;
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
            (context) => ComposeSettingsSheet(
 | 
			
		||||
              titleController: state.titleController,
 | 
			
		||||
              descriptionController: state.descriptionController,
 | 
			
		||||
              visibility: state.visibility,
 | 
			
		||||
              onVisibilityChanged: () {
 | 
			
		||||
                // Trigger rebuild if needed
 | 
			
		||||
              },
 | 
			
		||||
                    selected: visibility.value == 0,
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: Icon(Symbols.group),
 | 
			
		||||
                    title: Text('postVisibilityFriends'.tr()),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      visibility.value = 1;
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
                    },
 | 
			
		||||
                    selected: visibility.value == 1,
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: Icon(Symbols.link_off),
 | 
			
		||||
                    title: Text('postVisibilityUnlisted'.tr()),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      visibility.value = 2;
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
                    },
 | 
			
		||||
                    selected: visibility.value == 2,
 | 
			
		||||
                  ),
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    leading: Icon(Symbols.lock),
 | 
			
		||||
                    title: Text('postVisibilityPrivate'.tr()),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      visibility.value = 3;
 | 
			
		||||
                      Navigator.pop(context);
 | 
			
		||||
                    },
 | 
			
		||||
                    selected: visibility.value == 3,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Helper method to get the appropriate icon for each visibility status
 | 
			
		||||
    IconData getVisibilityIcon(int visibilityValue) {
 | 
			
		||||
      switch (visibilityValue) {
 | 
			
		||||
        case 1: // Friends
 | 
			
		||||
          return Symbols.group;
 | 
			
		||||
        case 2: // Unlisted
 | 
			
		||||
          return Symbols.link_off;
 | 
			
		||||
        case 3: // Private
 | 
			
		||||
          return Symbols.lock;
 | 
			
		||||
        default: // Public (0) or unknown
 | 
			
		||||
          return Symbols.public;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Helper method to get the translation key for each visibility status
 | 
			
		||||
    String getVisibilityText(int visibilityValue) {
 | 
			
		||||
      switch (visibilityValue) {
 | 
			
		||||
        case 1: // Friends
 | 
			
		||||
          return 'postVisibilityFriends';
 | 
			
		||||
        case 2: // Unlisted
 | 
			
		||||
          return 'postVisibilityUnlisted';
 | 
			
		||||
        case 3: // Private
 | 
			
		||||
          return 'postVisibilityPrivate';
 | 
			
		||||
        default: // Public (0) or unknown
 | 
			
		||||
          return 'postVisibilityPublic';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title:
 | 
			
		||||
            isWideScreen(context)
 | 
			
		||||
                ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr())
 | 
			
		||||
                : null,
 | 
			
		||||
        actions: [
 | 
			
		||||
          if (isWideScreen(context))
 | 
			
		||||
            Tooltip(
 | 
			
		||||
              message: 'keyboard_shortcuts'.tr(),
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.keyboard),
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
    void showKeyboardShortcutsDialog() {
 | 
			
		||||
      showDialog(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder:
 | 
			
		||||
@@ -367,13 +135,102 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildWideAttachmentGrid() {
 | 
			
		||||
      return GridView.builder(
 | 
			
		||||
        shrinkWrap: true,
 | 
			
		||||
        physics: const NeverScrollableScrollPhysics(),
 | 
			
		||||
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
 | 
			
		||||
          crossAxisCount: 3,
 | 
			
		||||
          crossAxisSpacing: 8,
 | 
			
		||||
          mainAxisSpacing: 8,
 | 
			
		||||
        ),
 | 
			
		||||
        itemCount: state.attachments.value.length,
 | 
			
		||||
        itemBuilder: (context, idx) {
 | 
			
		||||
          return AttachmentPreview(
 | 
			
		||||
            item: state.attachments.value[idx],
 | 
			
		||||
            progress: state.attachmentProgress.value[idx],
 | 
			
		||||
            onRequestUpload:
 | 
			
		||||
                () => ComposeLogic.uploadAttachment(ref, state, idx),
 | 
			
		||||
            onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
 | 
			
		||||
            onMove: (delta) {
 | 
			
		||||
              state.attachments.value = ComposeLogic.moveAttachment(
 | 
			
		||||
                state.attachments.value,
 | 
			
		||||
                idx,
 | 
			
		||||
                delta,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildNarrowAttachmentList() {
 | 
			
		||||
      return Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          for (var idx = 0; idx < state.attachments.value.length; idx++)
 | 
			
		||||
            Container(
 | 
			
		||||
              margin: const EdgeInsets.only(bottom: 8),
 | 
			
		||||
              child: AttachmentPreview(
 | 
			
		||||
                item: state.attachments.value[idx],
 | 
			
		||||
                progress: state.attachmentProgress.value[idx],
 | 
			
		||||
                onRequestUpload:
 | 
			
		||||
                    () => ComposeLogic.uploadAttachment(ref, state, idx),
 | 
			
		||||
                onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
 | 
			
		||||
                onMove: (delta) {
 | 
			
		||||
                  state.attachments.value = ComposeLogic.moveAttachment(
 | 
			
		||||
                    state.attachments.value,
 | 
			
		||||
                    idx,
 | 
			
		||||
                    delta,
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Build UI
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        title:
 | 
			
		||||
            isWideScreen(context)
 | 
			
		||||
                ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr())
 | 
			
		||||
                : null,
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: submitting.value ? null : performAction,
 | 
			
		||||
            icon: const Icon(Symbols.settings),
 | 
			
		||||
            onPressed: showSettingsSheet,
 | 
			
		||||
            tooltip: 'postSettings'.tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          if (isWideScreen(context))
 | 
			
		||||
            Tooltip(
 | 
			
		||||
              message: 'keyboard_shortcuts'.tr(),
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.keyboard),
 | 
			
		||||
                onPressed: showKeyboardShortcutsDialog,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ValueListenableBuilder<bool>(
 | 
			
		||||
            valueListenable: state.submitting,
 | 
			
		||||
            builder: (context, submitting, _) {
 | 
			
		||||
              return IconButton(
 | 
			
		||||
                onPressed:
 | 
			
		||||
                    submitting
 | 
			
		||||
                        ? null
 | 
			
		||||
                        : () => ComposeLogic.performAction(
 | 
			
		||||
                          ref,
 | 
			
		||||
                          state,
 | 
			
		||||
                          context,
 | 
			
		||||
                          originalPost: originalPost,
 | 
			
		||||
                          repliedPost: repliedPost,
 | 
			
		||||
                          forwardedPost: forwardedPost,
 | 
			
		||||
                          postType: 0, // Regular post type
 | 
			
		||||
                        ),
 | 
			
		||||
                icon:
 | 
			
		||||
                submitting.value
 | 
			
		||||
                    submitting
 | 
			
		||||
                        ? SizedBox(
 | 
			
		||||
                          width: 28,
 | 
			
		||||
                          height: 28,
 | 
			
		||||
@@ -382,9 +239,11 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                            strokeWidth: 2.5,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ).center()
 | 
			
		||||
                    : originalPost != null
 | 
			
		||||
                    ? const Icon(Symbols.edit)
 | 
			
		||||
                    : const Icon(Symbols.upload),
 | 
			
		||||
                        : Icon(
 | 
			
		||||
                          originalPost != null ? Symbols.edit : Symbols.upload,
 | 
			
		||||
                        ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
@@ -392,59 +251,22 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
      body: Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          if (repliedPost != null)
 | 
			
		||||
            Container(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
			
		||||
              color: Theme.of(
 | 
			
		||||
                context,
 | 
			
		||||
              ).colorScheme.surfaceVariant.withOpacity(0.5),
 | 
			
		||||
              child: Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  const Icon(Symbols.reply, size: 16),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: Text(
 | 
			
		||||
                      '${'reply'.tr()}: ${repliedPost!.publisher.nick}',
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                      maxLines: 1,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          if (forwardedPost != null)
 | 
			
		||||
            Container(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | 
			
		||||
              color: Theme.of(
 | 
			
		||||
                context,
 | 
			
		||||
              ).colorScheme.surfaceVariant.withOpacity(0.5),
 | 
			
		||||
              child: Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  const Icon(Symbols.forward, size: 16),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: Text(
 | 
			
		||||
                      '${'forward'.tr()}: ${forwardedPost!.publisher.nick}',
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                      maxLines: 1,
 | 
			
		||||
                      overflow: TextOverflow.ellipsis,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          // Reply/Forward info section
 | 
			
		||||
          _buildInfoBanner(context),
 | 
			
		||||
 | 
			
		||||
          // Main content area
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Row(
 | 
			
		||||
              spacing: 12,
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                // Publisher profile picture
 | 
			
		||||
                GestureDetector(
 | 
			
		||||
                  child: ProfilePictureWidget(
 | 
			
		||||
                    fileId: currentPublisher.value?.picture?.id,
 | 
			
		||||
                    fileId: state.currentPublisher.value?.picture?.id,
 | 
			
		||||
                    radius: 20,
 | 
			
		||||
                    fallbackIcon:
 | 
			
		||||
                        currentPublisher.value == null
 | 
			
		||||
                        state.currentPublisher.value == null
 | 
			
		||||
                            ? Symbols.question_mark
 | 
			
		||||
                            : null,
 | 
			
		||||
                  ),
 | 
			
		||||
@@ -452,93 +274,43 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                    showModalBottomSheet(
 | 
			
		||||
                      isScrollControlled: true,
 | 
			
		||||
                      context: context,
 | 
			
		||||
                      builder: (context) => PublisherModal(),
 | 
			
		||||
                      builder: (context) => const PublisherModal(),
 | 
			
		||||
                    ).then((value) {
 | 
			
		||||
                      if (value is SnPublisher) currentPublisher.value = value;
 | 
			
		||||
                      if (value != null) {
 | 
			
		||||
                        state.currentPublisher.value = value;
 | 
			
		||||
                      }
 | 
			
		||||
                    });
 | 
			
		||||
                  },
 | 
			
		||||
                ).padding(top: 16),
 | 
			
		||||
 | 
			
		||||
                // Post content form
 | 
			
		||||
                Expanded(
 | 
			
		||||
                  child: SingleChildScrollView(
 | 
			
		||||
                    padding: EdgeInsets.symmetric(vertical: 16),
 | 
			
		||||
                    padding: const EdgeInsets.symmetric(vertical: 12),
 | 
			
		||||
                    child: Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Row(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            OutlinedButton(
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                showVisibilityModal();
 | 
			
		||||
                              },
 | 
			
		||||
                              style: OutlinedButton.styleFrom(
 | 
			
		||||
                                shape: RoundedRectangleBorder(
 | 
			
		||||
                                  borderRadius: BorderRadius.circular(20),
 | 
			
		||||
                                ),
 | 
			
		||||
                                side: BorderSide(
 | 
			
		||||
                                  color: Theme.of(
 | 
			
		||||
                                    context,
 | 
			
		||||
                                  ).colorScheme.primary.withOpacity(0.5),
 | 
			
		||||
                                ),
 | 
			
		||||
                                padding: EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                                visualDensity: const VisualDensity(
 | 
			
		||||
                                  vertical: -2,
 | 
			
		||||
                                  horizontal: -4,
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                              child: Row(
 | 
			
		||||
                                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  Icon(
 | 
			
		||||
                                    getVisibilityIcon(visibility.value),
 | 
			
		||||
                                    size: 16,
 | 
			
		||||
                                    color:
 | 
			
		||||
                                        Theme.of(context).colorScheme.primary,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  const SizedBox(width: 6),
 | 
			
		||||
                                  Text(
 | 
			
		||||
                                    getVisibilityText(visibility.value).tr(),
 | 
			
		||||
                                    style: TextStyle(
 | 
			
		||||
                                      fontSize: 14,
 | 
			
		||||
                                      color:
 | 
			
		||||
                                          Theme.of(context).colorScheme.primary,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ).padding(bottom: 6),
 | 
			
		||||
                        TextField(
 | 
			
		||||
                          controller: titleController,
 | 
			
		||||
                          decoration: InputDecoration.collapsed(
 | 
			
		||||
                            hintText: 'postTitle'.tr(),
 | 
			
		||||
                          ),
 | 
			
		||||
                          style: TextStyle(fontSize: 16),
 | 
			
		||||
                          onTapOutside:
 | 
			
		||||
                              (_) =>
 | 
			
		||||
                                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        TextField(
 | 
			
		||||
                          controller: descriptionController,
 | 
			
		||||
                          decoration: InputDecoration.collapsed(
 | 
			
		||||
                            hintText: 'postDescription'.tr(),
 | 
			
		||||
                          ),
 | 
			
		||||
                          style: TextStyle(fontSize: 16),
 | 
			
		||||
                          onTapOutside:
 | 
			
		||||
                              (_) =>
 | 
			
		||||
                                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
                        // Content field with borderless design
 | 
			
		||||
                        RawKeyboardListener(
 | 
			
		||||
                          focusNode: FocusNode(),
 | 
			
		||||
                          onKey: handleKeyPress,
 | 
			
		||||
                          onKey:
 | 
			
		||||
                              (event) => ComposeLogic.handleKeyPress(
 | 
			
		||||
                                event,
 | 
			
		||||
                                state,
 | 
			
		||||
                                ref,
 | 
			
		||||
                                context,
 | 
			
		||||
                                originalPost: originalPost,
 | 
			
		||||
                                repliedPost: repliedPost,
 | 
			
		||||
                                forwardedPost: forwardedPost,
 | 
			
		||||
                                postType: 0, // Regular post type
 | 
			
		||||
                              ),
 | 
			
		||||
                          child: TextField(
 | 
			
		||||
                            controller: contentController,
 | 
			
		||||
                            style: TextStyle(fontSize: 14),
 | 
			
		||||
                            controller: state.contentController,
 | 
			
		||||
                            style: theme.textTheme.bodyMedium,
 | 
			
		||||
                            decoration: InputDecoration(
 | 
			
		||||
                              border: InputBorder.none,
 | 
			
		||||
                              hintText: 'postPlaceholder'.tr(),
 | 
			
		||||
                              isDense: true,
 | 
			
		||||
                              hintText: 'postContent'.tr(),
 | 
			
		||||
                              contentPadding: const EdgeInsets.all(8),
 | 
			
		||||
                            ),
 | 
			
		||||
                            maxLines: null,
 | 
			
		||||
                            onTapOutside:
 | 
			
		||||
@@ -547,81 +319,16 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
                                        ?.unfocus(),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
 | 
			
		||||
                        const Gap(8),
 | 
			
		||||
 | 
			
		||||
                        // Attachments preview
 | 
			
		||||
                        LayoutBuilder(
 | 
			
		||||
                          builder: (context, constraints) {
 | 
			
		||||
                            final isWide = isWideScreen(context);
 | 
			
		||||
                            return isWide
 | 
			
		||||
                                ? Wrap(
 | 
			
		||||
                                  spacing: 8,
 | 
			
		||||
                                  runSpacing: 8,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    for (
 | 
			
		||||
                                      var idx = 0;
 | 
			
		||||
                                      idx < attachments.value.length;
 | 
			
		||||
                                      idx++
 | 
			
		||||
                                    )
 | 
			
		||||
                                      SizedBox(
 | 
			
		||||
                                        width: constraints.maxWidth / 2 - 4,
 | 
			
		||||
                                        child: AttachmentPreview(
 | 
			
		||||
                                          item: attachments.value[idx],
 | 
			
		||||
                                          progress:
 | 
			
		||||
                                              attachmentProgress.value[idx],
 | 
			
		||||
                                          onRequestUpload:
 | 
			
		||||
                                              () => uploadAttachment(idx),
 | 
			
		||||
                                          onDelete: () => deleteAttachment(idx),
 | 
			
		||||
                                          onMove: (delta) {
 | 
			
		||||
                                            if (idx + delta < 0 ||
 | 
			
		||||
                                                idx + delta >=
 | 
			
		||||
                                                    attachments.value.length) {
 | 
			
		||||
                                              return;
 | 
			
		||||
                                            }
 | 
			
		||||
                                            final clone = List.of(
 | 
			
		||||
                                              attachments.value,
 | 
			
		||||
                                            );
 | 
			
		||||
                                            clone.insert(
 | 
			
		||||
                                              idx + delta,
 | 
			
		||||
                                              clone.removeAt(idx),
 | 
			
		||||
                                            );
 | 
			
		||||
                                            attachments.value = clone;
 | 
			
		||||
                                          },
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                )
 | 
			
		||||
                                : Column(
 | 
			
		||||
                                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                                  spacing: 8,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    for (
 | 
			
		||||
                                      var idx = 0;
 | 
			
		||||
                                      idx < attachments.value.length;
 | 
			
		||||
                                      idx++
 | 
			
		||||
                                    )
 | 
			
		||||
                                      AttachmentPreview(
 | 
			
		||||
                                        item: attachments.value[idx],
 | 
			
		||||
                                        progress: attachmentProgress.value[idx],
 | 
			
		||||
                                        onRequestUpload:
 | 
			
		||||
                                            () => uploadAttachment(idx),
 | 
			
		||||
                                        onDelete: () => deleteAttachment(idx),
 | 
			
		||||
                                        onMove: (delta) {
 | 
			
		||||
                                          if (idx + delta < 0 ||
 | 
			
		||||
                                              idx + delta >=
 | 
			
		||||
                                                  attachments.value.length) {
 | 
			
		||||
                                            return;
 | 
			
		||||
                                          }
 | 
			
		||||
                                          final clone = List.of(
 | 
			
		||||
                                            attachments.value,
 | 
			
		||||
                                          );
 | 
			
		||||
                                          clone.insert(
 | 
			
		||||
                                            idx + delta,
 | 
			
		||||
                                            clone.removeAt(idx),
 | 
			
		||||
                                          );
 | 
			
		||||
                                          attachments.value = clone;
 | 
			
		||||
                                        },
 | 
			
		||||
                                      ),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                );
 | 
			
		||||
                                ? buildWideAttachmentGrid()
 | 
			
		||||
                                : buildNarrowAttachmentList();
 | 
			
		||||
                          },
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
@@ -631,19 +338,21 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(horizontal: 16),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          // Bottom toolbar
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 4,
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: pickPhotoMedia,
 | 
			
		||||
                  onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
 | 
			
		||||
                  icon: const Icon(Symbols.add_a_photo),
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                  color: colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: pickVideoMedia,
 | 
			
		||||
                  onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
 | 
			
		||||
                  icon: const Icon(Symbols.videocam),
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                  color: colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(
 | 
			
		||||
@@ -656,4 +365,37 @@ class PostComposeScreen extends HookConsumerWidget {
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildInfoBanner(BuildContext context) {
 | 
			
		||||
    if (originalPost != null) {
 | 
			
		||||
      return Container(
 | 
			
		||||
        width: double.infinity,
 | 
			
		||||
        color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                Icon(
 | 
			
		||||
                  repliedPost != null ? Symbols.reply : Symbols.forward,
 | 
			
		||||
                  size: 16,
 | 
			
		||||
                ),
 | 
			
		||||
                const Gap(4),
 | 
			
		||||
                Text(
 | 
			
		||||
                  repliedPost != null
 | 
			
		||||
                      ? 'postReplyingTo'.tr()
 | 
			
		||||
                      : 'postForwardingTo'.tr(),
 | 
			
		||||
                  style: Theme.of(context).textTheme.labelMedium,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            PostItem(item: originalPost!, isOpenable: false),
 | 
			
		||||
          ],
 | 
			
		||||
        ).padding(all: 16),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return const SizedBox.shrink();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,401 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/screens/creators/publishers.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/screens/posts/detail.dart';
 | 
			
		||||
import 'package:island/widgets/content/attachment_preview.dart';
 | 
			
		||||
import 'package:island/widgets/post/compose_shared.dart';
 | 
			
		||||
import 'package:island/widgets/post/publishers_modal.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_files.dart';
 | 
			
		||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
class ArticleEditScreen extends HookConsumerWidget {
 | 
			
		||||
  final String id;
 | 
			
		||||
  const ArticleEditScreen({super.key, @PathParam('id') required this.id});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final post = ref.watch(postProvider(id));
 | 
			
		||||
    return post.when(
 | 
			
		||||
      data: (post) => ArticleComposeScreen(originalPost: post),
 | 
			
		||||
      loading:
 | 
			
		||||
          () => AppScaffold(
 | 
			
		||||
            appBar: AppBar(leading: const PageBackButton()),
 | 
			
		||||
            body: const Center(child: CircularProgressIndicator()),
 | 
			
		||||
          ),
 | 
			
		||||
      error:
 | 
			
		||||
          (e, _) => AppScaffold(
 | 
			
		||||
            appBar: AppBar(leading: const PageBackButton()),
 | 
			
		||||
            body: Text('Error: $e', textAlign: TextAlign.center),
 | 
			
		||||
          ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@RoutePage()
 | 
			
		||||
class ArticleComposeScreen extends HookConsumerWidget {
 | 
			
		||||
  final SnPost? originalPost;
 | 
			
		||||
 | 
			
		||||
  const ArticleComposeScreen({super.key, this.originalPost});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final theme = Theme.of(context);
 | 
			
		||||
    final colorScheme = theme.colorScheme;
 | 
			
		||||
 | 
			
		||||
    final publishers = ref.watch(publishersManagedProvider);
 | 
			
		||||
    final state = useMemoized(
 | 
			
		||||
      () => ComposeLogic.createState(originalPost: originalPost),
 | 
			
		||||
      [originalPost],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final showPreview = useState(false);
 | 
			
		||||
 | 
			
		||||
    // Initialize publisher once when data is available
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (publishers.value?.isNotEmpty ?? false) {
 | 
			
		||||
        state.currentPublisher.value = publishers.value!.first;
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [publishers]);
 | 
			
		||||
 | 
			
		||||
    // Dispose state when widget is disposed
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      return () => ComposeLogic.dispose(state);
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    // Helper methods
 | 
			
		||||
    void showSettingsSheet() {
 | 
			
		||||
      showModalBottomSheet(
 | 
			
		||||
        context: context,
 | 
			
		||||
        isScrollControlled: true,
 | 
			
		||||
        builder:
 | 
			
		||||
            (context) => ComposeSettingsSheet(
 | 
			
		||||
              titleController: state.titleController,
 | 
			
		||||
              descriptionController: state.descriptionController,
 | 
			
		||||
              visibility: state.visibility,
 | 
			
		||||
              onVisibilityChanged: () {
 | 
			
		||||
                // Trigger rebuild if needed
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void showKeyboardShortcutsDialog() {
 | 
			
		||||
      showDialog(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder:
 | 
			
		||||
            (context) => AlertDialog(
 | 
			
		||||
              title: Text('keyboard_shortcuts'.tr()),
 | 
			
		||||
              content: Column(
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'),
 | 
			
		||||
                  Text('Ctrl/Cmd + V: ${'paste'.tr()}'),
 | 
			
		||||
                  Text('Ctrl/Cmd + I: ${'add_image'.tr()}'),
 | 
			
		||||
                  Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'),
 | 
			
		||||
                  Text('Ctrl/Cmd + P: ${'toggle_preview'.tr()}'),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              actions: [
 | 
			
		||||
                TextButton(
 | 
			
		||||
                  onPressed: () => Navigator.of(context).pop(),
 | 
			
		||||
                  child: Text('close'.tr()),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildPreviewPane() {
 | 
			
		||||
      return Container(
 | 
			
		||||
        decoration: BoxDecoration(
 | 
			
		||||
          border: Border.all(color: colorScheme.outline.withOpacity(0.3)),
 | 
			
		||||
          borderRadius: BorderRadius.circular(8),
 | 
			
		||||
        ),
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Container(
 | 
			
		||||
              padding: const EdgeInsets.all(16),
 | 
			
		||||
              decoration: BoxDecoration(
 | 
			
		||||
                color: colorScheme.surfaceVariant.withOpacity(0.3),
 | 
			
		||||
                borderRadius: const BorderRadius.only(
 | 
			
		||||
                  topLeft: Radius.circular(8),
 | 
			
		||||
                  topRight: Radius.circular(8),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              child: Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  Icon(Symbols.preview, size: 20),
 | 
			
		||||
                  const Gap(8),
 | 
			
		||||
                  Text('preview'.tr(), style: theme.textTheme.titleMedium),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            Expanded(
 | 
			
		||||
              child: SingleChildScrollView(
 | 
			
		||||
                padding: const EdgeInsets.all(16),
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    if (state.titleController.text.isNotEmpty) ...[
 | 
			
		||||
                      Text(
 | 
			
		||||
                        state.titleController.text,
 | 
			
		||||
                        style: theme.textTheme.headlineSmall?.copyWith(
 | 
			
		||||
                          fontWeight: FontWeight.bold,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(16),
 | 
			
		||||
                    ],
 | 
			
		||||
                    if (state.descriptionController.text.isNotEmpty) ...[
 | 
			
		||||
                      Text(
 | 
			
		||||
                        state.descriptionController.text,
 | 
			
		||||
                        style: theme.textTheme.bodyLarge?.copyWith(
 | 
			
		||||
                          color: colorScheme.onSurface.withOpacity(0.7),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      const Gap(16),
 | 
			
		||||
                    ],
 | 
			
		||||
                    if (state.contentController.text.isNotEmpty)
 | 
			
		||||
                      Text(
 | 
			
		||||
                        state.contentController.text,
 | 
			
		||||
                        style: theme.textTheme.bodyMedium,
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildEditorPane() {
 | 
			
		||||
      return Column(
 | 
			
		||||
        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
        children: [
 | 
			
		||||
          // Publisher row
 | 
			
		||||
          Card(
 | 
			
		||||
            elevation: 1,
 | 
			
		||||
            child: Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(12),
 | 
			
		||||
              child: Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  GestureDetector(
 | 
			
		||||
                    child: ProfilePictureWidget(
 | 
			
		||||
                      fileId: state.currentPublisher.value?.picture?.id,
 | 
			
		||||
                      radius: 20,
 | 
			
		||||
                      fallbackIcon:
 | 
			
		||||
                          state.currentPublisher.value == null
 | 
			
		||||
                              ? Symbols.question_mark
 | 
			
		||||
                              : null,
 | 
			
		||||
                    ),
 | 
			
		||||
                    onTap: () {
 | 
			
		||||
                      showModalBottomSheet(
 | 
			
		||||
                        isScrollControlled: true,
 | 
			
		||||
                        context: context,
 | 
			
		||||
                        builder: (context) => const PublisherModal(),
 | 
			
		||||
                      ).then((value) {
 | 
			
		||||
                        if (value != null) {
 | 
			
		||||
                          state.currentPublisher.value = value;
 | 
			
		||||
                        }
 | 
			
		||||
                      });
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(12),
 | 
			
		||||
                  Text(
 | 
			
		||||
                    state.currentPublisher.value?.name ??
 | 
			
		||||
                        'postPublisherUnselected'.tr(),
 | 
			
		||||
                    style: theme.textTheme.bodyMedium,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          // Content field with keyboard listener
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: RawKeyboardListener(
 | 
			
		||||
              focusNode: FocusNode(),
 | 
			
		||||
              onKey:
 | 
			
		||||
                  (event) => ComposeLogic.handleKeyPress(
 | 
			
		||||
                    event,
 | 
			
		||||
                    state,
 | 
			
		||||
                    ref,
 | 
			
		||||
                    context,
 | 
			
		||||
                    originalPost: originalPost,
 | 
			
		||||
                    postType: 1, // Article type
 | 
			
		||||
                  ),
 | 
			
		||||
              child: TextField(
 | 
			
		||||
                controller: state.contentController,
 | 
			
		||||
                style: theme.textTheme.bodyMedium,
 | 
			
		||||
                decoration: InputDecoration(
 | 
			
		||||
                  border: InputBorder.none,
 | 
			
		||||
                  hintText: 'postContent'.tr(),
 | 
			
		||||
                  contentPadding: const EdgeInsets.all(8),
 | 
			
		||||
                ),
 | 
			
		||||
                maxLines: null,
 | 
			
		||||
                expands: true,
 | 
			
		||||
                textAlignVertical: TextAlignVertical.top,
 | 
			
		||||
                onTapOutside:
 | 
			
		||||
                    (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          // Attachments preview
 | 
			
		||||
          if (state.attachments.value.isNotEmpty) ...[
 | 
			
		||||
            const Gap(16),
 | 
			
		||||
            Wrap(
 | 
			
		||||
              spacing: 8,
 | 
			
		||||
              runSpacing: 8,
 | 
			
		||||
              children: [
 | 
			
		||||
                for (var idx = 0; idx < state.attachments.value.length; idx++)
 | 
			
		||||
                  SizedBox(
 | 
			
		||||
                    width: 120,
 | 
			
		||||
                    height: 120,
 | 
			
		||||
                    child: AttachmentPreview(
 | 
			
		||||
                      item: state.attachments.value[idx],
 | 
			
		||||
                      progress: state.attachmentProgress.value[idx],
 | 
			
		||||
                      onRequestUpload:
 | 
			
		||||
                          () => ComposeLogic.uploadAttachment(ref, state, idx),
 | 
			
		||||
                      onDelete:
 | 
			
		||||
                          () => ComposeLogic.deleteAttachment(ref, state, idx),
 | 
			
		||||
                      onMove: (delta) {
 | 
			
		||||
                        state.attachments.value = ComposeLogic.moveAttachment(
 | 
			
		||||
                          state.attachments.value,
 | 
			
		||||
                          idx,
 | 
			
		||||
                          delta,
 | 
			
		||||
                        );
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppScaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: const PageBackButton(),
 | 
			
		||||
        actions: [
 | 
			
		||||
          IconButton(
 | 
			
		||||
            icon: const Icon(Symbols.settings),
 | 
			
		||||
            onPressed: showSettingsSheet,
 | 
			
		||||
            tooltip: 'postSettings'.tr(),
 | 
			
		||||
          ),
 | 
			
		||||
          Tooltip(
 | 
			
		||||
            message: 'togglePreview'.tr(),
 | 
			
		||||
            child: IconButton(
 | 
			
		||||
              icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
 | 
			
		||||
              onPressed: () => showPreview.value = !showPreview.value,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          if (isWideScreen(context))
 | 
			
		||||
            Tooltip(
 | 
			
		||||
              message: 'keyboard_shortcuts'.tr(),
 | 
			
		||||
              child: IconButton(
 | 
			
		||||
                icon: const Icon(Symbols.keyboard),
 | 
			
		||||
                onPressed: showKeyboardShortcutsDialog,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ValueListenableBuilder<bool>(
 | 
			
		||||
            valueListenable: state.submitting,
 | 
			
		||||
            builder: (context, submitting, _) {
 | 
			
		||||
              return IconButton(
 | 
			
		||||
                onPressed:
 | 
			
		||||
                    submitting
 | 
			
		||||
                        ? null
 | 
			
		||||
                        : () => ComposeLogic.performAction(
 | 
			
		||||
                          ref,
 | 
			
		||||
                          state,
 | 
			
		||||
                          context,
 | 
			
		||||
                          originalPost: originalPost,
 | 
			
		||||
                          postType: 1, // Article type
 | 
			
		||||
                        ),
 | 
			
		||||
                icon:
 | 
			
		||||
                    submitting
 | 
			
		||||
                        ? SizedBox(
 | 
			
		||||
                          width: 28,
 | 
			
		||||
                          height: 28,
 | 
			
		||||
                          child: const CircularProgressIndicator(
 | 
			
		||||
                            color: Colors.white,
 | 
			
		||||
                            strokeWidth: 2.5,
 | 
			
		||||
                          ),
 | 
			
		||||
                        ).center()
 | 
			
		||||
                        : Icon(
 | 
			
		||||
                          originalPost != null ? Symbols.edit : Symbols.upload,
 | 
			
		||||
                        ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
          const Gap(8),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
      body: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(16),
 | 
			
		||||
              child:
 | 
			
		||||
                  isWideScreen(context)
 | 
			
		||||
                      ? Row(
 | 
			
		||||
                        spacing: 16,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Expanded(
 | 
			
		||||
                            flex: showPreview.value ? 1 : 2,
 | 
			
		||||
                            child: buildEditorPane(),
 | 
			
		||||
                          ),
 | 
			
		||||
                          if (showPreview.value)
 | 
			
		||||
                            Expanded(child: buildPreviewPane()),
 | 
			
		||||
                        ],
 | 
			
		||||
                      )
 | 
			
		||||
                      : showPreview.value
 | 
			
		||||
                      ? buildPreviewPane()
 | 
			
		||||
                      : buildEditorPane(),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          // Bottom toolbar
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 4,
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: () => ComposeLogic.pickPhotoMedia(ref, state),
 | 
			
		||||
                  icon: const Icon(Symbols.add_a_photo),
 | 
			
		||||
                  color: colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: () => ComposeLogic.pickVideoMedia(ref, state),
 | 
			
		||||
                  icon: const Icon(Symbols.videocam),
 | 
			
		||||
                  color: colorScheme.primary,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ).padding(
 | 
			
		||||
              bottom: MediaQuery.of(context).padding.bottom + 16,
 | 
			
		||||
              horizontal: 16,
 | 
			
		||||
              top: 8,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -4,6 +4,7 @@ import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/pods/userinfo.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
@@ -29,6 +30,7 @@ class PostDetailScreen extends HookConsumerWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final post = ref.watch(postProvider(id));
 | 
			
		||||
    final user = ref.watch(userInfoProvider);
 | 
			
		||||
 | 
			
		||||
    final isWide = isWideScreen(context);
 | 
			
		||||
 | 
			
		||||
@@ -58,6 +60,7 @@ class PostDetailScreen extends HookConsumerWidget {
 | 
			
		||||
                  SliverGap(MediaQuery.of(context).padding.bottom + 80),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              if (user.value != null)
 | 
			
		||||
                Positioned(
 | 
			
		||||
                  bottom: 0,
 | 
			
		||||
                  left: 0,
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import 'package:cross_file/cross_file.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:island/models/file.dart';
 | 
			
		||||
import 'package:native_exif/native_exif.dart';
 | 
			
		||||
import 'package:tus_client_dart/tus_client_dart.dart';
 | 
			
		||||
 | 
			
		||||
Future<XFile?> cropImage(
 | 
			
		||||
@@ -46,7 +47,91 @@ Completer<SnCloudFile?> putMediaToCloud({
 | 
			
		||||
  String? mimetype,
 | 
			
		||||
  Function(double progress, Duration estimate)? onProgress,
 | 
			
		||||
}) {
 | 
			
		||||
  XFile file;
 | 
			
		||||
  final completer = Completer<SnCloudFile?>();
 | 
			
		||||
 | 
			
		||||
  // Process the image to remove GPS EXIF data if needed
 | 
			
		||||
  if (fileData.isOnDevice && fileData.type == UniversalFileType.image) {
 | 
			
		||||
    final data = fileData.data;
 | 
			
		||||
    if (data is XFile && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
 | 
			
		||||
      // Use native_exif to selectively remove GPS data
 | 
			
		||||
      Exif.fromPath(data.path)
 | 
			
		||||
          .then((exif) {
 | 
			
		||||
            // Remove GPS-related attributes
 | 
			
		||||
            final gpsAttributes = [
 | 
			
		||||
              'GPSLatitude',
 | 
			
		||||
              'GPSLatitudeRef',
 | 
			
		||||
              'GPSLongitude',
 | 
			
		||||
              'GPSLongitudeRef',
 | 
			
		||||
              'GPSAltitude',
 | 
			
		||||
              'GPSAltitudeRef',
 | 
			
		||||
              'GPSTimeStamp',
 | 
			
		||||
              'GPSProcessingMethod',
 | 
			
		||||
              'GPSDateStamp',
 | 
			
		||||
            ];
 | 
			
		||||
 | 
			
		||||
            // Create a map of attributes to clear
 | 
			
		||||
            final clearAttributes = <String, String>{};
 | 
			
		||||
            for (final attr in gpsAttributes) {
 | 
			
		||||
              clearAttributes[attr] = '';
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Write empty values to remove GPS data
 | 
			
		||||
            return exif.writeAttributes(clearAttributes);
 | 
			
		||||
          })
 | 
			
		||||
          .then((_) {
 | 
			
		||||
            // Continue with upload after GPS data is removed
 | 
			
		||||
            _processUpload(
 | 
			
		||||
              fileData,
 | 
			
		||||
              atk,
 | 
			
		||||
              baseUrl,
 | 
			
		||||
              filename,
 | 
			
		||||
              mimetype,
 | 
			
		||||
              onProgress,
 | 
			
		||||
              completer,
 | 
			
		||||
            );
 | 
			
		||||
          })
 | 
			
		||||
          .catchError((e) {
 | 
			
		||||
            // If there's an error, continue with the original file
 | 
			
		||||
            debugPrint('Error removing GPS EXIF data: $e');
 | 
			
		||||
            _processUpload(
 | 
			
		||||
              fileData,
 | 
			
		||||
              atk,
 | 
			
		||||
              baseUrl,
 | 
			
		||||
              filename,
 | 
			
		||||
              mimetype,
 | 
			
		||||
              onProgress,
 | 
			
		||||
              completer,
 | 
			
		||||
            );
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
      return completer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // If not an image or on web, continue with normal upload
 | 
			
		||||
  _processUpload(
 | 
			
		||||
    fileData,
 | 
			
		||||
    atk,
 | 
			
		||||
    baseUrl,
 | 
			
		||||
    filename,
 | 
			
		||||
    mimetype,
 | 
			
		||||
    onProgress,
 | 
			
		||||
    completer,
 | 
			
		||||
  );
 | 
			
		||||
  return completer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper method to process the upload after any EXIF processing
 | 
			
		||||
Completer<SnCloudFile?> _processUpload(
 | 
			
		||||
  UniversalFile fileData,
 | 
			
		||||
  String atk,
 | 
			
		||||
  String baseUrl,
 | 
			
		||||
  String? filename,
 | 
			
		||||
  String? mimetype,
 | 
			
		||||
  Function(double progress, Duration estimate)? onProgress,
 | 
			
		||||
  Completer<SnCloudFile?> completer,
 | 
			
		||||
) {
 | 
			
		||||
  late XFile file;
 | 
			
		||||
  String actualFilename = filename ?? 'randomly_file';
 | 
			
		||||
  String actualMimetype = mimetype ?? '';
 | 
			
		||||
  Uint8List? byteData;
 | 
			
		||||
@@ -63,16 +148,23 @@ Completer<SnCloudFile?> putMediaToCloud({
 | 
			
		||||
    actualFilename = filename ?? 'uploaded_file';
 | 
			
		||||
    actualMimetype = mimetype ?? 'application/octet-stream';
 | 
			
		||||
    if (mimetype == null) {
 | 
			
		||||
      throw ArgumentError('Mimetype is required when providing raw bytes.');
 | 
			
		||||
      completer.completeError(
 | 
			
		||||
        ArgumentError('Mimetype is required when providing raw bytes.'),
 | 
			
		||||
      );
 | 
			
		||||
      return completer;
 | 
			
		||||
    }
 | 
			
		||||
    file = XFile.fromData(byteData!, mimeType: actualMimetype);
 | 
			
		||||
  } else if (data is SnCloudFile) {
 | 
			
		||||
    // If the file is already on the cloud, just return it
 | 
			
		||||
    return Completer<SnCloudFile?>()..complete(data);
 | 
			
		||||
    completer.complete(data);
 | 
			
		||||
    return completer;
 | 
			
		||||
  } else {
 | 
			
		||||
    throw ArgumentError(
 | 
			
		||||
    completer.completeError(
 | 
			
		||||
      ArgumentError(
 | 
			
		||||
        'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.',
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    return completer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  final Map<String, String> metadata = {
 | 
			
		||||
@@ -80,8 +172,6 @@ Completer<SnCloudFile?> putMediaToCloud({
 | 
			
		||||
    'content-type': actualMimetype,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  final completer = Completer<SnCloudFile?>();
 | 
			
		||||
 | 
			
		||||
  final client = TusClient(file);
 | 
			
		||||
  client
 | 
			
		||||
      .upload(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								lib/services/text.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/services/text.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
extension StringExtension on String {
 | 
			
		||||
  String capitalizeEachWord() {
 | 
			
		||||
    if (isEmpty) return this;
 | 
			
		||||
 | 
			
		||||
    return split(' ')
 | 
			
		||||
        .map(
 | 
			
		||||
          (word) =>
 | 
			
		||||
              word.isNotEmpty
 | 
			
		||||
                  ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'
 | 
			
		||||
                  : '',
 | 
			
		||||
        )
 | 
			
		||||
        .join(' ');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,236 +0,0 @@
 | 
			
		||||
// ignore_for_file: implementation_imports, invalid_use_of_internal_member
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
 | 
			
		||||
import 'package:riverpod_paging_utils/src/paging_data.dart';
 | 
			
		||||
import 'package:riverpod_paging_utils/src/paging_helper_view_theme.dart';
 | 
			
		||||
import 'package:riverpod_paging_utils/src/paging_notifier_mixin.dart';
 | 
			
		||||
import 'package:visibility_detector/visibility_detector.dart';
 | 
			
		||||
 | 
			
		||||
/// A generic widget for pagination.
 | 
			
		||||
///
 | 
			
		||||
/// Main features:
 | 
			
		||||
/// 1. Displays the widget created by [contentBuilder] when data is available.
 | 
			
		||||
/// 2. Shows a CircularProgressIndicator while loading the first page.
 | 
			
		||||
/// 3. Displays an error widget when there is an error on the first page.
 | 
			
		||||
/// 4. Shows error messages using a SnackBar.
 | 
			
		||||
/// 5. Loads the next page when the last item is displayed.
 | 
			
		||||
/// 6. Supports pull-to-refresh functionality.
 | 
			
		||||
///
 | 
			
		||||
/// You can customize the appearance of the loading view, error view, and endItemView using [PagingHelperViewTheme].
 | 
			
		||||
final class PagingHelperSliverView<D extends PagingData<I>, I>
 | 
			
		||||
    extends ConsumerWidget {
 | 
			
		||||
  const PagingHelperSliverView({
 | 
			
		||||
    required this.provider,
 | 
			
		||||
    required this.futureRefreshable,
 | 
			
		||||
    required this.notifierRefreshable,
 | 
			
		||||
    required this.contentBuilder,
 | 
			
		||||
    this.showSecondPageError = true,
 | 
			
		||||
    super.key,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  final ProviderListenable<AsyncValue<D>> provider;
 | 
			
		||||
  final Refreshable<Future<D>> futureRefreshable;
 | 
			
		||||
  final Refreshable<PagingNotifierMixin<D, I>> notifierRefreshable;
 | 
			
		||||
 | 
			
		||||
  /// Specifies a function that returns a widget to display when data is available.
 | 
			
		||||
  /// endItemView is a widget to detect when the last displayed item is visible.
 | 
			
		||||
  /// If endItemView is non-null, it is displayed at the end of the list.
 | 
			
		||||
  final Widget Function(D data, int widgetCount, Widget endItemView)
 | 
			
		||||
  contentBuilder;
 | 
			
		||||
 | 
			
		||||
  final bool showSecondPageError;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final theme = Theme.of(context).extension<PagingHelperViewTheme>();
 | 
			
		||||
 | 
			
		||||
    final loadingBuilder =
 | 
			
		||||
        theme?.loadingViewBuilder ??
 | 
			
		||||
        (context) => SliverFillRemaining(
 | 
			
		||||
          child: const Center(child: CircularProgressIndicator()),
 | 
			
		||||
        );
 | 
			
		||||
    final errorBuilder =
 | 
			
		||||
        theme?.errorViewBuilder ??
 | 
			
		||||
        (context, e, st, onPressed) => SliverFillRemaining(
 | 
			
		||||
          child: Center(
 | 
			
		||||
            child: Column(
 | 
			
		||||
              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
              children: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: onPressed,
 | 
			
		||||
                  icon: const Icon(Icons.refresh),
 | 
			
		||||
                ),
 | 
			
		||||
                Text(e.toString()),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    return ref
 | 
			
		||||
        .watch(provider)
 | 
			
		||||
        .whenIgnorableError(
 | 
			
		||||
          data: (
 | 
			
		||||
            data, {
 | 
			
		||||
            required hasError,
 | 
			
		||||
            required isLoading,
 | 
			
		||||
            required error,
 | 
			
		||||
          }) {
 | 
			
		||||
            final content = contentBuilder(
 | 
			
		||||
              data,
 | 
			
		||||
              // Add 1 to the length to include the endItemView
 | 
			
		||||
              data.items.length + 1,
 | 
			
		||||
              switch ((data.hasMore, hasError, isLoading)) {
 | 
			
		||||
                // Display a widget to detect when the last element is reached
 | 
			
		||||
                // if there are more pages and no errors
 | 
			
		||||
                (true, false, _) => _EndVDLoadingItemView(
 | 
			
		||||
                  onScrollEnd:
 | 
			
		||||
                      () async => ref.read(notifierRefreshable).loadNext(),
 | 
			
		||||
                ),
 | 
			
		||||
                (true, true, false) when showSecondPageError =>
 | 
			
		||||
                  _EndErrorItemView(
 | 
			
		||||
                    error: error,
 | 
			
		||||
                    onRetryButtonPressed:
 | 
			
		||||
                        () async => ref.read(notifierRefreshable).loadNext(),
 | 
			
		||||
                  ),
 | 
			
		||||
                (true, true, true) => const _EndLoadingItemView(),
 | 
			
		||||
                _ => const SizedBox.shrink(),
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            return content;
 | 
			
		||||
          },
 | 
			
		||||
          // Loading state for the first page
 | 
			
		||||
          loading: () => loadingBuilder(context),
 | 
			
		||||
          // Error state for the first page
 | 
			
		||||
          error:
 | 
			
		||||
              (e, st) => errorBuilder(
 | 
			
		||||
                context,
 | 
			
		||||
                e,
 | 
			
		||||
                st,
 | 
			
		||||
                () => ref.read(notifierRefreshable).forceRefresh(),
 | 
			
		||||
              ),
 | 
			
		||||
          // Prioritize data for errors on the second page and beyond
 | 
			
		||||
          skipErrorOnHasValue: true,
 | 
			
		||||
        );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class _EndLoadingItemView extends StatelessWidget {
 | 
			
		||||
  const _EndLoadingItemView();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final theme = Theme.of(context).extension<PagingHelperViewTheme>();
 | 
			
		||||
    final childBuilder =
 | 
			
		||||
        theme?.endLoadingViewBuilder ??
 | 
			
		||||
        (context) => const Center(
 | 
			
		||||
          child: Padding(
 | 
			
		||||
            padding: EdgeInsets.all(16),
 | 
			
		||||
            child: CircularProgressIndicator(),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    return childBuilder(context);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class _EndVDLoadingItemView extends StatelessWidget {
 | 
			
		||||
  const _EndVDLoadingItemView({required this.onScrollEnd});
 | 
			
		||||
  final VoidCallback onScrollEnd;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return VisibilityDetector(
 | 
			
		||||
      key: key ?? const Key('EndItem'),
 | 
			
		||||
      onVisibilityChanged: (info) {
 | 
			
		||||
        if (info.visibleFraction > 0.1) {
 | 
			
		||||
          onScrollEnd();
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: const _EndLoadingItemView(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class _EndErrorItemView extends StatelessWidget {
 | 
			
		||||
  const _EndErrorItemView({
 | 
			
		||||
    required this.error,
 | 
			
		||||
    required this.onRetryButtonPressed,
 | 
			
		||||
  });
 | 
			
		||||
  final Object? error;
 | 
			
		||||
  final VoidCallback onRetryButtonPressed;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final theme = Theme.of(context).extension<PagingHelperViewTheme>();
 | 
			
		||||
    final childBuilder =
 | 
			
		||||
        theme?.endErrorViewBuilder ??
 | 
			
		||||
        (context, e, onPressed) => Center(
 | 
			
		||||
          child: Padding(
 | 
			
		||||
            padding: const EdgeInsets.all(16),
 | 
			
		||||
            child: Column(
 | 
			
		||||
              children: [
 | 
			
		||||
                IconButton(
 | 
			
		||||
                  onPressed: onPressed,
 | 
			
		||||
                  icon: const Icon(Icons.refresh),
 | 
			
		||||
                ),
 | 
			
		||||
                Text(error.toString()),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    return childBuilder(context, error, onRetryButtonPressed);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
extension _AsyncValueX<T> on AsyncValue<T> {
 | 
			
		||||
  /// Extends the [when] method to handle async data states more effectively,
 | 
			
		||||
  /// especially when maintaining data integrity despite errors.
 | 
			
		||||
  ///
 | 
			
		||||
  /// Use `skipErrorOnHasValue` to retain and display existing data
 | 
			
		||||
  /// even if subsequent fetch attempts result in errors,
 | 
			
		||||
  /// ideal for maintaining a seamless user experience.
 | 
			
		||||
  R whenIgnorableError<R>({
 | 
			
		||||
    required R Function(
 | 
			
		||||
      T data, {
 | 
			
		||||
      required bool hasError,
 | 
			
		||||
      required bool isLoading,
 | 
			
		||||
      required Object? error,
 | 
			
		||||
    })
 | 
			
		||||
    data,
 | 
			
		||||
    required R Function(Object error, StackTrace stackTrace) error,
 | 
			
		||||
    required R Function() loading,
 | 
			
		||||
    bool skipLoadingOnReload = false,
 | 
			
		||||
    bool skipLoadingOnRefresh = true,
 | 
			
		||||
    bool skipError = false,
 | 
			
		||||
    bool skipErrorOnHasValue = false,
 | 
			
		||||
  }) {
 | 
			
		||||
    if (skipErrorOnHasValue) {
 | 
			
		||||
      if (hasValue && hasError) {
 | 
			
		||||
        return data(
 | 
			
		||||
          requireValue,
 | 
			
		||||
          hasError: true,
 | 
			
		||||
          isLoading: isLoading,
 | 
			
		||||
          error: this.error,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return when(
 | 
			
		||||
      skipLoadingOnReload: skipLoadingOnReload,
 | 
			
		||||
      skipLoadingOnRefresh: skipLoadingOnRefresh,
 | 
			
		||||
      skipError: skipError,
 | 
			
		||||
      data:
 | 
			
		||||
          (d) => data(
 | 
			
		||||
            d,
 | 
			
		||||
            hasError: hasError,
 | 
			
		||||
            isLoading: isLoading,
 | 
			
		||||
            error: this.error,
 | 
			
		||||
          ),
 | 
			
		||||
      error: error,
 | 
			
		||||
      loading: loading,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
 | 
			
		||||
class ComposeSettingsSheet extends HookWidget {
 | 
			
		||||
  final TextEditingController titleController;
 | 
			
		||||
  final TextEditingController descriptionController;
 | 
			
		||||
  final ValueNotifier<int> visibility;
 | 
			
		||||
  final VoidCallback? onVisibilityChanged;
 | 
			
		||||
 | 
			
		||||
  const ComposeSettingsSheet({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.titleController,
 | 
			
		||||
    required this.descriptionController,
 | 
			
		||||
    required this.visibility,
 | 
			
		||||
    this.onVisibilityChanged,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final theme = Theme.of(context);
 | 
			
		||||
    final colorScheme = theme.colorScheme;
 | 
			
		||||
 | 
			
		||||
    IconData getVisibilityIcon(int visibilityValue) {
 | 
			
		||||
      switch (visibilityValue) {
 | 
			
		||||
        case 1:
 | 
			
		||||
          return Symbols.group;
 | 
			
		||||
        case 2:
 | 
			
		||||
          return Symbols.link_off;
 | 
			
		||||
        case 3:
 | 
			
		||||
          return Symbols.lock;
 | 
			
		||||
        default:
 | 
			
		||||
          return Symbols.public;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    String getVisibilityText(int visibilityValue) {
 | 
			
		||||
      switch (visibilityValue) {
 | 
			
		||||
        case 1:
 | 
			
		||||
          return 'postVisibilityFriends';
 | 
			
		||||
        case 2:
 | 
			
		||||
          return 'postVisibilityUnlisted';
 | 
			
		||||
        case 3:
 | 
			
		||||
          return 'postVisibilityPrivate';
 | 
			
		||||
        default:
 | 
			
		||||
          return 'postVisibilityPublic';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildVisibilityOption(
 | 
			
		||||
      BuildContext context,
 | 
			
		||||
      int value,
 | 
			
		||||
      IconData icon,
 | 
			
		||||
      String textKey,
 | 
			
		||||
    ) {
 | 
			
		||||
      return ListTile(
 | 
			
		||||
        leading: Icon(icon),
 | 
			
		||||
        title: Text(textKey.tr()),
 | 
			
		||||
        onTap: () {
 | 
			
		||||
          visibility.value = value;
 | 
			
		||||
          onVisibilityChanged?.call();
 | 
			
		||||
          Navigator.pop(context);
 | 
			
		||||
        },
 | 
			
		||||
        selected: visibility.value == value,
 | 
			
		||||
        contentPadding: const EdgeInsets.symmetric(horizontal: 20),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void showVisibilitySheet() {
 | 
			
		||||
      showModalBottomSheet(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder: (context) => SheetScaffold(
 | 
			
		||||
          titleText: 'postVisibility'.tr(),
 | 
			
		||||
          child: Column(
 | 
			
		||||
            mainAxisSize: MainAxisSize.min,
 | 
			
		||||
            children: [
 | 
			
		||||
              buildVisibilityOption(
 | 
			
		||||
                context,
 | 
			
		||||
                0,
 | 
			
		||||
                Symbols.public,
 | 
			
		||||
                'postVisibilityPublic',
 | 
			
		||||
              ),
 | 
			
		||||
              buildVisibilityOption(
 | 
			
		||||
                context,
 | 
			
		||||
                1,
 | 
			
		||||
                Symbols.group,
 | 
			
		||||
                'postVisibilityFriends',
 | 
			
		||||
              ),
 | 
			
		||||
              buildVisibilityOption(
 | 
			
		||||
                context,
 | 
			
		||||
                2,
 | 
			
		||||
                Symbols.link_off,
 | 
			
		||||
                'postVisibilityUnlisted',
 | 
			
		||||
              ),
 | 
			
		||||
              buildVisibilityOption(
 | 
			
		||||
                context,
 | 
			
		||||
                3,
 | 
			
		||||
                Symbols.lock,
 | 
			
		||||
                'postVisibilityPrivate',
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'postSettings'.tr(),
 | 
			
		||||
      child: SingleChildScrollView(
 | 
			
		||||
        padding: const EdgeInsets.all(16),
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            // Title field
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: titleController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                labelText: 'postTitle'.tr(),
 | 
			
		||||
                hintText: 'postTitle'.tr(),
 | 
			
		||||
                border: OutlineInputBorder(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(12),
 | 
			
		||||
                ),
 | 
			
		||||
                contentPadding: const EdgeInsets.all(16),
 | 
			
		||||
              ),
 | 
			
		||||
              style: theme.textTheme.titleLarge,
 | 
			
		||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
            const SizedBox(height: 16),
 | 
			
		||||
 | 
			
		||||
            // Description field
 | 
			
		||||
            TextField(
 | 
			
		||||
              controller: descriptionController,
 | 
			
		||||
              decoration: InputDecoration(
 | 
			
		||||
                labelText: 'postDescription'.tr(),
 | 
			
		||||
                hintText: 'postDescription'.tr(),
 | 
			
		||||
                border: OutlineInputBorder(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(12),
 | 
			
		||||
                ),
 | 
			
		||||
                contentPadding: const EdgeInsets.all(16),
 | 
			
		||||
              ),
 | 
			
		||||
              style: theme.textTheme.bodyLarge,
 | 
			
		||||
              maxLines: 3,
 | 
			
		||||
              onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
			
		||||
            ),
 | 
			
		||||
            const SizedBox(height: 24),
 | 
			
		||||
 | 
			
		||||
            // Visibility setting
 | 
			
		||||
            Container(
 | 
			
		||||
              decoration: BoxDecoration(
 | 
			
		||||
                border: Border.all(
 | 
			
		||||
                  color: colorScheme.outline,
 | 
			
		||||
                  width: 1,
 | 
			
		||||
                ),
 | 
			
		||||
                borderRadius: BorderRadius.circular(12),
 | 
			
		||||
              ),
 | 
			
		||||
              child: ListTile(
 | 
			
		||||
                leading: Icon(getVisibilityIcon(visibility.value)),
 | 
			
		||||
                title: Text('postVisibility'.tr()),
 | 
			
		||||
                subtitle: Text(getVisibilityText(visibility.value).tr()),
 | 
			
		||||
                trailing: const Icon(Symbols.chevron_right),
 | 
			
		||||
                onTap: showVisibilitySheet,
 | 
			
		||||
                shape: RoundedRectangleBorder(
 | 
			
		||||
                  borderRadius: BorderRadius.circular(12),
 | 
			
		||||
                ),
 | 
			
		||||
                contentPadding: const EdgeInsets.symmetric(
 | 
			
		||||
                  horizontal: 16,
 | 
			
		||||
                  vertical: 8,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,308 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:image_picker/image_picker.dart';
 | 
			
		||||
import 'package:island/models/file.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/config.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/services/file.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:pasteboard/pasteboard.dart';
 | 
			
		||||
 | 
			
		||||
class ComposeState {
 | 
			
		||||
  final ValueNotifier<List<UniversalFile>> attachments;
 | 
			
		||||
  final TextEditingController titleController;
 | 
			
		||||
  final TextEditingController descriptionController;
 | 
			
		||||
  final TextEditingController contentController;
 | 
			
		||||
  final ValueNotifier<int> visibility;
 | 
			
		||||
  final ValueNotifier<bool> submitting;
 | 
			
		||||
  final ValueNotifier<Map<int, double>> attachmentProgress;
 | 
			
		||||
  final ValueNotifier<SnPublisher?> currentPublisher;
 | 
			
		||||
 | 
			
		||||
  ComposeState({
 | 
			
		||||
    required this.attachments,
 | 
			
		||||
    required this.titleController,
 | 
			
		||||
    required this.descriptionController,
 | 
			
		||||
    required this.contentController,
 | 
			
		||||
    required this.visibility,
 | 
			
		||||
    required this.submitting,
 | 
			
		||||
    required this.attachmentProgress,
 | 
			
		||||
    required this.currentPublisher,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ComposeLogic {
 | 
			
		||||
  static ComposeState createState({
 | 
			
		||||
    SnPost? originalPost,
 | 
			
		||||
    SnPost? forwardedPost,
 | 
			
		||||
  }) {
 | 
			
		||||
    return ComposeState(
 | 
			
		||||
      attachments: ValueNotifier<List<UniversalFile>>(
 | 
			
		||||
        originalPost?.attachments
 | 
			
		||||
                .map(
 | 
			
		||||
                  (e) => UniversalFile(
 | 
			
		||||
                    data: e,
 | 
			
		||||
                    type: switch (e.mimeType?.split('/').firstOrNull) {
 | 
			
		||||
                      'image' => UniversalFileType.image,
 | 
			
		||||
                      'video' => UniversalFileType.video,
 | 
			
		||||
                      'audio' => UniversalFileType.audio,
 | 
			
		||||
                      _ => UniversalFileType.file,
 | 
			
		||||
                    },
 | 
			
		||||
                  ),
 | 
			
		||||
                )
 | 
			
		||||
                .toList() ??
 | 
			
		||||
            [],
 | 
			
		||||
      ),
 | 
			
		||||
      titleController: TextEditingController(text: originalPost?.title),
 | 
			
		||||
      descriptionController: TextEditingController(
 | 
			
		||||
        text: originalPost?.description,
 | 
			
		||||
      ),
 | 
			
		||||
      contentController: TextEditingController(
 | 
			
		||||
        text:
 | 
			
		||||
            originalPost?.content ??
 | 
			
		||||
            (forwardedPost != null ? '> ${forwardedPost.content}\n\n' : null),
 | 
			
		||||
      ),
 | 
			
		||||
      visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
 | 
			
		||||
      submitting: ValueNotifier<bool>(false),
 | 
			
		||||
      attachmentProgress: ValueNotifier<Map<int, double>>({}),
 | 
			
		||||
      currentPublisher: ValueNotifier<SnPublisher?>(null),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static String getMimeTypeFromFileType(UniversalFileType type) {
 | 
			
		||||
    return switch (type) {
 | 
			
		||||
      UniversalFileType.image => 'image/unknown',
 | 
			
		||||
      UniversalFileType.video => 'video/unknown',
 | 
			
		||||
      UniversalFileType.audio => 'audio/unknown',
 | 
			
		||||
      UniversalFileType.file => 'application/octet-stream',
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
 | 
			
		||||
    final result = await ref
 | 
			
		||||
        .watch(imagePickerProvider)
 | 
			
		||||
        .pickMultiImage(requestFullMetadata: true);
 | 
			
		||||
    if (result.isEmpty) return;
 | 
			
		||||
    state.attachments.value = [
 | 
			
		||||
      ...state.attachments.value,
 | 
			
		||||
      ...result.map(
 | 
			
		||||
        (e) => UniversalFile(data: e, type: UniversalFileType.image),
 | 
			
		||||
      ),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> pickVideoMedia(WidgetRef ref, ComposeState state) async {
 | 
			
		||||
    final result = await ref
 | 
			
		||||
        .watch(imagePickerProvider)
 | 
			
		||||
        .pickVideo(source: ImageSource.gallery);
 | 
			
		||||
    if (result == null) return;
 | 
			
		||||
    state.attachments.value = [
 | 
			
		||||
      ...state.attachments.value,
 | 
			
		||||
      UniversalFile(data: result, type: UniversalFileType.video),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> uploadAttachment(
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    ComposeState state,
 | 
			
		||||
    int index,
 | 
			
		||||
  ) async {
 | 
			
		||||
    final attachment = state.attachments.value[index];
 | 
			
		||||
    if (attachment.isOnCloud) return;
 | 
			
		||||
 | 
			
		||||
    final baseUrl = ref.watch(serverUrlProvider);
 | 
			
		||||
    final token = await getToken(ref.watch(tokenProvider));
 | 
			
		||||
    if (token == null) throw ArgumentError('Token is null');
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Update progress state
 | 
			
		||||
      state.attachmentProgress.value = {
 | 
			
		||||
        ...state.attachmentProgress.value,
 | 
			
		||||
        index: 0,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Upload file to cloud
 | 
			
		||||
      final cloudFile =
 | 
			
		||||
          await putMediaToCloud(
 | 
			
		||||
            fileData: attachment,
 | 
			
		||||
            atk: token,
 | 
			
		||||
            baseUrl: baseUrl,
 | 
			
		||||
            filename: attachment.data.name ?? 'Post media',
 | 
			
		||||
            mimetype:
 | 
			
		||||
                attachment.data.mimeType ??
 | 
			
		||||
                getMimeTypeFromFileType(attachment.type),
 | 
			
		||||
            onProgress: (progress, _) {
 | 
			
		||||
              state.attachmentProgress.value = {
 | 
			
		||||
                ...state.attachmentProgress.value,
 | 
			
		||||
                index: progress,
 | 
			
		||||
              };
 | 
			
		||||
            },
 | 
			
		||||
          ).future;
 | 
			
		||||
 | 
			
		||||
      if (cloudFile == null) {
 | 
			
		||||
        throw ArgumentError('Failed to upload the file...');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Update attachments list with cloud file
 | 
			
		||||
      final clone = List.of(state.attachments.value);
 | 
			
		||||
      clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
 | 
			
		||||
      state.attachments.value = clone;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      showErrorAlert(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      // Clean up progress state
 | 
			
		||||
      state.attachmentProgress.value = {...state.attachmentProgress.value}
 | 
			
		||||
        ..remove(index);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<UniversalFile> moveAttachment(
 | 
			
		||||
    List<UniversalFile> attachments,
 | 
			
		||||
    int idx,
 | 
			
		||||
    int delta,
 | 
			
		||||
  ) {
 | 
			
		||||
    if (idx + delta < 0 || idx + delta >= attachments.length) {
 | 
			
		||||
      return attachments;
 | 
			
		||||
    }
 | 
			
		||||
    final clone = List.of(attachments);
 | 
			
		||||
    clone.insert(idx + delta, clone.removeAt(idx));
 | 
			
		||||
    return clone;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> deleteAttachment(
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    ComposeState state,
 | 
			
		||||
    int index,
 | 
			
		||||
  ) async {
 | 
			
		||||
    final attachment = state.attachments.value[index];
 | 
			
		||||
    if (attachment.isOnCloud) {
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
      await client.delete('/files/${attachment.data.id}');
 | 
			
		||||
    }
 | 
			
		||||
    final clone = List.of(state.attachments.value);
 | 
			
		||||
    clone.removeAt(index);
 | 
			
		||||
    state.attachments.value = clone;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> performAction(
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    ComposeState state,
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
    SnPost? originalPost,
 | 
			
		||||
    SnPost? repliedPost,
 | 
			
		||||
    SnPost? forwardedPost,
 | 
			
		||||
    int? postType, // 0 for regular post, 1 for article
 | 
			
		||||
  }) async {
 | 
			
		||||
    if (state.submitting.value) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      state.submitting.value = true;
 | 
			
		||||
 | 
			
		||||
      // Upload any local attachments first
 | 
			
		||||
      await Future.wait(
 | 
			
		||||
        state.attachments.value
 | 
			
		||||
            .asMap()
 | 
			
		||||
            .entries
 | 
			
		||||
            .where((entry) => entry.value.isOnDevice)
 | 
			
		||||
            .map((entry) => uploadAttachment(ref, state, entry.key)),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // Prepare API request
 | 
			
		||||
      final client = ref.watch(apiClientProvider);
 | 
			
		||||
      final isNewPost = originalPost == null;
 | 
			
		||||
      final endpoint = isNewPost ? '/posts' : '/posts/${originalPost.id}';
 | 
			
		||||
 | 
			
		||||
      // Create request payload
 | 
			
		||||
      final payload = {
 | 
			
		||||
        'title': state.titleController.text,
 | 
			
		||||
        'description': state.descriptionController.text,
 | 
			
		||||
        'content': state.contentController.text,
 | 
			
		||||
        'visibility': state.visibility.value,
 | 
			
		||||
        'attachments':
 | 
			
		||||
            state.attachments.value
 | 
			
		||||
                .where((e) => e.isOnCloud)
 | 
			
		||||
                .map((e) => e.data.id)
 | 
			
		||||
                .toList(),
 | 
			
		||||
        if (postType != null) 'type': postType,
 | 
			
		||||
        if (repliedPost != null) 'replied_post_id': repliedPost.id,
 | 
			
		||||
        if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Send request
 | 
			
		||||
      await client.request(
 | 
			
		||||
        endpoint,
 | 
			
		||||
        data: payload,
 | 
			
		||||
        options: Options(
 | 
			
		||||
          headers: {'X-Pub': state.currentPublisher.value?.name},
 | 
			
		||||
          method: isNewPost ? 'POST' : 'PATCH',
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (context.mounted) {
 | 
			
		||||
        Navigator.of(context).maybePop(true);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      showErrorAlert(err);
 | 
			
		||||
    } finally {
 | 
			
		||||
      state.submitting.value = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<void> handlePaste(ComposeState state) async {
 | 
			
		||||
    final clipboard = await Pasteboard.image;
 | 
			
		||||
    if (clipboard == null) return;
 | 
			
		||||
 | 
			
		||||
    state.attachments.value = [
 | 
			
		||||
      ...state.attachments.value,
 | 
			
		||||
      UniversalFile(
 | 
			
		||||
        data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
 | 
			
		||||
        type: UniversalFileType.image,
 | 
			
		||||
      ),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static void handleKeyPress(
 | 
			
		||||
    RawKeyEvent event,
 | 
			
		||||
    ComposeState state,
 | 
			
		||||
    WidgetRef ref,
 | 
			
		||||
    BuildContext context, {
 | 
			
		||||
    SnPost? originalPost,
 | 
			
		||||
    SnPost? repliedPost,
 | 
			
		||||
    SnPost? forwardedPost,
 | 
			
		||||
    int? postType,
 | 
			
		||||
  }) {
 | 
			
		||||
    if (event is! RawKeyDownEvent) return;
 | 
			
		||||
 | 
			
		||||
    final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
 | 
			
		||||
    final isModifierPressed = event.isMetaPressed || event.isControlPressed;
 | 
			
		||||
    final isSubmit = event.logicalKey == LogicalKeyboardKey.enter;
 | 
			
		||||
 | 
			
		||||
    if (isPaste && isModifierPressed) {
 | 
			
		||||
      handlePaste(state);
 | 
			
		||||
    } else if (isSubmit && isModifierPressed && !state.submitting.value) {
 | 
			
		||||
      performAction(
 | 
			
		||||
        ref,
 | 
			
		||||
        state,
 | 
			
		||||
        context,
 | 
			
		||||
        originalPost: originalPost,
 | 
			
		||||
        repliedPost: repliedPost,
 | 
			
		||||
        forwardedPost: forwardedPost,
 | 
			
		||||
        postType: postType,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static void dispose(ComposeState state) {
 | 
			
		||||
    state.titleController.dispose();
 | 
			
		||||
    state.descriptionController.dispose();
 | 
			
		||||
    state.contentController.dispose();
 | 
			
		||||
    state.attachments.dispose();
 | 
			
		||||
    state.visibility.dispose();
 | 
			
		||||
    state.submitting.dispose();
 | 
			
		||||
    state.attachmentProgress.dispose();
 | 
			
		||||
    state.currentPublisher.dispose();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -19,6 +19,7 @@ import 'package:island/widgets/app_scaffold.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_file_collection.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_files.dart';
 | 
			
		||||
import 'package:island/widgets/content/markdown.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_replies_sheet.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:super_context_menu/super_context_menu.dart';
 | 
			
		||||
@@ -235,19 +236,53 @@ class PostItem extends HookConsumerWidget {
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              PostReactionList(
 | 
			
		||||
              Row(
 | 
			
		||||
                children: [
 | 
			
		||||
                  // Replies count button
 | 
			
		||||
                  Padding(
 | 
			
		||||
                    padding: const EdgeInsets.only(left: 48, right: 12),
 | 
			
		||||
                    child: ActionChip(
 | 
			
		||||
                      avatar: Icon(Symbols.reply, size: 16),
 | 
			
		||||
                      label: Text(
 | 
			
		||||
                        (item.repliesCount > 0)
 | 
			
		||||
                            ? 'repliesCount'.plural(item.repliesCount)
 | 
			
		||||
                            : 'reply'.tr(),
 | 
			
		||||
                      ),
 | 
			
		||||
                      visualDensity: const VisualDensity(
 | 
			
		||||
                        horizontal: VisualDensity.minimumDensity,
 | 
			
		||||
                        vertical: VisualDensity.minimumDensity,
 | 
			
		||||
                      ),
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                         if (isOpenable) {
 | 
			
		||||
                           showModalBottomSheet(
 | 
			
		||||
                             context: context,
 | 
			
		||||
                             isScrollControlled: true,
 | 
			
		||||
                             builder: (context) => PostRepliesSheet(post: item),
 | 
			
		||||
                           );
 | 
			
		||||
                         }
 | 
			
		||||
                       },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  // Reactions list
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: PostReactionList(
 | 
			
		||||
                      parentId: item.id,
 | 
			
		||||
                      reactions: item.reactionsCount,
 | 
			
		||||
                padding: EdgeInsets.only(left: 48),
 | 
			
		||||
                      padding: EdgeInsets.zero,
 | 
			
		||||
                      onReact: (symbol, attitude, delta) {
 | 
			
		||||
                        final reactionsCount = Map<String, int>.from(
 | 
			
		||||
                          item.reactionsCount,
 | 
			
		||||
                        );
 | 
			
		||||
                        reactionsCount[symbol] =
 | 
			
		||||
                            (reactionsCount[symbol] ?? 0) + delta;
 | 
			
		||||
                  onUpdate?.call(item.copyWith(reactionsCount: reactionsCount));
 | 
			
		||||
                        onUpdate?.call(
 | 
			
		||||
                          item.copyWith(reactionsCount: reactionsCount),
 | 
			
		||||
                        );
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										457
									
								
								lib/widgets/post/post_item_creator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										457
									
								
								lib/widgets/post/post_item_creator.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,457 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:gap/gap.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/route.gr.dart';
 | 
			
		||||
import 'package:island/services/time.dart';
 | 
			
		||||
import 'package:island/widgets/alert.dart';
 | 
			
		||||
import 'package:island/widgets/content/cloud_file_collection.dart';
 | 
			
		||||
import 'package:island/widgets/content/markdown.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
import 'package:super_context_menu/super_context_menu.dart';
 | 
			
		||||
 | 
			
		||||
class PostItemCreator extends HookConsumerWidget {
 | 
			
		||||
  final Color? backgroundColor;
 | 
			
		||||
  final SnPost item;
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
  final bool isOpenable;
 | 
			
		||||
  final Function? onRefresh;
 | 
			
		||||
  final Function(SnPost)? onUpdate;
 | 
			
		||||
 | 
			
		||||
  const PostItemCreator({
 | 
			
		||||
    super.key,
 | 
			
		||||
    required this.item,
 | 
			
		||||
    this.backgroundColor,
 | 
			
		||||
    this.padding,
 | 
			
		||||
    this.isOpenable = true,
 | 
			
		||||
    this.onRefresh,
 | 
			
		||||
    this.onUpdate,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final renderingPadding =
 | 
			
		||||
        padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 16);
 | 
			
		||||
 | 
			
		||||
    return ContextMenuWidget(
 | 
			
		||||
      menuProvider: (_) {
 | 
			
		||||
        return Menu(
 | 
			
		||||
          children: [
 | 
			
		||||
            MenuAction(
 | 
			
		||||
              title: 'edit'.tr(),
 | 
			
		||||
              image: MenuImage.icon(Symbols.edit),
 | 
			
		||||
              callback: () {
 | 
			
		||||
                context.router.push(PostEditRoute(id: item.id)).then((value) {
 | 
			
		||||
                  if (value != null) {
 | 
			
		||||
                    onRefresh?.call();
 | 
			
		||||
                  }
 | 
			
		||||
                });
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            MenuAction(
 | 
			
		||||
              title: 'delete'.tr(),
 | 
			
		||||
              image: MenuImage.icon(Symbols.delete),
 | 
			
		||||
              callback: () {
 | 
			
		||||
                showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then(
 | 
			
		||||
                  (confirm) {
 | 
			
		||||
                    if (confirm) {
 | 
			
		||||
                      final client = ref.watch(apiClientProvider);
 | 
			
		||||
                      client
 | 
			
		||||
                          .delete('/posts/${item.id}')
 | 
			
		||||
                          .catchError((err) {
 | 
			
		||||
                            showErrorAlert(err);
 | 
			
		||||
                            return err;
 | 
			
		||||
                          })
 | 
			
		||||
                          .then((_) {
 | 
			
		||||
                            onRefresh?.call();
 | 
			
		||||
                          });
 | 
			
		||||
                    }
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
            MenuSeparator(),
 | 
			
		||||
            MenuAction(
 | 
			
		||||
              title: 'copyLink'.tr(),
 | 
			
		||||
              image: MenuImage.icon(Symbols.link),
 | 
			
		||||
              callback: () {
 | 
			
		||||
                // Copy post link to clipboard
 | 
			
		||||
                context.router.push(PostDetailRoute(id: item.id));
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      child: Material(
 | 
			
		||||
        color: backgroundColor,
 | 
			
		||||
        borderRadius: BorderRadius.circular(12),
 | 
			
		||||
        elevation: 1,
 | 
			
		||||
        child: InkWell(
 | 
			
		||||
          borderRadius: BorderRadius.circular(12),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            if (isOpenable) {
 | 
			
		||||
              context.router.push(PostDetailRoute(id: item.id));
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          child: Padding(
 | 
			
		||||
            padding: renderingPadding,
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                _buildPostHeader(context),
 | 
			
		||||
                _buildPostContent(context),
 | 
			
		||||
                const Gap(16),
 | 
			
		||||
                _buildAnalyticsSection(context),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildPostHeader(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        // Post ID and timestamp row
 | 
			
		||||
        Row(
 | 
			
		||||
          children: [
 | 
			
		||||
            Container(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
 | 
			
		||||
              decoration: BoxDecoration(
 | 
			
		||||
                color: Theme.of(context).colorScheme.primaryContainer,
 | 
			
		||||
                borderRadius: BorderRadius.circular(4),
 | 
			
		||||
              ),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                'ID: ${item.id.substring(0, 6)}',
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontSize: 12,
 | 
			
		||||
                  fontWeight: FontWeight.bold,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.onPrimaryContainer,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const Spacer(),
 | 
			
		||||
            Icon(
 | 
			
		||||
              _getVisibilityIcon(item.visibility),
 | 
			
		||||
              size: 16,
 | 
			
		||||
              color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
            ),
 | 
			
		||||
            const SizedBox(width: 4),
 | 
			
		||||
            Text(
 | 
			
		||||
              _getVisibilityText(item.visibility).tr(),
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                fontSize: 12,
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            const Gap(8),
 | 
			
		||||
            Text(
 | 
			
		||||
              item.publishedAt.formatSystem(),
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                fontSize: 12,
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
 | 
			
		||||
        // Title and description
 | 
			
		||||
        if (item.title?.isNotEmpty ?? false)
 | 
			
		||||
          Text(
 | 
			
		||||
            item.title!,
 | 
			
		||||
            style: Theme.of(
 | 
			
		||||
              context,
 | 
			
		||||
            ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
        if (item.description?.isNotEmpty ?? false)
 | 
			
		||||
          Text(
 | 
			
		||||
            item.description!,
 | 
			
		||||
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | 
			
		||||
              color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
            ),
 | 
			
		||||
          ).padding(top: 4),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildPostContent(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        // Content preview
 | 
			
		||||
        if (item.content?.isNotEmpty ?? false)
 | 
			
		||||
          Container(
 | 
			
		||||
            margin: const EdgeInsets.only(top: 12),
 | 
			
		||||
            child: MarkdownTextContent(content: item.content!),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
        // Attachments
 | 
			
		||||
        if (item.attachments.isNotEmpty)
 | 
			
		||||
          CloudFileList(
 | 
			
		||||
            files: item.attachments,
 | 
			
		||||
            maxWidth: MediaQuery.of(context).size.width * 0.85,
 | 
			
		||||
            minWidth: MediaQuery.of(context).size.width * 0.9,
 | 
			
		||||
          ).padding(top: 8),
 | 
			
		||||
 | 
			
		||||
        // Reference post indicator
 | 
			
		||||
        if (item.repliedPost != null || item.forwardedPost != null)
 | 
			
		||||
          Container(
 | 
			
		||||
            margin: const EdgeInsets.only(top: 8),
 | 
			
		||||
            child: Row(
 | 
			
		||||
              children: [
 | 
			
		||||
                Icon(
 | 
			
		||||
                  item.repliedPost != null ? Symbols.reply : Symbols.forward,
 | 
			
		||||
                  size: 16,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                ),
 | 
			
		||||
                const SizedBox(width: 4),
 | 
			
		||||
                Text(
 | 
			
		||||
                  item.repliedPost != null
 | 
			
		||||
                      ? 'repliedTo'.tr()
 | 
			
		||||
                      : 'forwarded'.tr(),
 | 
			
		||||
                  style: TextStyle(
 | 
			
		||||
                    fontSize: 12,
 | 
			
		||||
                    color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildAnalyticsSection(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text('Analytics', style: Theme.of(context).textTheme.titleSmall),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
 | 
			
		||||
        // Engagement metrics in a card
 | 
			
		||||
        Card(
 | 
			
		||||
          elevation: 1,
 | 
			
		||||
          margin: EdgeInsets.zero,
 | 
			
		||||
          shape: RoundedRectangleBorder(
 | 
			
		||||
            borderRadius: BorderRadius.circular(12),
 | 
			
		||||
            side: BorderSide(
 | 
			
		||||
              color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          child: Padding(
 | 
			
		||||
            padding: const EdgeInsets.all(16),
 | 
			
		||||
            child: Row(
 | 
			
		||||
              mainAxisAlignment: MainAxisAlignment.spaceAround,
 | 
			
		||||
              children: [
 | 
			
		||||
                _buildMetricItem(
 | 
			
		||||
                  context,
 | 
			
		||||
                  Symbols.visibility,
 | 
			
		||||
                  'Views',
 | 
			
		||||
                  '${item.viewsUnique} / ${item.viewsTotal}',
 | 
			
		||||
                  'Unique / Total',
 | 
			
		||||
                ),
 | 
			
		||||
                _buildMetricItem(
 | 
			
		||||
                  context,
 | 
			
		||||
                  Symbols.thumb_up,
 | 
			
		||||
                  'Upvotes',
 | 
			
		||||
                  '${item.upvotes}',
 | 
			
		||||
                  null,
 | 
			
		||||
                ),
 | 
			
		||||
                _buildMetricItem(
 | 
			
		||||
                  context,
 | 
			
		||||
                  Symbols.thumb_down,
 | 
			
		||||
                  'Downvotes',
 | 
			
		||||
                  '${item.downvotes}',
 | 
			
		||||
                  null,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
 | 
			
		||||
        // Reactions summary
 | 
			
		||||
        if (item.reactionsCount.isNotEmpty) _buildReactionsSection(context),
 | 
			
		||||
 | 
			
		||||
        // Metadata section
 | 
			
		||||
        if (item.meta != null && item.meta!.isNotEmpty)
 | 
			
		||||
          _buildMetadataSection(context),
 | 
			
		||||
 | 
			
		||||
        // Creation and modification timestamps
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        Row(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
          children: [
 | 
			
		||||
            Text(
 | 
			
		||||
              'Created: ${item.createdAt.formatSystem()}',
 | 
			
		||||
              style: TextStyle(
 | 
			
		||||
                fontSize: 12,
 | 
			
		||||
                color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            if (item.editedAt != null)
 | 
			
		||||
              Text(
 | 
			
		||||
                'Edited: ${item.editedAt!.formatSystem()}',
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontSize: 12,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildMetricItem(
 | 
			
		||||
    BuildContext context,
 | 
			
		||||
    IconData icon,
 | 
			
		||||
    String label,
 | 
			
		||||
    String value,
 | 
			
		||||
    String? subtitle,
 | 
			
		||||
  ) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      children: [
 | 
			
		||||
        Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
 | 
			
		||||
        const Gap(4),
 | 
			
		||||
        Text(
 | 
			
		||||
          label,
 | 
			
		||||
          style: TextStyle(
 | 
			
		||||
            fontSize: 12,
 | 
			
		||||
            color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        Text(
 | 
			
		||||
          value,
 | 
			
		||||
          style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
 | 
			
		||||
        ),
 | 
			
		||||
        if (subtitle != null)
 | 
			
		||||
          Text(
 | 
			
		||||
            subtitle,
 | 
			
		||||
            style: TextStyle(
 | 
			
		||||
              fontSize: 10,
 | 
			
		||||
              color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildReactionsSection(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          'reactions'.plural(
 | 
			
		||||
            item.reactionsCount.isNotEmpty
 | 
			
		||||
                ? item.reactionsCount.values.reduce((a, b) => a + b)
 | 
			
		||||
                : 0,
 | 
			
		||||
          ),
 | 
			
		||||
          style: TextStyle(
 | 
			
		||||
            fontSize: 14,
 | 
			
		||||
            fontWeight: FontWeight.w500,
 | 
			
		||||
            color: Theme.of(context).colorScheme.secondary,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        PostReactionList(
 | 
			
		||||
          parentId: item.id,
 | 
			
		||||
          reactions: item.reactionsCount,
 | 
			
		||||
          padding: EdgeInsets.zero,
 | 
			
		||||
          onReact: (symbol, attitude, delta) {
 | 
			
		||||
            final reactionsCount = Map<String, int>.from(item.reactionsCount);
 | 
			
		||||
            reactionsCount[symbol] = (reactionsCount[symbol] ?? 0) + delta;
 | 
			
		||||
            onUpdate?.call(item.copyWith(reactionsCount: reactionsCount));
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildMetadataSection(BuildContext context) {
 | 
			
		||||
    return Column(
 | 
			
		||||
      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
      children: [
 | 
			
		||||
        const Gap(16),
 | 
			
		||||
        Text('Metadata', style: Theme.of(context).textTheme.titleSmall),
 | 
			
		||||
        const Gap(8),
 | 
			
		||||
        Container(
 | 
			
		||||
          padding: const EdgeInsets.all(12),
 | 
			
		||||
          decoration: BoxDecoration(
 | 
			
		||||
            color: Theme.of(
 | 
			
		||||
              context,
 | 
			
		||||
            ).colorScheme.surfaceVariant.withOpacity(0.5),
 | 
			
		||||
            borderRadius: BorderRadius.circular(8),
 | 
			
		||||
            border: Border.all(
 | 
			
		||||
              color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          child: Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
            children: [
 | 
			
		||||
              for (final entry in item.meta!.entries)
 | 
			
		||||
                Padding(
 | 
			
		||||
                  padding: const EdgeInsets.only(bottom: 8),
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Text(
 | 
			
		||||
                        '${entry.key}: ',
 | 
			
		||||
                        style: const TextStyle(
 | 
			
		||||
                          fontWeight: FontWeight.bold,
 | 
			
		||||
                          fontSize: 12,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      Expanded(
 | 
			
		||||
                        child: Text(
 | 
			
		||||
                          '${entry.value}',
 | 
			
		||||
                          style: const TextStyle(fontSize: 12),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper method to get the appropriate icon for each visibility status
 | 
			
		||||
IconData _getVisibilityIcon(int visibility) {
 | 
			
		||||
  switch (visibility) {
 | 
			
		||||
    case 1: // Friends
 | 
			
		||||
      return Symbols.group;
 | 
			
		||||
    case 2: // Unlisted
 | 
			
		||||
      return Symbols.link_off;
 | 
			
		||||
    case 3: // Private
 | 
			
		||||
      return Symbols.lock;
 | 
			
		||||
    default: // Public (0) or unknown
 | 
			
		||||
      return Symbols.public;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper method to get the translation key for each visibility status
 | 
			
		||||
String _getVisibilityText(int visibility) {
 | 
			
		||||
  switch (visibility) {
 | 
			
		||||
    case 1: // Friends
 | 
			
		||||
      return 'postVisibilityFriends';
 | 
			
		||||
    case 2: // Unlisted
 | 
			
		||||
      return 'postVisibilityUnlisted';
 | 
			
		||||
    case 3: // Private
 | 
			
		||||
      return 'postVisibilityPrivate';
 | 
			
		||||
    default: // Public (0) or unknown
 | 
			
		||||
      return 'postVisibilityPublic';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/widgets/content/paging_helper_ext.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item_creator.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
 | 
			
		||||
 | 
			
		||||
@@ -46,9 +46,34 @@ class PostListNotifier extends _$PostListNotifier
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Defines which post item widget to use in the list
 | 
			
		||||
enum PostItemType {
 | 
			
		||||
  /// Regular post item with user information
 | 
			
		||||
  regular,
 | 
			
		||||
 | 
			
		||||
  /// Creator view with analytics and metadata
 | 
			
		||||
  creator,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SliverPostList extends HookConsumerWidget {
 | 
			
		||||
  final String? pubName;
 | 
			
		||||
  const SliverPostList({super.key, this.pubName});
 | 
			
		||||
  final PostItemType itemType;
 | 
			
		||||
  final Color? backgroundColor;
 | 
			
		||||
  final EdgeInsets? padding;
 | 
			
		||||
  final bool isOpenable;
 | 
			
		||||
  final Function? onRefresh;
 | 
			
		||||
  final Function(SnPost)? onUpdate;
 | 
			
		||||
 | 
			
		||||
  const SliverPostList({
 | 
			
		||||
    super.key,
 | 
			
		||||
    this.pubName,
 | 
			
		||||
    this.itemType = PostItemType.regular,
 | 
			
		||||
    this.backgroundColor,
 | 
			
		||||
    this.padding,
 | 
			
		||||
    this.isOpenable = true,
 | 
			
		||||
    this.onRefresh,
 | 
			
		||||
    this.onUpdate,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
@@ -64,14 +89,29 @@ class SliverPostList extends HookConsumerWidget {
 | 
			
		||||
                return endItemView;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              final post = data.items[index];
 | 
			
		||||
 | 
			
		||||
              return Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  PostItem(item: data.items[index]),
 | 
			
		||||
                  const Divider(height: 1),
 | 
			
		||||
                ],
 | 
			
		||||
                children: [_buildPostItem(post), const Divider(height: 1)],
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Widget _buildPostItem(SnPost post) {
 | 
			
		||||
    switch (itemType) {
 | 
			
		||||
      case PostItemType.creator:
 | 
			
		||||
        return PostItemCreator(
 | 
			
		||||
          item: post,
 | 
			
		||||
          backgroundColor: backgroundColor,
 | 
			
		||||
          padding: padding,
 | 
			
		||||
          isOpenable: isOpenable,
 | 
			
		||||
          onRefresh: onRefresh,
 | 
			
		||||
          onUpdate: onUpdate,
 | 
			
		||||
        );
 | 
			
		||||
      case PostItemType.regular:
 | 
			
		||||
        return PostItem(item: post);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/pods/network.dart';
 | 
			
		||||
import 'package:island/services/responsive.dart';
 | 
			
		||||
import 'package:island/widgets/content/paging_helper_ext.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_item.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
 | 
			
		||||
@@ -57,7 +56,8 @@ class PostRepliesNotifier extends _$PostRepliesNotifier
 | 
			
		||||
 | 
			
		||||
class PostRepliesList extends HookConsumerWidget {
 | 
			
		||||
  final String postId;
 | 
			
		||||
  const PostRepliesList({super.key, required this.postId});
 | 
			
		||||
  final Color? backgroundColor;
 | 
			
		||||
  const PostRepliesList({super.key, required this.postId, this.backgroundColor});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
@@ -93,7 +93,7 @@ class PostRepliesList extends HookConsumerWidget {
 | 
			
		||||
              children: [
 | 
			
		||||
                PostItem(
 | 
			
		||||
                  item: data.items[index],
 | 
			
		||||
                  backgroundColor: isWide ? Colors.transparent : null,
 | 
			
		||||
                  backgroundColor: backgroundColor ?? (isWide ? Colors.transparent : null),
 | 
			
		||||
                  showReferencePost: false,
 | 
			
		||||
                ),
 | 
			
		||||
                const Divider(height: 1),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										48
									
								
								lib/widgets/post/post_replies_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/widgets/post/post_replies_sheet.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:island/models/post.dart';
 | 
			
		||||
import 'package:island/widgets/content/sheet.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_replies.dart';
 | 
			
		||||
import 'package:island/widgets/post/post_quick_reply.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
 | 
			
		||||
class PostRepliesSheet extends HookConsumerWidget {
 | 
			
		||||
  final SnPost post;
 | 
			
		||||
 | 
			
		||||
  const PostRepliesSheet({super.key, required this.post});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    return SheetScaffold(
 | 
			
		||||
      titleText: 'repliesCount'.plural(post.repliesCount),
 | 
			
		||||
      child: Column(
 | 
			
		||||
        children: [
 | 
			
		||||
          // Replies list
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: CustomScrollView(
 | 
			
		||||
              slivers: [PostRepliesList(
 | 
			
		||||
                postId: post.id.toString(),
 | 
			
		||||
                backgroundColor: Colors.transparent,
 | 
			
		||||
              )],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          // Quick reply section
 | 
			
		||||
          Material(
 | 
			
		||||
            elevation: 2,
 | 
			
		||||
            child: PostQuickReply(
 | 
			
		||||
              parent: post,
 | 
			
		||||
              onPosted: () {
 | 
			
		||||
                ref.invalidate(postRepliesNotifierProvider(post.id));
 | 
			
		||||
              },
 | 
			
		||||
            ).padding(
 | 
			
		||||
              bottom: MediaQuery.of(context).padding.bottom + 16,
 | 
			
		||||
              top: 16,
 | 
			
		||||
              horizontal: 16,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -27,6 +27,7 @@ import pasteboard
 | 
			
		||||
import path_provider_foundation
 | 
			
		||||
import record_macos
 | 
			
		||||
import shared_preferences_foundation
 | 
			
		||||
import sign_in_with_apple
 | 
			
		||||
import sqflite_darwin
 | 
			
		||||
import sqlite3_flutter_libs
 | 
			
		||||
import super_native_extensions
 | 
			
		||||
@@ -57,6 +58,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
 | 
			
		||||
  PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
 | 
			
		||||
  RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
 | 
			
		||||
  SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
 | 
			
		||||
  SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
 | 
			
		||||
  SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
 | 
			
		||||
  Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
 | 
			
		||||
  SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										319
									
								
								macos/Podfile.lock
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								macos/Podfile.lock
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,319 @@
 | 
			
		||||
PODS:
 | 
			
		||||
  - bitsdojo_window_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - connectivity_plus (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - croppy (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - device_info_plus (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - file_picker (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - file_selector_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - Firebase/CoreOnly (11.13.0):
 | 
			
		||||
    - FirebaseCore (~> 11.13.0)
 | 
			
		||||
  - Firebase/Messaging (11.13.0):
 | 
			
		||||
    - Firebase/CoreOnly
 | 
			
		||||
    - FirebaseMessaging (~> 11.13.0)
 | 
			
		||||
  - firebase_core (3.14.0):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.13.0)
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - firebase_messaging (15.2.7):
 | 
			
		||||
    - Firebase/CoreOnly (~> 11.13.0)
 | 
			
		||||
    - Firebase/Messaging (~> 11.13.0)
 | 
			
		||||
    - firebase_core
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - FirebaseCore (11.13.0):
 | 
			
		||||
    - FirebaseCoreInternal (~> 11.13.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.1)
 | 
			
		||||
    - GoogleUtilities/Logger (~> 8.1)
 | 
			
		||||
  - FirebaseCoreInternal (11.13.0):
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib (~> 8.1)"
 | 
			
		||||
  - FirebaseInstallations (11.13.0):
 | 
			
		||||
    - FirebaseCore (~> 11.13.0)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.1)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.1)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - FirebaseMessaging (11.13.0):
 | 
			
		||||
    - FirebaseCore (~> 11.13.0)
 | 
			
		||||
    - FirebaseInstallations (~> 11.0)
 | 
			
		||||
    - GoogleDataTransport (~> 10.0)
 | 
			
		||||
    - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
 | 
			
		||||
    - GoogleUtilities/Environment (~> 8.1)
 | 
			
		||||
    - GoogleUtilities/Reachability (~> 8.1)
 | 
			
		||||
    - GoogleUtilities/UserDefaults (~> 8.1)
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
  - flutter_inappwebview_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - OrderedSet (~> 6.0.3)
 | 
			
		||||
  - flutter_platform_alert (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - flutter_timezone (0.1.0):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - flutter_udid (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
  - flutter_webrtc (0.14.0):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.07)
 | 
			
		||||
  - FlutterMacOS (1.0.0)
 | 
			
		||||
  - gal (1.0.0):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - GoogleDataTransport (10.1.0):
 | 
			
		||||
    - nanopb (~> 3.30910.0)
 | 
			
		||||
    - PromisesObjC (~> 2.4)
 | 
			
		||||
  - GoogleUtilities/AppDelegateSwizzler (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Environment
 | 
			
		||||
    - GoogleUtilities/Logger
 | 
			
		||||
    - GoogleUtilities/Network
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - GoogleUtilities/Environment (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - GoogleUtilities/Logger (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Environment
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - GoogleUtilities/Network (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Logger
 | 
			
		||||
    - "GoogleUtilities/NSData+zlib"
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
    - GoogleUtilities/Reachability
 | 
			
		||||
  - "GoogleUtilities/NSData+zlib (8.1.0)":
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - GoogleUtilities/Privacy (8.1.0)
 | 
			
		||||
  - GoogleUtilities/Reachability (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Logger
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - GoogleUtilities/UserDefaults (8.1.0):
 | 
			
		||||
    - GoogleUtilities/Logger
 | 
			
		||||
    - GoogleUtilities/Privacy
 | 
			
		||||
  - irondash_engine_context (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - livekit_client (2.4.8):
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - WebRTC-SDK (= 125.6422.07)
 | 
			
		||||
  - media_kit_libs_macos_video (1.0.4):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - media_kit_video (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - nanopb (3.30910.0):
 | 
			
		||||
    - nanopb/decode (= 3.30910.0)
 | 
			
		||||
    - nanopb/encode (= 3.30910.0)
 | 
			
		||||
  - nanopb/decode (3.30910.0)
 | 
			
		||||
  - nanopb/encode (3.30910.0)
 | 
			
		||||
  - OrderedSet (6.0.3)
 | 
			
		||||
  - package_info_plus (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - pasteboard (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - path_provider_foundation (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - PromisesObjC (2.4.0)
 | 
			
		||||
  - record_macos (1.0.0):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - SAMKeychain (1.5.3)
 | 
			
		||||
  - shared_preferences_foundation (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - sign_in_with_apple (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - sqflite_darwin (0.0.4):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - sqlite3 (3.50.1):
 | 
			
		||||
    - sqlite3/common (= 3.50.1)
 | 
			
		||||
  - sqlite3/common (3.50.1)
 | 
			
		||||
  - sqlite3/dbstatvtab (3.50.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/fts5 (3.50.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/math (3.50.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/perf-threadsafe (3.50.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3/rtree (3.50.1):
 | 
			
		||||
    - sqlite3/common
 | 
			
		||||
  - sqlite3_flutter_libs (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
    - sqlite3 (~> 3.50.1)
 | 
			
		||||
    - sqlite3/dbstatvtab
 | 
			
		||||
    - sqlite3/fts5
 | 
			
		||||
    - sqlite3/math
 | 
			
		||||
    - sqlite3/perf-threadsafe
 | 
			
		||||
    - sqlite3/rtree
 | 
			
		||||
  - super_native_extensions (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - url_launcher_macos (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - volume_controller (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - wakelock_plus (0.0.1):
 | 
			
		||||
    - FlutterMacOS
 | 
			
		||||
  - WebRTC-SDK (125.6422.07)
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES:
 | 
			
		||||
  - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`)
 | 
			
		||||
  - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
 | 
			
		||||
  - croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
 | 
			
		||||
  - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
 | 
			
		||||
  - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
 | 
			
		||||
  - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
 | 
			
		||||
  - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
 | 
			
		||||
  - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
 | 
			
		||||
  - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
 | 
			
		||||
  - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`)
 | 
			
		||||
  - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
 | 
			
		||||
  - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`)
 | 
			
		||||
  - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
 | 
			
		||||
  - FlutterMacOS (from `Flutter/ephemeral`)
 | 
			
		||||
  - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
 | 
			
		||||
  - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`)
 | 
			
		||||
  - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
 | 
			
		||||
  - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
 | 
			
		||||
  - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
 | 
			
		||||
  - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
 | 
			
		||||
  - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`)
 | 
			
		||||
  - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
 | 
			
		||||
  - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
 | 
			
		||||
  - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
 | 
			
		||||
  - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`)
 | 
			
		||||
  - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
 | 
			
		||||
  - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
 | 
			
		||||
  - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`)
 | 
			
		||||
  - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
 | 
			
		||||
  - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`)
 | 
			
		||||
  - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
 | 
			
		||||
 | 
			
		||||
SPEC REPOS:
 | 
			
		||||
  trunk:
 | 
			
		||||
    - Firebase
 | 
			
		||||
    - FirebaseCore
 | 
			
		||||
    - FirebaseCoreInternal
 | 
			
		||||
    - FirebaseInstallations
 | 
			
		||||
    - FirebaseMessaging
 | 
			
		||||
    - GoogleDataTransport
 | 
			
		||||
    - GoogleUtilities
 | 
			
		||||
    - nanopb
 | 
			
		||||
    - OrderedSet
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
    - SAMKeychain
 | 
			
		||||
    - sqlite3
 | 
			
		||||
    - WebRTC-SDK
 | 
			
		||||
 | 
			
		||||
EXTERNAL SOURCES:
 | 
			
		||||
  bitsdojo_window_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos
 | 
			
		||||
  connectivity_plus:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
 | 
			
		||||
  croppy:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/croppy/macos
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
 | 
			
		||||
  file_picker:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
 | 
			
		||||
  file_selector_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
 | 
			
		||||
  firebase_core:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos
 | 
			
		||||
  firebase_messaging:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
 | 
			
		||||
  flutter_inappwebview_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
 | 
			
		||||
  flutter_platform_alert:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos
 | 
			
		||||
  flutter_timezone:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos
 | 
			
		||||
  flutter_udid:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos
 | 
			
		||||
  flutter_webrtc:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
 | 
			
		||||
  FlutterMacOS:
 | 
			
		||||
    :path: Flutter/ephemeral
 | 
			
		||||
  gal:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
 | 
			
		||||
  irondash_engine_context:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
 | 
			
		||||
  livekit_client:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
 | 
			
		||||
  media_kit_libs_macos_video:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
 | 
			
		||||
  media_kit_video:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
 | 
			
		||||
  package_info_plus:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
 | 
			
		||||
  pasteboard:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos
 | 
			
		||||
  path_provider_foundation:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
 | 
			
		||||
  record_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
 | 
			
		||||
  shared_preferences_foundation:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
 | 
			
		||||
  sign_in_with_apple:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos
 | 
			
		||||
  sqflite_darwin:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
 | 
			
		||||
  sqlite3_flutter_libs:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin
 | 
			
		||||
  super_native_extensions:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos
 | 
			
		||||
  url_launcher_macos:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
 | 
			
		||||
  volume_controller:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos
 | 
			
		||||
  wakelock_plus:
 | 
			
		||||
    :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
 | 
			
		||||
 | 
			
		||||
SPEC CHECKSUMS:
 | 
			
		||||
  bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
 | 
			
		||||
  connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
 | 
			
		||||
  croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43
 | 
			
		||||
  device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
 | 
			
		||||
  file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
 | 
			
		||||
  file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
 | 
			
		||||
  Firebase: 3435bc66b4d494c2f22c79fd3aae4c1db6662327
 | 
			
		||||
  firebase_core: 1095fcf33161d99bc34aa10f7c0d89414a208d15
 | 
			
		||||
  firebase_messaging: 6417056ffb85141607618ddfef9fec9f3caab3ea
 | 
			
		||||
  FirebaseCore: c692c7f1c75305ab6aff2b367f25e11d73aa8bd0
 | 
			
		||||
  FirebaseCoreInternal: 29d7b3af4aaf0b8f3ed20b568c13df399b06f68c
 | 
			
		||||
  FirebaseInstallations: 0ee9074f2c1e86561ace168ee1470dc67aabaf02
 | 
			
		||||
  FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4
 | 
			
		||||
  flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
 | 
			
		||||
  flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284
 | 
			
		||||
  flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8
 | 
			
		||||
  flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495
 | 
			
		||||
  flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3
 | 
			
		||||
  FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
 | 
			
		||||
  gal: baecd024ebfd13c441269ca7404792a7152fde89
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
 | 
			
		||||
  irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
 | 
			
		||||
  livekit_client: 6a35243df3da61750c98e266e02dedcf5d25c888
 | 
			
		||||
  media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
 | 
			
		||||
  media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
 | 
			
		||||
  nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
 | 
			
		||||
  OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
 | 
			
		||||
  package_info_plus: f0052d280d17aa382b932f399edf32507174e870
 | 
			
		||||
  pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7
 | 
			
		||||
  path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
 | 
			
		||||
  PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
 | 
			
		||||
  record_macos: 295d70bd5fb47145df78df7b80e6697cd18403c0
 | 
			
		||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
			
		||||
  shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
 | 
			
		||||
  sign_in_with_apple: 6673c03c9e3643f6c8d33601943fbfa9ae99f94e
 | 
			
		||||
  sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
 | 
			
		||||
  sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
 | 
			
		||||
  sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
 | 
			
		||||
  super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
 | 
			
		||||
  url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
 | 
			
		||||
  volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd
 | 
			
		||||
  wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497
 | 
			
		||||
  WebRTC-SDK: dff00a3892bc570b6014e046297782084071657e
 | 
			
		||||
 | 
			
		||||
PODFILE CHECKSUM: 346bfb2deb41d4a6ebd6f6799f92188bde2d246f
 | 
			
		||||
 | 
			
		||||
COCOAPODS: 1.16.2
 | 
			
		||||
@@ -57,7 +57,7 @@
 | 
			
		||||
		331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
 | 
			
		||||
		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
 | 
			
		||||
		33CC10ED2044A3C60003C045 /* island.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = island.app; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		33CC10ED2044A3C60003C045 /* Solian.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Solian.app; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 | 
			
		||||
		33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
 | 
			
		||||
		33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
 | 
			
		||||
@@ -150,7 +150,7 @@
 | 
			
		||||
		33CC10EE2044A3C60003C045 /* Products */ = {
 | 
			
		||||
			isa = PBXGroup;
 | 
			
		||||
			children = (
 | 
			
		||||
				33CC10ED2044A3C60003C045 /* island.app */,
 | 
			
		||||
				33CC10ED2044A3C60003C045 /* Solian.app */,
 | 
			
		||||
				331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
 | 
			
		||||
			);
 | 
			
		||||
			name = Products;
 | 
			
		||||
@@ -242,7 +242,7 @@
 | 
			
		||||
			);
 | 
			
		||||
			name = Runner;
 | 
			
		||||
			productName = Runner;
 | 
			
		||||
			productReference = 33CC10ED2044A3C60003C045 /* island.app */;
 | 
			
		||||
			productReference = 33CC10ED2044A3C60003C045 /* Solian.app */;
 | 
			
		||||
			productType = "com.apple.product-type.application";
 | 
			
		||||
		};
 | 
			
		||||
/* End PBXNativeTarget section */
 | 
			
		||||
@@ -384,14 +384,10 @@
 | 
			
		||||
			inputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			inputPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			name = "[CP] Copy Pods Resources";
 | 
			
		||||
			outputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			outputPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
 | 
			
		||||
@@ -427,14 +423,10 @@
 | 
			
		||||
			inputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			inputPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			name = "[CP] Embed Pods Frameworks";
 | 
			
		||||
			outputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			outputPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
            <BuildableReference
 | 
			
		||||
               BuildableIdentifier = "primary"
 | 
			
		||||
               BlueprintIdentifier = "33CC10EC2044A3C60003C045"
 | 
			
		||||
               BuildableName = "island.app"
 | 
			
		||||
               BuildableName = "Solian.app"
 | 
			
		||||
               BlueprintName = "Runner"
 | 
			
		||||
               ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
            </BuildableReference>
 | 
			
		||||
@@ -31,7 +31,7 @@
 | 
			
		||||
         <BuildableReference
 | 
			
		||||
            BuildableIdentifier = "primary"
 | 
			
		||||
            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
 | 
			
		||||
            BuildableName = "island.app"
 | 
			
		||||
            BuildableName = "Solian.app"
 | 
			
		||||
            BlueprintName = "Runner"
 | 
			
		||||
            ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
         </BuildableReference>
 | 
			
		||||
@@ -66,7 +66,7 @@
 | 
			
		||||
         <BuildableReference
 | 
			
		||||
            BuildableIdentifier = "primary"
 | 
			
		||||
            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
 | 
			
		||||
            BuildableName = "island.app"
 | 
			
		||||
            BuildableName = "Solian.app"
 | 
			
		||||
            BlueprintName = "Runner"
 | 
			
		||||
            ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
         </BuildableReference>
 | 
			
		||||
@@ -83,7 +83,7 @@
 | 
			
		||||
         <BuildableReference
 | 
			
		||||
            BuildableIdentifier = "primary"
 | 
			
		||||
            BlueprintIdentifier = "33CC10EC2044A3C60003C045"
 | 
			
		||||
            BuildableName = "island.app"
 | 
			
		||||
            BuildableName = "Solian.app"
 | 
			
		||||
            BlueprintName = "Runner"
 | 
			
		||||
            ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
         </BuildableReference>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,10 +5,10 @@
 | 
			
		||||
// 'flutter create' template.
 | 
			
		||||
 | 
			
		||||
// The application's name. By default this is also the title of the Flutter window.
 | 
			
		||||
PRODUCT_NAME = island
 | 
			
		||||
PRODUCT_NAME = Solian
 | 
			
		||||
 | 
			
		||||
// The application's bundle identifier
 | 
			
		||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian
 | 
			
		||||
 | 
			
		||||
// The copyright displayed in application information
 | 
			
		||||
PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved.
 | 
			
		||||
PRODUCT_COPYRIGHT = Copyright © 2025 Solsynth LLC. All rights reserved.
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,12 @@
 | 
			
		||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>com.apple.developer.applesignin</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>Default</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>com.apple.developer.device-information.user-assigned-device-name</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.app-sandbox</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.cs.allow-jit</key>
 | 
			
		||||
@@ -18,7 +24,5 @@
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.network.server</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.developer.device-information.user-assigned-device-name</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
</dict>
 | 
			
		||||
</plist>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,12 @@
 | 
			
		||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>com.apple.developer.applesignin</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>Default</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>com.apple.developer.device-information.user-assigned-device-name</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.app-sandbox</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.device.audio-input</key>
 | 
			
		||||
@@ -16,7 +22,5 @@
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.network.server</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.developer.device-information.user-assigned-device-name</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
</dict>
 | 
			
		||||
</plist>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										84
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										84
									
								
								pubspec.lock
									
									
									
									
									
								
							@@ -149,10 +149,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: build
 | 
			
		||||
      sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
 | 
			
		||||
      sha256: "7cf79af8eb6023bee797a77b067fb6e63ac5650f3789546e023958098feb776e"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.4.2"
 | 
			
		||||
    version: "2.5.2"
 | 
			
		||||
  build_config:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -173,26 +173,26 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: build_resolvers
 | 
			
		||||
      sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
 | 
			
		||||
      sha256: "7a507e6026abe52074836d51a945bfad456daa7493eb7a6cac565e490e7d5b54"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.4.4"
 | 
			
		||||
    version: "2.5.2"
 | 
			
		||||
  build_runner:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description:
 | 
			
		||||
      name: build_runner
 | 
			
		||||
      sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99"
 | 
			
		||||
      sha256: "1ce1e5063b564f26c27bda54c82a3d38339df69ec58f90e0017f447de77e4839"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.4.15"
 | 
			
		||||
    version: "2.5.2"
 | 
			
		||||
  build_runner_core:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: build_runner_core
 | 
			
		||||
      sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
 | 
			
		||||
      sha256: "564230f3fd9363df7870058fef11ec5502ee620aec3b1ee8106b943be5c63a76"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "8.0.0"
 | 
			
		||||
    version: "9.1.0"
 | 
			
		||||
  built_collection:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -453,18 +453,18 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus
 | 
			
		||||
      sha256: "0c6396126421b590089447154c5f98a5de423b70cfb15b1578fd018843ee6f53"
 | 
			
		||||
      sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "11.4.0"
 | 
			
		||||
    version: "11.5.0"
 | 
			
		||||
  device_info_plus_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_platform_interface
 | 
			
		||||
      sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2"
 | 
			
		||||
      sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "7.0.2"
 | 
			
		||||
    version: "7.0.3"
 | 
			
		||||
  dio:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -493,18 +493,18 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: drift
 | 
			
		||||
      sha256: b584ddeb2b74436735dd2cf746d2d021e19a9a6770f409212fd5cbc2814ada85
 | 
			
		||||
      sha256: e60c715f045dd33624fc533efb0075e057debec9f39e83843e518f488a0e21fb
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.26.1"
 | 
			
		||||
    version: "2.27.0"
 | 
			
		||||
  drift_dev:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description:
 | 
			
		||||
      name: drift_dev
 | 
			
		||||
      sha256: "54dc207c6e4662741f60e5752678df183957ab907754ffab0372a7082f6d2816"
 | 
			
		||||
      sha256: "7ad88b8982e753eadcdbc0ea7c7d30500598af733601428b5c9d264baf5106d6"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.26.1"
 | 
			
		||||
    version: "2.27.0"
 | 
			
		||||
  drift_flutter:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -892,13 +892,13 @@ packages:
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.6.1"
 | 
			
		||||
  flutter_svg:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_svg
 | 
			
		||||
      sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
 | 
			
		||||
      sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.1.0"
 | 
			
		||||
    version: "2.2.0"
 | 
			
		||||
  flutter_test:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description: flutter
 | 
			
		||||
@@ -1217,10 +1217,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: lean_builder
 | 
			
		||||
      sha256: ac129cd2173aa4e53e1327bcee2233d738d68ee446f3c797135633deafe6ca8a
 | 
			
		||||
      sha256: dca2165cfe681c69ae903a0880cab90ee93d730777605a0f44c9dd08cec7e1b9
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.1.0-alpha.12"
 | 
			
		||||
    version: "0.1.0-alpha.13"
 | 
			
		||||
  lint:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1397,6 +1397,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  native_exif:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: native_exif
 | 
			
		||||
      sha256: "0d37444c1ed00cbcada69b7510aba1d505fed75d3b6ef3ea3c8c2c970040e4f1"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.6.2"
 | 
			
		||||
  nested:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1761,10 +1769,10 @@ packages:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: riverpod_paging_utils
 | 
			
		||||
      sha256: "18f59960807835b1d3cb993e825442d7b09928d0f55ad50bda65c002b5893bdc"
 | 
			
		||||
      sha256: a3eb7cc87d53d90dac9bf0b0d695ecdc049aae5dd6debd7d2d62ab3682cf5841
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.8.0"
 | 
			
		||||
    version: "0.8.1"
 | 
			
		||||
  rxdart:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -1901,6 +1909,30 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  sign_in_with_apple:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: sign_in_with_apple
 | 
			
		||||
      sha256: "8bd875c8e8748272749eb6d25b896f768e7e9d60988446d543fe85a37a2392b8"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "7.0.1"
 | 
			
		||||
  sign_in_with_apple_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: sign_in_with_apple_platform_interface
 | 
			
		||||
      sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.0"
 | 
			
		||||
  sign_in_with_apple_web:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: sign_in_with_apple_web
 | 
			
		||||
      sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  simple_gesture_detector:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -2431,10 +2463,10 @@ packages:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: win32
 | 
			
		||||
      sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
 | 
			
		||||
      sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "5.13.0"
 | 
			
		||||
    version: "5.14.0"
 | 
			
		||||
  win32_registry:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -2477,4 +2509,4 @@ packages:
 | 
			
		||||
    version: "3.1.3"
 | 
			
		||||
sdks:
 | 
			
		||||
  dart: ">=3.8.0 <4.0.0"
 | 
			
		||||
  flutter: ">=3.27.4"
 | 
			
		||||
  flutter: ">=3.29.0"
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
 | 
			
		||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 | 
			
		||||
# In Windows, build-name is used as the major, minor, and patch parts
 | 
			
		||||
# of the product and file versions while build-number is used as the build suffix.
 | 
			
		||||
version: 3.0.0+104
 | 
			
		||||
version: 3.0.0+106
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: ^3.7.2
 | 
			
		||||
@@ -113,6 +113,9 @@ dependencies:
 | 
			
		||||
  timezone: ^0.10.1
 | 
			
		||||
  flutter_timezone: ^4.1.1
 | 
			
		||||
  fl_chart: ^1.0.0
 | 
			
		||||
  sign_in_with_apple: ^7.0.1
 | 
			
		||||
  flutter_svg: ^2.1.0
 | 
			
		||||
  native_exif: ^0.6.2
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
  flutter_test:
 | 
			
		||||
@@ -147,6 +150,9 @@ flutter:
 | 
			
		||||
  # To add assets to your application, add an assets section, like this:
 | 
			
		||||
  assets:
 | 
			
		||||
    - assets/i18n/
 | 
			
		||||
    - assets/images/
 | 
			
		||||
    - assets/images/oidc/
 | 
			
		||||
    - assets/icons/
 | 
			
		||||
 | 
			
		||||
  # An image asset can refer to one or more resolution-specific "variants", see
 | 
			
		||||
  # https://flutter.dev/to/resolution-aware-images
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <!--
 | 
			
		||||
@@ -242,10 +242,18 @@
 | 
			
		||||
        alt=""
 | 
			
		||||
      />
 | 
			
		||||
    </picture>
 | 
			
		||||
 | 
			
		||||
    <!-- Alert -->
 | 
			
		||||
    <script
 | 
			
		||||
      src="https://unpkg.com/sweetalert@2.1.2/dist/sweetalert.min.js"
 | 
			
		||||
      async=""
 | 
			
		||||
    ></script>
 | 
			
		||||
    <!-- Sign in with Apple -->
 | 
			
		||||
    <script
 | 
			
		||||
      type="text/javascript"
 | 
			
		||||
      src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
 | 
			
		||||
    ></script>
 | 
			
		||||
 | 
			
		||||
    <script>
 | 
			
		||||
      document.oncontextmenu = (evt) => evt.preventDefault();
 | 
			
		||||
    </script>
 | 
			
		||||
@@ -275,4 +283,3 @@
 | 
			
		||||
    </script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user