Compare commits

..

27 Commits

Author SHA1 Message Date
8956723ac5 🚀 Launch 3.1.0+117 2025-08-06 03:30:33 +08:00
ccc3ac415e 🐛 Poll bug fixes 2025-08-06 03:14:09 +08:00
8c47a59b80 🐛 Bug fixes 2025-08-06 02:51:41 +08:00
a6d869ebf6 Poll answer 2025-08-06 01:37:38 +08:00
f3a8699389 🐛 Fixes poll editor 2025-08-05 18:09:34 +08:00
d345c00e84 Poll editor basic 2025-08-05 17:40:47 +08:00
a706f127b6 💄 Optimize hint 2025-08-05 02:59:08 +08:00
680ece0b6a Sensitive marks hidden image 2025-08-05 02:57:27 +08:00
b976c6ed37 File rename, sensitive 2025-08-05 00:53:00 +08:00
6ae6b132de Use Solar Network drive hint 2025-08-04 22:37:20 +08:00
95aec7c95b 💄 Change icon 2025-08-04 22:15:42 +08:00
edd760fbcb Ability to crop image 2025-08-04 22:08:18 +08:00
ba269dbbb8 💄 Optimization styles 2025-08-04 18:20:13 +08:00
1aa45dd9f1 🐛 Fix bottom sheet didn't avoid keyboard 2025-08-04 17:36:04 +08:00
92685d7410 💄 Optimize profile page 2025-08-04 17:24:17 +08:00
c8e351514d 🐛 Dozens of bug fixes 2025-08-04 13:34:50 +08:00
f3900825e3 🐛 Fix oidc 2025-08-03 12:34:12 +08:00
2cc6652f75 Add audio from existing file 2025-08-02 15:35:18 +08:00
4d4409de2e Show audio filename 2025-08-02 15:13:50 +08:00
e1286c797f Audio player 2025-08-02 14:54:56 +08:00
bec037622f Audio recorder 2025-08-02 14:06:58 +08:00
a0d8c1a9b3 🐛 Fixes call 2025-08-02 01:53:02 +08:00
26135d2116 🐛 Fix embed 2025-08-01 23:54:31 +08:00
71b67fd22d 🐛 Fixes of videos and more 2025-08-01 23:21:43 +08:00
855072dfea 🐛 Fix refresh failed after delete data 2025-08-01 21:46:43 +08:00
b39e2e2d64 💄 Video player optimized 2025-08-01 20:36:39 +08:00
84b1d6a346 💄 Optimization 2025-08-01 18:03:54 +08:00
84 changed files with 7348 additions and 1373 deletions

View File

@@ -59,7 +59,6 @@ dependencies {
implementation("com.google.android.material:material:1.12.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.firebase:firebase-messaging-ktx")
}
flutter {

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
@@ -89,6 +90,13 @@
</intent-filter>
</activity>
<!-- Livekit Screenshare -->
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Sign in with Apple -->
<activity
android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
@@ -109,14 +117,6 @@
android:enabled="true"
android:exported="true" />
<service
android:name=".service.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="dev.solsynth.solian.provider"
@@ -143,4 +143,4 @@
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -1,102 +0,0 @@
package dev.solsynth.solian.service
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dev.solsynth.solian.MainActivity
import dev.solsynth.solian.receiver.ReplyReceiver
import org.json.JSONObject
class MessagingService: FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val type = remoteMessage.data["type"]
if (type == "messages.new") {
handleMessageNotification(remoteMessage)
} else {
// Handle other notification types
}
}
private fun handleMessageNotification(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val metaString = data["meta"] ?: return
val meta = JSONObject(metaString)
val pfp = meta.optString("pfp", null)
val roomId = meta.optString("room_id", null)
val messageId = meta.optString("message_id", null)
val notificationId = System.currentTimeMillis().toInt()
val replyLabel = "Reply"
val remoteInput = RemoteInput.Builder("key_text_reply")
.setLabel(replyLabel)
.build()
val replyIntent = Intent(this, ReplyReceiver::class.java).apply {
putExtra("room_id", roomId)
putExtra("message_id", messageId)
putExtra("notification_id", notificationId)
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val replyPendingIntent = PendingIntent.getBroadcast(
applicationContext,
notificationId,
replyIntent,
pendingIntentFlags
)
val action = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
replyLabel,
replyPendingIntent
)
.addRemoteInput(remoteInput)
.build()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.putExtra("room_id", roomId)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
val notificationBuilder = NotificationCompat.Builder(this, "messages")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(remoteMessage.notification?.title)
.setContentText(remoteMessage.notification?.body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.addAction(action)
if (pfp != null) {
Glide.with(applicationContext)
.asBitmap()
.load(pfp)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notificationBuilder.setLargeIcon(resource)
NotificationManagerCompat.from(applicationContext).notify(notificationId, notificationBuilder.build())
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
} else {
NotificationManagerCompat.from(this).notify(notificationId, notificationBuilder.build())
}
}
}

View File

@@ -146,9 +146,12 @@
"edited": "Edited",
"addVideo": "Add video",
"addPhoto": "Add photo",
"addAudio": "Add audio",
"addFile": "Add file",
"recordAudio": "Record Audio",
"linkAttachment": "Link Attachment",
"fileIdCannotBeEmpty": "File ID cannot be empty",
"fileIdLinkHint": "Haven't upload to the Solar Network? Tap here to open Solar Network Drive to customize your uploads.",
"failedToFetchFile": "Failed to fetch file: {}",
"createDirectMessage": "Send new DM",
"gotoDirectMessage": "Go to DM",
@@ -728,5 +731,35 @@
"selectCamera": "Select Camera",
"switchedTo": "Switched to {}",
"connecting": "Connecting",
"repliesLoadMore": "Load more replies"
"reconnecting": "Reconnecting",
"disconnected": "Disconnected",
"connected": "Connected",
"repliesLoadMore": "Load more replies",
"attachmentsRecentUploads": "Recent Uploads",
"attachmentsManualInput": "Manual Input",
"crop": "Crop",
"rename": "Rename",
"markAsSensitive": "Mark as Sensitive",
"fileName": "File name",
"sensitiveCategories.language": "Language",
"sensitiveCategories.sexualContent": "Sexual Content",
"sensitiveCategories.violence": "Violence",
"sensitiveCategories.profanity": "Profanity",
"sensitiveCategories.hateSpeech": "Hate Speech",
"sensitiveCategories.racism": "Racism",
"sensitiveCategories.adultContent": "Adult Content",
"sensitiveCategories.drugAbuse": "Drug Abuse",
"sensitiveCategories.alcoholAbuse": "Alcohol Abuse",
"sensitiveCategories.gambling": "Gambling",
"sensitiveCategories.selfHarm": "Self-harm",
"sensitiveCategories.childAbuse": "Child Abuse",
"sensitiveCategories.other": "Other",
"poll": "Poll",
"pollsRecent": "Recent Polls",
"pollCreateNew": "Create New",
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
"publisher": "Publisher",
"publisherHint": "Enter the publisher name",
"publisherCannotBeEmpty": "Publisher cannot be empty",
"operationFailed": "Operation failed: {}"
}

View File

@@ -10,6 +10,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; };
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73D4264B2DEB815D006C0AAE /* NotifyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */; };
@@ -32,6 +34,13 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 73ACDFAA2E3D0E6100B63535;
remoteInfo = SolianBroadcastExtension;
};
73C305D62E0BE878009035B9 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -55,6 +64,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */,
73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */,
73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */,
);
@@ -91,6 +101,9 @@
3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; };
73ACDFB82E3D0E6100B63535 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyDelegate.swift; sourceTree = "<group>"; };
@@ -117,6 +130,13 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */;
};
73C305DC2E0BE878009035B9 /* Exceptions for "SolianShareExtension" folder in "SolianShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -150,6 +170,14 @@
path = Services;
sourceTree = "<group>";
};
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */,
);
path = SolianBroadcastExtension;
sourceTree = "<group>";
};
73C305CF2E0BE878009035B9 /* SolianShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -177,6 +205,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
73ACDFA82E3D0E6100B63535 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
73C305CB2E0BE878009035B9 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -220,6 +256,8 @@
AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */,
39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */,
7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */,
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */,
73ACDFB82E3D0E6100B63535 /* UIKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -264,6 +302,7 @@
97C146F01CF9000F007C117D /* Runner */,
73CDD67B2DEC00480059D95D /* SolianNotificationService */,
73C305CF2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
91E124CE95BCB4DCD890160D /* Pods */,
@@ -279,6 +318,7 @@
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */,
73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */,
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -323,6 +363,26 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */;
buildPhases = (
73ACDFA72E3D0E6100B63535 /* Sources */,
73ACDFA82E3D0E6100B63535 /* Frameworks */,
73ACDFA92E3D0E6100B63535 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
);
name = SolianBroadcastExtension;
productName = SolianBroadcastExtension;
productReference = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
73C305CD2E0BE878009035B9 /* SolianShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */;
@@ -385,6 +445,7 @@
dependencies = (
73CDD6802DEC00480059D95D /* PBXTargetDependency */,
73C305D72E0BE878009035B9 /* PBXTargetDependency */,
73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
73268D272DEB012A0076E970 /* Services */,
@@ -409,6 +470,9 @@
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
73ACDFAA2E3D0E6100B63535 = {
CreatedOnToolsVersion = 16.4;
};
73C305CD2E0BE878009035B9 = {
CreatedOnToolsVersion = 16.4;
};
@@ -438,6 +502,7 @@
331C8080294A63A400263BE5 /* RunnerTests */,
73CDD6792DEC00480059D95D /* SolianNotificationService */,
73C305CD2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */,
);
};
/* End PBXProject section */
@@ -450,6 +515,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
73ACDFA92E3D0E6100B63535 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
73C305CC2E0BE878009035B9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -643,6 +715,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
73ACDFA72E3D0E6100B63535 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
73C305CA2E0BE878009035B9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -675,6 +754,11 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */;
targetProxy = 73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */;
};
73C305D72E0BE878009035B9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 73C305CD2E0BE878009035B9 /* SolianShareExtension */;
@@ -773,7 +857,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -836,6 +920,123 @@
};
name = Profile;
};
73ACDFC42E3D0E6100B63535 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianBroadcastExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
73ACDFC52E3D0E6100B63535 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianBroadcastExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
73ACDFC62E3D0E6100B63535 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianBroadcastExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
73C305D92E0BE878009035B9 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */;
@@ -1204,7 +1405,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1232,7 +1433,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1258,6 +1459,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
73ACDFC42E3D0E6100B63535 /* Debug */,
73ACDFC52E3D0E6100B63535 /* Release */,
73ACDFC62E3D0E6100B63535 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -0,0 +1,37 @@
//
// Atomic.swift
// Broadcast Extension
//
// Created by Maksym Shcheglov.
// https://www.onswiftwings.com/posts/atomic-property-wrapper/
//
import Foundation
@propertyWrapper
struct Atomic<Value> {
private var value: Value
private let lock = NSLock()
init(wrappedValue value: Value) {
self.value = value
}
var wrappedValue: Value {
get { load() }
set { store(newValue: newValue) }
}
func load() -> Value {
lock.lock()
defer { lock.unlock() }
return value
}
mutating func store(newValue: Value) {
lock.lock()
defer { lock.unlock() }
value = newValue
}
}

View File

@@ -0,0 +1,29 @@
//
// DarwinNotificationCenter.swift
// Broadcast Extension
//
// Created by Alex-Dan Bumbu on 23/03/2021.
// Copyright © 2021 8x8, Inc. All rights reserved.
//
import Foundation
enum DarwinNotification: String {
case broadcastStarted = "iOS_BroadcastStarted"
case broadcastStopped = "iOS_BroadcastStopped"
}
class DarwinNotificationCenter {
static let shared = DarwinNotificationCenter()
private let notificationCenter: CFNotificationCenter
init() {
notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
}
func postNotification(_ name: DarwinNotification) {
CFNotificationCenterPostNotification(notificationCenter, CFNotificationName(rawValue: name.rawValue as CFString), nil, nil, true)
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.broadcast-services-upload</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
<key>RPBroadcastProcessMode</key>
<string>RPBroadcastProcessModeSampleBuffer</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,103 @@
//
// SampleHandler.swift
// Broadcast Extension
//
// Created by Alex-Dan Bumbu on 04.06.2021.
//
import ReplayKit
import OSLog
let broadcastLogger = OSLog(subsystem: "dev.solsynth.solian", category: "Broadcast")
private enum Constants {
// the App Group ID value that the app and the broadcast extension targets are setup with. It differs for each app.
static let appGroupIdentifier = "group.solsynth.solian"
}
class SampleHandler: RPBroadcastSampleHandler {
private var clientConnection: SocketConnection?
private var uploader: SampleUploader?
private var frameCount: Int = 0
var socketFilePath: String {
let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier)
return sharedContainer?.appendingPathComponent("rtc_SSFD").path ?? ""
}
override init() {
super.init()
if let connection = SocketConnection(filePath: socketFilePath) {
clientConnection = connection
setupConnection()
uploader = SampleUploader(connection: connection)
}
os_log(.debug, log: broadcastLogger, "%{public}s", socketFilePath)
}
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
// User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional.
frameCount = 0
DarwinNotificationCenter.shared.postNotification(.broadcastStarted)
openConnection()
}
override func broadcastPaused() {
// User has requested to pause the broadcast. Samples will stop being delivered.
}
override func broadcastResumed() {
// User has requested to resume the broadcast. Samples delivery will resume.
}
override func broadcastFinished() {
// User has requested to finish the broadcast.
DarwinNotificationCenter.shared.postNotification(.broadcastStopped)
clientConnection?.close()
}
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
switch sampleBufferType {
case RPSampleBufferType.video:
uploader?.send(sample: sampleBuffer)
default:
break
}
}
}
private extension SampleHandler {
func setupConnection() {
clientConnection?.didClose = { [weak self] error in
os_log(.debug, log: broadcastLogger, "client connection did close \(String(describing: error))")
if let error = error {
self?.finishBroadcastWithError(error)
} else {
// the displayed failure message is more user friendly when using NSError instead of Error
let JMScreenSharingStopped = 10001
let customError = NSError(domain: RPRecordingErrorDomain, code: JMScreenSharingStopped, userInfo: [NSLocalizedDescriptionKey: "Screen sharing stopped"])
self?.finishBroadcastWithError(customError)
}
}
}
func openConnection() {
let queue = DispatchQueue(label: "broadcast.connectTimer")
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(500))
timer.setEventHandler { [weak self] in
guard self?.clientConnection?.open() == true else {
return
}
timer.cancel()
}
timer.resume()
}
}

View File

@@ -0,0 +1,147 @@
//
// SampleUploader.swift
// Broadcast Extension
//
// Created by Alex-Dan Bumbu on 22/03/2021.
// Copyright © 2021 8x8, Inc. All rights reserved.
//
import Foundation
import ReplayKit
import OSLog
private enum Constants {
static let bufferMaxLength = 10240
}
class SampleUploader {
private static var imageContext = CIContext(options: nil)
@Atomic private var isReady = false
private var connection: SocketConnection
private var dataToSend: Data?
private var byteIndex = 0
private let serialQueue: DispatchQueue
init(connection: SocketConnection) {
self.connection = connection
self.serialQueue = DispatchQueue(label: "org.jitsi.meet.broadcast.sampleUploader")
setupConnection()
}
@discardableResult func send(sample buffer: CMSampleBuffer) -> Bool {
guard isReady else {
return false
}
isReady = false
dataToSend = prepare(sample: buffer)
byteIndex = 0
serialQueue.async { [weak self] in
self?.sendDataChunk()
}
return true
}
}
private extension SampleUploader {
func setupConnection() {
connection.didOpen = { [weak self] in
self?.isReady = true
}
connection.streamHasSpaceAvailable = { [weak self] in
self?.serialQueue.async {
if let success = self?.sendDataChunk() {
self?.isReady = !success
}
}
}
}
@discardableResult func sendDataChunk() -> Bool {
guard let dataToSend = dataToSend else {
return false
}
var bytesLeft = dataToSend.count - byteIndex
var length = bytesLeft > Constants.bufferMaxLength ? Constants.bufferMaxLength : bytesLeft
length = dataToSend[byteIndex..<(byteIndex + length)].withUnsafeBytes {
guard let ptr = $0.bindMemory(to: UInt8.self).baseAddress else {
return 0
}
return connection.writeToStream(buffer: ptr, maxLength: length)
}
if length > 0 {
byteIndex += length
bytesLeft -= length
if bytesLeft == 0 {
self.dataToSend = nil
byteIndex = 0
}
} else {
os_log(.debug, log: broadcastLogger, "writeBufferToStream failure")
}
return true
}
func prepare(sample buffer: CMSampleBuffer) -> Data? {
guard let imageBuffer = CMSampleBufferGetImageBuffer(buffer) else {
os_log(.debug, log: broadcastLogger, "image buffer not available")
return nil
}
CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
let scaleFactor = 1.0
let width = CVPixelBufferGetWidth(imageBuffer)/Int(scaleFactor)
let height = CVPixelBufferGetHeight(imageBuffer)/Int(scaleFactor)
let orientation = CMGetAttachment(buffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil)?.uintValue ?? 0
let scaleTransform = CGAffineTransform(scaleX: CGFloat(1.0/scaleFactor), y: CGFloat(1.0/scaleFactor))
let bufferData = self.jpegData(from: imageBuffer, scale: scaleTransform)
CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly)
guard let messageData = bufferData else {
os_log(.debug, log: broadcastLogger, "corrupted image buffer")
return nil
}
let httpResponse = CFHTTPMessageCreateResponse(nil, 200, nil, kCFHTTPVersion1_1).takeRetainedValue()
CFHTTPMessageSetHeaderFieldValue(httpResponse, "Content-Length" as CFString, String(messageData.count) as CFString)
CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Width" as CFString, String(width) as CFString)
CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Height" as CFString, String(height) as CFString)
CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Orientation" as CFString, String(orientation) as CFString)
CFHTTPMessageSetBody(httpResponse, messageData as CFData)
let serializedMessage = CFHTTPMessageCopySerializedMessage(httpResponse)?.takeRetainedValue() as Data?
return serializedMessage
}
func jpegData(from buffer: CVPixelBuffer, scale scaleTransform: CGAffineTransform) -> Data? {
let image = CIImage(cvPixelBuffer: buffer).transformed(by: scaleTransform)
guard let colorSpace = image.colorSpace else {
return nil
}
let options: [CIImageRepresentationOption: Float] = [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 1.0]
return SampleUploader.imageContext.jpegRepresentation(of: image, colorSpace: colorSpace, options: options)
}
}

