Compare commits
25 Commits
36fb06b81c
...
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 | |||
217a0c0a54 | |||
5c0f7225e6 | |||
a7cb7170b8 | |||
2fac5e5383 | |||
00b9c4b957 | |||
6fbf3d9fc4 |
@ -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>
|
||||
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:croppy/croppy.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -26,6 +27,7 @@ import 'package:relative_time/relative_time.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
void main() async {
|
||||
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -102,7 +104,7 @@ void main() async {
|
||||
);
|
||||
}
|
||||
|
||||
final _appRouter = AppRouter();
|
||||
final appRouter = AppRouter();
|
||||
|
||||
class IslandApp extends HookConsumerWidget {
|
||||
const IslandApp({super.key});
|
||||
@ -111,6 +113,33 @@ class IslandApp extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = ref.watch(themeProvider);
|
||||
|
||||
void handleMessage(RemoteMessage notification) {
|
||||
if (notification.data['action_uri'] != null) {
|
||||
var uri = notification.data['action_uri'] as String;
|
||||
if (uri.startsWith('/')) {
|
||||
// In-app routes
|
||||
appRouter.pushPath(notification.data['action_uri']);
|
||||
} else {
|
||||
// External links
|
||||
launchUrlString(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
Future(() async {
|
||||
RemoteMessage? initialMessage =
|
||||
await FirebaseMessaging.instance.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
handleMessage(initialMessage);
|
||||
}
|
||||
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(handleMessage);
|
||||
});
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useEffect(() {
|
||||
// Load userinfo
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
@ -135,7 +164,7 @@ class IslandApp extends HookConsumerWidget {
|
||||
theme: theme?.light,
|
||||
darkTheme: theme?.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: _appRouter.config(
|
||||
routerConfig: appRouter.config(
|
||||
navigatorObservers:
|
||||
() => [
|
||||
TabNavigationObserver(
|
||||
@ -158,9 +187,9 @@ class IslandApp extends HookConsumerWidget {
|
||||
OverlayEntry(
|
||||
builder:
|
||||
(_) => WindowScaffold(
|
||||
router: _appRouter,
|
||||
router: appRouter,
|
||||
child: TabsNavigationWidget(
|
||||
router: _appRouter,
|
||||
router: appRouter,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -230,6 +230,9 @@ class CallNotifier extends _$CallNotifier {
|
||||
String? get roomId => _roomId;
|
||||
|
||||
Future<void> joinRoom(String roomId) async {
|
||||
if (_roomId == roomId && _room != null) {
|
||||
return;
|
||||
}
|
||||
_roomId = roomId;
|
||||
if (_room != null) {
|
||||
await _room!.disconnect();
|
||||
|
@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'2082a572b5cfb4bf929dc1ed492c52cd2735452e';
|
||||
String _$callNotifierHash() => r'e04cea314c823e407d49fd616d90d77491232c12';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
@ -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,6 +175,89 @@ class _LoginCheckScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
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': code ?? challenge!.id,
|
||||
},
|
||||
);
|
||||
final token = tokenResp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Do post login tasks
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
userNotifier.fetchUser().then((_) {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
});
|
||||
|
||||
// Update the sessions' device name is available
|
||||
if (!kIsWeb) {
|
||||
String? name;
|
||||
if (Platform.isIOS) {
|
||||
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
||||
name = deviceInfo.name;
|
||||
} else if (Platform.isAndroid) {
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
name = deviceInfo.name;
|
||||
} else if (Platform.isWindows) {
|
||||
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
||||
name = deviceInfo.computerName;
|
||||
}
|
||||
if (name != null) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.patch(
|
||||
'/accounts/me/sessions/current/label',
|
||||
data: jsonEncode(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@ -190,47 +276,7 @@ class _LoginCheckScreen extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token if challenge is completed
|
||||
final tokenResp = await client.post(
|
||||
'/auth/token',
|
||||
data: {'grant_type': 'authorization_code', 'code': result.id},
|
||||
);
|
||||
final token = tokenResp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Do post login tasks
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
userNotifier.fetchUser().then((_) {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
});
|
||||
|
||||
// Update the sessions' device name is available
|
||||
if (!kIsWeb) {
|
||||
String? name;
|
||||
if (Platform.isIOS) {
|
||||
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
||||
name = deviceInfo.name;
|
||||
} else if (Platform.isAndroid) {
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
name = deviceInfo.name;
|
||||
} else if (Platform.isWindows) {
|
||||
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
||||
name = deviceInfo.computerName;
|
||||
}
|
||||
if (name != null) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.patch(
|
||||
'/accounts/me/sessions/current/label',
|
||||
data: jsonEncode(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,14 +334,12 @@ class _LoginCheckScreen extends HookConsumerWidget {
|
||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||
),
|
||||
const Gap(12),
|
||||
Card(
|
||||
child: 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(),
|
||||
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(
|
||||
@ -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,282 +48,150 @@ 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);
|
||||
},
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
(context) => ComposeSettingsSheet(
|
||||
titleController: state.titleController,
|
||||
descriptionController: state.descriptionController,
|
||||
visibility: state.visibility,
|
||||
onVisibilityChanged: () {
|
||||
// Trigger rebuild if needed
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
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()}'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
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(),
|
||||
@ -338,53 +200,50 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr())
|
||||
: null,
|
||||
actions: [
|
||||
IconButton(
|
||||
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: () {
|
||||
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()}'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onPressed: showKeyboardShortcutsDialog,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: submitting.value ? null : performAction,
|
||||
icon:
|
||||
submitting.value
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
).center()
|
||||
: originalPost != null
|
||||
? const Icon(Symbols.edit)
|
||||
: const Icon(Symbols.upload),
|
||||
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
|
||||
? 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),
|
||||
],
|
||||
@ -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,24 +60,25 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
child: PostQuickReply(
|
||||
parent: post,
|
||||
onPosted: () {
|
||||
ref.invalidate(postRepliesNotifierProvider(id));
|
||||
},
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
top: 16,
|
||||
horizontal: 16,
|
||||
if (user.value != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
child: PostQuickReply(
|
||||
parent: post,
|
||||
onPosted: () {
|
||||
ref.invalidate(postRepliesNotifierProvider(id));
|
||||
},
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
top: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
@ -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(
|
||||
'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.',
|
||||
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(' ');
|
||||
}
|
||||
}
|
462
lib/widgets/app_notification.dart
Normal file
462
lib/widgets/app_notification.dart
Normal file
@ -0,0 +1,462 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/models/user.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
part 'app_notification.freezed.dart';
|
||||
part 'app_notification.g.dart';
|
||||
|
||||
class AppNotificationToast extends HookConsumerWidget {
|
||||
const AppNotificationToast({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifications = ref.watch(appNotificationsProvider);
|
||||
|
||||
// Create a global key for AnimatedList
|
||||
final listKey = useMemoized(() => GlobalKey<AnimatedListState>());
|
||||
|
||||
// Track visual notification count (including those being animated out)
|
||||
final visualCount = useState(notifications.length);
|
||||
|
||||
// Track notifications being removed to manage visual count
|
||||
final animatingOutIds = useState<Set<String>>({});
|
||||
|
||||
// Track previous notifications to detect changes
|
||||
final previousNotifications = usePrevious(notifications) ?? [];
|
||||
|
||||
// Handle notification changes
|
||||
useEffect(() {
|
||||
final currentIds = notifications.map((n) => n.data.id).toSet();
|
||||
final previousIds = previousNotifications.map((n) => n.data.id).toSet();
|
||||
|
||||
// Find new notifications (added)
|
||||
final newIds = currentIds.difference(previousIds);
|
||||
|
||||
// Update visual count for new notifications
|
||||
if (newIds.isNotEmpty) {
|
||||
visualCount.value += newIds.length;
|
||||
}
|
||||
|
||||
// Insert new notifications with animation
|
||||
for (final id in newIds) {
|
||||
final index = notifications.indexWhere((n) => n.data.id == id);
|
||||
if (index != -1 &&
|
||||
listKey.currentState != null &&
|
||||
index >= 0 &&
|
||||
index <= notifications.length) {
|
||||
try {
|
||||
listKey.currentState!.insertItem(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
} catch (e) {
|
||||
// Log error but don't crash the app
|
||||
debugPrint('Error inserting notification: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [notifications]);
|
||||
|
||||
return Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 50,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: SizedBox(
|
||||
// Use visualCount instead of notifications.length for height calculation
|
||||
height: visualCount.value * 80,
|
||||
child: AnimatedList(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
key: listKey,
|
||||
initialItemCount: notifications.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
// Safely access notifications with bounds check
|
||||
if (index >= notifications.length) {
|
||||
return const SizedBox.shrink(); // Return empty widget if out of bounds
|
||||
}
|
||||
|
||||
final notification = notifications[index];
|
||||
final now = DateTime.now();
|
||||
final createdAt = notification.createdAt ?? now;
|
||||
final duration =
|
||||
notification.duration ?? const Duration(seconds: 5);
|
||||
final elapsedTime = now.difference(createdAt);
|
||||
final remainingTime = duration - elapsedTime;
|
||||
final progress =
|
||||
1.0 -
|
||||
(remainingTime.inMilliseconds / duration.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
); // Ensure progress is clamped
|
||||
|
||||
return SizeTransition(
|
||||
sizeFactor: animation.drive(
|
||||
CurveTween(curve: Curves.fastLinearToSlowEaseIn),
|
||||
),
|
||||
child: _NotificationCard(
|
||||
notification: notification,
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
onDismiss: () {
|
||||
// Find the current index before removal
|
||||
final currentIndex = notifications.indexWhere(
|
||||
(n) => n.data.id == notification.data.id,
|
||||
);
|
||||
|
||||
// Add to animating out set
|
||||
final notificationId = notification.data.id;
|
||||
if (!animatingOutIds.value.contains(notificationId)) {
|
||||
animatingOutIds.value = {
|
||||
...animatingOutIds.value,
|
||||
notificationId,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentIndex != -1 &&
|
||||
listKey.currentState != null &&
|
||||
currentIndex >= 0 &&
|
||||
currentIndex < notifications.length) {
|
||||
try {
|
||||
// Remove the item with animation
|
||||
listKey.currentState!.removeItem(
|
||||
currentIndex,
|
||||
(context, animation) => SizeTransition(
|
||||
sizeFactor: animation.drive(
|
||||
CurveTween(curve: Curves.fastLinearToSlowEaseIn),
|
||||
),
|
||||
child: _NotificationCard(
|
||||
notification: notification,
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
onDismiss:
|
||||
() {}, // Empty because it's being removed
|
||||
),
|
||||
),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
// When animation completes, update the visual count
|
||||
);
|
||||
|
||||
// Schedule decrementing the visual count after animation completes
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (animatingOutIds.value.contains(notificationId)) {
|
||||
visualCount.value =
|
||||
visualCount.value > 0 ? visualCount.value - 1 : 0;
|
||||
animatingOutIds.value =
|
||||
animatingOutIds.value
|
||||
.where((id) => id != notificationId)
|
||||
.toSet();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Log error but don't crash the app
|
||||
log('[Notification] Error removing notification: $e');
|
||||
// Still update visual count in case of error
|
||||
visualCount.value =
|
||||
visualCount.value > 0 ? visualCount.value - 1 : 0;
|
||||
animatingOutIds.value =
|
||||
animatingOutIds.value
|
||||
.where((id) => id != notificationId)
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
// Actually remove from state
|
||||
ref
|
||||
.read(appNotificationsProvider.notifier)
|
||||
.removeNotification(notification);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationCard extends HookConsumerWidget {
|
||||
final AppNotification notification;
|
||||
final double progress;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _NotificationCard({
|
||||
required this.notification,
|
||||
required this.progress,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Use state to track the current progress for smooth animation
|
||||
final progressState = useState(progress);
|
||||
|
||||
// Use effect to update progress smoothly
|
||||
useEffect(() {
|
||||
if (progress < 1.0) {
|
||||
// Update progress every 16ms (roughly 60fps) for smooth animation
|
||||
final timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
|
||||
final now = DateTime.now();
|
||||
final createdAt = notification.createdAt ?? now;
|
||||
final duration = notification.duration ?? const Duration(seconds: 5);
|
||||
final elapsedTime = now.difference(createdAt);
|
||||
final remainingTime = duration - elapsedTime;
|
||||
final newProgress = (1.0 -
|
||||
(remainingTime.inMilliseconds / duration.inMilliseconds))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
progressState.value = newProgress;
|
||||
|
||||
// Auto-dismiss when complete
|
||||
if (newProgress >= 1.0) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
|
||||
return timer.cancel;
|
||||
}
|
||||
return null;
|
||||
}, [notification.createdAt, notification.duration]);
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () {
|
||||
if (notification.data.meta['action_uri'] != null) {
|
||||
var uri = notification.data.meta['action_uri'] as String;
|
||||
if (uri.startsWith('/')) {
|
||||
// In-app routes
|
||||
appRouter.pushPath(notification.data.meta['action_uri']);
|
||||
} else {
|
||||
// External URLs
|
||||
launchUrlString(uri);
|
||||
}
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress indicator
|
||||
if (progressState.value > 0 && progressState.value < 1.0)
|
||||
AnimatedBuilder(
|
||||
animation: progressState,
|
||||
builder: (context, _) {
|
||||
return LinearProgressIndicator(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
value: 1.0 - progressState.value,
|
||||
backgroundColor: Colors.transparent,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
minHeight: 3,
|
||||
stopIndicatorColor: Colors.transparent,
|
||||
stopIndicatorRadius: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (notification.data.meta['avatar'] != null)
|
||||
ProfilePictureWidget(
|
||||
fileId: notification.data.meta['avatar'],
|
||||
radius: 12,
|
||||
).padding(right: 12, top: 2)
|
||||
else if (notification.icon != null)
|
||||
Icon(
|
||||
notification.icon,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 24,
|
||||
).padding(right: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
notification.data.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (notification.data.content.isNotEmpty)
|
||||
Text(
|
||||
notification.data.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (notification.data.subtitle.isNotEmpty)
|
||||
Text(
|
||||
notification.data.subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close, size: 18),
|
||||
onPressed: onDismiss,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class AppNotification with _$AppNotification {
|
||||
const factory AppNotification({
|
||||
required SnNotification data,
|
||||
@JsonKey(ignore: true) IconData? icon,
|
||||
@JsonKey(ignore: true) Duration? duration,
|
||||
@Default(null) DateTime? createdAt,
|
||||
@Default(false) @JsonKey(ignore: true) bool isAnimatingOut,
|
||||
}) = _AppNotification;
|
||||
|
||||
factory AppNotification.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppNotificationFromJson(json);
|
||||
}
|
||||
|
||||
// Using riverpod_generator for cleaner provider code
|
||||
@riverpod
|
||||
class AppNotifications extends _$AppNotifications {
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
@override
|
||||
List<AppNotification> build() {
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
});
|
||||
|
||||
_initWebSocketListener();
|
||||
return [];
|
||||
}
|
||||
|
||||
void _initWebSocketListener() {
|
||||
final service = ref.read(websocketProvider);
|
||||
_subscription = service.dataStream.listen((packet) {
|
||||
// Handle notification packets
|
||||
if (packet.type == 'notifications.new') {
|
||||
try {
|
||||
final data = SnNotification.fromJson(packet.data!);
|
||||
|
||||
IconData? icon;
|
||||
switch (data.topic) {
|
||||
case 'general':
|
||||
default:
|
||||
icon = Symbols.info;
|
||||
break;
|
||||
}
|
||||
|
||||
addNotification(
|
||||
AppNotification(
|
||||
data: data,
|
||||
icon: icon,
|
||||
createdAt: data.createdAt.toLocal(),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
log('[Notification] Error processing notification: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void addNotification(AppNotification notification) {
|
||||
// Create a new notification with createdAt if not provided
|
||||
final newNotification =
|
||||
notification.createdAt == null
|
||||
? notification.copyWith(createdAt: DateTime.now())
|
||||
: notification;
|
||||
|
||||
// Add to state
|
||||
state = [...state, newNotification];
|
||||
|
||||
// Auto-remove notification after duration
|
||||
final duration = newNotification.duration ?? const Duration(seconds: 5);
|
||||
Future.delayed(duration, () {
|
||||
// Find the notification in the current state
|
||||
final notificationToRemove = state.firstWhereOrNull(
|
||||
(n) => n.data.id == newNotification.data.id,
|
||||
);
|
||||
|
||||
// Only proceed if the notification still exists in state
|
||||
if (notificationToRemove != null) {
|
||||
// Call removeNotification which will handle the animation
|
||||
removeNotification(notificationToRemove);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map to track notifications that are being animated out
|
||||
final Map<String, bool> _animatingNotifications = {};
|
||||
|
||||
// Map to track which notifications should animate out
|
||||
final Map<String, bool> _animatingOutNotifications = {};
|
||||
|
||||
void removeNotification(AppNotification notification) {
|
||||
final notificationId = notification.data.id;
|
||||
|
||||
// If this notification is already being removed, don't do anything
|
||||
if (_animatingNotifications[notificationId] == true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark this notification as being removed
|
||||
_animatingNotifications[notificationId] = true;
|
||||
|
||||
// Remove from state immediately - AnimatedList handles the animation
|
||||
state = state.where((n) => n.data.id != notificationId).toList();
|
||||
|
||||
// Clean up tracking
|
||||
_animatingNotifications.remove(notificationId);
|
||||
_animatingOutNotifications.remove(notificationId);
|
||||
}
|
||||
|
||||
// Helper method to check if a notification should animate out
|
||||
bool isAnimatingOut(String notificationId) {
|
||||
return _animatingOutNotifications[notificationId] == true;
|
||||
}
|
||||
|
||||
// Helper method to manually add a notification for testing
|
||||
void showNotification({
|
||||
required SnNotification data,
|
||||
IconData? icon,
|
||||
Duration? duration,
|
||||
}) {
|
||||
addNotification(
|
||||
AppNotification(
|
||||
data: data,
|
||||
icon: icon,
|
||||
duration: duration,
|
||||
createdAt: data.createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
190
lib/widgets/app_notification.freezed.dart
Normal file
190
lib/widgets/app_notification.freezed.dart
Normal file
@ -0,0 +1,190 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'app_notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AppNotification implements DiagnosticableTreeMixin {
|
||||
|
||||
SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt;@JsonKey(ignore: true) bool get isAnimatingOut;
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$AppNotificationCopyWith<AppNotification> get copyWith => _$AppNotificationCopyWithImpl<AppNotification>(this as AppNotification, _$identity);
|
||||
|
||||
/// Serializes this AppNotification to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..add(DiagnosticsProperty('type', 'AppNotification'))
|
||||
..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut);
|
||||
|
||||
@override
|
||||
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
||||
return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $AppNotificationCopyWith<$Res> {
|
||||
factory $AppNotificationCopyWith(AppNotification value, $Res Function(AppNotification) _then) = _$AppNotificationCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut
|
||||
});
|
||||
|
||||
|
||||
$SnNotificationCopyWith<$Res> get data;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$AppNotificationCopyWithImpl<$Res>
|
||||
implements $AppNotificationCopyWith<$Res> {
|
||||
_$AppNotificationCopyWithImpl(this._self, this._then);
|
||||
|
||||
final AppNotification _self;
|
||||
final $Res Function(AppNotification) _then;
|
||||
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
|
||||
as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
|
||||
as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnNotificationCopyWith<$Res> get data {
|
||||
|
||||
return $SnNotificationCopyWith<$Res>(_self.data, (value) {
|
||||
return _then(_self.copyWith(data: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _AppNotification with DiagnosticableTreeMixin implements AppNotification {
|
||||
const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null, @JsonKey(ignore: true) this.isAnimatingOut = false});
|
||||
factory _AppNotification.fromJson(Map<String, dynamic> json) => _$AppNotificationFromJson(json);
|
||||
|
||||
@override final SnNotification data;
|
||||
@override@JsonKey(ignore: true) final IconData? icon;
|
||||
@override@JsonKey(ignore: true) final Duration? duration;
|
||||
@override@JsonKey() final DateTime? createdAt;
|
||||
@override@JsonKey(ignore: true) final bool isAnimatingOut;
|
||||
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$AppNotificationCopyWith<_AppNotification> get copyWith => __$AppNotificationCopyWithImpl<_AppNotification>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$AppNotificationToJson(this, );
|
||||
}
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..add(DiagnosticsProperty('type', 'AppNotification'))
|
||||
..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut);
|
||||
|
||||
@override
|
||||
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
|
||||
return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotificationCopyWith<$Res> {
|
||||
factory _$AppNotificationCopyWith(_AppNotification value, $Res Function(_AppNotification) _then) = __$AppNotificationCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut
|
||||
});
|
||||
|
||||
|
||||
@override $SnNotificationCopyWith<$Res> get data;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$AppNotificationCopyWithImpl<$Res>
|
||||
implements _$AppNotificationCopyWith<$Res> {
|
||||
__$AppNotificationCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _AppNotification _self;
|
||||
final $Res Function(_AppNotification) _then;
|
||||
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) {
|
||||
return _then(_AppNotification(
|
||||
data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
|
||||
as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
|
||||
as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of AppNotification
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnNotificationCopyWith<$Res> get data {
|
||||
|
||||
return $SnNotificationCopyWith<$Res>(_self.data, (value) {
|
||||
return _then(_self.copyWith(data: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
48
lib/widgets/app_notification.g.dart
Normal file
48
lib/widgets/app_notification.g.dart
Normal file
@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AppNotification _$AppNotificationFromJson(Map<String, dynamic> json) =>
|
||||
_AppNotification(
|
||||
data: SnNotification.fromJson(json['data'] as Map<String, dynamic>),
|
||||
createdAt:
|
||||
json['created_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppNotificationToJson(_AppNotification instance) =>
|
||||
<String, dynamic>{
|
||||
'data': instance.data.toJson(),
|
||||
'created_at': instance.createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d';
|
||||
|
||||
/// See also [AppNotifications].
|
||||
@ProviderFor(AppNotifications)
|
||||
final appNotificationsProvider = AutoDisposeNotifierProvider<
|
||||
AppNotifications,
|
||||
List<AppNotification>
|
||||
>.internal(
|
||||
AppNotifications.new,
|
||||
name: r'appNotificationsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appNotificationsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppNotifications = AutoDisposeNotifier<List<AppNotification>>;
|
||||
// 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
|
@ -10,6 +10,7 @@ import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/app_notification.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@ -83,6 +84,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
AppNotificationToast(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -90,7 +92,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [child, _WebSocketIndicator()],
|
||||
children: [child, _WebSocketIndicator(), AppNotificationToast()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,18 +236,52 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
PostReactionList(
|
||||
parentId: item.id,
|
||||
reactions: item.reactionsCount,
|
||||
padding: EdgeInsets.only(left: 48),
|
||||
onReact: (symbol, attitude, delta) {
|
||||
final reactionsCount = Map<String, int>.from(
|
||||
item.reactionsCount,
|
||||
);
|
||||
reactionsCount[symbol] =
|
||||
(reactionsCount[symbol] ?? 0) + delta;
|
||||
onUpdate?.call(item.copyWith(reactionsCount: reactionsCount));
|
||||
},
|
||||
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.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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
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