Compare commits
21 Commits
a0d8c1a9b3
...
3.1.0+116
| Author | SHA1 | Date | |
|---|---|---|---|
| 8956723ac5 | |||
| ccc3ac415e | |||
| 8c47a59b80 | |||
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 | |||
| a706f127b6 | |||
| 680ece0b6a | |||
| b976c6ed37 | |||
| 6ae6b132de | |||
| 95aec7c95b | |||
| edd760fbcb | |||
| ba269dbbb8 | |||
| 1aa45dd9f1 | |||
| 92685d7410 | |||
| c8e351514d | |||
| f3900825e3 | |||
| 2cc6652f75 | |||
| 4d4409de2e | |||
| e1286c797f | |||
| bec037622f |
@@ -59,7 +59,6 @@ dependencies {
|
|||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
implementation("com.github.bumptech.glide:glide:4.16.0")
|
implementation("com.github.bumptech.glide:glide:4.16.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<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.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
@@ -116,14 +117,6 @@
|
|||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="dev.solsynth.solian.provider"
|
android:authorities="dev.solsynth.solian.provider"
|
||||||
@@ -150,4 +143,4 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -146,9 +146,12 @@
|
|||||||
"edited": "Edited",
|
"edited": "Edited",
|
||||||
"addVideo": "Add video",
|
"addVideo": "Add video",
|
||||||
"addPhoto": "Add photo",
|
"addPhoto": "Add photo",
|
||||||
|
"addAudio": "Add audio",
|
||||||
"addFile": "Add file",
|
"addFile": "Add file",
|
||||||
|
"recordAudio": "Record Audio",
|
||||||
"linkAttachment": "Link Attachment",
|
"linkAttachment": "Link Attachment",
|
||||||
"fileIdCannotBeEmpty": "File ID cannot be empty",
|
"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: {}",
|
"failedToFetchFile": "Failed to fetch file: {}",
|
||||||
"createDirectMessage": "Send new DM",
|
"createDirectMessage": "Send new DM",
|
||||||
"gotoDirectMessage": "Go to DM",
|
"gotoDirectMessage": "Go to DM",
|
||||||
@@ -731,5 +734,32 @@
|
|||||||
"reconnecting": "Reconnecting",
|
"reconnecting": "Reconnecting",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"repliesLoadMore": "Load more replies"
|
"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: {}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -379,8 +379,6 @@
|
|||||||
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
|
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
|
||||||
);
|
);
|
||||||
name = SolianBroadcastExtension;
|
name = SolianBroadcastExtension;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = SolianBroadcastExtension;
|
productName = SolianBroadcastExtension;
|
||||||
productReference = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */;
|
productReference = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
@@ -599,14 +597,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -664,14 +658,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ sealed class UniversalFile with _$UniversalFile {
|
|||||||
const factory UniversalFile({
|
const factory UniversalFile({
|
||||||
required dynamic data,
|
required dynamic data,
|
||||||
required UniversalFileType type,
|
required UniversalFileType type,
|
||||||
|
@Default(false) bool isLink,
|
||||||
}) = _UniversalFile;
|
}) = _UniversalFile;
|
||||||
|
|
||||||
factory UniversalFile.fromJson(Map<String, dynamic> json) =>
|
factory UniversalFile.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -41,6 +42,7 @@ sealed class SnCloudFile with _$SnCloudFile {
|
|||||||
required String? description,
|
required String? description,
|
||||||
required Map<String, dynamic>? fileMeta,
|
required Map<String, dynamic>? fileMeta,
|
||||||
required Map<String, dynamic>? userMeta,
|
required Map<String, dynamic>? userMeta,
|
||||||
|
@Default([]) List<int> sensitiveMarks,
|
||||||
required String? mimeType,
|
required String? mimeType,
|
||||||
required String? hash,
|
required String? hash,
|
||||||
required int size,
|
required int size,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$UniversalFile {
|
mixin _$UniversalFile {
|
||||||
|
|
||||||
dynamic get data; UniversalFileType get type;
|
dynamic get data; UniversalFileType get type; bool get isLink;
|
||||||
/// Create a copy of UniversalFile
|
/// Create a copy of UniversalFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
dynamic data, UniversalFileType type
|
dynamic data, UniversalFileType type, bool isLink
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -65,11 +65,12 @@ class _$UniversalFileCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of UniversalFile
|
/// Create a copy of UniversalFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
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 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) {
|
switch (_that) {
|
||||||
case _UniversalFile() when $default != null:
|
case _UniversalFile() when $default != null:
|
||||||
return $default(_that.data,_that.type);case _:
|
return $default(_that.data,_that.type,_that.isLink);case _:
|
||||||
return orElse();
|
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) {
|
switch (_that) {
|
||||||
case _UniversalFile():
|
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`
|
/// 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) {
|
switch (_that) {
|
||||||
case _UniversalFile() when $default != null:
|
case _UniversalFile() when $default != null:
|
||||||
return $default(_that.data,_that.type);case _:
|
return $default(_that.data,_that.type,_that.isLink);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -204,11 +205,12 @@ return $default(_that.data,_that.type);case _:
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _UniversalFile extends UniversalFile {
|
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);
|
factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
|
||||||
|
|
||||||
@override final dynamic data;
|
@override final dynamic data;
|
||||||
@override final UniversalFileType type;
|
@override final UniversalFileType type;
|
||||||
|
@override@JsonKey() final bool isLink;
|
||||||
|
|
||||||
/// Create a copy of UniversalFile
|
/// Create a copy of UniversalFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -223,16 +225,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
dynamic data, UniversalFileType type
|
dynamic data, UniversalFileType type, bool isLink
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -260,11 +262,12 @@ class __$UniversalFileCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of UniversalFile
|
/// Create a copy of UniversalFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_UniversalFile(
|
||||||
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
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 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
|
/// @nodoc
|
||||||
mixin _$SnCloudFile {
|
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
|
/// Create a copy of SnCloudFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -288,16 +291,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SnCloudFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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,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,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 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>?,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?,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 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
|
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) {
|
switch (_that) {
|
||||||
case _SnCloudFile() when $default != null:
|
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();
|
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) {
|
switch (_that) {
|
||||||
case _SnCloudFile():
|
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`
|
/// 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) {
|
switch (_that) {
|
||||||
case _SnCloudFile() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -475,7 +479,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _SnCloudFile implements SnCloudFile {
|
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);
|
factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json);
|
||||||
|
|
||||||
@override final String id;
|
@override final String id;
|
||||||
@@ -499,6 +503,13 @@ class _SnCloudFile implements SnCloudFile {
|
|||||||
return EqualUnmodifiableMapView(value);
|
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? mimeType;
|
||||||
@override final String? hash;
|
@override final String? hash;
|
||||||
@override final int size;
|
@override final int size;
|
||||||
@@ -521,16 +532,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SnCloudFile
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_SnCloudFile(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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,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,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 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>?,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?,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 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
|
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
|
|||||||
_UniversalFile(
|
_UniversalFile(
|
||||||
data: json['data'],
|
data: json['data'],
|
||||||
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
|
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
|
||||||
|
isLink: json['is_link'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
|
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'data': instance.data,
|
'data': instance.data,
|
||||||
'type': _$UniversalFileTypeEnumMap[instance.type]!,
|
'type': _$UniversalFileTypeEnumMap[instance.type]!,
|
||||||
|
'is_link': instance.isLink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$UniversalFileTypeEnumMap = {
|
const _$UniversalFileTypeEnumMap = {
|
||||||
@@ -31,6 +33,11 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
|
|||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
fileMeta: json['file_meta'] as Map<String, dynamic>?,
|
fileMeta: json['file_meta'] as Map<String, dynamic>?,
|
||||||
userMeta: json['user_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?,
|
mimeType: json['mime_type'] as String?,
|
||||||
hash: json['hash'] as String?,
|
hash: json['hash'] as String?,
|
||||||
size: (json['size'] as num).toInt(),
|
size: (json['size'] as num).toInt(),
|
||||||
@@ -54,6 +61,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
|
|||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'file_meta': instance.fileMeta,
|
'file_meta': instance.fileMeta,
|
||||||
'user_meta': instance.userMeta,
|
'user_meta': instance.userMeta,
|
||||||
|
'sensitive_marks': instance.sensitiveMarks,
|
||||||
'mime_type': instance.mimeType,
|
'mime_type': instance.mimeType,
|
||||||
'hash': instance.hash,
|
'hash': instance.hash,
|
||||||
'size': instance.size,
|
'size': instance.size,
|
||||||
|
|||||||
92
lib/models/poll.dart
Normal file
92
lib/models/poll.dart
Normal 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
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
133
lib/models/poll.g.dart
Normal 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,
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ part of 'call.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$callNotifierHash() => r'333a1cd566a339644c83932e15dae03f1c5cc24b';
|
String _$callNotifierHash() => r'18fb807f067eecd3ea42631c1426c3e5f1fb4280';
|
||||||
|
|
||||||
/// See also [CallNotifier].
|
/// See also [CallNotifier].
|
||||||
@ProviderFor(CallNotifier)
|
@ProviderFor(CallNotifier)
|
||||||
|
|||||||
@@ -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/posts/post_manage_list.dart';
|
||||||
import 'package:island/screens/creators/stickers/stickers.dart';
|
import 'package:island/screens/creators/stickers/stickers.dart';
|
||||||
import 'package:island/screens/creators/stickers/pack_detail.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/publishers.dart';
|
||||||
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
||||||
import 'package:island/screens/creators/webfeed/webfeed_edit.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/compose.dart';
|
||||||
import 'package:island/screens/posts/post_detail.dart';
|
import 'package:island/screens/posts/post_detail.dart';
|
||||||
import 'package:island/screens/posts/pub_profile.dart';
|
import 'package:island/screens/posts/pub_profile.dart';
|
||||||
@@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return CreatorPostListScreen(pubName: name);
|
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(
|
GoRoute(
|
||||||
name: 'creatorStickers',
|
name: 'creatorStickers',
|
||||||
path: '/creators/:name/stickers',
|
path: '/creators/:name/stickers',
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
showLoadingModal(context);
|
showLoadingModal(context);
|
||||||
final client = ref.watch(apiClientProvider);
|
final client = ref.watch(apiClientProvider);
|
||||||
await client.post('/subscriptions/${membership.identifier}/cancel');
|
await client.post('/id/subscriptions/${membership.identifier}/cancel');
|
||||||
ref.invalidate(accountStellarSubscriptionProvider);
|
ref.invalidate(accountStellarSubscriptionProvider);
|
||||||
ref.read(userInfoProvider.notifier).fetchUser();
|
ref.read(userInfoProvider.notifier).fetchUser();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -603,7 +603,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
showLoadingModal(context);
|
showLoadingModal(context);
|
||||||
final resp = await client.post(
|
final resp = await client.post(
|
||||||
'/subscriptions',
|
'/id/subscriptions',
|
||||||
data: {
|
data: {
|
||||||
'identifier': tierId,
|
'identifier': tierId,
|
||||||
'payment_method': 'solian.wallet',
|
'payment_method': 'solian.wallet',
|
||||||
@@ -615,7 +615,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
final subscription = SnWalletSubscription.fromJson(resp.data);
|
final subscription = SnWalletSubscription.fromJson(resp.data);
|
||||||
if (subscription.status == 1) return;
|
if (subscription.status == 1) return;
|
||||||
final orderResp = await client.post(
|
final orderResp = await client.post(
|
||||||
'/subscriptions/${subscription.identifier}/order',
|
'/id/subscriptions/${subscription.identifier}/order',
|
||||||
);
|
);
|
||||||
final order = SnWalletOrder.fromJson(orderResp.data);
|
final order = SnWalletOrder.fromJson(orderResp.data);
|
||||||
|
|
||||||
@@ -633,7 +633,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (paidOrder != null) {
|
if (paidOrder != null) {
|
||||||
await client.post(
|
await client.post(
|
||||||
'/subscriptions/order/handle',
|
'/id/subscriptions/order/handle',
|
||||||
data: {'order_id': paidOrder.id},
|
data: {'order_id': paidOrder.id},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import 'package:island/widgets/account/status.dart';
|
|||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.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:island/widgets/safety/abuse_report_helper.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
part 'profile.g.dart';
|
part 'profile.g.dart';
|
||||||
@@ -264,66 +266,89 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
AccountName(account: data, style: TextStyle(fontSize: 20)),
|
AccountName(account: data, style: TextStyle(fontSize: 20)),
|
||||||
const Gap(6),
|
const Gap(6),
|
||||||
Text('@${data.name}').fontSize(14).opacity(0.85),
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'@${data.name}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
).fontSize(14).opacity(0.85),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AccountStatusWidget(uname: name, padding: EdgeInsets.zero),
|
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 accountProfileDetail(SnAccount data) => Column(
|
Widget accountProfileBio(SnAccount data) => Card(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
spacing: 24,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (buildSubcolumn(data).isNotEmpty)
|
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
|
||||||
Column(
|
if (data.profile.bio.isEmpty)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text('descriptionNone').tr().italic()
|
||||||
spacing: 2,
|
else
|
||||||
children: buildSubcolumn(data),
|
MarkdownTextContent(
|
||||||
),
|
content: data.profile.bio,
|
||||||
Column(
|
linesMargin: EdgeInsets.zero,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('bio').tr().bold(),
|
|
||||||
Text(
|
|
||||||
data.profile.bio.isEmpty
|
|
||||||
? 'descriptionNone'.tr()
|
|
||||||
: data.profile.bio,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
).padding(horizontal: 24, vertical: 20),
|
||||||
if (data.profile.timeZone.isNotEmpty)
|
);
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Widget accountProfileDetail(SnAccount data) => Card(
|
||||||
children: [
|
child: Column(
|
||||||
Text('timeZone').tr().bold(),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Row(
|
spacing: 24,
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
children: [
|
||||||
textBaseline: TextBaseline.alphabetic,
|
if (buildSubcolumn(data).isNotEmpty)
|
||||||
spacing: 6,
|
Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(data.profile.timeZone),
|
spacing: 2,
|
||||||
Text(
|
children: buildSubcolumn(data),
|
||||||
getTzInfo(
|
),
|
||||||
data.profile.timeZone,
|
if (data.profile.timeZone.isNotEmpty)
|
||||||
).$2.formatCustomGlobal('HH:mm'),
|
Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
getTzInfo(data.profile.timeZone).$1.formatOffsetLocal(),
|
Text('timeZone').tr().bold(),
|
||||||
).fontSize(11),
|
Row(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}',
|
textBaseline: TextBaseline.alphabetic,
|
||||||
).fontSize(11).opacity(0.75),
|
spacing: 6,
|
||||||
],
|
children: [
|
||||||
),
|
Text(data.profile.timeZone),
|
||||||
],
|
Text(
|
||||||
),
|
getTzInfo(
|
||||||
],
|
data.profile.timeZone,
|
||||||
).padding(horizontal: 24);
|
).$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(
|
Widget accountAction(SnAccount data) => Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -390,7 +415,7 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 16),
|
),
|
||||||
Row(
|
Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
@@ -498,11 +523,19 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
progress: data.profile.levelingProgress,
|
progress: data.profile.levelingProgress,
|
||||||
),
|
),
|
||||||
if (data.profile.verification != null)
|
if (data.profile.verification != null)
|
||||||
VerificationStatusCard(
|
Card(
|
||||||
mark: data.profile.verification!,
|
child: VerificationStatusCard(
|
||||||
|
mark: data.profile.verification!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20),
|
).padding(horizontal: 4, top: 8),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: accountProfileBio(data).padding(top: 4),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: accountProfileDetail(data),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -510,10 +543,7 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverGap(24),
|
||||||
child: accountProfileDetail(data),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (user.value != null)
|
if (user.value != null)
|
||||||
SliverToBoxAdapter(child: accountAction(data)),
|
SliverToBoxAdapter(child: accountAction(data)),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -521,14 +551,15 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
child: FortuneGraphWidget(
|
child: FortuneGraphWidget(
|
||||||
events: accountEvents,
|
events: accountEvents,
|
||||||
eventCalanderUser: data.name,
|
eventCalanderUser: data.name,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
).padding(all: 8),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
).padding(horizontal: 24)
|
||||||
: CustomScrollView(
|
: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
@@ -579,34 +610,40 @@ class AccountProfileScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
spacing: 12,
|
|
||||||
children: [
|
children: [
|
||||||
LevelingProgressCard(
|
LevelingProgressCard(
|
||||||
level: data.profile.level,
|
level: data.profile.level,
|
||||||
experience: data.profile.experience,
|
experience: data.profile.experience,
|
||||||
progress: data.profile.levelingProgress,
|
progress: data.profile.levelingProgress,
|
||||||
),
|
).padding(top: 8, horizontal: 8, bottom: 4),
|
||||||
if (data.profile.verification != null)
|
if (data.profile.verification != null)
|
||||||
VerificationStatusCard(
|
Card(
|
||||||
mark: data.profile.verification!,
|
child: VerificationStatusCard(
|
||||||
),
|
mark: data.profile.verification!,
|
||||||
|
),
|
||||||
|
).padding(horizontal: 4),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
SliverToBoxAdapter(child: accountProfileDetail(data)),
|
|
||||||
|
|
||||||
if (user.value != null)
|
|
||||||
SliverToBoxAdapter(child: accountAction(data)),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: accountProfileBio(data).padding(horizontal: 4),
|
||||||
children: [
|
),
|
||||||
FortuneGraphWidget(
|
SliverToBoxAdapter(
|
||||||
events: accountEvents,
|
child: accountProfileDetail(
|
||||||
eventCalanderUser: data.name,
|
data,
|
||||||
),
|
).padding(horizontal: 4),
|
||||||
],
|
),
|
||||||
).padding(all: 8),
|
if (user.value != null)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: accountAction(data).padding(horizontal: 4),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Card(
|
||||||
|
child: FortuneGraphWidget(
|
||||||
|
events: accountEvents,
|
||||||
|
eventCalanderUser: data.name,
|
||||||
|
),
|
||||||
|
).padding(horizontal: 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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',
|
: '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(
|
initialUrlRequest: URLRequest(
|
||||||
url: WebUri('$serverUrl/auth/login/${widget.provider}'),
|
url: WebUri('$serverUrl/id/auth/login/${widget.provider}'),
|
||||||
headers: {
|
headers: {
|
||||||
if (token?.token.isNotEmpty ?? false)
|
if (token?.token.isNotEmpty ?? false)
|
||||||
'Authorization': 'AtField ${token!.token}',
|
'Authorization': 'AtField ${token!.token}',
|
||||||
@@ -120,7 +120,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
|
|||||||
final queryParams = url.queryParameters;
|
final queryParams = url.queryParameters;
|
||||||
|
|
||||||
// Check if we're on the token page
|
// Check if we're on the token page
|
||||||
if (path.endsWith('/id/auth/callback')) {
|
if (path.endsWith('/auth/callback')) {
|
||||||
// Extract token from URL
|
// Extract token from URL
|
||||||
final challenge = queryParams['challenge'];
|
final challenge = queryParams['challenge'];
|
||||||
// Return the token and close the webview
|
// Return the token and close the webview
|
||||||
@@ -205,7 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (currentUrl != null) {
|
if (currentUrl != null) {
|
||||||
Clipboard.setData(ClipboardData(text: currentUrl!));
|
Clipboard.setData(ClipboardData(text: currentUrl!));
|
||||||
showSnackBar('copyToClipboard');
|
showSnackBar('copyToClipboard'.tr());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1070,6 +1070,10 @@ class _ChatInput extends HookConsumerWidget {
|
|||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
onRequestUpload: () => onUploadAttachment(idx),
|
onRequestUpload: () => onUploadAttachment(idx),
|
||||||
onDelete: () => onDeleteAttachment(idx),
|
onDelete: () => onDeleteAttachment(idx),
|
||||||
|
onUpdate: (value) {
|
||||||
|
attachments[idx] = value;
|
||||||
|
onAttachmentsChanged(attachments);
|
||||||
|
},
|
||||||
onMove: (delta) => onMoveAttachment(idx, delta),
|
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
title: Text('publisherMembers').tr(),
|
title: Text('publisherMembers').tr(),
|
||||||
|
|||||||
175
lib/screens/creators/poll/poll_list.dart
Normal file
175
lib/screens/creators/poll/poll_list.dart
Normal 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.
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/screens/creators/poll/poll_list.g.dart
Normal file
179
lib/screens/creators/poll/poll_list.g.dart
Normal 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
|
||||||
1094
lib/screens/poll/poll_editor.dart
Normal file
1094
lib/screens/poll/poll_editor.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -238,6 +238,8 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
onRequestUpload:
|
onRequestUpload:
|
||||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
||||||
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
|
onUpdate:
|
||||||
|
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||||
onMove: (delta) {
|
onMove: (delta) {
|
||||||
state.attachments.value = ComposeLogic.moveAttachment(
|
state.attachments.value = ComposeLogic.moveAttachment(
|
||||||
state.attachments.value,
|
state.attachments.value,
|
||||||
@@ -265,6 +267,9 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
||||||
onDelete:
|
onDelete:
|
||||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
|
onUpdate:
|
||||||
|
(value) =>
|
||||||
|
ComposeLogic.updateAttachment(state, value, idx),
|
||||||
onMove: (delta) {
|
onMove: (delta) {
|
||||||
state.attachments.value = ComposeLogic.moveAttachment(
|
state.attachments.value = ComposeLogic.moveAttachment(
|
||||||
state.attachments.value,
|
state.attachments.value,
|
||||||
|
|||||||
@@ -308,6 +308,13 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
|||||||
state,
|
state,
|
||||||
idx,
|
idx,
|
||||||
),
|
),
|
||||||
|
onUpdate:
|
||||||
|
(value) =>
|
||||||
|
ComposeLogic.updateAttachment(
|
||||||
|
state,
|
||||||
|
value,
|
||||||
|
idx,
|
||||||
|
),
|
||||||
onDelete:
|
onDelete:
|
||||||
() => ComposeLogic.deleteAttachment(
|
() => ComposeLogic.deleteAttachment(
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'package:island/widgets/account/status.dart';
|
|||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.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:island/widgets/post/post_list.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
@@ -233,25 +234,36 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
).padding(horizontal: 24, top: 24);
|
).padding(horizontal: 24, top: 24);
|
||||||
|
|
||||||
Widget publisherVerificationWidget(SnPublisher data) => Card(
|
Widget publisherBadgesWidget(SnPublisher data) =>
|
||||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
(badges.value?.isNotEmpty ?? false)
|
||||||
child: Column(
|
? Card(
|
||||||
children: [
|
child: BadgeList(
|
||||||
if (badges.value?.isNotEmpty ?? false)
|
badges: badges.value!,
|
||||||
BadgeList(badges: badges.value!).padding(top: 16),
|
).padding(horizontal: 26, vertical: 20),
|
||||||
if (data.verification != null)
|
).padding(horizontal: 4)
|
||||||
VerificationStatusCard(mark: data.verification!),
|
: const SizedBox.shrink();
|
||||||
],
|
|
||||||
),
|
|
||||||
).padding(top: 16);
|
|
||||||
|
|
||||||
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),
|
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('bio').tr().bold().padding(bottom: 2),
|
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
|
||||||
Text(data.bio.isEmpty ? 'descriptionNone'.tr() : data.bio),
|
if (data.bio.isEmpty)
|
||||||
|
Text('descriptionNone').tr().italic()
|
||||||
|
else
|
||||||
|
MarkdownTextContent(
|
||||||
|
content: data.bio,
|
||||||
|
linesMargin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, vertical: 16),
|
).padding(horizontal: 20, vertical: 16),
|
||||||
);
|
);
|
||||||
@@ -325,8 +337,9 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
publisherBasisWidget(data),
|
publisherBasisWidget(data),
|
||||||
|
publisherBadgesWidget(data),
|
||||||
publisherVerificationWidget(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(
|
SliverToBoxAdapter(
|
||||||
child: publisherVerificationWidget(data),
|
child: publisherVerificationWidget(data),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: publisherDetailWidget(data)),
|
SliverToBoxAdapter(child: publisherBioWidget(data)),
|
||||||
SliverPostList(pubName: name),
|
SliverPostList(pubName: name),
|
||||||
SliverGap(MediaQuery.of(context).padding.bottom + 16),
|
SliverGap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Future<XFile?> cropImage(
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required XFile image,
|
required XFile image,
|
||||||
List<CropAspectRatio?>? allowedAspectRatios,
|
List<CropAspectRatio?>? allowedAspectRatios,
|
||||||
|
bool replacePath = false,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await showMaterialImageCropper(
|
final result = await showMaterialImageCropper(
|
||||||
context,
|
context,
|
||||||
@@ -34,7 +35,7 @@ Future<XFile?> cropImage(
|
|||||||
croppedFile.dispose();
|
croppedFile.dispose();
|
||||||
return XFile.fromData(
|
return XFile.fromData(
|
||||||
croppedBytes.buffer.asUint8List(),
|
croppedBytes.buffer.asUint8List(),
|
||||||
path: image.path,
|
path: !replacePath ? image.path : null,
|
||||||
mimeType: image.mimeType,
|
mimeType: image.mimeType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,33 @@ extension DurationFormatter on Duration {
|
|||||||
return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
|
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() {
|
String formatOffset() {
|
||||||
final isNegative = inMicroseconds < 0;
|
final isNegative = inMicroseconds < 0;
|
||||||
final positiveDuration = isNegative ? -this : this;
|
final positiveDuration = isNegative ? -this : this;
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ class BadgeItem extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (template?.color ?? Colors.blue).withOpacity(0.1),
|
color: (template?.color ?? Colors.blue).withOpacity(0.2),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
template?.icon ?? Icons.stars,
|
template?.icon ?? Icons.stars,
|
||||||
color: template?.color ?? Colors.orange,
|
color: template?.color ?? Colors.blue,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class RestorePurchaseSheet extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
await client.post(
|
await client.post(
|
||||||
'/subscriptions/order/restore/${selectedProvider.value!}',
|
'/id/subscriptions/order/restore/${selectedProvider.value!}',
|
||||||
data: {'order_id': orderIdController.text.trim()},
|
data: {'order_id': orderIdController.text.trim()},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class AccountStatusCreationWidget extends HookConsumerWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
builder:
|
builder:
|
||||||
(context) => AccountStatusCreationSheet(
|
(context) => AccountStatusCreationSheet(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:island/pods/network.dart';
|
|||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/widgets/account/status.dart';
|
import 'package:island/widgets/account/status.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
class AccountStatusCreationSheet extends HookConsumerWidget {
|
class AccountStatusCreationSheet extends HookConsumerWidget {
|
||||||
@@ -71,178 +72,145 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return SheetScaffold(
|
||||||
constraints: BoxConstraints(
|
heightFactor: 0.6,
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
titleText:
|
||||||
),
|
initialStatus == null ? 'statusCreate'.tr() : 'statusUpdate'.tr(),
|
||||||
child: Column(
|
actions: [
|
||||||
children: [
|
TextButton.icon(
|
||||||
Padding(
|
onPressed:
|
||||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
submitting.value
|
||||||
child: Row(
|
? null
|
||||||
children: [
|
: () {
|
||||||
Text(
|
submitStatus();
|
||||||
initialStatus == null
|
},
|
||||||
? 'statusCreate'.tr()
|
icon: const Icon(Symbols.upload),
|
||||||
: 'statusUpdate'.tr(),
|
label: Text(initialStatus == null ? 'create' : 'update').tr(),
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: ButtonStyle(
|
||||||
fontWeight: FontWeight.w600,
|
visualDensity: VisualDensity(
|
||||||
letterSpacing: -0.5,
|
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(
|
onTapOutside:
|
||||||
onPressed:
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
submitting.value
|
),
|
||||||
? null
|
const SizedBox(height: 24),
|
||||||
: () {
|
Text(
|
||||||
submitStatus();
|
'statusAttitude'.tr(),
|
||||||
},
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
icon: const Icon(Symbols.upload),
|
),
|
||||||
label: Text(initialStatus == null ? 'create' : 'update').tr(),
|
const SizedBox(height: 8),
|
||||||
style: ButtonStyle(
|
SegmentedButton(
|
||||||
visualDensity: VisualDensity(
|
segments: [
|
||||||
horizontal: VisualDensity.minimumDensity,
|
ButtonSegment(
|
||||||
),
|
value: 0,
|
||||||
foregroundColor: WidgetStatePropertyAll(
|
icon: const Icon(Symbols.sentiment_satisfied),
|
||||||
Theme.of(context).colorScheme.onSurface,
|
label: Text('attitudePositive'.tr()),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (initialStatus != null)
|
ButtonSegment(
|
||||||
IconButton(
|
value: 1,
|
||||||
icon: const Icon(Symbols.delete),
|
icon: const Icon(Symbols.sentiment_stressed),
|
||||||
onPressed: submitting.value ? null : () => clearStatus(),
|
label: Text('attitudeNeutral'.tr()),
|
||||||
style: IconButton.styleFrom(
|
),
|
||||||
minimumSize: const Size(36, 36),
|
ButtonSegment(
|
||||||
),
|
value: 2,
|
||||||
),
|
icon: const Icon(Symbols.sentiment_sad),
|
||||||
IconButton(
|
label: Text('attitudeNegative'.tr()),
|
||||||
icon: const Icon(Symbols.close),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
selected: {attitude.value},
|
||||||
|
onSelectionChanged: (Set<int> newSelection) {
|
||||||
|
attitude.value = newSelection.first;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
const Gap(12),
|
||||||
const Divider(height: 1),
|
SwitchListTile(
|
||||||
Expanded(
|
title: Text('statusInvisible'.tr()),
|
||||||
child: SingleChildScrollView(
|
subtitle: Text('statusInvisibleDescription'.tr()),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
value: isInvisible.value,
|
||||||
child: Column(
|
contentPadding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onChanged: (bool value) {
|
||||||
children: [
|
isInvisible.value = value;
|
||||||
const Gap(24),
|
},
|
||||||
TextField(
|
),
|
||||||
controller: labelController,
|
SwitchListTile(
|
||||||
decoration: InputDecoration(
|
title: Text('statusNotDisturb'.tr()),
|
||||||
labelText: 'statusLabel'.tr(),
|
subtitle: Text('statusNotDisturbDescription'.tr()),
|
||||||
border: const OutlineInputBorder(
|
value: isNotDisturb.value,
|
||||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
contentPadding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
),
|
onChanged: (bool value) {
|
||||||
),
|
isNotDisturb.value = value;
|
||||||
onTapOutside:
|
},
|
||||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 24),
|
Text(
|
||||||
Text(
|
'statusClearTime'.tr(),
|
||||||
'statusAttitude'.tr(),
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
SegmentedButton(
|
title: Text(
|
||||||
segments: [
|
clearedAt.value == null
|
||||||
ButtonSegment(
|
? 'statusNoAutoClear'.tr()
|
||||||
value: 0,
|
: DateFormat.yMMMd().add_jm().format(clearedAt.value!),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class CallParticipantCard extends HookConsumerWidget {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Slider(
|
child: Slider(
|
||||||
|
max: 2,
|
||||||
value: volumeSliderValue.value,
|
value: volumeSliderValue.value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
volumeSliderValue.value = value;
|
volumeSliderValue.value = value;
|
||||||
@@ -52,9 +53,12 @@ class CallParticipantCard extends HookConsumerWidget {
|
|||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(16),
|
||||||
Text(
|
SizedBox(
|
||||||
'${(volumeSliderValue.value * 100).toStringAsFixed(0)}%',
|
width: 40,
|
||||||
|
child: Text(
|
||||||
|
'${(volumeSliderValue.value * 100).toStringAsFixed(0)}%',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cross_file/cross_file.dart';
|
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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/file.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/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.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 UniversalFile item;
|
||||||
final double? progress;
|
final double? progress;
|
||||||
final Function(int)? onMove;
|
final Function(int)? onMove;
|
||||||
final Function? onDelete;
|
final Function? onDelete;
|
||||||
final Function? onInsert;
|
final Function? onInsert;
|
||||||
|
final Function(UniversalFile)? onUpdate;
|
||||||
final Function? onRequestUpload;
|
final Function? onRequestUpload;
|
||||||
|
|
||||||
const AttachmentPreview({
|
const AttachmentPreview({
|
||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
@@ -24,11 +96,170 @@ class AttachmentPreview extends StatelessWidget {
|
|||||||
this.onRequestUpload,
|
this.onRequestUpload,
|
||||||
this.onMove,
|
this.onMove,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
|
this.onUpdate,
|
||||||
this.onInsert,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var ratio =
|
var ratio =
|
||||||
item.isOnCloud
|
item.isOnCloud
|
||||||
? (item.data.fileMeta?['ratio'] is num
|
? (item.data.fileMeta?['ratio'] is num
|
||||||
@@ -37,217 +268,265 @@ class AttachmentPreview extends StatelessWidget {
|
|||||||
: 1.0;
|
: 1.0;
|
||||||
if (ratio == 0) ratio = 1.0;
|
if (ratio == 0) ratio = 1.0;
|
||||||
|
|
||||||
return AspectRatio(
|
final contentWidget = ClipRRect(
|
||||||
aspectRatio: ratio,
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: ClipRRect(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(8),
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Stack(
|
child: Column(
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
child: Builder(
|
children: [
|
||||||
builder: (context) {
|
ClipRRect(
|
||||||
if (item.isOnCloud) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
return CloudFileWidget(item: item.data);
|
child: Container(
|
||||||
} else if (item.data is XFile) {
|
color: Colors.black.withOpacity(0.5),
|
||||||
if (item.type == UniversalFileType.image) {
|
child: Material(
|
||||||
final file = item.data as XFile;
|
color: Colors.transparent,
|
||||||
if (file.path.isEmpty) {
|
child: Row(
|
||||||
return FutureBuilder<Uint8List>(
|
mainAxisSize: MainAxisSize.min,
|
||||||
future: file.readAsBytes(),
|
children: [
|
||||||
builder: (context, snapshot) {
|
if (onDelete != null)
|
||||||
if (snapshot.hasData) {
|
InkWell(
|
||||||
return Image.memory(snapshot.data!);
|
borderRadius: BorderRadius.circular(8),
|
||||||
}
|
child: Icon(
|
||||||
return const Center(
|
item.isLink ? Symbols.link_off : Symbols.delete,
|
||||||
child: CircularProgressIndicator(),
|
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
|
return Placeholder();
|
||||||
? Image.network(file.path)
|
},
|
||||||
: Image.file(File(file.path));
|
),
|
||||||
} else {
|
if (progress != null)
|
||||||
return Center(
|
Positioned.fill(
|
||||||
child: Text(
|
child: Container(
|
||||||
'Preview is not supported for ${item.type}',
|
color: Colors.black.withOpacity(0.3),
|
||||||
textAlign: TextAlign.center,
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 40,
|
||||||
|
vertical: 16,
|
||||||
),
|
),
|
||||||
);
|
child: Column(
|
||||||
}
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
} else if (item is List<int> || item is Uint8List) {
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
if (item.type == UniversalFileType.image) {
|
children: [
|
||||||
return Image.memory(item.data);
|
if (progress != null)
|
||||||
} else {
|
Text(
|
||||||
return Center(
|
'${progress!.toStringAsFixed(2)}%',
|
||||||
child: Text(
|
style: TextStyle(color: Colors.white),
|
||||||
'Preview is not supported for ${item.type}',
|
)
|
||||||
textAlign: TextAlign.center,
|
else
|
||||||
),
|
Text(
|
||||||
);
|
'uploading'.tr(),
|
||||||
}
|
style: TextStyle(color: Colors.white),
|
||||||
}
|
),
|
||||||
return Placeholder();
|
Gap(6),
|
||||||
},
|
Center(
|
||||||
),
|
child: LinearProgressIndicator(
|
||||||
),
|
value:
|
||||||
if (progress != null)
|
progress != null ? progress! / 100.0 : 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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
lib/widgets/content/audio.dart
Normal file
168
lib/widgets/content/audio.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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/pods/network.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.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:island/widgets/content/sheet.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
@@ -63,6 +65,27 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
if (files.isEmpty) return const SizedBox.shrink();
|
if (files.isEmpty) return const SizedBox.shrink();
|
||||||
if (files.length == 1) {
|
if (files.length == 1) {
|
||||||
final isImage = files.first.mimeType?.startsWith('image') ?? false;
|
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(
|
return Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@@ -70,29 +93,14 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
minWidth: minWidth ?? 0,
|
minWidth: minWidth ?? 0,
|
||||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||||
),
|
),
|
||||||
child: AspectRatio(
|
height: isAudio ? 120 : null,
|
||||||
aspectRatio: calculateAspectRatio(),
|
child:
|
||||||
child: ClipRRect(
|
isAudio
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
? widgetItem
|
||||||
child: _CloudFileListEntry(
|
: AspectRatio(
|
||||||
file: files.first,
|
aspectRatio: calculateAspectRatio(),
|
||||||
heroTag: heroTags.first,
|
child: widgetItem,
|
||||||
isImage: isImage,
|
),
|
||||||
disableZoomIn: disableZoomIn,
|
|
||||||
onTap: () {
|
|
||||||
if (!isImage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!disableZoomIn) {
|
|
||||||
context.pushTransparentRoute(
|
|
||||||
CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
|
|
||||||
rootNavigator: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,51 +114,57 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
|
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: calculateAspectRatio(),
|
aspectRatio: calculateAspectRatio(),
|
||||||
child: CarouselView(
|
child: Padding(
|
||||||
padding: padding,
|
padding: padding ?? EdgeInsets.zero,
|
||||||
itemSnapping: true,
|
child: CarouselView(
|
||||||
itemExtent: math.min(
|
itemSnapping: true,
|
||||||
MediaQuery.of(context).size.width * 0.85,
|
itemExtent: math.min(
|
||||||
maxWidth * 0.85,
|
math.min(
|
||||||
),
|
MediaQuery.of(context).size.width * 0.75,
|
||||||
shape: RoundedRectangleBorder(
|
maxWidth * 0.75,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
640,
|
||||||
onTap: (i) {
|
),
|
||||||
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
|
shape: RoundedRectangleBorder(
|
||||||
return;
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
}
|
),
|
||||||
if (!disableZoomIn) {
|
children: [
|
||||||
context.pushTransparentRoute(
|
for (var i = 0; i < files.length; i++)
|
||||||
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
|
Stack(
|
||||||
rootNavigator: true,
|
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;
|
showOriginal.value = !showOriginal.value;
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
showOriginal.value ? Symbols.raw_on : Symbols.raw_off,
|
showOriginal.value ? Symbols.hd : Symbols.sd,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 24,
|
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
@@ -547,7 +560,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CloudFileListEntry extends StatelessWidget {
|
class _CloudFileListEntry extends HookConsumerWidget {
|
||||||
final SnCloudFile file;
|
final SnCloudFile file;
|
||||||
final String heroTag;
|
final String heroTag;
|
||||||
final bool isImage;
|
final bool isImage;
|
||||||
@@ -563,8 +576,10 @@ class _CloudFileListEntry extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final content = Stack(
|
final showMature = useState(false);
|
||||||
|
|
||||||
|
var content = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (isImage)
|
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) {
|
if (onTap != null) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
onTap: onTap,
|
onTap: () {
|
||||||
|
if (!showMature.value) {
|
||||||
|
showMature.value = true;
|
||||||
|
} else {
|
||||||
|
onTap?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: content,
|
child: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@@ -5,13 +8,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
|
import 'package:island/widgets/content/audio.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
import 'image.dart';
|
import 'image.dart';
|
||||||
import 'video.dart';
|
import 'video.dart';
|
||||||
|
|
||||||
class CloudFileWidget extends ConsumerWidget {
|
class CloudFileWidget extends HookConsumerWidget {
|
||||||
final SnCloudFile item;
|
final SnCloudFile item;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
final String? heroTag;
|
final String? heroTag;
|
||||||
@@ -34,7 +38,7 @@ class CloudFileWidget extends ConsumerWidget {
|
|||||||
? item.fileMeta!['ratio'].toDouble()
|
? item.fileMeta!['ratio'].toDouble()
|
||||||
: 1.0;
|
: 1.0;
|
||||||
if (ratio == 0) ratio = 1.0;
|
if (ratio == 0) ratio = 1.0;
|
||||||
final content = switch (item.mimeType?.split('/').firstOrNull) {
|
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
"image" => AspectRatio(
|
"image" => AspectRatio(
|
||||||
aspectRatio: ratio,
|
aspectRatio: ratio,
|
||||||
child: UniversalImage(
|
child: UniversalImage(
|
||||||
@@ -49,11 +53,19 @@ class CloudFileWidget extends ConsumerWidget {
|
|||||||
aspectRatio: ratio,
|
aspectRatio: ratio,
|
||||||
child: CloudVideoWidget(item: item),
|
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}'),
|
_ => Text('Unable render for ${item.mimeType}'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (heroTag != null) {
|
if (heroTag != null) {
|
||||||
return Hero(tag: heroTag!, child: content);
|
content = Hero(tag: heroTag!, child: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
|
|||||||
71
lib/widgets/content/sensitive.dart
Normal file
71
lib/widgets/content/sensitive.dart
Normal 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,
|
||||||
|
];
|
||||||
@@ -33,6 +33,7 @@ class SheetScaffold extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
padding: MediaQuery.of(context).viewInsets,
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor,
|
maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
728
lib/widgets/poll/poll_submit.dart
Normal file
728
lib/widgets/poll/poll_submit.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/widgets/post/compose_link_attachments.dart
Normal file
204
lib/widgets/post/compose_link_attachments.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/widgets/post/compose_link_attachments.g.dart
Normal file
31
lib/widgets/post/compose_link_attachments.g.dart
Normal 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
|
||||||
201
lib/widgets/post/compose_poll.dart
Normal file
201
lib/widgets/post/compose_poll.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
lib/widgets/post/compose_recorder.dart
Normal file
147
lib/widgets/post/compose_recorder.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:gap/gap.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:island/models/file.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/file.dart';
|
||||||
import 'package:island/services/compose_storage_db.dart';
|
import 'package:island/services/compose_storage_db.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/sheet.dart';
|
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:island/widgets/post/compose_poll.dart';
|
||||||
|
import 'package:island/widgets/post/compose_recorder.dart';
|
||||||
import 'package:pasteboard/pasteboard.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:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:textfield_tags/textfield_tags.dart';
|
|
||||||
|
|
||||||
class ComposeState {
|
class ComposeState {
|
||||||
final TextEditingController titleController;
|
final TextEditingController titleController;
|
||||||
final TextEditingController descriptionController;
|
final TextEditingController descriptionController;
|
||||||
@@ -36,6 +34,8 @@ class ComposeState {
|
|||||||
StringTagController categoriesController;
|
StringTagController categoriesController;
|
||||||
final String draftId;
|
final String draftId;
|
||||||
int postType;
|
int postType;
|
||||||
|
// Linked poll id for this compose session (nullable)
|
||||||
|
final ValueNotifier<String?> pollId;
|
||||||
Timer? _autoSaveTimer;
|
Timer? _autoSaveTimer;
|
||||||
|
|
||||||
ComposeState({
|
ComposeState({
|
||||||
@@ -51,7 +51,8 @@ class ComposeState {
|
|||||||
required this.categoriesController,
|
required this.categoriesController,
|
||||||
required this.draftId,
|
required this.draftId,
|
||||||
this.postType = 0,
|
this.postType = 0,
|
||||||
});
|
String? pollId,
|
||||||
|
}) : pollId = ValueNotifier<String?>(pollId);
|
||||||
|
|
||||||
void startAutoSave(WidgetRef ref) {
|
void startAutoSave(WidgetRef ref) {
|
||||||
_autoSaveTimer?.cancel();
|
_autoSaveTimer?.cancel();
|
||||||
@@ -114,6 +115,8 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: id,
|
draftId: id,
|
||||||
postType: postType,
|
postType: postType,
|
||||||
|
// initialize without poll by default
|
||||||
|
pollId: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +144,7 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
postType: postType,
|
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(
|
static Future<void> linkAttachment(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ComposeState state,
|
ComposeState state,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) async {
|
||||||
final TextEditingController idController = TextEditingController();
|
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
|
||||||
String? errorMessage;
|
|
||||||
|
|
||||||
await showModalBottomSheet(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext dialogContext) {
|
useRootNavigator: true,
|
||||||
return StatefulBuilder(
|
isScrollControlled: true,
|
||||||
builder: (context, setState) {
|
builder: (context) => ComposeLinkAttachment(),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
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(
|
static Future<void> uploadAttachment(
|
||||||
@@ -561,7 +536,7 @@ class ComposeLogic {
|
|||||||
int index,
|
int index,
|
||||||
) async {
|
) async {
|
||||||
final attachment = state.attachments.value[index];
|
final attachment = state.attachments.value[index];
|
||||||
if (attachment.isOnCloud) {
|
if (attachment.isOnCloud && !attachment.isLink) {
|
||||||
final client = ref.watch(apiClientProvider);
|
final client = ref.watch(apiClientProvider);
|
||||||
await client.delete('/drive/files/${attachment.data.id}');
|
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(
|
static Future<void> performAction(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ComposeState state,
|
ComposeState state,
|
||||||
@@ -645,16 +641,15 @@ class ComposeLogic {
|
|||||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||||
'tags': state.tagsController.getTags,
|
'tags': state.tagsController.getTags,
|
||||||
'categories': state.categoriesController.getTags,
|
'categories': state.categoriesController.getTags,
|
||||||
|
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
await client.request(
|
await client.request(
|
||||||
endpoint,
|
endpoint,
|
||||||
|
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||||
data: payload,
|
data: payload,
|
||||||
options: Options(
|
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||||
headers: {'X-Pub': state.currentPublisher.value?.name},
|
|
||||||
method: isNewPost ? 'POST' : 'PATCH',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete draft after successful submission
|
// Delete draft after successful submission
|
||||||
@@ -737,5 +732,6 @@ class ComposeLogic {
|
|||||||
state.currentPublisher.dispose();
|
state.currentPublisher.dispose();
|
||||||
state.tagsController.dispose();
|
state.tagsController.dispose();
|
||||||
state.categoriesController.dispose();
|
state.categoriesController.dispose();
|
||||||
|
state.pollId.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
ComposeLogic.pickVideoMedia(ref, state);
|
ComposeLogic.pickVideoMedia(ref, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void addAudio() {
|
||||||
|
ComposeLogic.recordAudioMedia(ref, state, context);
|
||||||
|
}
|
||||||
|
|
||||||
void linkAttachment() {
|
void linkAttachment() {
|
||||||
ComposeLogic.linkAttachment(ref, state, context);
|
ComposeLogic.linkAttachment(ref, state, context);
|
||||||
}
|
}
|
||||||
@@ -32,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
ComposeLogic.saveDraft(ref, state);
|
ComposeLogic.saveDraft(ref, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void pickPoll() {
|
||||||
|
ComposeLogic.pickPoll(ref, state, context);
|
||||||
|
}
|
||||||
|
|
||||||
void showDraftManager() {
|
void showDraftManager() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -72,12 +80,37 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
icon: const Icon(Symbols.videocam),
|
icon: const Icon(Symbols.videocam),
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: addAudio,
|
||||||
|
tooltip: 'addAudio'.tr(),
|
||||||
|
icon: const Icon(Symbols.mic),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: linkAttachment,
|
onPressed: linkAttachment,
|
||||||
icon: const Icon(Symbols.attach_file),
|
icon: const Icon(Symbols.attach_file),
|
||||||
tooltip: 'linkAttachment'.tr(),
|
tooltip: 'linkAttachment'.tr(),
|
||||||
color: colorScheme.primary,
|
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(),
|
const Spacer(),
|
||||||
if (originalPost == null && state.isEmpty)
|
if (originalPost == null && state.isEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/embed.dart';
|
import 'package:island/models/embed.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/pods/config.dart';
|
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/translate.dart';
|
import 'package:island/pods/translate.dart';
|
||||||
import 'package:island/pods/userinfo.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/cloud_files.dart';
|
||||||
import 'package:island/widgets/content/embed/link.dart';
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
import 'package:island/widgets/content/markdown.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/post/post_replies_sheet.dart';
|
||||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||||
import 'package:island/widgets/share/share_sheet.dart';
|
import 'package:island/widgets/share/share_sheet.dart';
|
||||||
@@ -179,7 +180,7 @@ class PostActionableItem extends HookConsumerWidget {
|
|||||||
callback: () {
|
callback: () {
|
||||||
showShareSheetLink(
|
showShareSheetLink(
|
||||||
context: context,
|
context: context,
|
||||||
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
|
link: 'https://solian.app/posts/${item.id}',
|
||||||
title: 'sharePost'.tr(),
|
title: 'sharePost'.tr(),
|
||||||
toSystem: true,
|
toSystem: true,
|
||||||
);
|
);
|
||||||
@@ -410,7 +411,9 @@ class PostItem extends HookConsumerWidget {
|
|||||||
if (!isFullPost && item.type == 1)
|
if (!isFullPost && item.type == 1)
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
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)),
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
@@ -458,6 +461,24 @@ class PostItem extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
MarkdownTextContent(
|
||||||
content:
|
content:
|
||||||
item.isTruncated ? '${item.content!}...' : item.content!,
|
item.isTruncated ? '${item.content!}...' : item.content!,
|
||||||
@@ -523,23 +544,36 @@ class PostItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.meta?['embeds'] != null)
|
if (item.meta?['embeds'] != null)
|
||||||
...((item.meta!['embeds'] as List<dynamic>)
|
...((item.meta!['embeds'] as List<dynamic>).map(
|
||||||
.where((embed) => embed['Type'] == 'link')
|
(embedData) => switch (embedData['type']) {
|
||||||
.map(
|
'link' => EmbedLinkWidget(
|
||||||
(embedData) => EmbedLinkWidget(
|
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
maxWidth: math.min(
|
||||||
maxWidth: math.min(
|
MediaQuery.of(context).size.width,
|
||||||
MediaQuery.of(context).size.width,
|
kWideScreenWidth,
|
||||||
kWideScreenWidth,
|
|
||||||
),
|
|
||||||
margin: EdgeInsets.only(
|
|
||||||
top: 4,
|
|
||||||
bottom: 4,
|
|
||||||
left: renderingPadding.horizontal,
|
|
||||||
right: renderingPadding.horizontal,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)),
|
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)
|
if (isShowReference)
|
||||||
_buildReferencePost(context, item, renderingPadding),
|
_buildReferencePost(context, item, renderingPadding),
|
||||||
if (item.repliesCount > 0 && isEmbedReply)
|
if (item.repliesCount > 0 && isEmbedReply)
|
||||||
@@ -578,7 +612,7 @@ Widget _buildReferencePost(
|
|||||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -846,22 +880,22 @@ class PostReplyPreview extends HookConsumerWidget {
|
|||||||
: featuredReply!.when(
|
: featuredReply!.when(
|
||||||
data:
|
data:
|
||||||
(value) => Row(
|
(value) => Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ProfilePictureWidget(
|
ProfilePictureWidget(
|
||||||
file: value!.publisher.picture,
|
file: value?.publisher.picture,
|
||||||
radius: 12,
|
radius: 12,
|
||||||
).padding(top: 4),
|
).padding(top: 4),
|
||||||
if (value.content?.isNotEmpty ?? false)
|
if (value?.content?.isNotEmpty ?? false)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MarkdownTextContent(content: value.content!),
|
child: MarkdownTextContent(content: value!.content!),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'postHasAttachments',
|
'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),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
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)),
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class PublisherCard extends ConsumerWidget {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class RealmCard extends ConsumerWidget {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
|||||||
|
|
||||||
// Send message to chat room
|
// Send message to chat room
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
'/chat/${chatRoom.id}/messages',
|
'/sphere/chat/${chatRoom.id}/messages',
|
||||||
data: {'content': content, 'attachments_id': attachmentIds, 'meta': {}},
|
data: {'content': content, 'attachments_id': attachmentIds, 'meta': {}},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,12 +328,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showSnackBar('Failed to share to chat: $e');
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to share to chat: $e'),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -405,151 +400,137 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
|||||||
children: [
|
children: [
|
||||||
// Share options with keyboard avoidance
|
// Share options with keyboard avoidance
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedPadding(
|
child: SingleChildScrollView(
|
||||||
duration: const Duration(milliseconds: 300),
|
child: Column(
|
||||||
padding: EdgeInsets.only(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
children: [
|
||||||
),
|
// Content preview
|
||||||
child: SingleChildScrollView(
|
Container(
|
||||||
child: Column(
|
margin: const EdgeInsets.all(16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
// Content preview
|
color:
|
||||||
Container(
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
margin: const EdgeInsets.all(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Quick actions row (horizontally scrollable)
|
child: Column(
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
child: Column(
|
Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
'contentToShare'.tr(),
|
||||||
children: [
|
style: Theme.of(
|
||||||
Text(
|
context,
|
||||||
'quickActions'.tr(),
|
).textTheme.labelMedium?.copyWith(
|
||||||
style: Theme.of(
|
color:
|
||||||
context,
|
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
).textTheme.titleSmall?.copyWith(
|
|
||||||
color:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 80,
|
_ContentPreview(content: widget.content),
|
||||||
child: ListView(
|
],
|
||||||
scrollDirection: Axis.horizontal,
|
),
|
||||||
children: [
|
),
|
||||||
_CompactShareOption(
|
// Quick actions row (horizontally scrollable)
|
||||||
icon: Symbols.post_add,
|
Padding(
|
||||||
title: 'post'.tr(),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
onTap: _isLoading ? null : _shareToPost,
|
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),
|
const SizedBox(width: 12),
|
||||||
_CompactShareOption(
|
_CompactShareOption(
|
||||||
icon: Symbols.content_copy,
|
icon: Symbols.share,
|
||||||
title: 'copy'.tr(),
|
title: 'share'.tr(),
|
||||||
onTap: _isLoading ? null : _copyToClipboard,
|
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
|
// Chat section
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'sendToChat'.tr(),
|
'sendToChat'.tr(),
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleSmall?.copyWith(
|
).textTheme.titleSmall?.copyWith(
|
||||||
color:
|
color:
|
||||||
Theme.of(
|
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Additional message input
|
// Additional message input
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'addAdditionalMessage'.tr(),
|
hintText: 'addAdditionalMessage'.tr(),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
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(
|
_ChatRoomsList(
|
||||||
onChatSelected:
|
onChatSelected:
|
||||||
_isLoading ? null : _shareToSpecificChat,
|
_isLoading ? null : _shareToSpecificChat,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class WebArticleCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
height: 1.3,
|
height: 1.3,
|
||||||
),
|
),
|
||||||
maxLines: showDetails ? 3 : 2,
|
maxLines: showDetails ? 3 : 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (showDetails &&
|
if (showDetails &&
|
||||||
@@ -125,6 +125,8 @@ class WebArticleCard extends StatelessWidget {
|
|||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2572,6 +2572,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
web:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.7.2
|
sdk: ^3.7.2
|
||||||
@@ -132,6 +132,7 @@ dependencies:
|
|||||||
html2md: ^1.3.2
|
html2md: ^1.3.2
|
||||||
flutter_typeahead: ^5.2.0
|
flutter_typeahead: ^5.2.0
|
||||||
flutter_langdetect: ^0.0.2
|
flutter_langdetect: ^0.0.2
|
||||||
|
waveform_flutter: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user