View File

@@ -0,0 +1,199 @@
//
// SocketConnection.swift
// Broadcast Extension
//
// Created by Alex-Dan Bumbu on 22/03/2021.
// Copyright © 2021 Atlassian Inc. All rights reserved.
//
import Foundation
import OSLog
class SocketConnection: NSObject {
var didOpen: (() -> Void)?
var didClose: ((Error?) -> Void)?
var streamHasSpaceAvailable: (() -> Void)?
private let filePath: String
private var socketHandle: Int32 = -1
private var address: sockaddr_un?
private var inputStream: InputStream?
private var outputStream: OutputStream?
private var networkQueue: DispatchQueue?
private var shouldKeepRunning = false
init?(filePath path: String) {
filePath = path
socketHandle = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
guard socketHandle != -1 else {
os_log(.debug, log: broadcastLogger, "failure: create socket")
return nil
}
}
func open() -> Bool {
os_log(.debug, log: broadcastLogger, "open socket connection")
guard FileManager.default.fileExists(atPath: filePath) else {
os_log(.debug, log: broadcastLogger, "failure: socket file missing")
return false
}
guard setupAddress() == true else {
return false
}
guard connectSocket() == true else {
return false
}
setupStreams()
inputStream?.open()
outputStream?.open()
return true
}
func close() {
unscheduleStreams()
inputStream?.delegate = nil
outputStream?.delegate = nil
inputStream?.close()
outputStream?.close()
inputStream = nil
outputStream = nil
}
func writeToStream(buffer: UnsafePointer<UInt8>, maxLength length: Int) -> Int {
outputStream?.write(buffer, maxLength: length) ?? 0
}
}
extension SocketConnection: StreamDelegate {
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .openCompleted:
os_log(.debug, log: broadcastLogger, "client stream open completed")
if aStream == outputStream {
didOpen?()
}
case .hasBytesAvailable:
if aStream == inputStream {
var buffer: UInt8 = 0
let numberOfBytesRead = inputStream?.read(&buffer, maxLength: 1)
if numberOfBytesRead == 0 && aStream.streamStatus == .atEnd {
os_log(.debug, log: broadcastLogger, "server socket closed")
close()
notifyDidClose(error: nil)
}
}
case .hasSpaceAvailable:
if aStream == outputStream {
streamHasSpaceAvailable?()
}
case .errorOccurred:
os_log(.debug, log: broadcastLogger, "client stream error occured: \(String(describing: aStream.streamError))")
close()
notifyDidClose(error: aStream.streamError)
default:
break
}
}
}
private extension SocketConnection {
func setupAddress() -> Bool {
var addr = sockaddr_un()
guard filePath.count < MemoryLayout.size(ofValue: addr.sun_path) else {
os_log(.debug, log: broadcastLogger, "failure: fd path is too long")
return false
}
_ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in
filePath.withCString {
strncpy(ptr, $0, filePath.count)
}
}
address = addr
return true
}
func connectSocket() -> Bool {
guard var addr = address else {
return false
}
let status = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(socketHandle, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard status == noErr else {
os_log(.debug, log: broadcastLogger, "failure: \(status)")
return false
}
return true
}
func setupStreams() {
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocket(kCFAllocatorDefault, socketHandle, &readStream, &writeStream)
inputStream = readStream?.takeRetainedValue()
inputStream?.delegate = self
inputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String))
outputStream = writeStream?.takeRetainedValue()
outputStream?.delegate = self
outputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String))
scheduleStreams()
}
func scheduleStreams() {
shouldKeepRunning = true
networkQueue = DispatchQueue.global(qos: .userInitiated)
networkQueue?.async { [weak self] in
self?.inputStream?.schedule(in: .current, forMode: .common)
self?.outputStream?.schedule(in: .current, forMode: .common)
RunLoop.current.run()
var isRunning = false
repeat {
isRunning = self?.shouldKeepRunning ?? false && RunLoop.current.run(mode: .default, before: .distantFuture)
} while (isRunning)
}
}
func unscheduleStreams() {
networkQueue?.sync { [weak self] in
self?.inputStream?.remove(from: .current, forMode: .common)
self?.outputStream?.remove(from: .current, forMode: .common)
}
shouldKeepRunning = false
}
func notifyDidClose(error: Error?) {
if didClose != nil {
didClose?(error)
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.solsynth.solian</string>
</array>
</dict>
</plist>

View File

@@ -11,8 +11,8 @@ sealed class SnEmbedLink with _$SnEmbedLink {
@JsonKey(name: 'Title') required String title,
@JsonKey(name: 'Description') required String? description,
@JsonKey(name: 'ImageUrl') required String? imageUrl,
@JsonKey(name: 'FaviconUrl') required String faviconUrl,
@JsonKey(name: 'SiteName') required String siteName,
@JsonKey(name: 'FaviconUrl') @Default("") String faviconUrl,
@JsonKey(name: 'SiteName') @Default("") String siteName,
@JsonKey(name: 'ContentType') required String? contentType,
@JsonKey(name: 'Author') required String? author,
@JsonKey(name: 'PublishedDate') required DateTime? publishedDate,

View File

@@ -212,7 +212,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
@JsonSerializable()
class _SnEmbedLink implements SnEmbedLink {
const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') required this.faviconUrl, @JsonKey(name: 'SiteName') required this.siteName, @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate});
const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') this.faviconUrl = "", @JsonKey(name: 'SiteName') this.siteName = "", @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate});
factory _SnEmbedLink.fromJson(Map<String, dynamic> json) => _$SnEmbedLinkFromJson(json);
@override@JsonKey(name: 'Type') final String type;

View File

@@ -12,8 +12,8 @@ _SnEmbedLink _$SnEmbedLinkFromJson(Map<String, dynamic> json) => _SnEmbedLink(
title: json['Title'] as String,
description: json['Description'] as String?,
imageUrl: json['ImageUrl'] as String?,
faviconUrl: json['FaviconUrl'] as String,
siteName: json['SiteName'] as String,
faviconUrl: json['FaviconUrl'] as String? ?? "",
siteName: json['SiteName'] as String? ?? "",
contentType: json['ContentType'] as String?,
author: json['Author'] as String?,
publishedDate:

View File

@@ -12,6 +12,7 @@ sealed class UniversalFile with _$UniversalFile {
const factory UniversalFile({
required dynamic data,
required UniversalFileType type,
@Default(false) bool isLink,
}) = _UniversalFile;
factory UniversalFile.fromJson(Map<String, dynamic> json) =>
@@ -41,6 +42,7 @@ sealed class SnCloudFile with _$SnCloudFile {
required String? description,
required Map<String, dynamic>? fileMeta,
required Map<String, dynamic>? userMeta,
@Default([]) List<int> sensitiveMarks,
required String? mimeType,
required String? hash,
required int size,

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UniversalFile {
dynamic get data; UniversalFileType get type;
dynamic get data; UniversalFileType get type; bool get isLink;
/// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type);
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink);
@override
String toString() {
return 'UniversalFile(data: $data, type: $type)';
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)';
}
@@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res> {
factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl;
@useResult
$Res call({
dynamic data, UniversalFileType type
dynamic data, UniversalFileType type, bool isLink
});
@@ -65,11 +65,12 @@ class _$UniversalFileCopyWithImpl<$Res>
/// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) {
return _then(_self.copyWith(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as UniversalFileType,
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -151,10 +152,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _UniversalFile() when $default != null:
return $default(_that.data,_that.type);case _:
return $default(_that.data,_that.type,_that.isLink);case _:
return orElse();
}
@@ -172,10 +173,10 @@ return $default(_that.data,_that.type);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink) $default,) {final _that = this;
switch (_that) {
case _UniversalFile():
return $default(_that.data,_that.type);}
return $default(_that.data,_that.type,_that.isLink);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -189,10 +190,10 @@ return $default(_that.data,_that.type);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type, bool isLink)? $default,) {final _that = this;
switch (_that) {
case _UniversalFile() when $default != null:
return $default(_that.data,_that.type);case _:
return $default(_that.data,_that.type,_that.isLink);case _:
return null;
}
@@ -204,11 +205,12 @@ return $default(_that.data,_that.type);case _:
@JsonSerializable()
class _UniversalFile extends UniversalFile {
const _UniversalFile({required this.data, required this.type}): super._();
const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._();
factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
@override final dynamic data;
@override final UniversalFileType type;
@override@JsonKey() final bool isLink;
/// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values.
@@ -223,16 +225,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type);
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink);
@override
String toString() {
return 'UniversalFile(data: $data, type: $type)';
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)';
}
@@ -243,7 +245,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy
factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl;
@override @useResult
$Res call({
dynamic data, UniversalFileType type
dynamic data, UniversalFileType type, bool isLink
});
@@ -260,11 +262,12 @@ class __$UniversalFileCopyWithImpl<$Res>
/// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) {
return _then(_UniversalFile(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as UniversalFileType,
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -275,7 +278,7 @@ as UniversalFileType,
/// @nodoc
mixin _$SnCloudFile {
String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -288,16 +291,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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 SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -308,7 +311,7 @@ abstract mixin class $SnCloudFileCopyWith<$Res> {
factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl;
@useResult
$Res call({
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -325,14 +328,15 @@ class _$SnCloudFileCopyWithImpl<$Res>
/// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,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,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable
@@ -422,10 +426,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnCloudFile() when $default != null:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
@@ -443,10 +447,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnCloudFile():
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);}
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -460,10 +464,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnCloudFile() when $default != null:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
@@ -475,7 +479,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
@JsonSerializable()
class _SnCloudFile implements SnCloudFile {
const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta;
const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, final List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks;
factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json);
@override final String id;
@@ -499,6 +503,13 @@ class _SnCloudFile implements SnCloudFile {
return EqualUnmodifiableMapView(value);
}
final List<int> _sensitiveMarks;
@override@JsonKey() List<int> get sensitiveMarks {
if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_sensitiveMarks);
}
@override final String? mimeType;
@override final String? hash;
@override final int size;
@@ -521,16 +532,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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 _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(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,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -541,7 +552,7 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith
factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl;
@override @useResult
$Res call({
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -558,14 +569,15 @@ class __$SnCloudFileCopyWithImpl<$Res>
/// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnCloudFile(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable

View File

@@ -10,12 +10,14 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
_UniversalFile(
data: json['data'],
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
isLink: json['is_link'] as bool? ?? false,
);
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$UniversalFileTypeEnumMap[instance.type]!,
'is_link': instance.isLink,
};
const _$UniversalFileTypeEnumMap = {
@@ -31,6 +33,11 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
description: json['description'] as String?,
fileMeta: json['file_meta'] as Map<String, dynamic>?,
userMeta: json['user_meta'] as Map<String, dynamic>?,
sensitiveMarks:
(json['sensitive_marks'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
mimeType: json['mime_type'] as String?,
hash: json['hash'] as String?,
size: (json['size'] as num).toInt(),
@@ -54,6 +61,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
'description': instance.description,
'file_meta': instance.fileMeta,
'user_meta': instance.userMeta,
'sensitive_marks': instance.sensitiveMarks,
'mime_type': instance.mimeType,
'hash': instance.hash,
'size': instance.size,

92
lib/models/poll.dart Normal file
View File

@@ -0,0 +1,92 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/publisher.dart';
part 'poll.freezed.dart';
part 'poll.g.dart';
@freezed
sealed class SnPollWithStats with _$SnPollWithStats {
const factory SnPollWithStats({
required Map<String, dynamic>? userAnswer,
required Map<String, dynamic> stats,
required String id,
required List<SnPollQuestion> questions,
String? title,
String? description,
DateTime? endedAt,
required String publisherId,
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
}) = _SnPollWithStats;
factory SnPollWithStats.fromJson(Map<String, dynamic> json) =>
_$SnPollWithStatsFromJson(json);
}
@freezed
sealed class SnPoll with _$SnPoll {
const factory SnPoll({
required String id,
required List<SnPollQuestion> questions,
String? title,
String? description,
DateTime? endedAt,
required String publisherId,
SnPublisher? publisher,
// ModelBase fields
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
}) = _SnPoll;
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
}
@freezed
sealed class SnPollQuestion with _$SnPollQuestion {
const factory SnPollQuestion({
required String id,
required SnPollQuestionType type,
List<SnPollOption>? options,
required String title,
String? description,
required int order,
required bool isRequired,
}) = _SnPollQuestion;
factory SnPollQuestion.fromJson(Map<String, dynamic> json) =>
_$SnPollQuestionFromJson(json);
}
@freezed
sealed class SnPollOption with _$SnPollOption {
const factory SnPollOption({
required String id,
required String label,
String? description,
required int order,
}) = _SnPollOption;
factory SnPollOption.fromJson(Map<String, dynamic> json) =>
_$SnPollOptionFromJson(json);
}
enum SnPollQuestionType {
@JsonValue(0)
singleChoice,
@JsonValue(1)
multipleChoice,
@JsonValue(2)
yesNo,
@JsonValue(3)
rating,
@JsonValue(4)
freeText,
}

1186
lib/models/poll.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

133
lib/models/poll.g.dart Normal file
View File

@@ -0,0 +1,133 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'poll.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
_SnPollWithStats(
userAnswer: json['user_answer'] as Map<String, dynamic>?,
stats: json['stats'] as Map<String, dynamic>,
id: json['id'] as String,
questions:
(json['questions'] as List<dynamic>)
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
.toList(),
title: json['title'] as String?,
description: json['description'] as String?,
endedAt:
json['ended_at'] == null
? null
: DateTime.parse(json['ended_at'] as String),
publisherId: json['publisher_id'] 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> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
<String, dynamic>{
'user_answer': instance.userAnswer,
'stats': instance.stats,
'id': instance.id,
'questions': instance.questions.map((e) => e.toJson()).toList(),
'title': instance.title,
'description': instance.description,
'ended_at': instance.endedAt?.toIso8601String(),
'publisher_id': instance.publisherId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
id: json['id'] as String,
questions:
(json['questions'] as List<dynamic>)
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
.toList(),
title: json['title'] as String?,
description: json['description'] as String?,
endedAt:
json['ended_at'] == null
? null
: DateTime.parse(json['ended_at'] as String),
publisherId: json['publisher_id'] as String,
publisher:
json['publisher'] == null
? null
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
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> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{
'id': instance.id,
'questions': instance.questions.map((e) => e.toJson()).toList(),
'title': instance.title,
'description': instance.description,
'ended_at': instance.endedAt?.toIso8601String(),
'publisher_id': instance.publisherId,
'publisher': instance.publisher?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) =>
_SnPollQuestion(
id: json['id'] as String,
type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']),
options:
(json['options'] as List<dynamic>?)
?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
.toList(),
title: json['title'] as String,
description: json['description'] as String?,
order: (json['order'] as num).toInt(),
isRequired: json['is_required'] as bool,
);
Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$SnPollQuestionTypeEnumMap[instance.type]!,
'options': instance.options?.map((e) => e.toJson()).toList(),
'title': instance.title,
'description': instance.description,
'order': instance.order,
'is_required': instance.isRequired,
};
const _$SnPollQuestionTypeEnumMap = {
SnPollQuestionType.singleChoice: 0,
SnPollQuestionType.multipleChoice: 1,
SnPollQuestionType.yesNo: 2,
SnPollQuestionType.rating: 3,
SnPollQuestionType.freeText: 4,
};
_SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) =>
_SnPollOption(
id: json['id'] as String,
label: json['label'] as String,
description: json['description'] as String?,
order: (json['order'] as num).toInt(),
);
Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) =>
<String, dynamic>{
'id': instance.id,
'label': instance.label,
'description': instance.description,
'order': instance.order,
};

View File

@@ -1,4 +1,8 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -24,6 +28,7 @@ sealed class CallState with _$CallState {
required bool isMicrophoneEnabled,
required bool isCameraEnabled,
required bool isScreenSharing,
required bool isSpeakerphone,
@Default(Duration(seconds: 0)) Duration duration,
String? error,
}) = _CallState;
@@ -61,6 +66,8 @@ class CallNotifier extends _$CallNotifier {
List.unmodifiable(_participants);
LocalParticipant? get localParticipant => _localParticipant;
Map<String, double> participantsVolumes = {};
Timer? _durationTimer;
Room? get room => _room;
@@ -73,6 +80,7 @@ class CallNotifier extends _$CallNotifier {
isMicrophoneEnabled: true,
isCameraEnabled: false,
isScreenSharing: false,
isSpeakerphone: true,
);
}
@@ -203,7 +211,13 @@ class CallNotifier extends _$CallNotifier {
Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) {
log('[Call] Call skipped. Already has data');
return;
} else if (_room != null) {
if (!_room!.isDisposed &&
_room!.connectionState != ConnectionState.disconnected) {
throw Exception('Call already connected');
}
}
_roomId = roomId;
if (_room != null) {
@@ -257,6 +271,10 @@ class CallNotifier extends _$CallNotifier {
_initRoomListeners();
_updateLiveParticipants(participants);
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
Hardware.instance.setSpeakerphoneOn(true);
}
// Listen for connection updates
_room!.addListener(() {
state = state.copyWith(
@@ -311,6 +329,12 @@ class CallNotifier extends _$CallNotifier {
}
}
Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
state = state.copyWith();
}
Future<void> disconnect() async {
if (_room != null) {
await _room!.disconnect();
@@ -323,6 +347,26 @@ class CallNotifier extends _$CallNotifier {
}
}
void setParticipantVolume(CallParticipantLive live, double volume) {
if (participantsVolumes[live.remoteParticipant.sid] == null) {
participantsVolumes[live.remoteParticipant.sid] = 1;
}
Helper.setVolume(
volume,
live
.remoteParticipant
.audioTrackPublications
.first
.track!
.mediaStreamTrack,
);
participantsVolumes[live.remoteParticipant.sid] = volume;
}
double getParticipantVolume(CallParticipantLive live) {
return participantsVolumes[live.remoteParticipant.sid] ?? 1;
}
void dispose() {
state = state.copyWith(
error: null,
@@ -335,5 +379,7 @@ class CallNotifier extends _$CallNotifier {
_room?.removeListener(_onRoomChange);
_room?.dispose();
_durationTimer?.cancel();
_roomId = null;
participantsVolumes = {};
}
}

View File

@@ -12,9 +12,9 @@ part of 'call.dart';
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CallState {
mixin _$CallState implements DiagnosticableTreeMixin {
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; Duration get duration; String? get error;
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; String? get error;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -22,19 +22,25 @@ mixin _$CallState {
$CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>(this as CallState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error);
@override
String toString() {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)';
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)';
}
@@ -45,7 +51,7 @@ abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult
$Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error
});
@@ -62,12 +68,13 @@ class _$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) {
return _then(_self.copyWith(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
@@ -152,10 +159,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _:
return orElse();
}
@@ -173,10 +180,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error) $default,) {final _that = this;
switch (_that) {
case _CallState():
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);}
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -190,10 +197,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,) {final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _:
return null;
}
@@ -204,14 +211,15 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
/// @nodoc
class _CallState implements CallState {
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.duration = const Duration(seconds: 0), this.error});
class _CallState with DiagnosticableTreeMixin implements CallState {
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.error});
@override final bool isConnected;
@override final bool isMicrophoneEnabled;
@override final bool isCameraEnabled;
@override final bool isScreenSharing;
@override final bool isSpeakerphone;
@override@JsonKey() final Duration duration;
@override final String? error;
@@ -222,19 +230,25 @@ class _CallState implements CallState {
_$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error);
@override
String toString() {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)';
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)';
}
@@ -245,7 +259,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult
$Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error
});
@@ -262,12 +276,13 @@ class __$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) {
return _then(_CallState(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
@@ -278,7 +293,7 @@ as String?,
}
/// @nodoc
mixin _$CallParticipantLive {
mixin _$CallParticipantLive implements DiagnosticableTreeMixin {
CallParticipant get participant; Participant get remoteParticipant;
/// Create a copy of CallParticipantLive
@@ -288,6 +303,12 @@ mixin _$CallParticipantLive {
$CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallParticipantLive'))
..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant));
}
@override
bool operator ==(Object other) {
@@ -299,7 +320,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}
@@ -475,7 +496,7 @@ return $default(_that.participant,_that.remoteParticipant);case _:
/// @nodoc
class _CallParticipantLive extends CallParticipantLive {
class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMixin {
const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._();
@@ -489,6 +510,12 @@ class _CallParticipantLive extends CallParticipantLive {
_$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallParticipantLive'))
..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant));
}
@override
bool operator ==(Object other) {
@@ -500,7 +527,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator
// **************************************************************************
String _$callNotifierHash() => r'e4312feadb5b34f186b5349a7ee8b671b842dafc';
String _$callNotifierHash() => r'18fb807f067eecd3ea42631c1426c3e5f1fb4280';
/// See also [CallNotifier].
@ProviderFor(CallNotifier)

View File

@@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart';
import 'package:island/screens/creators/posts/post_manage_list.dart';
import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/screens/posts/pub_profile.dart';
@@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPostListScreen(pubName: name);
},
),
// Poll list route
GoRoute(
name: 'creatorPolls',
path: '/creators/:name/polls',
builder: (context, state) {
final name = state.pathParameters['name']!;
return CreatorPollListScreen(pubName: name);
},
),
// Poll routes
GoRoute(
name: 'creatorPollNew',
path: '/creators/:name/polls/new',
builder: (context, state) {
final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled
return PollEditorScreen(initialPublisher: name);
},
),
GoRoute(
name: 'creatorPollEdit',
path: '/creators/:name/polls/:id/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final id = state.pathParameters['id']!;
return PollEditorScreen(
initialPollId: id,
initialPublisher: name,
);
},
),
GoRoute(
name: 'creatorStickers',
path: '/creators/:name/stickers',
@@ -433,14 +466,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/account/relationships',
builder: (context, state) => const RelationshipScreen(),
),
GoRoute(
name: 'accountProfile',
path: '/account/:name',
builder: (context, state) {
final name = state.pathParameters['name']!;
return AccountProfileScreen(name: name);
},
),
GoRoute(
name: 'profileUpdate',
path: '/account/me/update',
@@ -463,6 +488,15 @@ final routerProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
name: 'accountProfile',
path: '/account/:name',
builder: (context, state) {
final name = state.pathParameters['name']!;
return AccountProfileScreen(name: name);
},
),
],
),
],

View File

@@ -93,7 +93,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
final theme = Theme.of(context);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(title: Text('about'.tr()), elevation: 0),
body:
_isLoading

View File

@@ -64,7 +64,7 @@ class AccountScreen extends HookConsumerWidget {
}
return AppScaffold(
noBackground: isWide,
isNoBackground: isWide,
appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0),
body: SingleChildScrollView(
padding: getTabbedPadding(context),

View File

@@ -46,7 +46,7 @@ class EventCalanderScreen extends HookConsumerWidget {
}
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('eventCalander').tr(),

View File

@@ -280,7 +280,7 @@ class LevelingScreen extends HookConsumerWidget {
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post('/subscriptions/${membership.identifier}/cancel');
await client.post('/id/subscriptions/${membership.identifier}/cancel');
ref.invalidate(accountStellarSubscriptionProvider);
ref.read(userInfoProvider.notifier).fetchUser();
if (context.mounted) {
@@ -603,7 +603,7 @@ class LevelingScreen extends HookConsumerWidget {
try {
showLoadingModal(context);
final resp = await client.post(
'/subscriptions',
'/id/subscriptions',
data: {
'identifier': tierId,
'payment_method': 'solian.wallet',
@@ -615,7 +615,7 @@ class LevelingScreen extends HookConsumerWidget {
final subscription = SnWalletSubscription.fromJson(resp.data);
if (subscription.status == 1) return;
final orderResp = await client.post(
'/subscriptions/${subscription.identifier}/order',
'/id/subscriptions/${subscription.identifier}/order',
);
final order = SnWalletOrder.fromJson(orderResp.data);
@@ -633,7 +633,7 @@ class LevelingScreen extends HookConsumerWidget {
if (paidOrder != null) {
await client.post(
'/subscriptions/order/handle',
'/id/subscriptions/order/handle',
data: {'order_id': paidOrder.id},
);

View File

@@ -12,6 +12,7 @@ import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/services/timezone/native.dart';
import 'package:island/widgets/account/account_name.dart';
@@ -22,10 +23,12 @@ import 'package:island/widgets/account/status.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/markdown.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart';
part 'profile.g.dart';
@@ -248,294 +251,402 @@ class AccountProfileScreen extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
Widget accountBasicInfo(SnAccount data) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(file: data.profile.picture, radius: 32),
const Gap(20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
AccountName(account: data, style: TextStyle(fontSize: 20)),
const Gap(6),
Flexible(
child: Text(
'@${data.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(14).opacity(0.85),
),
],
),
AccountStatusWidget(uname: name, padding: EdgeInsets.zero),
],
),
),
IconButton(
onPressed: () {
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('https://id.solian.app/@${data.name}'),
),
);
},
icon: const Icon(Symbols.share),
),
],
),
);
Widget accountProfileBio(SnAccount data) => Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
if (data.profile.bio.isEmpty)
Text('descriptionNone').tr().italic()
else
MarkdownTextContent(
content: data.profile.bio,
linesMargin: EdgeInsets.zero,
),
],
).padding(horizontal: 24, vertical: 20),
);
Widget accountProfileDetail(SnAccount data) => Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 24,
children: [
if (buildSubcolumn(data).isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: buildSubcolumn(data),
),
if (data.profile.timeZone.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('timeZone').tr().bold(),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 6,
children: [
Text(data.profile.timeZone),
Text(
getTzInfo(
data.profile.timeZone,
).$2.formatCustomGlobal('HH:mm'),
),
Text(
getTzInfo(data.profile.timeZone).$1.formatOffsetLocal(),
).fontSize(11),
Text(
'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}',
).fontSize(11).opacity(0.75),
],
),
],
),
],
).padding(horizontal: 24, vertical: 16),
);
Widget accountAction(SnAccount data) => Card(
child: Column(
children: [
Row(
spacing: 8,
children: [
if (accountRelationship.value == null ||
accountRelationship.value!.status > -100)
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: relationshipAction,
label:
Text(
accountRelationship.value == null
? 'addFriendShort'
: 'added',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.person_add)
: const Icon(Symbols.person_check),
),
),
if (accountRelationship.value == null ||
accountRelationship.value!.status <= -100)
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: blockAction,
label:
Text(
accountRelationship.value == null
? 'blockUser'
: 'unblockUser',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.block)
: const Icon(Symbols.person_cancel),
),
),
],
),
Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
onPressed: directMessageAction,
icon: const Icon(Symbols.message),
label:
Text(
accountChat.value == null
? 'createDirectMessage'
: 'gotoDirectMessage',
maxLines: 1,
).tr(),
),
),
IconButton.filled(
onPressed: () {
showAbuseReportSheet(
context,
resourceIdentifier: 'account/${data.id}',
);
},
icon: Icon(
Symbols.flag,
color: Theme.of(context).colorScheme.onError,
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.error,
),
),
),
],
),
],
).padding(horizontal: 16, vertical: 8),
);
return account.when(
data:
(data) => AppScaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
foregroundColor: appbarColor.value,
expandedHeight: 180,
pinned: true,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.profile.background?.id != null
? CloudImageWidget(
file: data.profile.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
isNoBackground: false,
appBar:
isWideScreen(context)
? AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [appbarShadow],
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.profile.background?.id != null
? CloudImageWidget(
file: data.profile.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
),
],
),
)
: null,
body:
isWideScreen(context)
? Row(
children: [
Flexible(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: accountBasicInfo(data)),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 24, bottom: 24),
),
SliverToBoxAdapter(
child: Column(
spacing: 12,
children: [
LevelingProgressCard(
level: data.profile.level,
experience: data.profile.experience,
progress: data.profile.levelingProgress,
),
if (data.profile.verification != null)
Card(
child: VerificationStatusCard(
mark: data.profile.verification!,
),
),
],
).padding(horizontal: 4, top: 8),
),
SliverToBoxAdapter(
child: accountProfileBio(data).padding(top: 4),
),
SliverToBoxAdapter(
child: accountProfileDetail(data),
),
],
),
),
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(
file: data.profile.picture,
radius: 32,
),
const Gap(20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
AccountName(
account: data,
style: TextStyle(fontSize: 20),
Flexible(
child: CustomScrollView(
slivers: [
SliverGap(24),
if (user.value != null)
SliverToBoxAdapter(child: accountAction(data)),
SliverToBoxAdapter(
child: Card(
child: FortuneGraphWidget(
events: accountEvents,
eventCalanderUser: data.name,
margin: EdgeInsets.zero,
),
const Gap(6),
Text(
'@${data.name}',
).fontSize(14).opacity(0.85),
],
),
AccountStatusWidget(
uname: name,
padding: EdgeInsets.zero,
),
),
],
),
),
],
),
),
),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 24, bottom: 24),
),
SliverToBoxAdapter(
child: Column(
spacing: 12,
children: [
LevelingProgressCard(
level: data.profile.level,
experience: data.profile.experience,
progress: data.profile.levelingProgress,
),
if (data.profile.verification != null)
VerificationStatusCard(
mark: data.profile.verification!,
),
],
).padding(horizontal: 20),
),
SliverToBoxAdapter(
child: const Divider(height: 1).padding(vertical: 24),
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 24,
children: [
if (buildSubcolumn(data).isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: buildSubcolumn(data),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('bio').tr().bold(),
Text(
data.profile.bio.isEmpty
? 'descriptionNone'.tr()
: data.profile.bio,
).padding(horizontal: 24)
: CustomScrollView(
slivers: [
SliverAppBar(
foregroundColor: appbarColor.value,
expandedHeight: 180,
pinned: true,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
],
),
if (data.profile.timeZone.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('timeZone').tr().bold(),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 6,
children: [
Text(data.profile.timeZone),
Text(
getTzInfo(
data.profile.timeZone,
).$2.formatCustomGlobal('HH:mm'),
),
Text(
getTzInfo(
data.profile.timeZone,
).$1.formatOffsetLocal(),
).fontSize(11),
Text(
'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}',
).fontSize(11).opacity(0.75),
],
),
],
),
],
).padding(horizontal: 24),
),
if (user.value != null)
SliverToBoxAdapter(
child: const Divider(
height: 1,
).padding(top: 24, bottom: 12),
),
if (user.value != null)
SliverToBoxAdapter(
child: Row(
spacing: 8,
children: [
if (accountRelationship.value == null ||
accountRelationship.value!.status > -100)
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(
context,
).colorScheme.onSecondary,
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.profile.background?.id != null
? CloudImageWidget(
file: data.profile.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
),
onPressed: relationshipAction,
label:
Text(
accountRelationship.value == null
? 'addFriendShort'
: 'added',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.person_add)
: const Icon(Symbols.person_check),
),
],
),
if (accountRelationship.value == null ||
accountRelationship.value!.status <= -100)
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(
context,
).colorScheme.onSecondary,
),
),
onPressed: blockAction,
label:
Text(
accountRelationship.value == null
? 'blockUser'
: 'unblockUser',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.block)
: const Icon(Symbols.person_cancel),
),
),
SliverToBoxAdapter(child: accountBasicInfo(data)),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 24, bottom: 24),
),
SliverToBoxAdapter(
child: Column(
children: [
LevelingProgressCard(
level: data.profile.level,
experience: data.profile.experience,
progress: data.profile.levelingProgress,
).padding(top: 8, horizontal: 8, bottom: 4),
if (data.profile.verification != null)
Card(
child: VerificationStatusCard(
mark: data.profile.verification!,
),
).padding(horizontal: 4),
],
),
),
SliverToBoxAdapter(
child: accountProfileBio(data).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: accountProfileDetail(
data,
).padding(horizontal: 4),
),
if (user.value != null)
SliverToBoxAdapter(
child: accountAction(data).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: Card(
child: FortuneGraphWidget(
events: accountEvents,
eventCalanderUser: data.name,
),
).padding(horizontal: 4),
),
],
).padding(horizontal: 16),
),
SliverToBoxAdapter(
child: Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
onPressed: directMessageAction,
icon: const Icon(Symbols.message),
label:
Text(
accountChat.value == null
? 'createDirectMessage'
: 'gotoDirectMessage',
maxLines: 1,
).tr(),
),
),
IconButton.filled(
onPressed: () {
showAbuseReportSheet(
context,
resourceIdentifier: 'account/${data.id}',
);
},
icon: Icon(
Symbols.flag,
color: Theme.of(context).colorScheme.onError,
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.error,
),
),
),
],
).padding(horizontal: 16, top: 4),
),
SliverToBoxAdapter(
child: const Divider(height: 1).padding(top: 12),
),
SliverToBoxAdapter(
child: Column(
children: [
FortuneGraphWidget(
events: accountEvents,
eventCalanderUser: data.name,
),
],
).padding(all: 8),
),
],
),
),
),
error:
(error, stackTrace) => AppScaffold(

View File

@@ -73,7 +73,7 @@ class CreateAccountScreen extends HookConsumerWidget {
}
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('createAccount').tr(),

View File

@@ -55,7 +55,7 @@ class LoginScreen extends HookConsumerWidget {
final factorPicked = useState<SnAuthFactor?>(null);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('login').tr(),

View File

@@ -80,7 +80,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
: '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}'),
url: WebUri('$serverUrl/id/auth/login/${widget.provider}'),
headers: {
if (token?.token.isNotEmpty ?? false)
'Authorization': 'AtField ${token!.token}',
@@ -120,7 +120,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
final queryParams = url.queryParameters;
// Check if we're on the token page
if (path.endsWith('/id/auth/callback')) {
if (path.endsWith('/auth/callback')) {
// Extract token from URL
final challenge = queryParams['challenge'];
// Return the token and close the webview
@@ -205,7 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
onPressed: () {
if (currentUrl != null) {
Clipboard.setData(ClipboardData(text: currentUrl!));
showSnackBar('copyToClipboard');
showSnackBar('copyToClipboard'.tr());
}
},
),

View File

@@ -1,5 +1,7 @@
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,6 +10,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -23,7 +26,19 @@ class CallScreen extends HookConsumerWidget {
final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() {
callNotifier.joinRoom(roomId);
log('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) {
showConfirmAlert(
'Seems there already has a call connected, do you want override it?',
'Call already connected',
).then((value) {
if (value != true) return;
log('[Call] Joining the call... with overrides');
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
});
});
return null;
}, []);
@@ -31,12 +46,16 @@ class CallScreen extends HookConsumerWidget {
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) => pub.track != null && pub.kind == TrackType.VIDEO,
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: PageBackButton(),
title: Column(
@@ -49,7 +68,12 @@ class CallScreen extends HookConsumerWidget {
Text(
callState.isConnected
? formatDuration(callState.duration)
: 'connecting',
: (switch (callNotifier.room?.connectionState) {
ConnectionState.connected => 'connected',
ConnectionState.connecting => 'connecting',
ConnectionState.reconnecting => 'reconnecting',
_ => 'disconnected',
}).tr(),
style: const TextStyle(fontSize: 14),
),
],

View File

@@ -1070,6 +1070,10 @@ class _ChatInput extends HookConsumerWidget {
item: attachments[idx],
onRequestUpload: () => onUploadAttachment(idx),
onDelete: () => onDeleteAttachment(idx),
onUpdate: (value) {
attachments[idx] = value;
onAttachmentsChanged(attachments);
},
onMove: (delta) => onMoveAttachment(idx, delta),
);
},

View File

@@ -421,10 +421,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
showConfirmAlert(
'deleteChatRoomHint'.tr(),
'deleteChatRoom'.tr(),
).then((confirm) {
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/sphere/chat/$id');
await client.delete('/sphere/chat/$id');
ref.invalidate(chatroomsJoinedProvider);
if (context.mounted) {
context.pop();
@@ -454,10 +454,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
showConfirmAlert(
'leaveChatRoomHint'.tr(),
'leaveChatRoom'.tr(),
).then((confirm) {
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/sphere/chat/$id/members/me');
await client.delete('/sphere/chat/$id/members/me');
ref.invalidate(chatroomsJoinedProvider);
if (context.mounted) {
context.pop();

View File

@@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget {
);
},
),
ListTile(
minTileHeight: 48,
title: const Text('Polls'),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.poll),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () {
context.pushNamed(
'creatorPolls',
pathParameters: {
'name': currentPublisher.value!.name,
},
);
},
),
ListTile(
minTileHeight: 48,
title: Text('publisherMembers').tr(),

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
part 'poll_list.g.dart';
@riverpod
class PollListNotifier extends _$PollListNotifier
with CursorPagingNotifierMixin<SnPoll> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnPoll>> build(String? pubName) {
// immediately load first page
return fetch(cursor: null);
}
@override
Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
// read the current family argument passed to provider
final currentPub = pubName;
final queryParams = {
'offset': offset,
'take': _pageSize,
if (currentPub != null) 'pub': currentPub,
};
final response = await client.get(
'/sphere/polls/me',
queryParameters: queryParams,
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final items = data.map((json) => SnPoll.fromJson(json)).toList();
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
class CreatorPollListScreen extends HookConsumerWidget {
const CreatorPollListScreen({super.key, required this.pubName});
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await GoRouter.of(
context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
if (result is SnPoll && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Polls')),
floatingActionButton: FloatingActionButton(
onPressed: () => _createPoll(context),
child: const Icon(Icons.add),
),
body: RefreshIndicator(
onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future),
child: CustomScrollView(
slivers: [
PagingHelperSliverView(
provider: pollListNotifierProvider(pubName),
futureRefreshable: pollListNotifierProvider(pubName).future,
notifierRefreshable: pollListNotifierProvider(pubName).notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final poll = data.items[index];
return _CreatorPollItem(poll: poll, pubName: pubName);
},
),
),
],
),
),
);
}
}
class _CreatorPollItem extends StatelessWidget {
final String pubName;
const _CreatorPollItem({required this.poll, required this.pubName});
final SnPoll poll;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ended = poll.endedAt;
final endedText =
ended == null
? 'No end'
: MaterialLocalizations.of(context).formatFullDate(ended);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: ListTile(
title: Text(poll.title ?? 'Untitled poll'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (poll.description != null && poll.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
poll.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Questions: ${poll.questions.length} · Ends: $endedText',
style: theme.textTheme.bodySmall,
),
),
],
),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('Edit'),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': poll.id},
);
},
),
],
),
onTap: () {
// Open editor for edit
// Navigator push by path to keep consistency with rest of app:
// Note: pub name string may be required in route; when absent, route may need query or pick later.
// For safety, just do nothing if no publisher in list item.
},
),
);
}
}

View File

@@ -0,0 +1,179 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'poll_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$PollListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> {
late final String? pubName;
FutureOr<CursorPagingData<SnPoll>> build(String? pubName);
}
/// See also [PollListNotifier].
@ProviderFor(PollListNotifier)
const pollListNotifierProvider = PollListNotifierFamily();
/// See also [PollListNotifier].
class PollListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnPoll>>> {
/// See also [PollListNotifier].
const PollListNotifierFamily();
/// See also [PollListNotifier].
PollListNotifierProvider call(String? pubName) {
return PollListNotifierProvider(pubName);
}
@override
PollListNotifierProvider getProviderOverride(
covariant PollListNotifierProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'pollListNotifierProvider';
}
/// See also [PollListNotifier].
class PollListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
PollListNotifier,
CursorPagingData<SnPoll>
> {
/// See also [PollListNotifier].
PollListNotifierProvider(String? pubName)
: this._internal(
() => PollListNotifier()..pubName = pubName,
from: pollListNotifierProvider,
name: r'pollListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$pollListNotifierHash,
dependencies: PollListNotifierFamily._dependencies,
allTransitiveDependencies:
PollListNotifierFamily._allTransitiveDependencies,
pubName: pubName,
);
PollListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String? pubName;
@override
FutureOr<CursorPagingData<SnPoll>> runNotifierBuild(
covariant PollListNotifier notifier,
) {
return notifier.build(pubName);
}
@override
Override overrideWith(PollListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: PollListNotifierProvider._internal(
() => create()..pubName = pubName,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
PollListNotifier,
CursorPagingData<SnPoll>
>
createElement() {
return _PollListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PollListNotifierProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PollListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> {
/// The parameter `pubName` of this provider.
String? get pubName;
}
class _PollListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
PollListNotifier,
CursorPagingData<SnPoll>
>
with PollListNotifierRef {
_PollListNotifierProviderElement(super.provider);
@override
String? get pubName => (origin as PollListNotifierProvider).pubName;
}
// 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

View File

@@ -111,7 +111,7 @@ class DeveloperHubScreen extends HookConsumerWidget {
);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: !isWide ? const PageBackButton() : null,
title: Text('developerHub').tr(),

View File

@@ -17,7 +17,7 @@ class DiscoveryRealmsScreen extends HookConsumerWidget {
final currentQuery = useState<String?>(null);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(title: Text('discoverRealms'.tr())),
body: Stack(
children: [

View File

@@ -87,7 +87,7 @@ class ExploreScreen extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
toolbarHeight: 0,
bottom: PreferredSize(
@@ -344,6 +344,10 @@ class _DiscoveryActivityItem extends StatelessWidget {
flexWeights:
isWideScreen(context) ? <int>[3, 2, 1] : <int>[4, 1],
consumeMaxWeight: false,
enableSplash: false,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
children: [
for (final item in items)
switch (type) {
@@ -364,7 +368,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
],
),
),
).padding(bottom: 8),
).padding(bottom: 8, horizontal: 8),
],
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -53,13 +53,13 @@ class PostEditScreen extends HookConsumerWidget {
data: (post) => PostComposeScreen(originalPost: post),
loading:
() => AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: const Center(child: CircularProgressIndicator()),
),
error:
(e, _) => AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Text('Error: $e', textAlign: TextAlign.center),
),
@@ -238,6 +238,8 @@ class PostComposeScreen extends HookConsumerWidget {
onRequestUpload:
() => ComposeLogic.uploadAttachment(ref, state, idx),
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) => ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
@@ -265,6 +267,9 @@ class PostComposeScreen extends HookConsumerWidget {
() => ComposeLogic.uploadAttachment(ref, state, idx),
onDelete:
() => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) =>
ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
@@ -287,7 +292,7 @@ class PostComposeScreen extends HookConsumerWidget {
}
},
child: AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
actions: [

View File

@@ -308,6 +308,13 @@ class ArticleComposeScreen extends HookConsumerWidget {
state,
idx,
),
onUpdate:
(value) =>
ComposeLogic.updateAttachment(
state,
value,
idx,
),
onDelete:
() => ComposeLogic.deleteAttachment(
ref,
@@ -352,7 +359,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
}
},
child: AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: ValueListenableBuilder<TextEditingValue>(

View File

@@ -54,7 +54,7 @@ class PostDetailScreen extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(title: const Text('Post')),
body: postState.when(
data: (post) {

View File

@@ -110,7 +110,7 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
title: TextField(
controller: _searchController,

View File

@@ -18,6 +18,7 @@ import 'package:island/widgets/account/status.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/markdown.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:palette_generator/palette_generator.dart';
@@ -233,25 +234,36 @@ class PublisherProfileScreen extends HookConsumerWidget {
],
).padding(horizontal: 24, top: 24);
Widget publisherVerificationWidget(SnPublisher data) => Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
if (badges.value?.isNotEmpty ?? false)
BadgeList(badges: badges.value!).padding(top: 16),
if (data.verification != null)
VerificationStatusCard(mark: data.verification!),
],
),
).padding(top: 16);
Widget publisherBadgesWidget(SnPublisher data) =>
(badges.value?.isNotEmpty ?? false)
? Card(
child: BadgeList(
badges: badges.value!,
).padding(horizontal: 26, vertical: 20),
).padding(horizontal: 4)
: const SizedBox.shrink();
Widget publisherDetailWidget(SnPublisher data) => Card(
Widget publisherVerificationWidget(SnPublisher data) =>
(data.verification != null)
? Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: VerificationStatusCard(mark: data.verification!),
)
: const SizedBox.shrink();
Widget publisherBioWidget(SnPublisher data) => Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('bio').tr().bold().padding(bottom: 2),
Text(data.bio.isEmpty ? 'descriptionNone'.tr() : data.bio),
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
if (data.bio.isEmpty)
Text('descriptionNone').tr().italic()
else
MarkdownTextContent(
content: data.bio,
linesMargin: EdgeInsets.zero,
),
],
).padding(horizontal: 20, vertical: 16),
);
@@ -259,7 +271,7 @@ class PublisherProfileScreen extends HookConsumerWidget {
return publisher.when(
data:
(data) => AppScaffold(
noBackground: false,
isNoBackground: false,
appBar:
isWideScreen(context)
? AppBar(
@@ -325,8 +337,9 @@ class PublisherProfileScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
publisherBasisWidget(data),
publisherBadgesWidget(data),
publisherVerificationWidget(data),
publisherDetailWidget(data),
publisherBioWidget(data),
],
),
),
@@ -377,11 +390,14 @@ class PublisherProfileScreen extends HookConsumerWidget {
],
),
),
SliverToBoxAdapter(child: publisherBasisWidget(data)),
SliverToBoxAdapter(
child: publisherBasisWidget(data).padding(bottom: 8),
),
SliverToBoxAdapter(child: publisherBadgesWidget(data)),
SliverToBoxAdapter(
child: publisherVerificationWidget(data),
),
SliverToBoxAdapter(child: publisherDetailWidget(data)),
SliverToBoxAdapter(child: publisherBioWidget(data)),
SliverPostList(pubName: name),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -389,11 +405,13 @@ class PublisherProfileScreen extends HookConsumerWidget {
),
error:
(error, stackTrace) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: Text(error.toString())),
),
loading:
() => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: CircularProgressIndicator()),
),

View File

@@ -79,7 +79,7 @@ class RealmDetailScreen extends HookConsumerWidget {
);
return AppScaffold(
noBackground: false,
isNoBackground: false,
body: realmState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
@@ -321,10 +321,10 @@ class _RealmActionMenu extends HookConsumerWidget {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) {
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete(
await client.delete(
'/sphere/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
@@ -361,10 +361,12 @@ class _RealmActionMenu extends HookConsumerWidget {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) {
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/sphere/realms/$realmSlug/members/me');
await client.delete(
'/sphere/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);

View File

@@ -41,7 +41,7 @@ class RealmListScreen extends HookConsumerWidget {
final realmInvites = ref.watch(realmInvitesProvider);
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
title: const Text('realms').tr(),
actions: [
@@ -279,7 +279,7 @@ class EditRealmScreen extends HookConsumerWidget {
}
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()),
leading: const PageBackButton(),

View File

@@ -552,7 +552,7 @@ class SettingsScreen extends HookConsumerWidget {
}
return AppScaffold(
noBackground: false,
isNoBackground: false,
appBar: AppBar(
title: Text('settings').tr(),
actions:

View File

@@ -15,6 +15,7 @@ Future<XFile?> cropImage(
BuildContext context, {
required XFile image,
List<CropAspectRatio?>? allowedAspectRatios,
bool replacePath = false,
}) async {
final result = await showMaterialImageCropper(
context,
@@ -34,7 +35,7 @@ Future<XFile?> cropImage(
croppedFile.dispose();
return XFile.fromData(
croppedBytes.buffer.asUint8List(),
path: image.path,
path: !replacePath ? image.path : null,
mimeType: image.mimeType,
);
}

View File

@@ -20,6 +20,33 @@ extension DurationFormatter on Duration {
return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
}
String formatShortDuration() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours;
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
2,
'0',
);
final milliseconds = (positiveDuration.inMilliseconds % 1000)
.toString()
.padLeft(3, '0');
String result;
if (hours > 0) {
result =
'${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds';
} else {
result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds';
}
return result;
}
String formatOffset() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;

View File

@@ -32,12 +32,12 @@ class BadgeItem extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: (template?.color ?? Colors.blue).withOpacity(0.1),
color: (template?.color ?? Colors.blue).withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
template?.icon ?? Icons.stars,
color: template?.color ?? Colors.orange,
color: template?.color ?? Colors.blue,
size: 20,
),
),

View File

@@ -32,7 +32,7 @@ class RestorePurchaseSheet extends HookConsumerWidget {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/subscriptions/order/restore/${selectedProvider.value!}',
'/id/subscriptions/order/restore/${selectedProvider.value!}',
data: {'order_id': orderIdController.text.trim()},
);

View File

@@ -86,6 +86,7 @@ class AccountStatusCreationWidget extends HookConsumerWidget {
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => AccountStatusCreationSheet(

View File

@@ -9,6 +9,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
class AccountStatusCreationSheet extends HookConsumerWidget {
@@ -71,178 +72,145 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
initialStatus == null
? 'statusCreate'.tr()
: 'statusUpdate'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
return SheetScaffold(
heightFactor: 0.6,
titleText:
initialStatus == null ? 'statusCreate'.tr() : 'statusUpdate'.tr(),
actions: [
TextButton.icon(
onPressed:
submitting.value
? null
: () {
submitStatus();
},
icon: const Icon(Symbols.upload),
label: Text(initialStatus == null ? 'create' : 'update').tr(),
style: ButtonStyle(
visualDensity: VisualDensity(
horizontal: VisualDensity.minimumDensity,
),
foregroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.onSurface,
),
),
),
if (initialStatus != null)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: submitting.value ? null : () => clearStatus(),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap(24),
TextField(
controller: labelController,
decoration: InputDecoration(
labelText: 'statusLabel'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
const Spacer(),
TextButton.icon(
onPressed:
submitting.value
? null
: () {
submitStatus();
},
icon: const Icon(Symbols.upload),
label: Text(initialStatus == null ? 'create' : 'update').tr(),
style: ButtonStyle(
visualDensity: VisualDensity(
horizontal: VisualDensity.minimumDensity,
),
foregroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.onSurface,
),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 24),
Text(
'statusAttitude'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SegmentedButton(
segments: [
ButtonSegment(
value: 0,
icon: const Icon(Symbols.sentiment_satisfied),
label: Text('attitudePositive'.tr()),
),
if (initialStatus != null)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: submitting.value ? null : () => clearStatus(),
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
ButtonSegment(
value: 1,
icon: const Icon(Symbols.sentiment_stressed),
label: Text('attitudeNeutral'.tr()),
),
ButtonSegment(
value: 2,
icon: const Icon(Symbols.sentiment_sad),
label: Text('attitudeNegative'.tr()),
),
],
selected: {attitude.value},
onSelectionChanged: (Set<int> newSelection) {
attitude.value = newSelection.first;
},
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(24),
TextField(
controller: labelController,
decoration: InputDecoration(
labelText: 'statusLabel'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 24),
Text(
'statusAttitude'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SegmentedButton(
segments: [
ButtonSegment(
value: 0,
icon: const Icon(Symbols.sentiment_satisfied),
label: Text('attitudePositive'.tr()),
),
ButtonSegment(
value: 1,
icon: const Icon(Symbols.sentiment_stressed),
label: Text('attitudeNeutral'.tr()),
),
ButtonSegment(
value: 2,
icon: const Icon(Symbols.sentiment_sad),
label: Text('attitudeNegative'.tr()),
),
],
selected: {attitude.value},
onSelectionChanged: (Set<int> newSelection) {
attitude.value = newSelection.first;
},
),
const Gap(12),
SwitchListTile(
title: Text('statusInvisible'.tr()),
subtitle: Text('statusInvisibleDescription'.tr()),
value: isInvisible.value,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
onChanged: (bool value) {
isInvisible.value = value;
},
),
SwitchListTile(
title: Text('statusNotDisturb'.tr()),
subtitle: Text('statusNotDisturbDescription'.tr()),
value: isNotDisturb.value,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
onChanged: (bool value) {
isNotDisturb.value = value;
},
),
const SizedBox(height: 24),
Text(
'statusClearTime'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
title: Text(
clearedAt.value == null
? 'statusNoAutoClear'.tr()
: DateFormat.yMMMd().add_jm().format(
clearedAt.value!,
),
),
trailing: const Icon(Symbols.schedule),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
onTap: () async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
initialDate: now,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null) return;
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time == null) return;
clearedAt.value = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
},
),
Gap(MediaQuery.of(context).padding.bottom + 24),
],
const Gap(12),
SwitchListTile(
title: Text('statusInvisible'.tr()),
subtitle: Text('statusInvisibleDescription'.tr()),
value: isInvisible.value,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
onChanged: (bool value) {
isInvisible.value = value;
},
),
SwitchListTile(
title: Text('statusNotDisturb'.tr()),
subtitle: Text('statusNotDisturbDescription'.tr()),
value: isNotDisturb.value,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
onChanged: (bool value) {
isNotDisturb.value = value;
},
),
const SizedBox(height: 24),
Text(
'statusClearTime'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListTile(
title: Text(
clearedAt.value == null
? 'statusNoAutoClear'.tr()
: DateFormat.yMMMd().add_jm().format(clearedAt.value!),
),
trailing: const Icon(Symbols.schedule),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).colorScheme.outline),
),
onTap: () async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
initialDate: now,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null) return;
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time == null) return;
clearedAt.value = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
},
),
),
],
Gap(MediaQuery.of(context).padding.bottom + 24),
],
),
),
);
}

View File

@@ -165,7 +165,7 @@ class AppScaffold extends StatelessWidget {
final AppBar? appBar;
final DrawerCallback? onDrawerChanged;
final DrawerCallback? onEndDrawerChanged;
final bool? noBackground;
final bool? isNoBackground;
final bool? extendBody;
const AppScaffold({
@@ -181,7 +181,7 @@ class AppScaffold extends StatelessWidget {
this.endDrawer,
this.onDrawerChanged,
this.onEndDrawerChanged,
this.noBackground,
this.isNoBackground,
this.extendBody,
});
@@ -190,7 +190,7 @@ class AppScaffold extends StatelessWidget {
final appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top;
final noBackground = this.noBackground ?? isWideScreen(context);
final noBackground = isNoBackground ?? isWideScreen(context);
final content = Column(
children: [

View File

@@ -22,8 +22,10 @@ class CallControlsBar extends HookConsumerWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 16,
spacing: 16,
children: [
_buildCircularButtonWithDropdown(
context: context,
@@ -35,7 +37,6 @@ class CallControlsBar extends HookConsumerWidget {
hasDropdown: true,
deviceType: 'videoinput',
),
const Gap(16),
_buildCircularButton(
icon:
callState.isScreenSharing
@@ -44,7 +45,6 @@ class CallControlsBar extends HookConsumerWidget {
onPressed: () => callNotifier.toggleScreenShare(),
backgroundColor: const Color(0xFF424242),
),
const Gap(16),
_buildCircularButtonWithDropdown(
context: context,
ref: ref,
@@ -54,7 +54,14 @@ class CallControlsBar extends HookConsumerWidget {
hasDropdown: true,
deviceType: 'audioinput',
),
const Gap(16),
_buildCircularButton(
icon:
callState.isSpeakerphone
? Symbols.mobile_speaker
: Symbols.ear_sound,
onPressed: () => callNotifier.toggleSpeakerphone(),
backgroundColor: const Color(0xFF424242),
),
_buildCircularButton(
icon: Icons.call_end,
onPressed:
@@ -63,41 +70,44 @@ class CallControlsBar extends HookConsumerWidget {
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(),
onTap: () {
callNotifier.disconnect();
Navigator.of(context).pop();
},
),
ListTile(
leading: const Icon(Symbols.call_end, fill: 1),
iconColor: Colors.red,
title: Text('callEnd').tr(),
onTap: () async {
callNotifier.disconnect();
final apiClient = ref.watch(apiClientProvider);
(innerContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(),
onTap: () {
callNotifier.disconnect();
Navigator.of(context).pop();
Navigator.of(innerContext).pop();
},
),
ListTile(
leading: const Icon(Symbols.call_end, fill: 1),
iconColor: Colors.red,
title: Text('callEnd').tr(),
onTap: () async {
callNotifier.disconnect();
final apiClient = ref.watch(apiClientProvider);
try {
showLoadingModal(context);
await apiClient.delete(
'/sphere/chat/realtime/${callNotifier.roomId}',
);
callNotifier.dispose();
if (context.mounted) {
Navigator.of(context).pop();
Navigator.of(innerContext).pop();
}
},
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
),
backgroundColor: const Color(0xFFE53E3E),
@@ -256,24 +266,14 @@ class CallControlsBar extends HookConsumerWidget {
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${'switchedTo'.tr()} ${device.label.isNotEmpty ? device.label : 'selectedDevice'.tr()}',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${'failedToSwitchDevice'.tr()}: $e'),
backgroundColor: Colors.red,
showSnackBar(
'switchedTo'.tr(
args: [device.label.isNotEmpty ? device.label : 'device'],
),
);
}
} catch (err) {
showErrorAlert(err);
}
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -18,6 +19,10 @@ class CallParticipantCard extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final width =
math.min(MediaQuery.of(context).size.width - 80, 360).toDouble();
final callNotifier = ref.watch(callNotifierProvider.notifier);
final volumeSliderValue = useState(callNotifier.getParticipantVolume(live));
return PopupCard(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
@@ -28,7 +33,35 @@ class CallParticipantCard extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
spacing: 4,
children: [
Row(
children: [
const Icon(Symbols.sound_detection_loud_sound, size: 16),
const Gap(8),
Expanded(
child: Slider(
max: 2,
value: volumeSliderValue.value,
onChanged: (value) {
volumeSliderValue.value = value;
},
onChangeEnd: (value) {
callNotifier.setParticipantVolume(live, value);
},
year2023: true,
padding: EdgeInsets.zero,
),
),
const Gap(16),
SizedBox(
width: 40,
child: Text(
'${(volumeSliderValue.value * 100).toStringAsFixed(0)}%',
),
),
],
),
Row(
children: [
const Icon(Symbols.wifi, size: 16),

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:cross_file/cross_file.dart';
@@ -5,18 +6,89 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/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';
class AttachmentPreview extends StatelessWidget {
import 'sensitive.dart';
class SensitiveMarksSelector extends StatefulWidget {
final List<int> initial;
final ValueChanged<List<int>>? onChanged;
const SensitiveMarksSelector({
super.key,
required this.initial,
this.onChanged,
});
@override
State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState();
}
class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
late List<int> _selected;
List<int> get current => _selected;
@override
void initState() {
super.initState();
_selected = [...widget.initial];
}
void _toggle(int value) {
setState(() {
if (_selected.contains(value)) {
_selected.remove(value);
} else {
_selected.add(value);
}
});
widget.onChanged?.call([..._selected]);
}
@override
Widget build(BuildContext context) {
// Build a list of all categories in fixed order as int list indices
final categories = kSensitiveCategoriesOrdered;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 8,
children: [
for (var i = 0; i < categories.length; i++)
FilterChip(
label: Text(categories[i].i18nKey.tr()),
avatar: Text(categories[i].symbol),
selected: _selected.contains(i),
onSelected: (_) => _toggle(i),
),
],
),
],
);
}
}
class AttachmentPreview extends HookConsumerWidget {
final UniversalFile item;
final double? progress;
final Function(int)? onMove;
final Function? onDelete;
final Function? onInsert;
final Function(UniversalFile)? onUpdate;
final Function? onRequestUpload;
const AttachmentPreview({
super.key,
required this.item,
@@ -24,11 +96,170 @@ class AttachmentPreview extends StatelessWidget {
this.onRequestUpload,
this.onMove,
this.onDelete,
this.onUpdate,
this.onInsert,
});
// GlobalKey for selector
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
GlobalKey<SensitiveMarksSelectorState>();
Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async {
final nameController = TextEditingController(text: item.data.name);
String? errorMessage;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'rename'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 24,
),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'fileName'.tr(),
border: const OutlineInputBorder(),
errorText: errorMessage,
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isEmpty) {
errorMessage = 'fieldCannotBeEmpty'.tr();
return;
}
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/drive/files/${item.data.id}/name',
data: jsonEncode(newName),
);
final newData = item.data;
newData.name = newName;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('rename'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'markAsSensitive'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 24,
),
child: Column(
children: [
// Sensitive categories checklist
SensitiveMarksSelector(
key: _sensitiveSelectorKey,
initial:
(item.data.sensitiveMarks ?? [])
.map((e) => e as int)
.cast<int>()
.toList(),
onChanged: (marks) {
// Update local data immediately (optimistic)
final newData = item.data;
newData.sensitiveMarks = marks;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
// Use the current selections from stateful selector via GlobalKey
final selectorState =
_sensitiveSelectorKey.currentState;
final marks = selectorState?.current ?? <int>[];
await apiClient.put(
'/drive/files/${item.data.id}/marks',
data: jsonEncode({'sensitive_marks': marks}),
);
final newData = item.data as SnCloudFile;
final updatedFile = item.copyWith(
data: newData.copyWith(sensitiveMarks: marks),
);
onUpdate?.call(updatedFile);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('confirm'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
var ratio =
item.isOnCloud
? (item.data.fileMeta?['ratio'] is num
@@ -37,217 +268,265 @@ class AttachmentPreview extends StatelessWidget {
: 1.0;
if (ratio == 0) ratio = 1.0;
return AspectRatio(
aspectRatio: ratio,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
final contentWidget = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Column(
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Builder(
builder: (context) {
if (item.isOnCloud) {
return CloudFileWidget(item: item.data);
} else if (item.data is XFile) {
if (item.type == UniversalFileType.image) {
final file = item.data as XFile;
if (file.path.isEmpty) {
return FutureBuilder<Uint8List>(
future: file.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(snapshot.data!);
}
return const Center(
child: CircularProgressIndicator(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Material(
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onDelete != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: Icon(
item.isLink ? Symbols.link_off : Symbols.delete,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onDelete?.call();
},
),
if (onDelete != null && onMove != null)
SizedBox(
height: 26,
child: const VerticalDivider(
width: 0.3,
color: Colors.white,
thickness: 0.3,
),
).padding(horizontal: 2),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_up,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(-1);
},
),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_down,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(1);
},
),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
],
),
),
),
),
if (onRequestUpload != null)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onRequestUpload?.call(),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child:
(item.isOnCloud)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud,
size: 16,
color: Colors.white,
),
const Gap(8),
Text(
'On-cloud',
style: TextStyle(color: Colors.white),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud_off,
size: 16,
color: Colors.white,
),
const Gap(8),
Text(
'On-device',
style: TextStyle(color: Colors.white),
),
],
),
),
),
),
],
).padding(horizontal: 12, vertical: 8),
AspectRatio(
aspectRatio: ratio,
child: Stack(
fit: StackFit.expand,
children: [
Builder(
key: ValueKey(item.hashCode),
builder: (context) {
if (item.isOnCloud) {
return CloudFileWidget(item: item.data);
} else if (item.data is XFile) {
final file = item.data as XFile;
if (file.path.isEmpty) {
return FutureBuilder<Uint8List>(
future: file.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(snapshot.data!);
}
return const Center(
child: CircularProgressIndicator(),
);
},
);
}
switch (item.type) {
case UniversalFileType.image:
return kIsWeb
? Image.network(file.path)
: Image.file(File(file.path));
default:
return Column(
children: [
const Icon(Symbols.document_scanner),
Text(file.name),
],
);
},
);
}
} else if (item is List<int> || item is Uint8List) {
switch (item.type) {
case UniversalFileType.image:
return Image.memory(item.data);
default:
return Column(
children: [const Icon(Symbols.document_scanner)],
);
}
}
return kIsWeb
? Image.network(file.path)
: Image.file(File(file.path));
} else {
return Center(
child: Text(
'Preview is not supported for ${item.type}',
textAlign: TextAlign.center,
return Placeholder();
},
),
if (progress != null)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
);
}
} else if (item is List<int> || item is Uint8List) {
if (item.type == UniversalFileType.image) {
return Image.memory(item.data);
} else {
return Center(
child: Text(
'Preview is not supported for ${item.type}',
textAlign: TextAlign.center,
),
);
}
}
return Placeholder();
},
),
),
if (progress != null)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (progress != null)
Text(
'${progress!.toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
)
else
Text(
'uploading'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(
child: LinearProgressIndicator(
value: progress != null ? progress! / 100.0 : null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (progress != null)
Text(
'${progress!.toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
)
else
Text(
'uploading'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(
child: LinearProgressIndicator(
value:
progress != null ? progress! / 100.0 : null,
),
),
],
),
),
],
),
),
),
Positioned(
left: 8,
top: 8,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Material(
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onDelete != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.delete,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onDelete?.call();
},
),
if (onDelete != null && onMove != null)
SizedBox(
height: 26,
child: const VerticalDivider(
width: 0.3,
color: Colors.white,
thickness: 0.3,
),
).padding(horizontal: 2),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_up,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(-1);
},
),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_down,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(1);
},
),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
],
),
),
),
],
),
),
if (onRequestUpload != null)
Positioned(
top: 8,
right: 8,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onRequestUpload?.call(),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child:
(item.isOnCloud)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud,
size: 16,
color: Colors.white,
),
const Gap(8),
Text(
'On-cloud',
style: TextStyle(color: Colors.white),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud_off,
size: 16,
color: Colors.white,
),
const Gap(8),
Text(
'On-device',
style: TextStyle(color: Colors.white),
),
],
),
),
),
),
),
],
),
),
);
return ContextMenuWidget(
menuProvider:
(MenuRequest request) => Menu(
children: [
if (item.isOnDevice && item.type == UniversalFileType.image)
MenuAction(
title: 'crop'.tr(),
image: MenuImage.icon(Symbols.crop),
callback: () async {
final result = await cropImage(
context,
image: item.data,
replacePath: true,
);
if (result == null) return;
onUpdate?.call(item.copyWith(data: result));
},
),
if (item.isOnCloud)
MenuAction(
title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
await _showRenameDialog(context, ref);
},
),
if (item.isOnCloud)
MenuAction(
title: 'markAsSensitive'.tr(),
image: MenuImage.icon(Symbols.no_adult_content),
callback: () async {
await _showSensitiveDialog(context, ref);
},
),
],
),
child: contentWidget,
);
}
}

View File

@@ -0,0 +1,168 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/time.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:media_kit/media_kit.dart';
import 'package:styled_widget/styled_widget.dart';
class UniversalAudio extends ConsumerStatefulWidget {
final String uri;
final String filename;
final bool autoplay;
const UniversalAudio({
super.key,
required this.uri,
required this.filename,
this.autoplay = false,
});
@override
ConsumerState<UniversalAudio> createState() => _UniversalAudioState();
}
class _UniversalAudioState extends ConsumerState<UniversalAudio> {
Player? _player;
Duration _duration = Duration(seconds: 1);
Duration _duartionBuffered = Duration(seconds: 1);
Duration _position = Duration(seconds: 0);
bool _sliderWorking = false;
Duration _sliderPosition = Duration(seconds: 0);
void _openAudio() async {
final url = widget.uri;
MediaKit.ensureInitialized();
_player = Player();
_player!.stream.position.listen((value) {
_position = value;
if (!_sliderWorking) _sliderPosition = _position;
setState(() {});
});
_player!.stream.buffer.listen((value) {
_duartionBuffered = value;
setState(() {});
});
_player!.stream.duration.listen((value) {
_duration = value;
setState(() {});
});
String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url');
final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile(
url,
authHeaders: {'Authorization': 'AtField $token'},
);
uri = url;
} else {
uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url');
}
_player!.open(Media(uri), play: widget.autoplay);
}
@override
void initState() {
super.initState();
_openAudio();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (_player == null) {
return Center(child: CircularProgressIndicator());
}
return Card(
color: Theme.of(context).colorScheme.surfaceContainerLowest,
child: Row(
children: [
IconButton.filled(
onPressed: () {
_player!.playOrPause().then((_) {
if (mounted) setState(() {});
});
},
icon:
_player!.state.playing
? const Icon(Symbols.pause, fill: 1, color: Colors.white)
: const Icon(
Symbols.play_arrow,
fill: 1,
color: Colors.white,
),
),
const Gap(20),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child:
(_player!.state.playing || _sliderWorking)
? SizedBox(
width: double.infinity,
key: const ValueKey('playing'),
child: Text(
'${_position.formatShortDuration()} / ${_duration.formatShortDuration()}',
),
)
: SizedBox(
width: double.infinity,
key: const ValueKey('filename'),
child: Text(
widget.filename.isEmpty
? 'Audio'
: widget.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Slider(
value: _sliderPosition.inMilliseconds.toDouble(),
secondaryTrackValue:
_duartionBuffered.inMilliseconds.toDouble(),
max: _duration.inMilliseconds.toDouble(),
onChangeStart: (_) {
_sliderWorking = true;
},
onChanged: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
setState(() {});
},
onChangeEnd: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
_sliderWorking = false;
_player!.seek(_sliderPosition);
},
year2023: true,
padding: EdgeInsets.zero,
),
],
),
),
],
).padding(horizontal: 24, vertical: 16),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -13,6 +14,7 @@ import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sensitive.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
@@ -63,6 +65,27 @@ class CloudFileList extends HookConsumerWidget {
if (files.isEmpty) return const SizedBox.shrink();
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: files.first,
heroTag: heroTags.first,
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
rootNavigator: true,
);
}
},
),
);
return Container(
padding: padding,
constraints: BoxConstraints(
@@ -70,29 +93,14 @@ class CloudFileList extends HookConsumerWidget {
minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity,
),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: files.first,
heroTag: heroTags.first,
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
rootNavigator: true,
);
}
},
),
),
),
height: isAudio ? 120 : null,
child:
isAudio
? widgetItem
: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: widgetItem,
),
);
}
@@ -106,51 +114,57 @@ class CloudFileList extends HookConsumerWidget {
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: CarouselView(
padding: padding,
itemSnapping: true,
itemExtent: math.min(
MediaQuery.of(context).size.width * 0.85,
maxWidth * 0.85,
),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage: files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: CarouselView(
itemSnapping: true,
itemExtent: math.min(
math.min(
MediaQuery.of(context).size.width * 0.75,
maxWidth * 0.75,
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
640,
),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
),
),
),
);
@@ -434,9 +448,8 @@ class CloudFileZoomIn extends HookConsumerWidget {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.raw_on : Symbols.raw_off,
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
size: 24,
shadows: [
Shadow(
color: Colors.black54,
@@ -547,7 +560,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
}
}
class _CloudFileListEntry extends StatelessWidget {
class _CloudFileListEntry extends HookConsumerWidget {
final SnCloudFile file;
final String heroTag;
final bool isImage;
@@ -563,8 +576,10 @@ class _CloudFileListEntry extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
final content = Stack(
Widget build(BuildContext context, WidgetRef ref) {
final showMature = useState(false);
var content = Stack(
fit: StackFit.expand,
children: [
if (isImage)
@@ -589,10 +604,133 @@ class _CloudFileListEntry extends StatelessWidget {
],
);
if (file.sensitiveMarks.isNotEmpty) {
// Show a blurred overlay only when not revealed yet, with a smooth transition
content = Stack(
children: [
content,
// Toggle blur overlay with animation
Positioned.fill(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
layoutBuilder:
(currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
child:
showMature.value
? const SizedBox.shrink(key: ValueKey('revealed'))
: ColoredBox(
key: const ValueKey('blurred'),
color: Colors.transparent,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
child: Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.transparent),
Center(
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 280,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.warning,
color: Colors.white,
fill: 1,
size: 24,
),
const Gap(4),
Text(
file.sensitiveMarks
.map(
(e) =>
SensitiveCategory
.values[e]
.i18nKey
.tr(),
)
.join(' · '),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
Text(
'Sensitive Content',
style: TextStyle(
color: Colors.white,
fontSize: 13,
),
),
const Gap(4),
Text(
'Tap to Reveal',
style: TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
),
).padding(horizontal: 24, vertical: 16),
),
),
],
),
),
),
),
),
// When revealed (no blur), show a small control at top-left to re-enable blur
if (showMature.value)
Positioned(
top: 3,
left: 4,
child: IconButton(
iconSize: 16,
constraints: const BoxConstraints(),
icon: const Icon(Icons.visibility_off, color: Colors.white),
tooltip: 'Blur content',
onPressed: () {
showMature.value = false;
},
),
),
],
);
}
if (onTap != null) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: onTap,
onTap: () {
if (!showMature.value) {
showMature.value = true;
} else {
onTap?.call();
}
},
child: content,
);
}

View File

@@ -1,15 +1,21 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/content/audio.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'image.dart';
import 'video.dart';
class CloudFileWidget extends ConsumerWidget {
class CloudFileWidget extends HookConsumerWidget {
final SnCloudFile item;
final BoxFit fit;
final String? heroTag;
@@ -32,7 +38,7 @@ class CloudFileWidget extends ConsumerWidget {
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
final content = switch (item.mimeType?.split('/').firstOrNull) {
var content = switch (item.mimeType?.split('/').firstOrNull) {
"image" => AspectRatio(
aspectRatio: ratio,
child: UniversalImage(
@@ -45,19 +51,140 @@ class CloudFileWidget extends ConsumerWidget {
),
"video" => AspectRatio(
aspectRatio: ratio,
child: UniversalVideo(uri: uri, aspectRatio: ratio),
child: CloudVideoWidget(item: item),
),
"audio" => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
),
_ => Text('Unable render for ${item.mimeType}'),
};
if (heroTag != null) {
return Hero(tag: heroTag!, child: content);
content = Hero(tag: heroTag!, child: content);
}
return content;
}
}
class CloudVideoWidget extends HookConsumerWidget {
final SnCloudFile item;
const CloudVideoWidget({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
final open = useState(false);
final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${item.id}';
var ratio =
item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
if (open.value) {
return UniversalVideo(uri: uri, aspectRatio: ratio, autoplay: true);
}
return GestureDetector(
child: Stack(
children: [
UniversalImage(uri: '$uri?thumbnail=true'),
Positioned.fill(
child: Center(
child: const Icon(
Symbols.play_arrow,
fill: 1,
size: 32,
shadows: [
BoxShadow(
color: Colors.black54,
offset: Offset(1, 1),
spreadRadius: 8,
blurRadius: 8,
),
],
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
spacing: 8,
children: [
if (item.fileMeta?['duration'] != null)
Text(
Duration(
milliseconds:
((item.fileMeta?['duration'] as num) * 1000)
.toInt(),
).formatDuration(),
style: TextStyle(
shadows: [
BoxShadow(
color: Colors.black54,
offset: Offset(1, 1),
spreadRadius: 8,
blurRadius: 8,
),
],
),
),
if (item.fileMeta?['bit_rate'] != null)
Text(
'${int.parse(item.fileMeta?['bit_rate'] as String) ~/ 1000} Kbps',
style: TextStyle(
shadows: [
BoxShadow(
color: Colors.black54,
offset: Offset(1, 1),
spreadRadius: 8,
blurRadius: 8,
),
],
),
),
],
),
Text(
item.name,
style: TextStyle(
fontWeight: FontWeight.bold,
shadows: [
BoxShadow(
color: Colors.black54,
offset: Offset(1, 1),
spreadRadius: 8,
blurRadius: 8,
),
],
),
),
],
),
).padding(horizontal: 16, bottom: 12),
],
),
onTap: () {
open.value = true;
},
);
}
}
class CloudImageWidget extends ConsumerWidget {
final String? fileId;
final SnCloudFile? file;

View File

@@ -81,7 +81,10 @@ class MarkdownTextContent extends HookConsumerWidget {
if (url != null) {
if (url.scheme == 'solian') {
if (url.host == 'account') {
context.pushNamed('accountProfile', pathParameters: {'name': url.pathSegments[0]});
context.pushNamed(
'accountProfile',
pathParameters: {'name': url.pathSegments[0]},
);
}
return;
}
@@ -153,7 +156,7 @@ class MarkdownTextContent extends HookConsumerWidget {
),
child: UniversalImage(
uri:
'$baseUrl/stickers/lookup/${uri.pathSegments[0]}/open',
'$baseUrl/sphere/stickers/lookup/${uri.pathSegments[0]}/open',
width: size,
height: size,
fit: BoxFit.cover,

View File

@@ -0,0 +1,71 @@
// Copyright (c) Solsynth
// Sensitive content categories for content warnings, in fixed order.
enum SensitiveCategory {
language,
sexualContent,
violence,
profanity,
hateSpeech,
racism,
adultContent,
drugAbuse,
alcoholAbuse,
gambling,
selfHarm,
childAbuse,
other,
}
extension SensitiveCategoryI18n on SensitiveCategory {
/// i18n key to look up localized label
String get i18nKey => switch (this) {
SensitiveCategory.language => 'sensitiveCategories.language',
SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent',
SensitiveCategory.violence => 'sensitiveCategories.violence',
SensitiveCategory.profanity => 'sensitiveCategories.profanity',
SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech',
SensitiveCategory.racism => 'sensitiveCategories.racism',
SensitiveCategory.adultContent => 'sensitiveCategories.adultContent',
SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse',
SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse',
SensitiveCategory.gambling => 'sensitiveCategories.gambling',
SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm',
SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse',
SensitiveCategory.other => 'sensitiveCategories.other',
};
/// Optional symbol you can use alongside the label in UI
String get symbol => switch (this) {
SensitiveCategory.language => '🌐',
SensitiveCategory.sexualContent => '🔞',
SensitiveCategory.violence => '⚠️',
SensitiveCategory.profanity => '🗯️',
SensitiveCategory.hateSpeech => '🚫',
SensitiveCategory.racism => '',
SensitiveCategory.adultContent => '🍑',
SensitiveCategory.drugAbuse => '💊',
SensitiveCategory.alcoholAbuse => '🍺',
SensitiveCategory.gambling => '🎲',
SensitiveCategory.selfHarm => '🆘',
SensitiveCategory.childAbuse => '🛑',
SensitiveCategory.other => '',
};
}
/// Ordered list for UI consumption, matching enum declaration order.
const List<SensitiveCategory> kSensitiveCategoriesOrdered = [
SensitiveCategory.language,
SensitiveCategory.sexualContent,
SensitiveCategory.violence,
SensitiveCategory.profanity,
SensitiveCategory.hateSpeech,
SensitiveCategory.racism,
SensitiveCategory.adultContent,
SensitiveCategory.drugAbuse,
SensitiveCategory.alcoholAbuse,
SensitiveCategory.gambling,
SensitiveCategory.selfHarm,
SensitiveCategory.childAbuse,
SensitiveCategory.other,
];

View File

@@ -33,6 +33,7 @@ class SheetScaffold extends StatelessWidget {
);
return Container(
padding: MediaQuery.of(context).viewInsets,
constraints: BoxConstraints(
maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor,
),

View File

@@ -11,10 +11,12 @@ import 'package:media_kit_video/media_kit_video.dart';
class UniversalVideo extends ConsumerStatefulWidget {
final String uri;
final double aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio = 16 / 9,
this.autoplay = false,
});
@override
@@ -47,7 +49,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
log('[MediaPlayer] Hit cache: $url');
}
_player!.open(Media(uri), play: false);
_player!.open(Media(uri), play: widget.autoplay);
}
@override

View File

@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
class UniversalVideo extends StatelessWidget {
final String uri;
final double aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
required this.aspectRatio,
this.autoplay = false,
});
@override

View File

@@ -1,84 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
typedef ContextMenuBuilder =
Widget Function(BuildContext context, Offset offset);
class ContextMenuRegion extends HookWidget {
final Offset? mobileAnchor;
final Widget child;
final ContextMenuBuilder contextMenuBuilder;
const ContextMenuRegion({
super.key,
required this.child,
required this.contextMenuBuilder,
this.mobileAnchor,
});
@override
Widget build(BuildContext context) {
final contextMenuController = useMemoized(() => ContextMenuController());
final mobileOffset = useState<Offset?>(null);
bool canBeTouchScreen = switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
void showMenu(Offset position) {
contextMenuController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return contextMenuBuilder(context, position);
},
);
}
void hideMenu() {
contextMenuController.remove();
}
void onSecondaryTapUp(TapUpDetails details) {
showMenu(details.globalPosition);
}
void onTap() {
if (!contextMenuController.isShown) {
return;
}
hideMenu();
}
void onLongPressStart(LongPressStartDetails details) {
mobileOffset.value = details.globalPosition;
}
void onLongPress() {
assert(mobileOffset.value != null);
showMenu(mobileAnchor ?? mobileOffset.value!);
mobileOffset.value = null;
}
useEffect(() {
return () {
hideMenu();
};
}, []);
return TapRegion(
behavior: HitTestBehavior.opaque,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapUp: onSecondaryTapUp,
onTap: onTap,
onLongPress: canBeTouchScreen ? onLongPress : null,
onLongPressStart: canBeTouchScreen ? onLongPressStart : null,
child: child,
),
onTapOutside: (_) {
hideMenu();
},
);
}
}

View File

@@ -0,0 +1,728 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
/// A poll answering widget that shows one question at a time and collects answers.
///
/// Usage:
/// PollSubmit(
/// poll: poll,
/// onSubmit: (answers) {
/// // answers is Map<String, dynamic>: questionId -> answer
/// // answer types by question:
/// // - singleChoice: String optionId
/// // - multipleChoice: List<String> optionIds
/// // - yesNo: bool
/// // - rating: int (1..5)
/// // - freeText: String
/// },
/// )
class PollSubmit extends ConsumerStatefulWidget {
const PollSubmit({
super.key,
required this.poll,
required this.onSubmit,
required this.stats,
this.initialAnswers,
this.onCancel,
this.showProgress = true,
});
final SnPollWithStats poll;
/// Callback when user submits all answers. Map questionId -> answer.
final void Function(Map<String, dynamic> answers) onSubmit;
/// Optional initial answers, keyed by questionId.
final Map<String, dynamic>? initialAnswers;
final Map<String, dynamic>? stats;
/// Optional cancel callback.
final VoidCallback? onCancel;
/// Whether to show a progress indicator (e.g., "2 / N").
final bool showProgress;
@override
ConsumerState<PollSubmit> createState() => _PollSubmitState();
}
class _PollSubmitState extends ConsumerState<PollSubmit> {
late final List<SnPollQuestion> _questions;
int _index = 0;
bool _submitting = false;
/// Collected answers, keyed by questionId
late Map<String, dynamic> _answers;
/// Local controller for free text input
final TextEditingController _textController = TextEditingController();
/// Local state holders for inputs to avoid rebuilding whole list
String? _singleChoiceSelected; // optionId
final Set<String> _multiChoiceSelected = {};
bool? _yesNoSelected;
int? _ratingSelected; // 1..5
@override
void initState() {
super.initState();
// Ensure questions are ordered by `order`
_questions = [...widget.poll.questions]
..sort((a, b) => a.order.compareTo(b.order));
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
_loadCurrentIntoLocalState();
}
@override
void didUpdateWidget(covariant PollSubmit oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.poll.id != widget.poll.id) {
_index = 0;
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
_questions
..clear()
..addAll(
[...widget.poll.questions]
..sort((a, b) => a.order.compareTo(b.order)),
);
_loadCurrentIntoLocalState();
}
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
SnPollQuestion get _current => _questions[_index];
void _loadCurrentIntoLocalState() {
final q = _current;
final saved = _answers[q.id];
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
_textController.text = '';
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) _textController.text = saved;
break;
}
}
bool _isCurrentAnswered() {
final q = _current;
if (!q.isRequired) return true;
switch (q.type) {
case SnPollQuestionType.singleChoice:
return _singleChoiceSelected != null;
case SnPollQuestionType.multipleChoice:
return _multiChoiceSelected.isNotEmpty;
case SnPollQuestionType.yesNo:
return _yesNoSelected != null;
case SnPollQuestionType.rating:
return (_ratingSelected ?? 0) > 0;
case SnPollQuestionType.freeText:
return _textController.text.trim().isNotEmpty;
}
}
void _persistCurrentAnswer() {
final q = _current;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (_singleChoiceSelected == null) {
_answers.remove(q.id);
} else {
_answers[q.id] = _singleChoiceSelected!;
}
break;
case SnPollQuestionType.multipleChoice:
if (_multiChoiceSelected.isEmpty) {
_answers.remove(q.id);
} else {
_answers[q.id] = _multiChoiceSelected.toList(growable: false);
}
break;
case SnPollQuestionType.yesNo:
if (_yesNoSelected == null) {
_answers.remove(q.id);
} else {
_answers[q.id] = _yesNoSelected!;
}
break;
case SnPollQuestionType.rating:
if (_ratingSelected == null) {
_answers.remove(q.id);
} else {
_answers[q.id] = _ratingSelected!;
}
break;
case SnPollQuestionType.freeText:
final text = _textController.text.trim();
if (text.isEmpty) {
_answers.remove(q.id);
} else {
_answers[q.id] = text;
}
break;
}
}
Future<void> _submitToServer() async {
// Persist current question before final submit
_persistCurrentAnswer();
setState(() {
_submitting = true;
});
try {
final dio = ref.read(apiClientProvider);
await dio.post(
'/sphere/polls/${widget.poll.id}/answer',
data: {'answer': _answers},
);
// Only call onSubmit after server accepts
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e')));
}
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
void _next() {
if (_submitting) return;
_persistCurrentAnswer();
if (_index < _questions.length - 1) {
setState(() {
_index++;
_loadCurrentIntoLocalState();
});
} else {
// Final submit to API
_submitToServer();
}
}
void _back() {
if (_submitting) return;
_persistCurrentAnswer();
if (_index > 0) {
setState(() {
_index--;
_loadCurrentIntoLocalState();
});
} else {
// at the first question; allow cancel if provided
widget.onCancel?.call();
}
}
Widget _buildHeader(BuildContext context) {
final q = _current;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null || widget.poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null)
Text(
widget.poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.poll.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
widget.poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withOpacity(0.7),
),
),
),
],
),
),
if (widget.showProgress)
Text(
'${_index + 1} / ${_questions.length}',
style: Theme.of(context).textTheme.labelMedium,
),
Row(
children: [
Expanded(
child: Text(
q.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (q.isRequired)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'*',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
if (q.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
q.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withOpacity(0.7),
),
),
),
],
);
}
Widget _buildStats(BuildContext context, SnPollQuestion q) {
if (widget.stats == null) return const SizedBox.shrink();
final raw = widget.stats![q.id];
if (raw == null) return const SizedBox.shrink();
Widget? body;
switch (q.type) {
case SnPollQuestionType.rating:
// rating: avg score (double or int)
final avg = (raw['rating'] as num?)?.toDouble();
if (avg == null) break;
final theme = Theme.of(context);
body = Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.star, color: Colors.amber.shade600, size: 18),
const SizedBox(width: 6),
Text(
avg.toStringAsFixed(1),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
break;
case SnPollQuestionType.yesNo:
// yes/no: map {true: count, false: count}
if (raw is Map) {
final int yes =
(raw[true] is int)
? raw[true] as int
: int.tryParse('${raw[true]}') ?? 0;
final int no =
(raw[false] is int)
? raw[false] as int
: int.tryParse('${raw[false]}') ?? 0;
final total = (yes + no).clamp(0, 1 << 31);
final yesPct = total == 0 ? 0.0 : yes / total;
final noPct = total == 0 ? 0.0 : no / total;
final theme = Theme.of(context);
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BarStatRow(
label: 'Yes',
count: yes,
fraction: yesPct,
color: Colors.green.shade600,
),
const SizedBox(height: 6),
_BarStatRow(
label: 'No',
count: no,
fraction: noPct,
color: Colors.red.shade600,
),
const SizedBox(height: 4),
Text(
'Total: $total',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
break;
case SnPollQuestionType.singleChoice:
case SnPollQuestionType.multipleChoice:
// map optionId -> count
if (raw is Map) {
final options = [...?q.options]
..sort((a, b) => a.order.compareTo(b.order));
final List<_OptionCount> items = [];
int total = 0;
for (final opt in options) {
final dynamic v = raw[opt.id];
final int count = v is int ? v : int.tryParse('$v') ?? 0;
total += count;
items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
}
if (items.isNotEmpty) {
items.sort(
(a, b) => b.count.compareTo(a.count),
); // show highest first
}
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final it in items)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _BarStatRow(
label: it.label,
count: it.count,
fraction: total == 0 ? 0 : it.count / total,
),
),
if (items.isNotEmpty)
Text(
'Total: $total',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
break;
case SnPollQuestionType.freeText:
// No stats
break;
}
if (body == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stats',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
body,
],
),
),
),
);
}
Widget _buildBody(BuildContext context) {
final q = _current;
switch (q.type) {
case SnPollQuestionType.singleChoice:
return _buildSingleChoice(context, q);
case SnPollQuestionType.multipleChoice:
return _buildMultipleChoice(context, q);
case SnPollQuestionType.yesNo:
return _buildYesNo(context, q);
case SnPollQuestionType.rating:
return _buildRating(context, q);
case SnPollQuestionType.freeText:
return _buildFreeText(context, q);
}
}
Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) {
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
return Column(
children: [
for (final opt in options)
RadioListTile<String>(
value: opt.id,
groupValue: _singleChoiceSelected,
onChanged: (val) => setState(() => _singleChoiceSelected = val),
title: Text(opt.label),
subtitle: opt.description != null ? Text(opt.description!) : null,
),
],
);
}
Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) {
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
return Column(
children: [
for (final opt in options)
CheckboxListTile(
value: _multiChoiceSelected.contains(opt.id),
onChanged: (val) {
setState(() {
if (val == true) {
_multiChoiceSelected.add(opt.id);
} else {
_multiChoiceSelected.remove(opt.id);
}
});
},
title: Text(opt.label),
subtitle: opt.description != null ? Text(opt.description!) : null,
),
],
);
}
Widget _buildYesNo(BuildContext context, SnPollQuestion q) {
return Row(
children: [
Expanded(
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('Yes')),
ButtonSegment(value: false, label: Text('No')),
],
selected: _yesNoSelected == null ? {} : {_yesNoSelected!},
onSelectionChanged: (sel) {
setState(() {
_yesNoSelected = sel.isEmpty ? null : sel.first;
});
},
multiSelectionEnabled: false,
),
),
],
);
}
Widget _buildRating(BuildContext context, SnPollQuestion q) {
const max = 5;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(max, (i) {
final value = i + 1;
final selected = (_ratingSelected ?? 0) >= value;
return IconButton(
icon: Icon(
selected ? Icons.star : Icons.star_border,
color: selected ? Colors.amber : null,
),
onPressed: () {
setState(() {
_ratingSelected = value;
});
},
);
}),
);
}
Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
return TextField(
controller: _textController,
maxLines: 6,
decoration: const InputDecoration(border: OutlineInputBorder()),
);
}
Widget _buildNavBar(BuildContext context) {
final isLast = _index == _questions.length - 1;
final canProceed = _isCurrentAnswered() && !_submitting;
return Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.arrow_back),
label: Text(_index == 0 ? 'Cancel' : 'Back'),
onPressed: _submitting ? null : _back,
),
const Spacer(),
FilledButton.icon(
icon:
_submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(isLast ? Icons.check : Icons.arrow_forward),
label: Text(isLast ? 'Submit' : 'Next'),
onPressed: canProceed ? _next : null,
),
],
);
}
@override
Widget build(BuildContext context) {
if (_questions.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
const SizedBox(height: 12),
_AnimatedStep(
key: ValueKey(_current.id),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildBody(context), _buildStats(context, _current)],
),
),
const SizedBox(height: 16),
_buildNavBar(context),
],
);
}
}
class _OptionCount {
final String id;
final String label;
final int count;
const _OptionCount({
required this.id,
required this.label,
required this.count,
});
}
class _BarStatRow extends StatelessWidget {
const _BarStatRow({
required this.label,
required this.count,
required this.fraction,
this.color,
});
final String label;
final int count;
final double fraction;
final Color? color;
@override
Widget build(BuildContext context) {
final barColor = color ?? Theme.of(context).colorScheme.primary;
final bgColor = Theme.of(
context,
).colorScheme.surfaceVariant.withOpacity(0.6);
final fg =
(fraction.isNaN || fraction.isInfinite)
? 0.0
: fraction.clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final filled = width * fg;
return Stack(
children: [
Container(
height: 8,
width: width,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(999),
),
),
Container(
height: 8,
width: filled,
decoration: BoxDecoration(
color: barColor,
borderRadius: BorderRadius.circular(999),
),
),
],
);
},
),
],
);
}
}
/// Simple fade/slide transition between questions.
class _AnimatedStep extends StatelessWidget {
const _AnimatedStep({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0.1, 0),
end: Offset.zero,
).animate(anim);
final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: child,
);
}
}

View File

@@ -0,0 +1,204 @@
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/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'compose_link_attachments.g.dart';
@riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<SnCloudFile> {
@override
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final queryParameters = {'offset': offset, 'take': take};
final response = await client.get(
'/drive/files/me',
queryParameters: queryParameters,
);
final List<SnCloudFile> items =
(response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList();
final total = int.parse(response.headers.value('X-Total') ?? '0');
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
class ComposeLinkAttachment extends HookConsumerWidget {
const ComposeLinkAttachment({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final idController = useTextEditingController();
final errorMessage = useState<String?>(null);
return SheetScaffold(
heightFactor: 0.6,
titleText: 'linkAttachment'.tr(),
child: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
tabs: [
Tab(text: 'attachmentsRecentUploads'.tr()),
Tab(text: 'attachmentsManualInput'.tr()),
],
),
Expanded(
child: TabBarView(
children: [
PagingHelperView(
provider: cloudFileListNotifierProvider,
futureRefreshable: cloudFileListNotifierProvider.future,
notifierRefreshable: cloudFileListNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.only(top: 8),
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index];
final itemType =
item.mimeType?.split('/').firstOrNull;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: SizedBox(
height: 48,
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: item),
'audio' =>
const Icon(
Symbols.audio_file,
fill: 1,
).center(),
'video' =>
const Icon(
Symbols.video_file,
fill: 1,
).center(),
_ =>
const Icon(
Symbols.body_system,
fill: 1,
).center(),
},
),
),
title:
item.name.isEmpty
? Text('untitled').tr().italic()
: Text(item.name),
onTap: () {
Navigator.pop(context, item);
},
);
},
),
),
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: idController,
decoration: InputDecoration(
labelText: 'fileId'.tr(),
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(16),
InkWell(
child: Text(
'fileIdLinkHint',
).tr().fontSize(13).opacity(0.85),
onTap: () {
launchUrlString('https://fs.solian.app');
},
).padding(horizontal: 14),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Symbols.add),
label: Text('add'.tr()),
onPressed: () async {
final fileId = idController.text.trim();
if (fileId.isEmpty) {
errorMessage.value = 'fileIdCannotBeEmpty'.tr();
return;
}
try {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/drive/files/$fileId/info',
);
final SnCloudFile cloudFile =
SnCloudFile.fromJson(response.data);
if (context.mounted) {
Navigator.of(context).pop(cloudFile);
}
} catch (e) {
errorMessage.value = 'failedToFetchFile'.tr(
args: [e.toString()],
);
}
},
),
),
],
).padding(horizontal: 24, vertical: 24),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'compose_link_attachments.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$cloudFileListNotifierHash() =>
r'e2c8a076a9e635c7b43a87d00f78775427ba6334';
/// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier)
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
CloudFileListNotifier,
CursorPagingData<SnCloudFile>
>.internal(
CloudFileListNotifier.new,
name: r'cloudFileListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$cloudFileListNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
// 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

View File

@@ -0,0 +1,201 @@
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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/publisher.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/post/publishers_modal.dart';
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
class ComposePollSheet extends HookConsumerWidget {
/// Optional publisher name to filter polls and prefill creation.
final String? pubName;
const ComposePollSheet({super.key, this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedPublisher = useState<String?>(pubName);
final isPushing = useState(false);
final errorText = useState<String?>(null);
return SheetScaffold(
heightFactor: 0.6,
titleText: 'poll'.tr(),
child: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
tabs: [
Tab(text: 'pollsRecent'.tr()),
Tab(text: 'pollCreateNew'.tr()),
],
),
Expanded(
child: TabBarView(
children: [
// Link/Select existing poll list
PagingHelperView(
provider: pollListNotifierProvider(pubName),
futureRefreshable: pollListNotifierProvider(pubName).future,
notifierRefreshable:
pollListNotifierProvider(pubName).notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final poll = data.items[index];
return ListTile(
leading: const Icon(Symbols.how_to_vote, fill: 1),
title: Text(poll.title ?? 'untitled'.tr()),
subtitle: _buildPollSubtitle(poll),
onTap: () {
Navigator.of(context).pop(poll);
},
);
},
),
),
// Create new poll and return it
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'pollCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
ListTile(
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: '@${selectedPublisher.value}',
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: 'selected'.tr(),
),
leading: const Icon(Symbols.account_circle),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder: (context) => const PublisherModal(),
);
if (picked != null) {
try {
final name = picked.name;
if (name.isNotEmpty) {
selectedPublisher.value = name;
errorText.value = null;
}
} catch (_) {
// ignore
}
}
},
),
if (errorText.value != null)
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
),
child: Text(
errorText.value!,
style: TextStyle(color: Colors.red[700]),
),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
icon:
isPushing.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Symbols.add_circle),
label: Text('create'.tr()),
onPressed:
isPushing.value
? null
: () async {
final pub = selectedPublisher.value ?? '';
if (pub.isEmpty) {
errorText.value =
'publisherCannotBeEmpty'.tr();
return;
}
errorText.value = null;
isPushing.value = true;
// Push to creatorPollNew route and await result
final result = await GoRouter.of(
context,
).push<SnPoll>(
'/creators/$pub/polls/new',
);
if (result == null) {
isPushing.value = false;
return;
}
if (!context.mounted) return;
// Return created poll to caller of this bottom sheet
Navigator.of(context).pop(result);
},
),
),
],
).padding(horizontal: 24, vertical: 24),
),
],
),
),
],
),
),
);
}
Widget? _buildPollSubtitle(SnPoll poll) {
try {
final SnPoll dyn = poll;
final List<SnPollQuestion>? options = dyn.questions;
if (options == null || options.isEmpty) return null;
final preview = options.take(3).map((e) => e.title).join(' · ');
if (preview.trim().isEmpty) return null;
return Text(preview);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:async';
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.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/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart' hide Amplitude;
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'package:waveform_flutter/waveform_flutter.dart';
class ComposeRecorder extends HookConsumerWidget {
const ComposeRecorder({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recording = useState(false);
final recordingStartAt = useState<DateTime?>(null);
final recordingDuration = useState<Duration>(Duration(seconds: 0));
StreamSubscription? originalAmplitude;
StreamController<Amplitude> amplitudeStream = StreamController();
var record = AudioRecorder();
final resultPath = useState<String?>(null);
Future<void> startRecord() async {
recording.value = true;
// Check and request permission if needed
final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp';
final uuid = const Uuid().v4().substring(0, 8);
if (!await record.hasPermission()) return;
const recordConfig = RecordConfig(
encoder: AudioEncoder.pcm16bits,
autoGain: true,
echoCancel: true,
noiseSuppress: true,
);
resultPath.value = '$tempPath/solar-network-record-$uuid.m4a';
await record.start(recordConfig, path: resultPath.value!);
recordingStartAt.value = DateTime.now();
originalAmplitude = record
.onAmplitudeChanged(const Duration(milliseconds: 100))
.listen((value) async {
amplitudeStream.add(
Amplitude(current: value.current, max: value.max),
);
recordingDuration.value = DateTime.now().difference(
recordingStartAt.value!,
);
});
}
useEffect(() {
return () {
// Called when widget is unmounted
log('[Recorder] Clean up!');
originalAmplitude?.cancel();
amplitudeStream.close();
record.dispose();
};
}, []);
Future<void> stopRecord() async {
recording.value = false;
await record.pause();
final newResult = await record.stop();
await record.cancel();
if (newResult != null) resultPath.value = newResult;
if (context.mounted) Navigator.of(context).pop(resultPath.value);
}
Future<void> addExistingAudio() async {
var result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['mp3', 'm4a', 'wav', 'aac', 'flac', 'ogg', 'opus'],
onFileLoading: (status) {
if (!context.mounted) return;
if (status == FilePickerStatus.picking) {
showLoadingModal(context);
} else {
hideLoadingModal(context);
}
},
);
if (result == null || result.count == 0) return;
if (context.mounted) Navigator.of(context).pop(result.files.first.path);
}
return SheetScaffold(
titleText: "recordAudio".tr(),
actions: [
IconButton(
onPressed: addExistingAudio,
icon: const Icon(Symbols.upload),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Gap(32),
Text(
recordingDuration.value.formatShortDuration(),
).fontSize(20).bold().padding(bottom: 8),
SizedBox(
height: 120,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AnimatedWaveList(stream: amplitudeStream.stream),
),
),
),
).padding(horizontal: 24),
const Gap(12),
IconButton.filled(
onPressed: recording.value ? stopRecord : startRecord,
iconSize: 32,
icon:
recording.value
? const Icon(Symbols.stop, fill: 1, color: Colors.white)
: const Icon(
Symbols.play_arrow,
fill: 1,
color: Colors.white,
),
),
],
),
);
}
}

View File

@@ -3,7 +3,6 @@ import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
@@ -14,15 +13,14 @@ import 'package:island/pods/network.dart';
import 'package:island/services/file.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/widgets/post/compose_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_recorder.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'dart:async';
import 'dart:developer';
import 'package:textfield_tags/textfield_tags.dart';
class ComposeState {
final TextEditingController titleController;
final TextEditingController descriptionController;
@@ -36,6 +34,8 @@ class ComposeState {
StringTagController categoriesController;
final String draftId;
int postType;
// Linked poll id for this compose session (nullable)
final ValueNotifier<String?> pollId;
Timer? _autoSaveTimer;
ComposeState({
@@ -51,7 +51,8 @@ class ComposeState {
required this.categoriesController,
required this.draftId,
this.postType = 0,
});
String? pollId,
}) : pollId = ValueNotifier<String?>(pollId);
void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel();
@@ -114,6 +115,8 @@ class ComposeLogic {
categoriesController: categoriesController,
draftId: id,
postType: postType,
// initialize without poll by default
pollId: null,
);
}
@@ -141,6 +144,7 @@ class ComposeLogic {
categoriesController: categoriesController,
draftId: draft.id,
postType: postType,
pollId: null,
);
}
@@ -399,93 +403,64 @@ class ComposeLogic {
];
}
static Future<void> recordAudioMedia(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
final audioPath = await showModalBottomSheet<String?>(
context: context,
builder: (context) => ComposeRecorder(),
);
if (audioPath == null) return;
state.attachments.value = [
...state.attachments.value,
UniversalFile(
data: XFile(audioPath, mimeType: 'audio/m4a'),
type: UniversalFileType.audio,
),
];
}
static Future<void> linkAttachment(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
final TextEditingController idController = TextEditingController();
String? errorMessage;
await showModalBottomSheet(
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return SheetScaffold(
titleText: 'linkAttachment'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: idController,
decoration: InputDecoration(
labelText: 'fileId'.tr(),
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage,
border: OutlineInputBorder(),
),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Symbols.add),
label: Text('add'.tr()),
onPressed: () async {
final fileId = idController.text.trim();
if (fileId.isEmpty) {
setState(() {
errorMessage = 'fileIdCannotBeEmpty'.tr();
});
return;
}
try {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/drive/files/$fileId/info',
);
final SnCloudFile cloudFile = SnCloudFile.fromJson(
response.data,
);
state.attachments.value = [
...state.attachments.value,
UniversalFile(
data: cloudFile,
type: switch (cloudFile.mimeType
?.split('/')
.firstOrNull) {
'image' => UniversalFileType.image,
'video' => UniversalFileType.video,
'audio' => UniversalFileType.audio,
_ => UniversalFileType.file,
},
),
];
if (context.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
setState(() {
errorMessage = 'failedToFetchFile'.tr(
args: [e.toString()],
);
});
}
},
),
),
],
).padding(horizontal: 24, vertical: 24),
);
},
);
},
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => ComposeLinkAttachment(),
);
if (cloudFile == null) return;
state.attachments.value = [
...state.attachments.value,
UniversalFile(
data: cloudFile,
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
'image' => UniversalFileType.image,
'video' => UniversalFileType.video,
'audio' => UniversalFileType.audio,
_ => UniversalFileType.file,
},
isLink: true,
),
];
}
static void updateAttachment(
ComposeState state,
UniversalFile value,
int index,
) {
state.attachments.value =
state.attachments.value.mapIndexed((idx, ele) {
if (idx == index) return value;
return ele;
}).toList();
}
static Future<void> uploadAttachment(
@@ -561,7 +536,7 @@ class ComposeLogic {
int index,
) async {
final attachment = state.attachments.value[index];
if (attachment.isOnCloud) {
if (attachment.isOnCloud && !attachment.isLink) {
final client = ref.watch(apiClientProvider);
await client.delete('/drive/files/${attachment.data.id}');
}
@@ -587,6 +562,27 @@ class ComposeLogic {
);
}
static Future<void> pickPoll(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
if (state.pollId.value != null) {
state.pollId.value = null;
return;
}
final poll = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
);
if (poll == null) return;
state.pollId.value = poll.id;
}
static Future<void> performAction(
WidgetRef ref,
ComposeState state,
@@ -645,16 +641,15 @@ class ComposeLogic {
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tagsController.getTags,
'categories': state.categoriesController.getTags,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
};
// Send request
await client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(
headers: {'X-Pub': state.currentPublisher.value?.name},
method: isNewPost ? 'POST' : 'PATCH',
),
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Delete draft after successful submission
@@ -737,5 +732,6 @@ class ComposeLogic {
state.currentPublisher.dispose();
state.tagsController.dispose();
state.categoriesController.dispose();
state.pollId.dispose();
}
}

View File

@@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget {
ComposeLogic.pickVideoMedia(ref, state);
}
void addAudio() {
ComposeLogic.recordAudioMedia(ref, state, context);
}
void linkAttachment() {
ComposeLogic.linkAttachment(ref, state, context);
}
@@ -32,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
ComposeLogic.saveDraft(ref, state);
}
void pickPoll() {
ComposeLogic.pickPoll(ref, state, context);
}
void showDraftManager() {
showModalBottomSheet(
context: context,
@@ -72,12 +80,37 @@ class ComposeToolbar extends HookConsumerWidget {
icon: const Icon(Symbols.videocam),
color: colorScheme.primary,
),
IconButton(
onPressed: addAudio,
tooltip: 'addAudio'.tr(),
icon: const Icon(Symbols.mic),
color: colorScheme.primary,
),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
tooltip: 'linkAttachment'.tr(),
color: colorScheme.primary,
),
// Poll button with visual state when a poll is linked
ListenableBuilder(
listenable: state.pollId,
builder: (context, _) {
return IconButton(
onPressed: pickPoll,
icon: const Icon(Symbols.how_to_vote),
tooltip: 'poll'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.pollId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
const Spacer(),
if (originalPost == null && state.isEmpty)
IconButton(

View File

@@ -8,8 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/embed.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/translate.dart';
import 'package:island/pods/userinfo.dart';
@@ -22,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/embed/link.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/poll/poll_submit.dart';
import 'package:island/widgets/post/post_replies_sheet.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:island/widgets/share/share_sheet.dart';
@@ -179,7 +180,7 @@ class PostActionableItem extends HookConsumerWidget {
callback: () {
showShareSheetLink(
context: context,
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
link: 'https://solian.app/posts/${item.id}',
title: 'sharePost'.tr(),
toSystem: true,
);
@@ -410,7 +411,9 @@ class PostItem extends HookConsumerWidget {
if (!isFullPost && item.type == 1)
Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -458,6 +461,24 @@ class PostItem extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if ((item.title?.isNotEmpty ?? false) ||
(item.description?.isNotEmpty ?? false))
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
],
).padding(bottom: 4),
MarkdownTextContent(
content:
item.isTruncated ? '${item.content!}...' : item.content!,
@@ -523,23 +544,36 @@ class PostItem extends HookConsumerWidget {
),
),
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map(
(embedData) => EmbedLinkWidget(
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
margin: EdgeInsets.only(
top: 4,
bottom: 4,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
...((item.meta!['embeds'] as List<dynamic>).map(
(embedData) => switch (embedData['type']) {
'link' => EmbedLinkWidget(
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
)),
margin: EdgeInsets.only(
top: 4,
bottom: 4,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
),
'poll' => Card(
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
child: PollSubmit(
initialAnswers: embedData['poll']?['user_answer']?['answer'],
stats: embedData['poll']?['stats'],
poll: SnPollWithStats.fromJson(embedData['poll']),
onSubmit: (_) {},
).padding(horizontal: 16, vertical: 12),
),
_ => Text('Unable show embed: ${embedData['type']}'),
},
)),
if (isShowReference)
_buildReferencePost(context, item, renderingPadding),
if (item.repliesCount > 0 && isEmbedReply)
@@ -578,7 +612,7 @@ Widget _buildReferencePost(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
),
child: Column(
@@ -846,22 +880,22 @@ class PostReplyPreview extends HookConsumerWidget {
: featuredReply!.when(
data:
(value) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8,
children: [
ProfilePictureWidget(
file: value!.publisher.picture,
file: value?.publisher.picture,
radius: 12,
).padding(top: 4),
if (value.content?.isNotEmpty ?? false)
if (value?.content?.isNotEmpty ?? false)
Expanded(
child: MarkdownTextContent(content: value.content!),
child: MarkdownTextContent(content: value!.content!),
)
else
Expanded(
child: Text(
'postHasAttachments',
).plural(value.attachments.length),
).plural(value?.attachments.length ?? 0),
),
],
),
@@ -894,7 +928,9 @@ class PostReplyPreview extends HookConsumerWidget {
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
border: Border.all(color: Theme.of(context).dividerColor),
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(

View File

@@ -27,9 +27,13 @@ class PublisherCard extends ConsumerWidget {
Widget card = Card(
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: () {
context.pushNamed('publisherProfile', pathParameters: {'name': publisher.name});
context.pushNamed(
'publisherProfile',
pathParameters: {'name': publisher.name},
);
},
child: AspectRatio(
aspectRatio: 16 / 7,
@@ -79,7 +83,7 @@ class PublisherCard extends ConsumerWidget {
color: Colors.white,
fontWeight: FontWeight.bold,
),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],

View File

@@ -29,9 +29,13 @@ class RealmCard extends ConsumerWidget {
Widget card = Card(
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: () {
context.pushNamed('realmDetail', pathParameters: {'slug': realm.slug});
context.pushNamed(
'realmDetail',
pathParameters: {'slug': realm.slug},
);
},
child: AspectRatio(
aspectRatio: 16 / 7,
@@ -82,7 +86,7 @@ class RealmCard extends ConsumerWidget {
color: Colors.white,
fontWeight: FontWeight.bold,
),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],

View File

@@ -284,7 +284,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
// Send message to chat room
await apiClient.post(
'/chat/${chatRoom.id}/messages',
'/sphere/chat/${chatRoom.id}/messages',
data: {'content': content, 'attachments_id': attachmentIds, 'meta': {}},
);
@@ -328,12 +328,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to share to chat: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
showSnackBar('Failed to share to chat: $e');
}
} finally {
if (mounted) {
@@ -405,151 +400,137 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
children: [
// Share options with keyboard avoidance
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Content preview
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'contentToShare'.tr(),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
_ContentPreview(content: widget.content),
],
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Content preview
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
// Quick actions row (horizontally scrollable)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'quickActions'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'contentToShare'.tr(),
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_CompactShareOption(
icon: Symbols.post_add,
title: 'post'.tr(),
onTap: _isLoading ? null : _shareToPost,
),
),
const SizedBox(height: 8),
_ContentPreview(content: widget.content),
],
),
),
// Quick actions row (horizontally scrollable)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'quickActions'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_CompactShareOption(
icon: Symbols.post_add,
title: 'post'.tr(),
onTap: _isLoading ? null : _shareToPost,
),
const SizedBox(width: 12),
_CompactShareOption(
icon: Symbols.content_copy,
title: 'copy'.tr(),
onTap: _isLoading ? null : _copyToClipboard,
),
if (widget.toSystem) ...<Widget>[
const SizedBox(width: 12),
_CompactShareOption(
icon: Symbols.content_copy,
title: 'copy'.tr(),
onTap: _isLoading ? null : _copyToClipboard,
icon: Symbols.share,
title: 'share'.tr(),
onTap: _isLoading ? null : _shareToSystem,
),
if (widget.toSystem) ...<Widget>[
const SizedBox(width: 12),
_CompactShareOption(
icon: Symbols.share,
title: 'share'.tr(),
onTap: _isLoading ? null : _shareToSystem,
),
],
],
),
],
),
],
),
),
],
),
),
const SizedBox(height: 24),
const SizedBox(height: 24),
// Chat section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'sendToChat'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
// Chat section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'sendToChat'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
),
const SizedBox(height: 12),
// Additional message input
Container(
margin: const EdgeInsets.only(bottom: 16),
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'addAdditionalMessage'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
// Additional message input
Container(
margin: const EdgeInsets.only(bottom: 16),
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'addAdditionalMessage'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
maxLines: 3,
minLines: 1,
enabled: !_isLoading,
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
maxLines: 3,
minLines: 1,
enabled: !_isLoading,
),
),
_ChatRoomsList(
onChatSelected:
_isLoading ? null : _shareToSpecificChat,
),
],
),
_ChatRoomsList(
onChatSelected:
_isLoading ? null : _shareToSpecificChat,
),
],
),
),
const SizedBox(height: 16),
],
),
const SizedBox(height: 16),
],
),
),
),

View File

@@ -28,6 +28,7 @@ class WebArticleCard extends StatelessWidget {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _onTap(context),
@@ -92,7 +93,7 @@ class WebArticleCard extends StatelessWidget {
fontWeight: FontWeight.bold,
height: 1.3,
),
maxLines: showDetails ? 3 : 2,
maxLines: showDetails ? 3 : 1,
overflow: TextOverflow.ellipsis,
),
if (showDetails &&
@@ -124,6 +125,8 @@ class WebArticleCard extends StatelessWidget {
fontSize: 9,
color: Colors.white70,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),

View File

@@ -573,10 +573,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
sha256: "13ba4e627ef24503a465d1d61b32596ce10eb6b8903678d362a528f9939b4aa8"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
version: "10.2.1"
file_selector_linux:
dependency: transitive
description:
@@ -1097,10 +1097,10 @@ packages:
dependency: "direct main"
description:
name: google_fonts
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8
url: "https://pub.dev"
source: hosted
version: "6.2.1"
version: "6.3.0"
graphs:
dependency: transitive
description:
@@ -2572,6 +2572,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
waveform_flutter:
dependency: "direct main"
description:
name: waveform_flutter
sha256: "08c9e98d4cf119428d8b3c083ed42c11c468623eaffdf30420ae38e36662922a"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
web:
dependency: "direct main"
description:

View File

@@ -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.1.0+116
version: 3.1.0+117
environment:
sdk: ^3.7.2
@@ -53,7 +53,7 @@ dependencies:
flutter_highlight: ^0.7.0
uuid: ^4.5.1
url_launcher: ^6.3.2
google_fonts: ^6.2.1
google_fonts: ^6.3.0
gap: ^3.0.1
cached_network_image: ^3.4.1
web: ^1.1.1
@@ -73,7 +73,7 @@ dependencies:
git: https://github.com/LittleSheep2Code/tus_client.git
cross_file: ^0.3.4+2
image_picker: ^1.1.2
file_picker: ^10.2.0
file_picker: ^10.2.1
riverpod_annotation: ^2.6.1
image_picker_platform_interface: ^2.10.1
image_picker_android: ^0.8.12+24
@@ -132,6 +132,7 @@ dependencies:
html2md: ^1.3.2
flutter_typeahead: ^5.2.0
flutter_langdetect: ^0.0.2
waveform_flutter: ^1.2.0
dev_dependencies:
flutter_test: