Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b66640c6df
|
|||
|
4ac10706ae
|
|||
|
bf2844162d
|
|||
|
23c11a2fbd
|
|||
|
2b99f54bc5
|
|||
|
ab2fc1013b
|
|||
|
|
9a41ff26ef | ||
|
63a55658ab
|
|||
|
|
3122ae4cc2 | ||
|
00e063e99f
|
|||
|
532bb30c6a
|
|||
|
d8c33b576f
|
|||
|
a984cba2fa
|
|||
|
b7d5aa5dfb
|
|||
|
826238a374
|
|||
|
88c4d648d5
|
|||
|
bf59108569
|
|||
|
eec181da55
|
|||
|
c93b543da9
|
|||
|
2fd93246c7
|
|||
|
1b2620e957
|
|||
|
d443343052
|
|||
|
9957905212
|
|||
|
e36d694397
|
|||
|
3847581f1f
|
|||
|
64903bf1f3
|
|||
|
a449fbb58a
|
BIN
assets/audio/messages.mp3
Normal file
BIN
assets/audio/messages.mp3
Normal file
Binary file not shown.
BIN
assets/audio/notification.mp3
Normal file
BIN
assets/audio/notification.mp3
Normal file
Binary file not shown.
@@ -1587,5 +1587,10 @@
|
||||
"fediversePostDescribe": "Post from the Fediverse Network",
|
||||
"settingsShowFediverseContent": "Show Fediverse Content",
|
||||
"universalSearch": "Universal Search",
|
||||
"universalSearchDescription": "Search content across the Solar Network and the fediverse network."
|
||||
"universalSearchDescription": "Search content across the Solar Network and the fediverse network.",
|
||||
"tasks": "Tasks",
|
||||
"tasksCount": {
|
||||
"one": "{} task",
|
||||
"other": "{} tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.11.0)
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- croppy (0.0.1):
|
||||
@@ -219,6 +221,9 @@ PODS:
|
||||
- Flutter
|
||||
- irondash_engine_context (0.0.1):
|
||||
- Flutter
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- KeychainAccess (4.2.2)
|
||||
- Kingfisher (8.6.2)
|
||||
- KingfisherWebP (1.7.2):
|
||||
@@ -324,12 +329,16 @@ PODS:
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_thumbnail (0.0.1):
|
||||
- Flutter
|
||||
- libwebp
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- WebRTC-SDK (137.7151.04)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
@@ -354,6 +363,7 @@ DEPENDENCIES:
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||
- Kingfisher (~> 8.0)
|
||||
- KingfisherWebP
|
||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||
@@ -379,6 +389,7 @@ DEPENDENCIES:
|
||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -414,6 +425,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
croppy:
|
||||
@@ -462,6 +475,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
irondash_engine_context:
|
||||
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
||||
just_audio:
|
||||
:path: ".symlinks/plugins/just_audio/darwin"
|
||||
livekit_client:
|
||||
:path: ".symlinks/plugins/livekit_client/ios"
|
||||
local_auth_darwin:
|
||||
@@ -508,11 +523,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_thumbnail:
|
||||
:path: ".symlinks/plugins/video_thumbnail/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
@@ -553,6 +571,7 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
|
||||
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
|
||||
@@ -587,6 +606,7 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ struct CheckNotificationsIntent: AppIntent {
|
||||
let value = result["value"] as? String ?? "You have new notifications"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "Dialog: \(value)"
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to check notifications"
|
||||
@@ -323,6 +323,142 @@ struct CheckNotificationsIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SendMessageIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Message"
|
||||
static var description = IntentDescription("Send a message to a chat channel")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
@Parameter(title: "Message Content")
|
||||
var content: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
guard let channelId = channelId, !channelId.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Channel ID is required")
|
||||
}
|
||||
|
||||
guard let content = content, !content.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Message content is required")
|
||||
}
|
||||
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "send_message",
|
||||
parameters: ["channelId": channelId, "content": content]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Message sent"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to send message"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ReadMessagesIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Read Messages"
|
||||
static var description = IntentDescription("Read recent messages from a chat channel")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
@Parameter(title: "Number of Messages", default: "5")
|
||||
var limit: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
guard let channelId = channelId, !channelId.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Channel ID is required")
|
||||
}
|
||||
|
||||
let limit = limit ?? "5"
|
||||
var parameters: [String: Any] = ["channelId": channelId]
|
||||
parameters["limit"] = limit
|
||||
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "read_messages",
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Messages retrieved"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to read messages"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct CheckUnreadChatsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Check Unread Chats"
|
||||
static var description = IntentDescription("Check number of unread chat messages")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "check_unread_chats",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "No unread messages"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to check unread chats"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct MarkNotificationsReadIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Mark Notifications Read"
|
||||
static var description = IntentDescription("Mark all notifications as read")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "mark_notifications_read",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Notifications marked as read"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to mark notifications as read"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppIntentError: Error {
|
||||
case executionFailed(String)
|
||||
}
|
||||
@@ -373,6 +509,42 @@ struct AppShortcuts: AppShortcutsProvider {
|
||||
"Get notifications using \(.applicationName)",
|
||||
"Do I have notifications in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Send message
|
||||
AppShortcut(
|
||||
intent: SendMessageIntent(),
|
||||
phrases: [
|
||||
"Send message with \(.applicationName)",
|
||||
"Post message using \(.applicationName)",
|
||||
"Send text using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Read messages
|
||||
AppShortcut(
|
||||
intent: ReadMessagesIntent(),
|
||||
phrases: [
|
||||
"Read messages with \(.applicationName)",
|
||||
"Get chat using \(.applicationName)",
|
||||
"Show messages with \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Check unread chats
|
||||
AppShortcut(
|
||||
intent: CheckUnreadChatsIntent(),
|
||||
phrases: [
|
||||
"Check unread chats with \(.applicationName)",
|
||||
"Do I have messages using \(.applicationName)",
|
||||
"Get unread messages with \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Mark notifications read
|
||||
AppShortcut(
|
||||
intent: MarkNotificationsReadIntent(),
|
||||
phrases: [
|
||||
"Mark notifications read with \(.applicationName)",
|
||||
"Clear notifications using \(.applicationName)",
|
||||
"Mark all read with \(.applicationName)"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,8 +53,10 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
|
||||
switch content.userInfo["type"] as? String {
|
||||
case "messages.new":
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxMessage.caf"))
|
||||
try handleMessagingNotification(request: request, content: content)
|
||||
default:
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxNotification.caf"))
|
||||
try handleDefaultNotification(content: content)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
ios/SolianNotificationService/SfxMessage.caf
Normal file
BIN
ios/SolianNotificationService/SfxMessage.caf
Normal file
Binary file not shown.
BIN
ios/SolianNotificationService/SfxNotification.caf
Normal file
BIN
ios/SolianNotificationService/SfxNotification.caf
Normal file
Binary file not shown.
135
lib/hooks/use_room_file_picker.dart
Normal file
135
lib/hooks/use_room_file_picker.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/widgets/chat/chat_link_attachments.dart';
|
||||
|
||||
class RoomFilePicker {
|
||||
final List<UniversalFile> attachments;
|
||||
final void Function(List<UniversalFile>) updateAttachments;
|
||||
final Future<void> Function() pickPhotos;
|
||||
final Future<void> Function() pickVideos;
|
||||
final Future<void> Function() pickAudio;
|
||||
final Future<void> Function() pickFiles;
|
||||
final Future<void> Function() linkAttachment;
|
||||
|
||||
RoomFilePicker({
|
||||
required this.attachments,
|
||||
required this.updateAttachments,
|
||||
required this.pickPhotos,
|
||||
required this.pickVideos,
|
||||
required this.pickAudio,
|
||||
required this.pickFiles,
|
||||
required this.linkAttachment,
|
||||
});
|
||||
}
|
||||
|
||||
RoomFilePicker useRoomFilePicker(
|
||||
BuildContext context,
|
||||
List<UniversalFile> currentAttachments,
|
||||
Function(List<UniversalFile>) onAttachmentsChanged,
|
||||
) {
|
||||
final attachments = useState<List<UniversalFile>>(currentAttachments);
|
||||
|
||||
Future<void> pickPhotos() async {
|
||||
final picker = ImagePicker();
|
||||
final results = await picker.pickMultiImage();
|
||||
if (results.isEmpty) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...results.map(
|
||||
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickVideos() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.video,
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickAudio() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.audio,
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.audio),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickFiles() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.file),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> linkAttachment() async {
|
||||
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ChatLinkAttachment(),
|
||||
);
|
||||
if (cloudFile == null) return;
|
||||
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
UniversalFile(
|
||||
data: cloudFile,
|
||||
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
isLink: true,
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
void updateAttachments(List<UniversalFile> newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
return RoomFilePicker(
|
||||
attachments: attachments.value,
|
||||
updateAttachments: updateAttachments,
|
||||
pickPhotos: pickPhotos,
|
||||
pickVideos: pickVideos,
|
||||
pickAudio: pickAudio,
|
||||
pickFiles: pickFiles,
|
||||
linkAttachment: linkAttachment,
|
||||
);
|
||||
}
|
||||
231
lib/hooks/use_room_input.dart
Normal file
231
lib/hooks/use_room_input.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/models/wallet.dart';
|
||||
import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:island/widgets/chat/message_item.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
|
||||
class RoomInputManager {
|
||||
final TextEditingController messageController;
|
||||
final List<UniversalFile> attachments;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final SnChatMessage? messageEditingTo;
|
||||
final SnChatMessage? messageReplyingTo;
|
||||
final SnChatMessage? messageForwardingTo;
|
||||
final SnPoll? selectedPoll;
|
||||
final SnWalletFund? selectedFund;
|
||||
final void Function(List<UniversalFile>) updateAttachments;
|
||||
final void Function(String, double?) updateAttachmentProgress;
|
||||
final void Function(SnChatMessage?) setEditingTo;
|
||||
final void Function(SnChatMessage?) setReplyingTo;
|
||||
final void Function(SnChatMessage?) setForwardingTo;
|
||||
final void Function(SnPoll?) setPoll;
|
||||
final void Function(SnWalletFund?) setFund;
|
||||
final void Function() clear;
|
||||
final void Function() clearAttachmentsOnly;
|
||||
final Future<void> Function() handlePaste;
|
||||
final void Function(WidgetRef ref) sendMessage;
|
||||
final void Function(String action, LocalChatMessage message) onMessageAction;
|
||||
|
||||
RoomInputManager({
|
||||
required this.messageController,
|
||||
required this.attachments,
|
||||
required this.attachmentProgress,
|
||||
this.messageEditingTo,
|
||||
this.messageReplyingTo,
|
||||
this.messageForwardingTo,
|
||||
this.selectedPoll,
|
||||
this.selectedFund,
|
||||
required this.updateAttachments,
|
||||
required this.updateAttachmentProgress,
|
||||
required this.setEditingTo,
|
||||
required this.setReplyingTo,
|
||||
required this.setForwardingTo,
|
||||
required this.setPoll,
|
||||
required this.setFund,
|
||||
required this.clear,
|
||||
required this.clearAttachmentsOnly,
|
||||
required this.handlePaste,
|
||||
required this.sendMessage,
|
||||
required this.onMessageAction,
|
||||
});
|
||||
}
|
||||
|
||||
RoomInputManager useRoomInputManager(WidgetRef ref, String roomId) {
|
||||
final messageController = useTextEditingController();
|
||||
final attachments = useState<List<UniversalFile>>([]);
|
||||
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
|
||||
final messageEditingTo = useState<SnChatMessage?>(null);
|
||||
final messageReplyingTo = useState<SnChatMessage?>(null);
|
||||
final messageForwardingTo = useState<SnChatMessage?>(null);
|
||||
final selectedPoll = useState<SnPoll?>(null);
|
||||
final selectedFund = useState<SnWalletFund?>(null);
|
||||
|
||||
final chatSubscribeNotifier = ref.read(
|
||||
chatSubscribeProvider(roomId).notifier,
|
||||
);
|
||||
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
|
||||
|
||||
void updateAttachments(List<UniversalFile> newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
}
|
||||
|
||||
void updateAttachmentProgress(String messageId, double? progress) {
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
messageId: {0: progress},
|
||||
};
|
||||
}
|
||||
|
||||
void setEditingTo(SnChatMessage? message) {
|
||||
messageEditingTo.value = message;
|
||||
if (message != null) {
|
||||
messageController.text = message.content ?? '';
|
||||
attachments.value = message.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
void setReplyingTo(SnChatMessage? message) {
|
||||
messageReplyingTo.value = message;
|
||||
}
|
||||
|
||||
void setForwardingTo(SnChatMessage? message) {
|
||||
messageForwardingTo.value = message;
|
||||
}
|
||||
|
||||
void setPoll(SnPoll? poll) {
|
||||
selectedPoll.value = poll;
|
||||
}
|
||||
|
||||
void setFund(SnWalletFund? fund) {
|
||||
selectedFund.value = fund;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
messageController.clear();
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
selectedPoll.value = null;
|
||||
selectedFund.value = null;
|
||||
attachments.value = [];
|
||||
}
|
||||
|
||||
void clearAttachmentsOnly() {
|
||||
messageController.clear();
|
||||
attachments.value = [];
|
||||
}
|
||||
|
||||
void onTextChange() {
|
||||
if (messageController.text.isNotEmpty) {
|
||||
chatSubscribeNotifier.sendTypingStatus();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
messageController.addListener(onTextChange);
|
||||
return () => messageController.removeListener(onTextChange);
|
||||
}, [messageController]);
|
||||
|
||||
Future<void> handlePaste() async {
|
||||
final image = await Pasteboard.image;
|
||||
if (image != null) {
|
||||
final newAttachments = [
|
||||
...attachments.value,
|
||||
UniversalFile(
|
||||
data: XFile.fromData(image, mimeType: "image/jpeg"),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
];
|
||||
attachments.value = newAttachments;
|
||||
}
|
||||
|
||||
final textData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (textData != null && textData.text != null) {
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
final newText = text.replaceRange(start, end, textData.text!);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: start + textData.text!.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onMessageAction(String action, LocalChatMessage message) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(message.id);
|
||||
case MessageItemAction.edit:
|
||||
setEditingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.forward:
|
||||
setForwardingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.reply:
|
||||
setReplyingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.resend:
|
||||
messagesNotifier.retryMessage(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
void sendMessage(WidgetRef ref) {
|
||||
if (messageController.text.trim().isNotEmpty ||
|
||||
attachments.value.isNotEmpty ||
|
||||
selectedPoll.value != null ||
|
||||
selectedFund.value != null) {
|
||||
messagesNotifier.sendMessage(
|
||||
ref,
|
||||
messageController.text.trim(),
|
||||
attachments.value,
|
||||
poll: selectedPoll.value,
|
||||
fund: selectedFund.value,
|
||||
editingTo: messageEditingTo.value,
|
||||
forwardingTo: messageForwardingTo.value,
|
||||
replyingTo: messageReplyingTo.value,
|
||||
onProgress: (messageId, progress) {
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
messageId: progress,
|
||||
};
|
||||
},
|
||||
);
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
return RoomInputManager(
|
||||
messageController: messageController,
|
||||
attachments: attachments.value,
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
messageEditingTo: messageEditingTo.value,
|
||||
messageReplyingTo: messageReplyingTo.value,
|
||||
messageForwardingTo: messageForwardingTo.value,
|
||||
selectedPoll: selectedPoll.value,
|
||||
selectedFund: selectedFund.value,
|
||||
updateAttachments: updateAttachments,
|
||||
updateAttachmentProgress: updateAttachmentProgress,
|
||||
setEditingTo: setEditingTo,
|
||||
setReplyingTo: setReplyingTo,
|
||||
setForwardingTo: setForwardingTo,
|
||||
setPoll: setPoll,
|
||||
setFund: setFund,
|
||||
clear: clear,
|
||||
clearAttachmentsOnly: clearAttachmentsOnly,
|
||||
handlePaste: handlePaste,
|
||||
sendMessage: sendMessage,
|
||||
onMessageAction: onMessageAction,
|
||||
);
|
||||
}
|
||||
127
lib/hooks/use_room_scroll.dart
Normal file
127
lib/hooks/use_room_scroll.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/chat/chat_room.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class RoomScrollManager {
|
||||
final ScrollController scrollController;
|
||||
final ListController listController;
|
||||
final ValueNotifier<double> bottomGradientOpacity;
|
||||
bool isScrollingToMessage;
|
||||
final void Function({
|
||||
required String messageId,
|
||||
required List<LocalChatMessage> messageList,
|
||||
})
|
||||
scrollToMessage;
|
||||
|
||||
RoomScrollManager({
|
||||
required this.scrollController,
|
||||
required this.listController,
|
||||
required this.bottomGradientOpacity,
|
||||
required this.scrollToMessage,
|
||||
this.isScrollingToMessage = false,
|
||||
});
|
||||
}
|
||||
|
||||
RoomScrollManager useRoomScrollManager(
|
||||
WidgetRef ref,
|
||||
String roomId,
|
||||
Future<int> Function(String) jumpToMessage,
|
||||
AsyncValue<List<LocalChatMessage>> messagesAsync,
|
||||
) {
|
||||
final scrollController = useScrollController();
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
final bottomGradientOpacity = useState(ValueNotifier<double>(0.0));
|
||||
|
||||
var isLoading = false;
|
||||
var isScrollingToMessage = false;
|
||||
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
|
||||
final flashingMessagesNotifier = ref.read(flashingMessagesProvider.notifier);
|
||||
|
||||
void performScrollAnimation({required int index, required String messageId}) {
|
||||
flashingMessagesNotifier.update((set) => set.union({messageId}));
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
listController.animateToItem(
|
||||
index: index,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (estimatedDistance) => Duration(
|
||||
milliseconds: (estimatedDistance * 0.5).clamp(200, 800).toInt(),
|
||||
),
|
||||
curve: (estimatedDistance) => Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
isScrollingToMessage = false;
|
||||
});
|
||||
} catch (e) {
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void scrollToMessageWrapper({
|
||||
required String messageId,
|
||||
required List<LocalChatMessage> messageList,
|
||||
}) {
|
||||
if (isScrollingToMessage) return;
|
||||
isScrollingToMessage = true;
|
||||
|
||||
final messageIndex = messageList.indexWhere((m) => m.id == messageId);
|
||||
|
||||
if (messageIndex == -1) {
|
||||
jumpToMessage(messageId).then((index) {
|
||||
if (index != -1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(index: index, messageId: messageId);
|
||||
});
|
||||
} else {
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(index: messageIndex, messageId: messageId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
messagesAsync.when(
|
||||
data: (messageList) {
|
||||
if (scrollController.position.pixels >=
|
||||
scrollController.position.maxScrollExtent - 200) {
|
||||
if (!isLoading) {
|
||||
isLoading = true;
|
||||
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
final pixels = scrollController.position.pixels;
|
||||
bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0);
|
||||
},
|
||||
loading: () {},
|
||||
error: (_, _) {},
|
||||
);
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController, messagesAsync]);
|
||||
|
||||
return RoomScrollManager(
|
||||
scrollController: scrollController,
|
||||
listController: listController,
|
||||
bottomGradientOpacity: bottomGradientOpacity.value,
|
||||
scrollToMessage: scrollToMessageWrapper,
|
||||
isScrollingToMessage: isScrollingToMessage,
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ sealed class SnScrappedLink with _$SnScrappedLink {
|
||||
const factory SnScrappedLink({
|
||||
required String type,
|
||||
required String url,
|
||||
required String title,
|
||||
required String? title,
|
||||
required String? description,
|
||||
required String? imageUrl,
|
||||
required String? faviconUrl,
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$SnScrappedLink {
|
||||
|
||||
String get type; String get url; String get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
|
||||
String get type; String get url; String? get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res> {
|
||||
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
});
|
||||
|
||||
|
||||
@@ -65,12 +65,12 @@ class _$SnScrappedLinkCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
|
||||
@@ -159,7 +159,7 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink() when $default != null:
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
|
||||
@@ -180,7 +180,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink():
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);}
|
||||
@@ -197,7 +197,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink() when $default != null:
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
|
||||
@@ -217,7 +217,7 @@ class _SnScrappedLink implements SnScrappedLink {
|
||||
|
||||
@override final String type;
|
||||
@override final String url;
|
||||
@override final String title;
|
||||
@override final String? title;
|
||||
@override final String? description;
|
||||
@override final String? imageUrl;
|
||||
@override final String? faviconUrl;
|
||||
@@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo
|
||||
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
});
|
||||
|
||||
|
||||
@@ -276,12 +276,12 @@ class __$SnScrappedLinkCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
return _then(_SnScrappedLink(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
|
||||
|
||||
@@ -10,7 +10,7 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) =>
|
||||
_SnScrappedLink(
|
||||
type: json['type'] as String,
|
||||
url: json['url'] as String,
|
||||
title: json['title'] as String,
|
||||
title: json['title'] as String?,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
faviconUrl: json['favicon_url'] as String?,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
|
||||
part 'reference.freezed.dart';
|
||||
part 'reference.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Reference with _$Reference {
|
||||
const factory Reference({
|
||||
required String id,
|
||||
@JsonKey(name: 'file_id') required String fileId,
|
||||
SnCloudFile? file,
|
||||
required String usage,
|
||||
@JsonKey(name: 'resource_id') required String resourceId,
|
||||
@JsonKey(name: 'expired_at') DateTime? expiredAt,
|
||||
@JsonKey(name: 'created_at') required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
@JsonKey(name: 'deleted_at') DateTime? deletedAt,
|
||||
}) = _Reference;
|
||||
|
||||
factory Reference.fromJson(Map<String, dynamic> json) =>
|
||||
_$ReferenceFromJson(json);
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'reference.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Reference {
|
||||
|
||||
String get id;@JsonKey(name: 'file_id') String get fileId; SnCloudFile? get file; String get usage;@JsonKey(name: 'resource_id') String get resourceId;@JsonKey(name: 'expired_at') DateTime? get expiredAt;@JsonKey(name: 'created_at') DateTime get createdAt;@JsonKey(name: 'updated_at') DateTime get updatedAt;@JsonKey(name: 'deleted_at') DateTime? get deletedAt;
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ReferenceCopyWith<Reference> get copyWith => _$ReferenceCopyWithImpl<Reference>(this as Reference, _$identity);
|
||||
|
||||
/// Serializes this Reference to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ReferenceCopyWith<$Res> {
|
||||
factory $ReferenceCopyWith(Reference value, $Res Function(Reference) _then) = _$ReferenceCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
$SnCloudFileCopyWith<$Res>? get file;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ReferenceCopyWithImpl<$Res>
|
||||
implements $ReferenceCopyWith<$Res> {
|
||||
_$ReferenceCopyWithImpl(this._self, this._then);
|
||||
|
||||
final Reference _self;
|
||||
final $Res Function(Reference) _then;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
|
||||
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<$Res>? get file {
|
||||
if (_self.file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
|
||||
return _then(_self.copyWith(file: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [Reference].
|
||||
extension ReferencePatterns on Reference {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Reference value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Reference value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference():
|
||||
return $default(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Reference value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference():
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _Reference implements Reference {
|
||||
const _Reference({required this.id, @JsonKey(name: 'file_id') required this.fileId, this.file, required this.usage, @JsonKey(name: 'resource_id') required this.resourceId, @JsonKey(name: 'expired_at') this.expiredAt, @JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'updated_at') required this.updatedAt, @JsonKey(name: 'deleted_at') this.deletedAt});
|
||||
factory _Reference.fromJson(Map<String, dynamic> json) => _$ReferenceFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override@JsonKey(name: 'file_id') final String fileId;
|
||||
@override final SnCloudFile? file;
|
||||
@override final String usage;
|
||||
@override@JsonKey(name: 'resource_id') final String resourceId;
|
||||
@override@JsonKey(name: 'expired_at') final DateTime? expiredAt;
|
||||
@override@JsonKey(name: 'created_at') final DateTime createdAt;
|
||||
@override@JsonKey(name: 'updated_at') final DateTime updatedAt;
|
||||
@override@JsonKey(name: 'deleted_at') final DateTime? deletedAt;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ReferenceCopyWith<_Reference> get copyWith => __$ReferenceCopyWithImpl<_Reference>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$ReferenceToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ReferenceCopyWith<$Res> implements $ReferenceCopyWith<$Res> {
|
||||
factory _$ReferenceCopyWith(_Reference value, $Res Function(_Reference) _then) = __$ReferenceCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@override $SnCloudFileCopyWith<$Res>? get file;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ReferenceCopyWithImpl<$Res>
|
||||
implements _$ReferenceCopyWith<$Res> {
|
||||
__$ReferenceCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Reference _self;
|
||||
final $Res Function(_Reference) _then;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_Reference(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
|
||||
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<$Res>? get file {
|
||||
if (_self.file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
|
||||
return _then(_self.copyWith(file: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,38 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'reference.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference(
|
||||
id: json['id'] as String,
|
||||
fileId: json['file_id'] as String,
|
||||
file: json['file'] == null
|
||||
? null
|
||||
: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
|
||||
usage: json['usage'] as String,
|
||||
resourceId: json['resource_id'] as String,
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ReferenceToJson(_Reference instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'file_id': instance.fileId,
|
||||
'file': instance.file?.toJson(),
|
||||
'usage': instance.usage,
|
||||
'resource_id': instance.resourceId,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import "dart:async";
|
||||
import "dart:convert";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:just_audio/just_audio.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/pods/chat/chat_room.dart";
|
||||
import "package:island/pods/lifecycle.dart";
|
||||
@@ -198,7 +200,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
return _typingStatuses;
|
||||
}
|
||||
|
||||
void onMessage(WebSocketPacket pkt) {
|
||||
Future<void> onMessage(WebSocketPacket pkt) async {
|
||||
if (!pkt.type.startsWith('messages')) return;
|
||||
if (['messages.read'].contains(pkt.type)) return;
|
||||
|
||||
@@ -238,6 +240,17 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
_messagesNotifier.receiveMessage(message);
|
||||
// Send read receipt for new message
|
||||
sendReadReceipt();
|
||||
// Play sound for new messages when app is unfocused
|
||||
if (pkt.type == 'messages.new' &&
|
||||
message.senderId != _chatIdentity.id &&
|
||||
ref.read(appLifecycleStateProvider).value != AppLifecycleState.resumed &&
|
||||
ref.read(appSettingsProvider).soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(AudioSource.asset('assets/audio/messages.mp3'));
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
if (ref.mounted) state = AsyncValue.data(uniqueMessages);
|
||||
} finally {
|
||||
_isUpdatingState = false;
|
||||
}
|
||||
@@ -350,7 +350,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
);
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) state = AsyncValue.data(newMessages);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,7 +408,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
} finally {
|
||||
talker.log('Finished message sync');
|
||||
// Always reset global syncing state, regardless of disposal
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(false));
|
||||
Future.microtask(() {
|
||||
if (ref.mounted) ref.read(chatSyncingProvider.notifier).set(false);
|
||||
});
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
@@ -498,7 +500,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_hasMore = messages.length == _pageSize;
|
||||
|
||||
state = AsyncValue.data(messages);
|
||||
if (ref.mounted) state = AsyncValue.data(messages);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
@@ -509,7 +511,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
|
||||
}
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final offset = currentMessages.length;
|
||||
|
||||
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
||||
@@ -518,9 +520,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_hasMore = false;
|
||||
}
|
||||
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
if (ref.mounted) {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
}
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error loading more messages',
|
||||
@@ -531,12 +535,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
// Always reset global syncing state, regardless of disposal
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(false));
|
||||
Future.microtask(() {
|
||||
if (ref.mounted) ref.read(chatSyncingProvider.notifier).set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
WidgetRef ref,
|
||||
WidgetRef outerRef,
|
||||
String content,
|
||||
List<UniversalFile> attachments, {
|
||||
SnPoll? poll,
|
||||
@@ -569,14 +575,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_fileUploadProgress[localMessage.id] = {};
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
|
||||
try {
|
||||
var cloudAttachments = List.empty(growable: true);
|
||||
for (var idx = 0; idx < attachments.length; idx++) {
|
||||
final cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
ref: outerRef,
|
||||
fileData: attachments[idx],
|
||||
onProgress: (progress, _) {
|
||||
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
|
||||
@@ -619,24 +625,27 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != localMessage.id) // remove pending message
|
||||
.map(
|
||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||
) // update original message
|
||||
.toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} else {
|
||||
final newMessages = currentMessages.map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != localMessage.id) // remove pending message
|
||||
.map(
|
||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||
) // update original message
|
||||
.toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} else {
|
||||
final newMessages = currentMessages.map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
}
|
||||
|
||||
talker.log('Message with nonce $nonce sent successfully');
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
@@ -651,13 +660,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
localMessage.id,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -698,13 +709,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
'Failed to retry message $pendingMessageId',
|
||||
@@ -718,13 +731,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
pendingMessageId,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(_sortMessages(newMessages));
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(_sortMessages(newMessages));
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -756,21 +771,23 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (!isSilentMessage) {
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) =>
|
||||
m.id == localMessage.id ||
|
||||
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
} else {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
if (ref.mounted) {
|
||||
if (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
} else {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,13 +854,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
|
||||
|
||||
if (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
if (ref.mounted) {
|
||||
if (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,7 +876,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
talker.log('Received message deletion $messageId');
|
||||
_pendingMessages.remove(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
||||
|
||||
LocalChatMessage? messageToUpdate;
|
||||
@@ -883,10 +902,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
if (ref.mounted) {
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,7 +928,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_pendingMessages.remove(messageId);
|
||||
await _database.deleteMessage(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != messageId)
|
||||
.toList();
|
||||
@@ -1063,7 +1084,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
// Check if message is already in current state to avoid duplicate loading
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ final class MessagesNotifierProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$messagesNotifierHash() => r'a721a4b92b48ee7c2289cdcd7130bbf1ca9dcb40';
|
||||
String _$messagesNotifierHash() => r'622fed0908eb04381a971e36540c516743246dff';
|
||||
|
||||
final class MessagesNotifierFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -256,8 +257,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
|
||||
void setThemeMode(String value) {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final oldValue = state.themeMode;
|
||||
prefs.setString(kAppThemeMode, value);
|
||||
state = state.copyWith(themeMode: value);
|
||||
|
||||
AnalyticsService().logThemeChanged(oldValue ?? 'system', value);
|
||||
}
|
||||
|
||||
void setAppTransparentBackground(double value) {
|
||||
|
||||
@@ -65,7 +65,7 @@ final class AppSettingsNotifierProvider
|
||||
}
|
||||
|
||||
String _$appSettingsNotifierHash() =>
|
||||
r'2437c621dcb1625a120ed1f21ab5c29906ba98be';
|
||||
r'64cf6dfda90fc634336d96bd5313b2a07b19eccb';
|
||||
|
||||
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
|
||||
AppSettings build();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/reference.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
|
||||
part 'file_references.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<Reference>> fileReferences(Ref ref, String fileId) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/drive/files/$fileId/references');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((json) => Reference.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_references.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(fileReferences)
|
||||
final fileReferencesProvider = FileReferencesFamily._();
|
||||
|
||||
final class FileReferencesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<Reference>>,
|
||||
List<Reference>,
|
||||
FutureOr<List<Reference>>
|
||||
>
|
||||
with $FutureModifier<List<Reference>>, $FutureProvider<List<Reference>> {
|
||||
FileReferencesProvider._({
|
||||
required FileReferencesFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'fileReferencesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$fileReferencesHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'fileReferencesProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<Reference>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<Reference>> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return fileReferences(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FileReferencesProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$fileReferencesHash() => r'd66c678c221f61978bdb242b98e6dbe31d0c204b';
|
||||
|
||||
final class FileReferencesFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<List<Reference>>, String> {
|
||||
FileReferencesFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'fileReferencesProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
FileReferencesProvider call(String fileId) =>
|
||||
FileReferencesProvider._(argument: fileId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'fileReferencesProvider';
|
||||
}
|
||||
@@ -29,30 +29,59 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
|
||||
}
|
||||
|
||||
void _handleWebSocketPacket(dynamic packet) {
|
||||
if (packet.type.startsWith('task.')) {
|
||||
if (packet.type.startsWith('task.') || packet.type == 'upload.completed') {
|
||||
final data = packet.data;
|
||||
if (data == null) return;
|
||||
if (data == null && packet.type != 'upload.completed') return;
|
||||
|
||||
// Debug logging
|
||||
talker.info(
|
||||
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
|
||||
);
|
||||
|
||||
final taskId = data['task_id'] as String?;
|
||||
if (taskId == null) return;
|
||||
final taskId = data != null ? (data['task_id'] as String?) : null;
|
||||
if (taskId == null && packet.type != 'upload.completed') return;
|
||||
|
||||
switch (packet.type) {
|
||||
case 'task.created':
|
||||
_handleTaskCreated(taskId, data);
|
||||
_handleTaskCreated(taskId!, data);
|
||||
break;
|
||||
case 'task.progress':
|
||||
_handleProgressUpdate(taskId, data);
|
||||
_handleProgressUpdate(taskId!, data);
|
||||
break;
|
||||
case 'task.completed':
|
||||
_handleUploadCompleted(taskId, data);
|
||||
_handleUploadCompleted(taskId!, data);
|
||||
break;
|
||||
case 'upload.completed':
|
||||
// For upload.completed, we need to find the taskId differently
|
||||
// Since data is null, we can't get task_id from data
|
||||
// We'll need to mark all in-progress uploads as completed
|
||||
// For now, assume we need to handle it per task, but since no task_id,
|
||||
// perhaps it's a broadcast or we need to modify the logic
|
||||
// Actually, looking at the logs, upload.completed has data: null, but maybe in real scenario it has task_id?
|
||||
// For now, let's assume it needs task_id, and modify accordingly
|
||||
// But since the original code expects data, perhaps the server sends data with task_id
|
||||
// Wait, in the logs: "upload.completed null" - the null is data, but perhaps in code it's null
|
||||
// To be safe, let's modify to handle upload.completed even with null data, but we need task_id
|
||||
// Perhaps search for in-progress tasks and complete them
|
||||
// But that's risky. Let's see the log again: the previous task.progress had task_id: YvvfVbaWSxj5vUnFnzJDu
|
||||
// So probably upload.completed should have the same task_id
|
||||
// Perhaps the server sends it with data containing task_id
|
||||
// The log says "upload.completed null" meaning data is null
|
||||
// But maybe it's a logging issue. To fix, let's assume data has task_id for upload.completed
|
||||
// If not, we can modify _handleUploadCompleted to accept null data and find the task
|
||||
if (data != null && data['task_id'] != null) {
|
||||
_handleUploadCompleted(data['task_id'], data);
|
||||
} else {
|
||||
// If no data, perhaps complete the most recent in-progress task
|
||||
final inProgressTasks = state.where((task) => task.status == DriveTaskStatus.inProgress).toList();
|
||||
if (inProgressTasks.isNotEmpty) {
|
||||
final task = inProgressTasks.last; // Assume the last one
|
||||
_handleUploadCompleted(task.taskId, {});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'task.failed':
|
||||
_handleUploadFailed(taskId, data);
|
||||
_handleUploadFailed(taskId!, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -256,6 +285,21 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void updateUploadProgress(String taskId, int uploadedBytes, int uploadedChunks) {
|
||||
state =
|
||||
state.map((task) {
|
||||
if (task.taskId == taskId) {
|
||||
return task.copyWith(
|
||||
uploadedBytes: uploadedBytes,
|
||||
uploadedChunks: uploadedChunks,
|
||||
status: DriveTaskStatus.inProgress,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void updateDownloadProgress(
|
||||
String taskId,
|
||||
int downloadedBytes,
|
||||
@@ -470,6 +514,7 @@ class EnhancedFileUploader extends FileUploader {
|
||||
|
||||
// Step 2: Upload chunks
|
||||
int bytesUploaded = 0;
|
||||
int chunksUploaded = 0;
|
||||
if (fileData is XFile) {
|
||||
// Use stream for XFile
|
||||
final subscription = fileData.openRead().listen(null);
|
||||
@@ -494,6 +539,11 @@ class EnhancedFileUploader extends FileUploader {
|
||||
},
|
||||
);
|
||||
bytesUploaded += chunkData.length;
|
||||
chunksUploaded += 1;
|
||||
// Update upload progress in UI
|
||||
ref
|
||||
.read(uploadTasksProvider.notifier)
|
||||
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||
}
|
||||
subscription.cancel();
|
||||
} else if (fileData is Uint8List) {
|
||||
@@ -521,6 +571,11 @@ class EnhancedFileUploader extends FileUploader {
|
||||
},
|
||||
);
|
||||
bytesUploaded += chunks[i].length;
|
||||
chunksUploaded += 1;
|
||||
// Update upload progress in UI
|
||||
ref
|
||||
.read(uploadTasksProvider.notifier)
|
||||
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||
}
|
||||
} else {
|
||||
throw ArgumentError('Invalid fileData type');
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -13,6 +9,7 @@ import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
@override
|
||||
@@ -31,9 +28,7 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
final response = await client.get('/pass/accounts/me');
|
||||
final user = SnAccount.fromJson(response.data);
|
||||
|
||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
||||
FirebaseAnalytics.instance.setUserId(id: user.id);
|
||||
}
|
||||
AnalyticsService().setUserId(user.id);
|
||||
return user;
|
||||
} catch (error, stackTrace) {
|
||||
if (error is DioException) {
|
||||
@@ -91,9 +86,8 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
await prefs.remove(kTokenPairStoreKey);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
||||
FirebaseAnalytics.instance.setUserId(id: null);
|
||||
}
|
||||
AnalyticsService().setUserId(null);
|
||||
AnalyticsService().logLogout();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/widgets/chat/message_item.dart';
|
||||
|
||||
// Provider to track animated messages to prevent replay
|
||||
final animatedMessagesProvider =
|
||||
NotifierProvider<AnimatedMessagesNotifier, Set<String>>(
|
||||
AnimatedMessagesNotifier.new,
|
||||
);
|
||||
final animatedMessagesProvider = NotifierProvider.autoDispose(
|
||||
AnimatedMessagesNotifier.new,
|
||||
);
|
||||
|
||||
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||
@override
|
||||
@@ -22,7 +22,7 @@ class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
class MessageItemWrapper extends ConsumerWidget {
|
||||
class MessageItemWrapper extends HookConsumerWidget {
|
||||
final LocalChatMessage message;
|
||||
final int index;
|
||||
final bool isLastInGroup;
|
||||
@@ -54,51 +54,6 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
required this.roomOpenTime,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Animation logic
|
||||
final animatedMessages = ref.watch(animatedMessagesProvider);
|
||||
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
|
||||
final hasAnimated = animatedMessages.contains(message.id);
|
||||
|
||||
// Only animate if:
|
||||
// 1. Animation is enabled
|
||||
// 2. Message is new (created after room open)
|
||||
// 3. Has not animated yet
|
||||
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
|
||||
|
||||
final child = chatIdentity.when(
|
||||
skipError: true,
|
||||
data: (identity) => _buildContent(context, identity),
|
||||
loading: () => _buildLoading(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
if (!shouldAnimate) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return TweenAnimationBuilder<double>(
|
||||
key: ValueKey('anim-${message.id}'), // Ensure unique key for animation
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(opacity: value, child: child),
|
||||
);
|
||||
},
|
||||
onEnd: () {
|
||||
// Mark as animated
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
|
||||
});
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, SnChatMember? identity) {
|
||||
final isSelected = selectedMessages.contains(message.id);
|
||||
final isCurrentUser = identity?.id == message.senderId;
|
||||
@@ -116,12 +71,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color:
|
||||
isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
MessageItem(
|
||||
@@ -130,10 +82,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
key: ValueKey('item-${message.id}'),
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
onAction:
|
||||
isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onAction: isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onJump: onJump,
|
||||
progress: attachmentProgress[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
@@ -178,4 +129,88 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
onJump: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Animation logic
|
||||
final animatedMessages = ref.watch(animatedMessagesProvider);
|
||||
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
|
||||
final hasAnimated = animatedMessages.contains(message.id);
|
||||
|
||||
// Only animate if:
|
||||
// 1. Animation is enabled
|
||||
// 2. Message is new (created after room open)
|
||||
// 3. Has not animated yet
|
||||
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
|
||||
|
||||
final child = chatIdentity.when(
|
||||
skipError: true,
|
||||
data: (identity) => _buildContent(context, identity),
|
||||
loading: () => _buildLoading(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
final controller = useAnimationController(
|
||||
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||
);
|
||||
|
||||
final hasStarted = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
if (shouldAnimate && !hasStarted.value) {
|
||||
hasStarted.value = true;
|
||||
controller.forward().then((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [shouldAnimate]);
|
||||
|
||||
if (!shouldAnimate) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final curvedAnimation = useMemoized(
|
||||
() => CurvedAnimation(parent: controller, curve: Curves.easeOutQuart),
|
||||
[controller],
|
||||
);
|
||||
|
||||
final sizeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final slideAnimation = useMemoized(
|
||||
() => Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final fadeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: const Interval(0.1, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
),
|
||||
[controller],
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) => FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SizeTransition(
|
||||
axis: Axis.vertical,
|
||||
sizeFactor: sizeAnimation,
|
||||
child: SlideTransition(position: slideAnimation, child: child),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/drive/file_references.dart';
|
||||
import 'package:island/services/file_download.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
import 'package:island/widgets/content/file_viewer_contents.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class FileDetailScreen extends HookConsumerWidget {
|
||||
final SnCloudFile item;
|
||||
@@ -165,22 +160,6 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add references button
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: Icon(Icons.link),
|
||||
onPressed: () => showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SheetScaffold(
|
||||
titleText: 'File References',
|
||||
child: ReferencesList(fileId: item.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Always add info button
|
||||
actions.add(
|
||||
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
|
||||
@@ -206,50 +185,3 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ReferencesList extends ConsumerWidget {
|
||||
const ReferencesList({super.key, required this.fileId});
|
||||
|
||||
final String fileId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncReferences = ref.watch(fileReferencesProvider(fileId));
|
||||
|
||||
return asyncReferences.when(
|
||||
data: (references) => ListView.builder(
|
||||
itemCount: references.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reference = references[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.link),
|
||||
title: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
reference.usage,
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
Text(reference.id, style: GoogleFonts.robotoMono(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(reference.createdAt.formatRelative(context)),
|
||||
const VerticalDivider(width: 1, thickness: 1).height(12),
|
||||
Text(reference.createdAt.formatSystem()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Error loading references: $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<
|
||||
|
||||
@@ -459,63 +459,85 @@ class _PostDetailLargeScreenLayout extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Material(
|
||||
color: Theme.of(context).cardTheme.color,
|
||||
elevation: 8,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CloudFileList(
|
||||
files: post.attachments,
|
||||
disableConstraint: true,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CloudFileList(
|
||||
files: post.attachments,
|
||||
disableConstraint: true,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PostHeader(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isCompact: false,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
elevation: 8,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PostHeader(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isCompact: false,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Gap(8),
|
||||
PostBody(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isTextSelectable: true,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
hideAttachments: true,
|
||||
textScale: post.type == 1 ? 1.2 : 1.1,
|
||||
),
|
||||
const Gap(12),
|
||||
PostActionButtons(
|
||||
post: post,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
PostBody(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isTextSelectable: true,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
hideAttachments: true,
|
||||
textScale: post.type == 1 ? 1.2 : 1.1,
|
||||
),
|
||||
const Gap(12),
|
||||
PostActionButtons(
|
||||
post: post,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PostRepliesList(postId: postId, maxWidth: 800),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
PostRepliesList(postId: postId, maxWidth: 800),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
if (user.value != null)
|
||||
Positioned(
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostQuickReply(
|
||||
parent: post,
|
||||
onPosted: () {
|
||||
ref.read(postRepliesProvider(postId).notifier).refresh();
|
||||
},
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
537
lib/services/analytics_service.dart
Normal file
537
lib/services/analytics_service.dart
Normal file
@@ -0,0 +1,537 @@
|
||||
import 'dart:io';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal() {
|
||||
_init();
|
||||
}
|
||||
|
||||
FirebaseAnalytics? _analytics;
|
||||
bool _enabled = true;
|
||||
|
||||
bool get _supportsAnalytics =>
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
|
||||
|
||||
void _init() {
|
||||
if (!_supportsAnalytics) return;
|
||||
try {
|
||||
_analytics = FirebaseAnalytics.instance;
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to init: $e');
|
||||
_analytics = null;
|
||||
}
|
||||
}
|
||||
|
||||
void logEvent(String name, Map<String, Object>? parameters) {
|
||||
if (!_enabled || !_supportsAnalytics) return;
|
||||
final analytics = _analytics;
|
||||
if (analytics == null) return;
|
||||
|
||||
try {
|
||||
analytics.logEvent(name: name, parameters: parameters);
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to log event $name: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
void setUserId(String? id) {
|
||||
if (!_supportsAnalytics) return;
|
||||
final analytics = _analytics;
|
||||
if (analytics == null) return;
|
||||
|
||||
try {
|
||||
analytics.setUserId(id: id);
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to set user ID: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void logAppOpen() {
|
||||
logEvent('app_open', null);
|
||||
}
|
||||
|
||||
void logLogin(String authMethod) {
|
||||
logEvent('login', {'auth_method': authMethod, 'platform': _getPlatform()});
|
||||
}
|
||||
|
||||
void logLogout() {
|
||||
logEvent('logout', null);
|
||||
}
|
||||
|
||||
void logPostViewed(String postId, String postType, String viewSource) {
|
||||
logEvent('post_viewed', {
|
||||
'post_id': postId,
|
||||
'post_type': postType,
|
||||
'view_source': viewSource,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostCreated(
|
||||
String postType,
|
||||
String visibility,
|
||||
bool hasAttachments,
|
||||
String publisherId,
|
||||
) {
|
||||
logEvent('post_created', {
|
||||
'post_type': postType,
|
||||
'visibility': visibility,
|
||||
'has_attachments': hasAttachments ? 'yes' : 'no',
|
||||
'publisher_id': publisherId,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostReacted(
|
||||
String postId,
|
||||
String reactionSymbol,
|
||||
int attitude,
|
||||
bool isRemoving,
|
||||
) {
|
||||
logEvent('post_reacted', {
|
||||
'post_id': postId,
|
||||
'reaction_symbol': reactionSymbol,
|
||||
'attitude': attitude,
|
||||
'is_removing': isRemoving ? 'yes' : 'no',
|
||||
});
|
||||
}
|
||||
|
||||
void logPostReplied(
|
||||
String postId,
|
||||
String parentId,
|
||||
int characterCount,
|
||||
bool hasAttachments,
|
||||
) {
|
||||
logEvent('post_replied', {
|
||||
'post_id': postId,
|
||||
'parent_id': parentId,
|
||||
'character_count': characterCount,
|
||||
'has_attachments': hasAttachments,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostShared(String postId, String shareMethod, String postType) {
|
||||
logEvent('post_shared', {
|
||||
'post_id': postId,
|
||||
'share_method': shareMethod,
|
||||
'post_type': postType,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostEdited(String postId, int contentChangeDelta) {
|
||||
logEvent('post_edited', {
|
||||
'post_id': postId,
|
||||
'content_change_delta': contentChangeDelta,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostDeleted(String postId, String postType, int timeSinceCreation) {
|
||||
logEvent('post_deleted', {
|
||||
'post_id': postId,
|
||||
'post_type': postType,
|
||||
'time_since_creation': timeSinceCreation,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostPinned(String postId, String pinMode, String realmId) {
|
||||
logEvent('post_pinned', {
|
||||
'post_id': postId,
|
||||
'pin_mode': pinMode,
|
||||
'realm_id': realmId,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostAwarded(
|
||||
String postId,
|
||||
double amount,
|
||||
String attitude,
|
||||
bool hasMessage,
|
||||
) {
|
||||
logEvent('post_awarded', {
|
||||
'post_id': postId,
|
||||
'amount': amount,
|
||||
'attitude': attitude,
|
||||
'has_message': hasMessage ? 'yes' : 'no',
|
||||
'currency': 'NSP',
|
||||
});
|
||||
}
|
||||
|
||||
void logPostTranslated(
|
||||
String postId,
|
||||
String sourceLanguage,
|
||||
String targetLanguage,
|
||||
) {
|
||||
logEvent('post_translated', {
|
||||
'post_id': postId,
|
||||
'source_language': sourceLanguage,
|
||||
'target_language': targetLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostForwarded(
|
||||
String postId,
|
||||
String originalPostId,
|
||||
String publisherId,
|
||||
) {
|
||||
logEvent('post_forwarded', {
|
||||
'post_id': postId,
|
||||
'original_post_id': originalPostId,
|
||||
'publisher_id': publisherId,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageSent(
|
||||
String channelId,
|
||||
String messageType,
|
||||
bool hasAttachments,
|
||||
int attachmentCount,
|
||||
) {
|
||||
logEvent('message_sent', {
|
||||
'channel_id': channelId,
|
||||
'message_type': messageType,
|
||||
'has_attachments': hasAttachments,
|
||||
'attachment_count': attachmentCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageReceived(
|
||||
String channelId,
|
||||
String messageType,
|
||||
bool isMentioned,
|
||||
) {
|
||||
logEvent('message_received', {
|
||||
'channel_id': channelId,
|
||||
'message_type': messageType,
|
||||
'is_mentioned': isMentioned,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageReplied(
|
||||
String channelId,
|
||||
String originalMessageId,
|
||||
int replyDepth,
|
||||
) {
|
||||
logEvent('message_replied', {
|
||||
'channel_id': channelId,
|
||||
'original_message_id': originalMessageId,
|
||||
'reply_depth': replyDepth,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageEdited(
|
||||
String channelId,
|
||||
String messageId,
|
||||
int contentChangeDelta,
|
||||
) {
|
||||
logEvent('message_edited', {
|
||||
'channel_id': channelId,
|
||||
'message_id': messageId,
|
||||
'content_change_delta': contentChangeDelta,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageDeleted(
|
||||
String channelId,
|
||||
String messageId,
|
||||
String messageType,
|
||||
bool isOwn,
|
||||
) {
|
||||
logEvent('message_deleted', {
|
||||
'channel_id': channelId,
|
||||
'message_id': messageId,
|
||||
'message_type': messageType,
|
||||
'is_own': isOwn,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatRoomOpened(String channelId, String roomType) {
|
||||
logEvent('chat_room_opened', {
|
||||
'channel_id': channelId,
|
||||
'room_type': roomType,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatJoined(String channelId, String roomType, bool isPublic) {
|
||||
logEvent('chat_joined', {
|
||||
'channel_id': channelId,
|
||||
'room_type': roomType,
|
||||
'is_public': isPublic,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatLeft(String channelId, String roomType) {
|
||||
logEvent('chat_left', {'channel_id': channelId, 'room_type': roomType});
|
||||
}
|
||||
|
||||
void logChatInvited(String channelId, String invitedUserId) {
|
||||
logEvent('chat_invited', {
|
||||
'channel_id': channelId,
|
||||
'invited_user_id': invitedUserId,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileUploaded(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String uploadSource,
|
||||
) {
|
||||
logEvent('file_uploaded', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'upload_source': uploadSource,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileDownloaded(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String downloadMethod,
|
||||
) {
|
||||
logEvent('file_downloaded', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'download_method': downloadMethod,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileDeleted(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String deleteSource,
|
||||
bool isBatchDelete,
|
||||
int batchCount,
|
||||
) {
|
||||
logEvent('file_deleted', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'delete_source': deleteSource,
|
||||
'is_batch_delete': isBatchDelete,
|
||||
'batch_count': batchCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerUsed(String packId, String stickerSlug, String context) {
|
||||
logEvent('sticker_used', {
|
||||
'pack_id': packId,
|
||||
'sticker_slug': stickerSlug,
|
||||
'context': context,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerPackAdded(String packId, String packName, int stickerCount) {
|
||||
logEvent('sticker_pack_added', {
|
||||
'pack_id': packId,
|
||||
'pack_name': packName,
|
||||
'sticker_count': stickerCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerPackViewed(String packId, int stickerCount, bool isOwned) {
|
||||
logEvent('sticker_pack_viewed', {
|
||||
'pack_id': packId,
|
||||
'sticker_count': stickerCount,
|
||||
'is_owned': isOwned,
|
||||
});
|
||||
}
|
||||
|
||||
void logSearchPerformed(
|
||||
String searchType,
|
||||
String query,
|
||||
int resultCount,
|
||||
bool hasFilters,
|
||||
) {
|
||||
logEvent('search_performed', {
|
||||
'search_type': searchType,
|
||||
'query': query,
|
||||
'result_count': resultCount,
|
||||
'has_filters': hasFilters,
|
||||
});
|
||||
}
|
||||
|
||||
void logFeedSubscribed(String feedId, String feedUrl, int articleCount) {
|
||||
logEvent('feed_subscribed', {
|
||||
'feed_id': feedId,
|
||||
'feed_url': feedUrl,
|
||||
'article_count': articleCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logFeedUnsubscribed(String feedId, String feedUrl) {
|
||||
logEvent('feed_unsubscribed', {'feed_id': feedId, 'feed_url': feedUrl});
|
||||
}
|
||||
|
||||
void logWalletTransfer(
|
||||
double amount,
|
||||
String currency,
|
||||
String payeeId,
|
||||
bool hasRemark,
|
||||
) {
|
||||
logEvent('wallet_transfer', {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'payee_id': payeeId,
|
||||
'has_remark': hasRemark,
|
||||
});
|
||||
}
|
||||
|
||||
void logWalletBalanceChecked(List<String> currenciesViewed) {
|
||||
logEvent('wallet_balance_checked', {
|
||||
'currencies_viewed': currenciesViewed.join(','),
|
||||
});
|
||||
}
|
||||
|
||||
void logWalletOpened(String activeTab) {
|
||||
logEvent('wallet_opened', {'active_tab': activeTab});
|
||||
}
|
||||
|
||||
void logRealmJoined(String realmSlug, String realmType) {
|
||||
logEvent('realm_joined', {
|
||||
'realm_slug': realmSlug,
|
||||
'realm_type': realmType,
|
||||
});
|
||||
}
|
||||
|
||||
void logRealmLeft(String realmSlug) {
|
||||
logEvent('realm_left', {'realm_slug': realmSlug});
|
||||
}
|
||||
|
||||
void logFriendAdded(String friendId, String pickerMethod) {
|
||||
logEvent('friend_added', {
|
||||
'friend_id': friendId,
|
||||
'picker_method': pickerMethod,
|
||||
});
|
||||
}
|
||||
|
||||
void logFriendRemoved(String relationshipId, String relationshipType) {
|
||||
logEvent('friend_removed', {
|
||||
'relationship_id': relationshipId,
|
||||
'relationship_type': relationshipType,
|
||||
});
|
||||
}
|
||||
|
||||
void logUserBlocked(String blockedUserId, String previousRelationship) {
|
||||
logEvent('user_blocked', {
|
||||
'blocked_user_id': blockedUserId,
|
||||
'previous_relationship': previousRelationship,
|
||||
});
|
||||
}
|
||||
|
||||
void logUserUnblocked(String unblockedUserId) {
|
||||
logEvent('user_unblocked', {'unblocked_user_id': unblockedUserId});
|
||||
}
|
||||
|
||||
void logFriendRequestAccepted(String requesterId) {
|
||||
logEvent('friend_request_accepted', {'requester_id': requesterId});
|
||||
}
|
||||
|
||||
void logFriendRequestDeclined(String requesterId) {
|
||||
logEvent('friend_request_declined', {'requester_id': requesterId});
|
||||
}
|
||||
|
||||
void logThemeChanged(String oldMode, String newMode) {
|
||||
logEvent('theme_changed', {'old_mode': oldMode, 'new_mode': newMode});
|
||||
}
|
||||
|
||||
void logLanguageChanged(String oldLanguage, String newLanguage) {
|
||||
logEvent('language_changed', {
|
||||
'old_language': oldLanguage,
|
||||
'new_language': newLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
void logAiQuerySent(
|
||||
int messageLength,
|
||||
String contextType,
|
||||
int attachedPostsCount,
|
||||
) {
|
||||
logEvent('ai_query_sent', {
|
||||
'message_length': messageLength,
|
||||
'context_type': contextType,
|
||||
'attached_posts_count': attachedPostsCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logAiResponseReceived(int responseThoughtCount, String sequenceId) {
|
||||
logEvent('ai_response_received', {
|
||||
'response_thought_count': responseThoughtCount,
|
||||
'sequence_id': sequenceId,
|
||||
});
|
||||
}
|
||||
|
||||
void logShuffleViewed(int postIndex, int totalPostsLoaded) {
|
||||
logEvent('shuffle_viewed', {
|
||||
'post_index': postIndex,
|
||||
'total_posts_loaded': totalPostsLoaded,
|
||||
});
|
||||
}
|
||||
|
||||
void logDraftSaved(String draftId, String postType, bool hasContent) {
|
||||
logEvent('draft_saved', {
|
||||
'draft_id': draftId,
|
||||
'post_type': postType,
|
||||
'has_content': hasContent,
|
||||
});
|
||||
}
|
||||
|
||||
void logDraftDeleted(String draftId, String postType) {
|
||||
logEvent('draft_deleted', {'draft_id': draftId, 'post_type': postType});
|
||||
}
|
||||
|
||||
void logCategoryViewed(String categorySlug, String categoryId) {
|
||||
logEvent('category_viewed', {
|
||||
'category_slug': categorySlug,
|
||||
'category_id': categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
void logTagViewed(String tagSlug, String tagId) {
|
||||
logEvent('tag_viewed', {'tag_slug': tagSlug, 'tag_id': tagId});
|
||||
}
|
||||
|
||||
void logCategorySubscribed(String categorySlug, String categoryId) {
|
||||
logEvent('category_subscribed', {
|
||||
'category_slug': categorySlug,
|
||||
'category_id': categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
void logTagSubscribed(String tagSlug, String tagId) {
|
||||
logEvent('tag_subscribed', {'tag_slug': tagSlug, 'tag_id': tagId});
|
||||
}
|
||||
|
||||
void logNotificationViewed() {
|
||||
logEvent('notification_viewed', null);
|
||||
}
|
||||
|
||||
void logNotificationActioned(String actionType, String notificationType) {
|
||||
logEvent('notification_actioned', {
|
||||
'action_type': actionType,
|
||||
'notification_type': notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
void logProfileUpdated(List<String> fieldsUpdated) {
|
||||
logEvent('profile_updated', {'fields_updated': fieldsUpdated.join(',')});
|
||||
}
|
||||
|
||||
void logAvatarChanged(String imageSource, bool isCropped) {
|
||||
logEvent('avatar_changed', {
|
||||
'image_source': imageSource,
|
||||
'is_cropped': isCropped,
|
||||
});
|
||||
}
|
||||
|
||||
String _getPlatform() {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isMacOS) return 'macos';
|
||||
if (Platform.isWindows) return 'windows';
|
||||
if (Platform.isLinux) return 'linux';
|
||||
return 'web';
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter_app_intents/flutter_app_intents.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/route.dart';
|
||||
|
||||
@@ -165,6 +167,75 @@ class AppIntentsService {
|
||||
.build(),
|
||||
_handleCheckNotificationsIntent,
|
||||
);
|
||||
|
||||
// Message Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('send_message')
|
||||
.title('Send Message')
|
||||
.description('Send a message to a chat channel')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'content',
|
||||
title: 'Message Content',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleSendMessageIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('read_messages')
|
||||
.title('Read Messages')
|
||||
.description('Read recent messages from a chat channel')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'limit',
|
||||
title: 'Number of Messages',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: true,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleReadMessagesIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('check_unread_chats')
|
||||
.title('Check Unread Chats')
|
||||
.description('Check number of unread chat messages')
|
||||
.build(),
|
||||
_handleCheckUnreadChatsIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('mark_notifications_read')
|
||||
.title('Mark Notifications Read')
|
||||
.description('Mark all notifications as read')
|
||||
.build(),
|
||||
_handleMarkNotificationsReadIntent,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
@@ -232,11 +303,7 @@ class AppIntentsService {
|
||||
try {
|
||||
talker.info('[AppIntents] Opening compose screen');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
||||
eventBus.fire(ShowComposeSheetEvent());
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
@@ -254,11 +321,7 @@ class AppIntentsService {
|
||||
try {
|
||||
talker.info('[AppIntents] Composing new post');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
||||
eventBus.fire(ShowComposeSheetEvent());
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
@@ -360,6 +423,200 @@ class AppIntentsService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleSendMessageIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
final content = parameters['content'] as String?;
|
||||
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
if (content == null || content.isEmpty) {
|
||||
throw ArgumentError('content is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Sending message to $channelId: $content');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final nonce = _generateNonce();
|
||||
|
||||
await _dio!.post(
|
||||
'/messager/chat/$channelId/messages',
|
||||
data: {'content': content, 'nonce': nonce},
|
||||
);
|
||||
|
||||
talker.info('[AppIntents] Message sent successfully');
|
||||
return AppIntentResult.successful(
|
||||
value: 'Message sent to channel $channelId',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error sending message', e);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to send message: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to send message', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to send message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleReadMessagesIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
final limitParam = parameters['limit'] as String?;
|
||||
final limit = limitParam != null ? int.tryParse(limitParam) ?? 5 : 5;
|
||||
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
if (limit < 1 || limit > 20) {
|
||||
return AppIntentResult.failed(error: 'limit must be between 1 and 20');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Reading $limit messages from $channelId');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get(
|
||||
'/messager/chat/$channelId/messages',
|
||||
queryParameters: {'offset': 0, 'take': limit},
|
||||
);
|
||||
|
||||
final messages = response.data as List;
|
||||
if (messages.isEmpty) {
|
||||
return AppIntentResult.successful(
|
||||
value: 'No messages found in channel $channelId',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
}
|
||||
|
||||
final formattedMessages = messages
|
||||
.map((msg) {
|
||||
final senderName =
|
||||
msg['sender']?['account']?['name'] ?? 'Unknown';
|
||||
final messageContent = msg['content'] ?? '';
|
||||
return '$senderName: $messageContent';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
talker.info('[AppIntents] Retrieved ${messages.length} messages');
|
||||
return AppIntentResult.successful(
|
||||
value: formattedMessages,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error reading messages', e);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to read messages: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to read messages', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to read messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _generateNonce() {
|
||||
return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
void _logDonation(String eventName, Map<String, Object> parameters) {
|
||||
try {
|
||||
FirebaseAnalytics.instance.logEvent(
|
||||
name: eventName,
|
||||
parameters: parameters.isEmpty ? null : parameters,
|
||||
);
|
||||
} catch (e) {
|
||||
talker.warning('[AppIntents] Failed to log analytics: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleCheckUnreadChatsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Checking unread chats count');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get('/messager/chat/unread');
|
||||
final count = response.data as int? ?? 0;
|
||||
|
||||
String message;
|
||||
if (count == 0) {
|
||||
message = 'You have no unread messages';
|
||||
} else if (count == 1) {
|
||||
message = 'You have 1 unread message';
|
||||
} else {
|
||||
message = 'You have $count unread messages';
|
||||
}
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: message,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error checking unread chats', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to fetch unread chats: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to check unread chats', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to check unread chats: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleMarkNotificationsReadIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Marking all notifications as read');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await _dio!.post('/ring/notifications/all/read');
|
||||
|
||||
talker.info('[AppIntents] Notifications marked as read');
|
||||
return AppIntentResult.successful(
|
||||
value: 'All notifications marked as read',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error marking notifications read', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to mark notifications: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to mark notifications read', e, stack);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to mark notifications read: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Donation Methods - to be called manually from your app code
|
||||
|
||||
Future<void> donateOpenChat(String channelId) async {
|
||||
@@ -371,6 +628,7 @@ class AppIntentsService {
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_chat', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated open_chat intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
|
||||
@@ -386,6 +644,7 @@ class AppIntentsService {
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'posts', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_post', {'post_id': postId});
|
||||
talker.info('[AppIntents] Donated open_post intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_post', e, stack);
|
||||
@@ -401,6 +660,7 @@ class AppIntentsService {
|
||||
relevanceScore: 0.9,
|
||||
context: {'feature': 'compose', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_compose', {});
|
||||
talker.info('[AppIntents] Donated compose intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate compose', e, stack);
|
||||
@@ -416,6 +676,7 @@ class AppIntentsService {
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'search', 'userAction': true},
|
||||
);
|
||||
_logDonation('search_content', {'query': query});
|
||||
talker.info('[AppIntents] Donated search intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate search', e, stack);
|
||||
@@ -431,6 +692,7 @@ class AppIntentsService {
|
||||
relevanceScore: 0.6,
|
||||
context: {'feature': 'notifications', 'userAction': true},
|
||||
);
|
||||
_logDonation('check_notifications', {});
|
||||
talker.info('[AppIntents] Donated check_notifications intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
@@ -440,4 +702,76 @@ class AppIntentsService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateSendMessage(String channelId, String content) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'send_message',
|
||||
{'channelId': channelId, 'content': content},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('send_message', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated send_message intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate send_message', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateReadMessages(String channelId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'read_messages',
|
||||
{'channelId': channelId},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('read_messages', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated read_messages intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate read_messages', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCheckUnreadChats() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'check_unread_chats',
|
||||
{},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('check_unread_chats', {});
|
||||
talker.info('[AppIntents] Donated check_unread_chats intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate check_unread_chats',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateMarkNotificationsRead() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'mark_notifications_read',
|
||||
{},
|
||||
relevanceScore: 0.6,
|
||||
context: {'feature': 'notifications', 'userAction': true},
|
||||
);
|
||||
_logDonation('mark_notifications_read', {});
|
||||
talker.info('[AppIntents] Donated mark_notifications_read intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate mark_notifications_read',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -104,6 +105,13 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
if (settings.soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(AudioSource.asset('assets/audio/notification.mp3'));
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/main.dart';
|
||||
@@ -31,9 +33,7 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
|
||||
|
||||
Future<void> initializeLocalNotifications() async {
|
||||
// Initialize Windows notification for Windows platform
|
||||
windowsNotification = winty.WindowsNotification(
|
||||
applicationId: "Solian",
|
||||
);
|
||||
windowsNotification = winty.WindowsNotification(applicationId: "Solian");
|
||||
|
||||
WidgetsBinding.instance.addObserver(
|
||||
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
|
||||
@@ -55,6 +55,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final ws = ref.watch(websocketProvider);
|
||||
return ws.dataStream.listen((pkt) async {
|
||||
if (pkt.type == "notifications.new") {
|
||||
@@ -64,6 +65,16 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
if (settings.soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(AudioSource.asset('assets/audio/notification.mp3'));
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
@@ -139,7 +150,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
final notificationMessage = NotificationMessage.fromPluginTemplate(
|
||||
notification.id, // unique id
|
||||
notification.title,
|
||||
[notification.subtitle, notification.content].where((e) => e.isNotEmpty).join('\n'),
|
||||
[
|
||||
notification.subtitle,
|
||||
notification.content,
|
||||
].where((e) => e.isNotEmpty).join('\n'),
|
||||
group: notification.topic,
|
||||
image: imagePath,
|
||||
largeImage: largeImagePath,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:island/widgets/post/post_shared.dart';
|
||||
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
|
||||
import 'package:screenshot/screenshot.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
/// Shares a post as a screenshot image
|
||||
Future<void> sharePostAsScreenshot(
|
||||
@@ -62,5 +63,9 @@ Future<void> sharePostAsScreenshot(
|
||||
.catchError((err) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
showErrorAlert(err);
|
||||
})
|
||||
.whenComplete(() {
|
||||
final postTypeStr = post.type == 0 ? 'regular' : 'article';
|
||||
AnalyticsService().logPostShared(post.id, 'screenshot', postTypeStr);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,24 +259,26 @@ void showErrorAlert(dynamic err, {IconData? icon}) {
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'somethingWentWrong'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(text),
|
||||
const Gap(8),
|
||||
],
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'somethingWentWrong'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
SelectableText(text),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/cmp/pattle.dart';
|
||||
import 'package:island/widgets/upload_overlay.dart';
|
||||
import 'package:island/widgets/task_overlay.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shake/shake.dart';
|
||||
@@ -146,16 +146,19 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
ShakeDetector detector = ShakeDetector.autoStart(
|
||||
onPhoneShake: (_) {
|
||||
showPalette.value = true;
|
||||
},
|
||||
);
|
||||
ShakeDetector? detactor;
|
||||
if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
|
||||
detactor = ShakeDetector.autoStart(
|
||||
onPhoneShake: (_) {
|
||||
showPalette.value = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return () {
|
||||
hotKeyManager.unregister(popHotKey);
|
||||
hotKeyManager.unregister(cmpHotKey);
|
||||
detector.stopListening();
|
||||
detactor?.stopListening();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -255,7 +258,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
@@ -268,7 +271,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
children: [
|
||||
Positioned.fill(child: child),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
|
||||
@@ -458,6 +458,10 @@ class ChatInput extends HookConsumerWidget {
|
||||
item: attachments[idx],
|
||||
progress:
|
||||
attachmentProgress['chat-upload']?[idx],
|
||||
isUploading:
|
||||
attachmentProgress['chat-upload']
|
||||
?.containsKey(idx) ??
|
||||
false,
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
|
||||
129
lib/widgets/chat/room_app_bar.dart
Normal file
129
lib/widgets/chat/room_app_bar.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
List<SnChatMember> getValidMembers(List<SnChatMember> members, String? userId) {
|
||||
return members.where((member) => member.accountId != userId).toList();
|
||||
}
|
||||
|
||||
class RoomAppBar extends ConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final int onlineCount;
|
||||
final bool compact;
|
||||
|
||||
const RoomAppBar({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.onlineCount,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
final validMembers = getValidMembers(
|
||||
room.members ?? [],
|
||||
userInfo.value?.id,
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(
|
||||
room: room,
|
||||
validMembers: validMembers,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
spacing: 4,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(room: room, validMembers: validMembers, size: 26),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnlineCountBadge extends StatelessWidget {
|
||||
final int onlineCount;
|
||||
final Widget child;
|
||||
|
||||
const _OnlineCountBadge({required this.onlineCount, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Badge(
|
||||
isLabelVisible: onlineCount > 1,
|
||||
label: Text('$onlineCount'),
|
||||
textStyle: GoogleFonts.robotoMono(fontSize: 10),
|
||||
textColor: Colors.white,
|
||||
backgroundColor: onlineCount > 1 ? Colors.green : Colors.grey,
|
||||
offset: const Offset(6, 14),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomAvatar extends StatelessWidget {
|
||||
final SnChatRoom room;
|
||||
final List<SnChatMember> validMembers;
|
||||
final double size;
|
||||
|
||||
const _RoomAvatar({
|
||||
required this.room,
|
||||
required this.validMembers,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: size,
|
||||
width: size,
|
||||
child: (room.type == 1 && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: validMembers
|
||||
.map((e) => e.account.profile.picture)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture != null
|
||||
? ProfilePictureWidget(file: room.picture, fallbackIcon: Symbols.chat)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/widgets/chat/room_message_list.dart
Normal file
170
lib/widgets/chat/room_message_list.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/screens/chat/widgets/message_item_wrapper.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class RoomMessageList extends HookConsumerWidget {
|
||||
final List<LocalChatMessage> messages;
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final AsyncValue<SnChatMember?> chatIdentity;
|
||||
final ScrollController scrollController;
|
||||
final ListController listController;
|
||||
final bool isSelectionMode;
|
||||
final Set<String> selectedMessages;
|
||||
final VoidCallback toggleSelectionMode;
|
||||
final void Function(String) toggleMessageSelection;
|
||||
final void Function(String action, LocalChatMessage message) onMessageAction;
|
||||
final void Function(String messageId) onJump;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final bool disableAnimation;
|
||||
final DateTime roomOpenTime;
|
||||
final double inputHeight;
|
||||
final double? previousInputHeight;
|
||||
|
||||
const RoomMessageList({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.roomAsync,
|
||||
required this.chatIdentity,
|
||||
required this.scrollController,
|
||||
required this.listController,
|
||||
required this.isSelectionMode,
|
||||
required this.selectedMessages,
|
||||
required this.toggleSelectionMode,
|
||||
required this.toggleMessageSelection,
|
||||
required this.onMessageAction,
|
||||
required this.onJump,
|
||||
required this.attachmentProgress,
|
||||
required this.disableAnimation,
|
||||
required this.roomOpenTime,
|
||||
required this.inputHeight,
|
||||
this.previousInputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
const messageKeyPrefix = 'message-';
|
||||
|
||||
final bottomPadding =
|
||||
inputHeight + MediaQuery.of(context).padding.bottom + 8;
|
||||
|
||||
final listWidget =
|
||||
previousInputHeight != null && previousInputHeight != inputHeight
|
||||
? TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: previousInputHeight, end: inputHeight),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, height, child) => SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: height + MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(top: 8, bottom: bottomPadding),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return listWidget;
|
||||
}
|
||||
}
|
||||
103
lib/widgets/chat/room_overlays.dart
Normal file
103
lib/widgets/chat/room_overlays.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class RoomOverlays extends ConsumerWidget {
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final bool isSyncing;
|
||||
final bool showGradient;
|
||||
final double bottomGradientOpacity;
|
||||
final double inputHeight;
|
||||
|
||||
const RoomOverlays({
|
||||
super.key,
|
||||
required this.roomAsync,
|
||||
required this.isSyncing,
|
||||
required this.showGradient,
|
||||
required this.bottomGradientOpacity,
|
||||
required this.inputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: roomAsync.when(
|
||||
data: (data) => data != null
|
||||
? CallOverlayBar(room: data).padding(horizontal: 8, top: 12)
|
||||
: const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (isSyncing)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
8 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Syncing...',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showGradient)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: bottomGradientOpacity,
|
||||
child: Container(
|
||||
height: math.min(MediaQuery.of(context).size.height * 0.1, 128),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/widgets/chat/room_selection_mode.dart
Normal file
54
lib/widgets/chat/room_selection_mode.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class RoomSelectionMode extends StatelessWidget {
|
||||
final bool visible;
|
||||
final int selectedCount;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onAIThink;
|
||||
|
||||
const RoomSelectionMode({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.selectedCount,
|
||||
required this.onClose,
|
||||
required this.onAIThink,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!visible) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
tooltip: 'Cancel selection',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
if (selectedCount > 0)
|
||||
FilledButton.icon(
|
||||
onPressed: onAIThink,
|
||||
icon: const Icon(Symbols.smart_toy),
|
||||
label: const Text('AI Think'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -14,13 +16,12 @@ import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sensitive.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
|
||||
import 'sensitive.dart';
|
||||
|
||||
class SensitiveMarksSelector extends StatefulWidget {
|
||||
final List<int> initial;
|
||||
final ValueChanged<List<int>>? onChanged;
|
||||
@@ -85,6 +86,7 @@ class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
|
||||
class AttachmentPreview extends HookConsumerWidget {
|
||||
final UniversalFile item;
|
||||
final double? progress;
|
||||
final bool isUploading;
|
||||
final Function(int)? onMove;
|
||||
final Function? onDelete;
|
||||
final Function? onInsert;
|
||||
@@ -96,6 +98,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
this.progress,
|
||||
this.isUploading = false,
|
||||
this.onRequestUpload,
|
||||
this.onMove,
|
||||
this.onDelete,
|
||||
@@ -125,79 +128,72 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: 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,
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
if (item.isOnCloud) {
|
||||
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;
|
||||
onUpdate?.call(
|
||||
item.copyWith(
|
||||
data: newData,
|
||||
displayName: newName,
|
||||
),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
} else {
|
||||
// Local file rename
|
||||
onUpdate?.call(item.copyWith(displayName: newName));
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text('rename'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
if (item.isOnCloud) {
|
||||
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;
|
||||
onUpdate?.call(
|
||||
item.copyWith(data: newData, displayName: newName),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
} else {
|
||||
// Local file rename
|
||||
onUpdate?.call(item.copyWith(displayName: newName));
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text('rename'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,92 +201,260 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
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,
|
||||
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));
|
||||
},
|
||||
),
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
// Use the current selections from stateful selector via GlobalKey
|
||||
final selectorState = _sensitiveSelectorKey.currentState;
|
||||
final marks = selectorState?.current ?? <int>[];
|
||||
await apiClient.put(
|
||||
'/drive/files/${item.data.id}/marks',
|
||||
data: jsonEncode({'sensitive_marks': marks}),
|
||||
);
|
||||
final newData = item.data as SnCloudFile;
|
||||
final updatedFile = item.copyWith(
|
||||
data: newData.copyWith(sensitiveMarks: marks),
|
||||
);
|
||||
onUpdate?.call(updatedFile);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var ratio =
|
||||
item.isOnCloud
|
||||
? (item.data.fileMeta?['ratio'] is num
|
||||
? item.data.fileMeta!['ratio'].toDouble()
|
||||
: 1.0)
|
||||
: 1.0;
|
||||
if (ratio == 0) ratio = 1.0;
|
||||
var ratio = item.isOnCloud
|
||||
? (item.data.fileMeta?['ratio'] is num
|
||||
? item.data.fileMeta!['ratio'].toDouble()
|
||||
: null)
|
||||
: null;
|
||||
|
||||
final innerContentWidget = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
HookBuilder(
|
||||
key: ValueKey(item.hashCode),
|
||||
builder: (context) {
|
||||
final fallbackIcon = switch (item.type) {
|
||||
UniversalFileType.video => Symbols.video_file,
|
||||
UniversalFileType.audio => Symbols.audio_file,
|
||||
UniversalFileType.image => Symbols.image,
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
|
||||
final mimeType = FileUploader.getMimeType(item);
|
||||
|
||||
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));
|
||||
case UniversalFileType.video:
|
||||
if (!kIsWeb) {
|
||||
final thumbnailFuture = useMemoized(
|
||||
() => VideoThumbnail.thumbnailData(
|
||||
video: file.path,
|
||||
imageFormat: ImageFormat.JPEG,
|
||||
maxWidth: 320,
|
||||
quality: 50,
|
||||
),
|
||||
[file.path],
|
||||
);
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: thumbnailFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data != null) {
|
||||
return Stack(
|
||||
children: [
|
||||
Image.memory(snapshot.data!),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
FutureBuilder(
|
||||
future: file.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final size = snapshot.data as int;
|
||||
return Text(formatFileSize(size)).fontSize(11);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
).padding(vertical: 32);
|
||||
} else if (item is List<int> || item is Uint8List) {
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
return Image.memory(item.data);
|
||||
default:
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(formatFileSize(item.data.length)).fontSize(11),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
if (isUploading && progress != null && (progress ?? 0) > 0)
|
||||
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: [
|
||||
Text(
|
||||
'${(progress! * 100).toStringAsFixed(2)}%',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: progress),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
builder: (context, value, child) =>
|
||||
LinearProgressIndicator(value: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUploading && (progress == null || progress == 0))
|
||||
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: [
|
||||
Text(
|
||||
'processing'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(child: LinearProgressIndicator(value: null)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final contentWidget = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -298,128 +462,13 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Builder(
|
||||
key: ValueKey(item.hashCode),
|
||||
builder: (context) {
|
||||
final fallbackIcon = switch (item.type) {
|
||||
UniversalFileType.video => Symbols.video_file,
|
||||
UniversalFileType.audio => Symbols.audio_file,
|
||||
UniversalFileType.image => Symbols.image,
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
|
||||
final mimeType = FileUploader.getMimeType(item);
|
||||
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
FutureBuilder(
|
||||
future: file.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final size = snapshot.data as int;
|
||||
return Text(
|
||||
formatFileSize(size),
|
||||
).fontSize(11);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
} else if (item is List<int> || item is Uint8List) {
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
return Image.memory(item.data);
|
||||
default:
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(
|
||||
formatFileSize(item.data.length),
|
||||
).fontSize(11),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
if (progress != null)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (progress != null)
|
||||
Text(
|
||||
'${(progress! * 100).toStringAsFixed(2)}%',
|
||||
style: TextStyle(color: Colors.white),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'uploading'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(
|
||||
child: LinearProgressIndicator(value: progress),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
if (ratio != null)
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: innerContentWidget,
|
||||
).center()
|
||||
else
|
||||
IntrinsicHeight(child: innerContentWidget),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -497,8 +546,9 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
if (onRequestUpload != null)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap:
|
||||
item.isOnCloud ? null : () => onRequestUpload?.call(),
|
||||
onTap: item.isOnCloud
|
||||
? null
|
||||
: () => onRequestUpload?.call(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
@@ -507,40 +557,39 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child:
|
||||
(item.isOnCloud)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
child: (item.isOnCloud)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnCloud'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnCloud'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnDevice'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnDevice'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -552,49 +601,48 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
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.isOnDevice)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'markAsSensitive'.tr(),
|
||||
image: MenuImage.icon(Symbols.no_adult_content),
|
||||
callback: () async {
|
||||
await _showSensitiveDialog(context, ref);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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.isOnDevice)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'markAsSensitive'.tr(),
|
||||
image: MenuImage.icon(Symbols.no_adult_content),
|
||||
callback: () async {
|
||||
await _showSensitiveDialog(context, ref);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: contentWidget,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,22 +244,20 @@ class CloudFileList extends HookConsumerWidget {
|
||||
minWidth: minWidth ?? 0,
|
||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||
),
|
||||
child:
|
||||
(ratio == null && isImage)
|
||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||
: (ratio == null && isAudio)
|
||||
? IntrinsicHeight(child: widgetItem)
|
||||
: AspectRatio(
|
||||
aspectRatio: ratio?.toDouble() ?? 1,
|
||||
child: widgetItem,
|
||||
),
|
||||
child: (ratio == null && isImage)
|
||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||
: (ratio == null && isAudio)
|
||||
? IntrinsicHeight(child: widgetItem)
|
||||
: AspectRatio(
|
||||
aspectRatio: ratio?.toDouble() ?? 1,
|
||||
child: widgetItem,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allImages =
|
||||
!files.any(
|
||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||
);
|
||||
final allImages = !files.any(
|
||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||
);
|
||||
|
||||
if (allImages) {
|
||||
return ConstrainedBox(
|
||||
@@ -270,10 +268,9 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth =
|
||||
constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width;
|
||||
final availableWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width;
|
||||
final itemExtent = math.min(
|
||||
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
||||
640.0,
|
||||
@@ -339,10 +336,9 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding,
|
||||
itemBuilder: (context, index) {
|
||||
return AspectRatio(
|
||||
aspectRatio:
|
||||
files[index].fileMeta?['ratio'] is num
|
||||
? files[index].fileMeta!['ratio'].toDouble()
|
||||
: 1.0,
|
||||
aspectRatio: files[index].fileMeta?['ratio'] is num
|
||||
? files[index].fileMeta!['ratio'].toDouble()
|
||||
: 1.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
@@ -440,40 +436,68 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
||||
Widget fg =
|
||||
fullyUnlocked
|
||||
? (isImage
|
||||
? CloudFileWidget(
|
||||
Widget fg = fullyUnlocked
|
||||
? (isImage
|
||||
? CloudFileWidget(
|
||||
item: file,
|
||||
heroTag: heroTag,
|
||||
noBlurhash: true,
|
||||
fit: fit,
|
||||
useInternalGate: false,
|
||||
)
|
||||
: CloudFileWidget(
|
||||
: CloudFileWidget(
|
||||
item: file,
|
||||
heroTag: heroTag,
|
||||
fit: fit,
|
||||
useInternalGate: false,
|
||||
))
|
||||
: const SizedBox.shrink();
|
||||
: const SizedBox.shrink();
|
||||
|
||||
Widget overlays;
|
||||
if (lockedByDS) {
|
||||
overlays = _DataSavingOverlay();
|
||||
} else if (file.sensitiveMarks.isNotEmpty) {
|
||||
overlays = _SensitiveOverlay(
|
||||
file: file,
|
||||
isRevealed: showMature.value,
|
||||
onHide: () => showMature.value = false,
|
||||
Widget overlays = AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: lockedByDS
|
||||
? _DataSavingOverlay(key: const ValueKey('ds'))
|
||||
: (file.sensitiveMarks.isNotEmpty && !showMature.value
|
||||
? _SensitiveOverlay(
|
||||
key: const ValueKey('sensitive-blur'),
|
||||
file: file,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none'))),
|
||||
);
|
||||
|
||||
Widget hideButton = const SizedBox.shrink();
|
||||
if (file.sensitiveMarks.isNotEmpty && showMature.value) {
|
||||
hideButton = Positioned(
|
||||
top: 3,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.visibility_off,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 5.0,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: 'Blur content',
|
||||
onPressed: () => showMature.value = false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
overlays = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final content = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [if (isImage) Positioned.fill(child: bg), fg, overlays],
|
||||
children: [
|
||||
if (isImage) Positioned.fill(child: bg),
|
||||
fg,
|
||||
overlays,
|
||||
hideButton,
|
||||
],
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
@@ -494,41 +518,11 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
|
||||
class _SensitiveOverlay extends StatelessWidget {
|
||||
final SnCloudFile file;
|
||||
final VoidCallback? onHide;
|
||||
final bool isRevealed;
|
||||
|
||||
const _SensitiveOverlay({
|
||||
required this.file,
|
||||
this.onHide,
|
||||
this.isRevealed = false,
|
||||
});
|
||||
const _SensitiveOverlay({required this.file, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isRevealed) {
|
||||
return Positioned(
|
||||
top: 3,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.visibility_off,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 5.0,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: 'Blur content',
|
||||
onPressed: onHide,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
|
||||
child: Container(
|
||||
@@ -549,6 +543,8 @@ class _SensitiveOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DataSavingOverlay extends StatelessWidget {
|
||||
const _DataSavingOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
|
||||
@@ -202,9 +202,9 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
|
||||
const Gap(8),
|
||||
|
||||
// Title
|
||||
if (widget.link.title.isNotEmpty) ...[
|
||||
if (widget.link.title?.isNotEmpty ?? false) ...[
|
||||
Text(
|
||||
widget.link.title,
|
||||
widget.link.title!,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
@@ -29,8 +30,16 @@ class UniversalImage extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loaded = useState(false);
|
||||
final isCached = useState<bool?>(null);
|
||||
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
|
||||
|
||||
useEffect(() {
|
||||
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
|
||||
isCached.value = fileInfo != null;
|
||||
});
|
||||
return null;
|
||||
}, [uri]);
|
||||
|
||||
if (isSvgImage) {
|
||||
return SvgPicture.network(
|
||||
uri,
|
||||
@@ -59,44 +68,69 @@ class UniversalImage extends HookWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (blurHash != null) BlurHash(hash: blurHash!),
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
progressIndicatorBuilder: (context, url, progress) {
|
||||
return Center(
|
||||
child: AnimatedCircularProgressIndicator(
|
||||
value: progress.progress,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
imageBuilder: (context, imageProvider) {
|
||||
Future(() {
|
||||
if (context.mounted) return loaded.value = true;
|
||||
});
|
||||
return AnimatedOpacity(
|
||||
opacity: loaded.value ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
if (isCached.value == null)
|
||||
Center(child: CircularProgressIndicator())
|
||||
else if (isCached.value!)
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
imageBuilder: (context, imageProvider) => Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
)
|
||||
else
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
progressIndicatorBuilder: (context, url, progress) {
|
||||
return Center(
|
||||
child: AnimatedCircularProgressIndicator(
|
||||
value: progress.progress,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
imageBuilder: (context, imageProvider) {
|
||||
Future(() {
|
||||
if (context.mounted) loaded.value = true;
|
||||
});
|
||||
return AnimatedOpacity(
|
||||
opacity: loaded.value ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
|
||||
typedef WidgetBuilder0 = Widget Function();
|
||||
typedef WidgetBuilder = Widget Function();
|
||||
|
||||
class DataSavingGate extends ConsumerWidget {
|
||||
final bool bypass;
|
||||
final WidgetBuilder0 content;
|
||||
final WidgetBuilder content;
|
||||
final Widget placeholder;
|
||||
|
||||
const DataSavingGate({
|
||||
|
||||
@@ -71,14 +71,14 @@ class ComposeAttachments extends ConsumerWidget {
|
||||
isCompact: isCompact,
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: ref.context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) =>
|
||||
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||
builder: (context) =>
|
||||
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
@@ -146,19 +146,21 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<
|
||||
AttachmentUploadConfig
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
final config =
|
||||
await showModalBottomSheet<
|
||||
AttachmentUploadConfig
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) =>
|
||||
AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
@@ -168,24 +170,15 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
onUpdate:
|
||||
(value) => ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete:
|
||||
() => ComposeLogic.deleteAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onInsert:
|
||||
() => ComposeLogic.insertAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onUpdate: (value) => ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete: () =>
|
||||
ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onInsert: () =>
|
||||
ComposeLogic.insertAttachment(ref, state, idx),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:island/pods/drive/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
class ComposeState {
|
||||
final TextEditingController titleController;
|
||||
@@ -738,6 +739,17 @@ class ComposeLogic {
|
||||
onSuccess();
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
|
||||
final postTypeStr = state.postType == 0 ? 'regular' : 'article';
|
||||
final visibilityStr = state.visibility.value.toString();
|
||||
final publisherId = state.currentPublisher.value?.id ?? 'unknown';
|
||||
|
||||
AnalyticsService().logPostCreated(
|
||||
postTypeStr,
|
||||
visibilityStr,
|
||||
state.attachments.value.isNotEmpty,
|
||||
publisherId,
|
||||
);
|
||||
|
||||
return post;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:island/widgets/post/compose_sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
@@ -367,7 +368,15 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
AnalyticsService().logPostReacted(
|
||||
item.id,
|
||||
symbol,
|
||||
attitude,
|
||||
isRemoving,
|
||||
);
|
||||
});
|
||||
|
||||
reacting.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -998,14 +998,15 @@ class _LinkPreview extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Title
|
||||
Text(
|
||||
embed.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
if (embed.title != null)
|
||||
Text(
|
||||
embed.title!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Description
|
||||
if (embed.description != null &&
|
||||
embed.description!.isNotEmpty)
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class UploadOverlay extends HookConsumerWidget {
|
||||
const UploadOverlay({super.key});
|
||||
class TaskOverlay extends HookConsumerWidget {
|
||||
const TaskOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -32,7 +32,9 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
final isVisibleOverride = useState<bool?>(null);
|
||||
final pendingHide = useState(false);
|
||||
final isExpandedLocal = useState(false);
|
||||
final isCompactLocal = useState(true); // Start compact
|
||||
final autoHideTimer = useState<Timer?>(null);
|
||||
final autoCompactTimer = useState<Timer?>(null);
|
||||
|
||||
final allFinished = activeTasks.every(
|
||||
(task) =>
|
||||
@@ -69,14 +71,35 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
}, [allFinished, activeTasks, isExpandedLocal.value, pendingHide.value]);
|
||||
|
||||
final isDesktop = isWideScreen(context);
|
||||
|
||||
// Auto-compact timer for mobile when not expanded
|
||||
useEffect(() {
|
||||
if (!isDesktop && !isCompactLocal.value && !isExpandedLocal.value) {
|
||||
// Start timer to auto-compact after 5 seconds
|
||||
autoCompactTimer.value?.cancel();
|
||||
autoCompactTimer.value = Timer(const Duration(seconds: 5), () {
|
||||
isCompactLocal.value = true;
|
||||
});
|
||||
} else {
|
||||
autoCompactTimer.value?.cancel();
|
||||
autoCompactTimer.value = null;
|
||||
}
|
||||
return null;
|
||||
}, [isCompactLocal.value, isExpandedLocal.value, isDesktop]);
|
||||
final isVisible =
|
||||
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
|
||||
!pendingHide.value;
|
||||
final slideController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
final isTopPositioned = !isDesktop; // Mobile: top, Desktop: bottom
|
||||
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 1), // Start from below the screen
|
||||
begin: isTopPositioned
|
||||
? const Offset(0, -1)
|
||||
: const Offset(0, 1), // Start from above/below the screen
|
||||
end: Offset.zero, // End at normal position
|
||||
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
|
||||
|
||||
@@ -95,33 +118,47 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isDesktop = isWideScreen(context);
|
||||
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
top: isTopPositioned ? 0 : null,
|
||||
bottom: !isTopPositioned ? 0 : null,
|
||||
left: isDesktop ? null : 0,
|
||||
right: isDesktop ? 24 : 0,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: _UploadOverlayContent(
|
||||
activeTasks: activeTasks,
|
||||
isExpanded: isExpandedLocal.value,
|
||||
onExpansionChanged: (expanded) => isExpandedLocal.value = expanded,
|
||||
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
|
||||
child:
|
||||
_TaskOverlayContent(
|
||||
activeTasks: activeTasks,
|
||||
isExpanded: isExpandedLocal.value,
|
||||
isCompact: isCompactLocal.value,
|
||||
onExpansionChanged: (expanded) =>
|
||||
isExpandedLocal.value = expanded,
|
||||
onCompactChanged: (compact) => isCompactLocal.value = compact,
|
||||
).padding(
|
||||
top: isTopPositioned
|
||||
? MediaQuery.of(context).padding.top + 16
|
||||
: 0,
|
||||
bottom: !isTopPositioned
|
||||
? 16 + MediaQuery.of(context).padding.bottom
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadOverlayContent extends HookConsumerWidget {
|
||||
class _TaskOverlayContent extends HookConsumerWidget {
|
||||
final List<DriveTask> activeTasks;
|
||||
final bool isExpanded;
|
||||
final bool isCompact;
|
||||
final Function(bool)? onExpansionChanged;
|
||||
final Function(bool)? onCompactChanged;
|
||||
|
||||
const _UploadOverlayContent({
|
||||
const _TaskOverlayContent({
|
||||
required this.activeTasks,
|
||||
required this.isExpanded,
|
||||
required this.isCompact,
|
||||
this.onExpansionChanged,
|
||||
this.onCompactChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -130,11 +167,16 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
initialValue: 0.0,
|
||||
);
|
||||
final heightAnimation = useAnimation(
|
||||
Tween<double>(begin: 60, end: 400).animate(
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||
),
|
||||
);
|
||||
final compactHeight = 32.0;
|
||||
final collapsedHeight = 60.0;
|
||||
final expandedHeight = 400.0;
|
||||
|
||||
final currentHeight = isCompact
|
||||
? compactHeight
|
||||
: isExpanded
|
||||
? expandedHeight
|
||||
: collapsedHeight;
|
||||
|
||||
final opacityAnimation = useAnimation(
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
@@ -152,233 +194,364 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
|
||||
final taskNotifier = ref.read(uploadTasksProvider.notifier);
|
||||
|
||||
void handleInteraction() {
|
||||
if (isCompact) {
|
||||
onCompactChanged?.call(false);
|
||||
} else if (!isExpanded) {
|
||||
onExpansionChanged?.call(true);
|
||||
} else {
|
||||
onExpansionChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget content = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
width: isCompact
|
||||
? _getCompactWidth(activeTasks)
|
||||
: (isMobile ? MediaQuery.of(context).size.width - 32 : 320),
|
||||
height: currentHeight,
|
||||
child: GestureDetector(
|
||||
onTap: isMobile ? handleInteraction : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
child: isCompact
|
||||
? // Compact view with progress bar background and text
|
||||
Container(
|
||||
key: const ValueKey('compact'),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
_getOverallStatusIcon(activeTasks),
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activeTasks.isEmpty
|
||||
? '0 tasks'
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status == DriveTaskStatus.inProgress &&
|
||||
task.uploadedBytes < task.fileSize,
|
||||
))
|
||||
CircularProgressIndicator(
|
||||
value: null, // Indeterminate
|
||||
strokeWidth: 3,
|
||||
trackGap: 0,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.secondary.withOpacity(0.5),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
)
|
||||
: Container(
|
||||
key: const ValueKey('expanded'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Collapsed Header
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Task icon with animation
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
key: ValueKey(isExpanded),
|
||||
isExpanded
|
||||
? Symbols.list_rounded
|
||||
: _getOverallStatusIcon(activeTasks),
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and count
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isExpanded
|
||||
? 'tasks'.tr()
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isExpanded && activeTasks.isNotEmpty)
|
||||
Text(
|
||||
_getOverallProgressText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Progress indicator (collapsed)
|
||||
if (!isExpanded)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status ==
|
||||
DriveTaskStatus.inProgress &&
|
||||
task.uploadedBytes < task.fileSize,
|
||||
))
|
||||
CircularProgressIndicator(
|
||||
value: null, // Indeterminate
|
||||
strokeWidth: 3,
|
||||
trackGap: 0,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.secondary
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expand/collapse button
|
||||
IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: opacityAnimation * 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
isExpanded
|
||||
? Symbols.expand_more
|
||||
: Symbols.chevron_right,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
onExpansionChanged?.call(!isExpanded),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded content
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline,
|
||||
width:
|
||||
1 /
|
||||
MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 18,
|
||||
right: 16,
|
||||
),
|
||||
title: const Text(
|
||||
'clearCompleted',
|
||||
).tr(),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearCompletedTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
trailing: IconButton(
|
||||
tooltip: 'clearAll'.tr(),
|
||||
icon: Icon(
|
||||
Symbols.close,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
taskNotifier.clearAllTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
),
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Task list
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final task = activeTasks[index];
|
||||
return AnimatedOpacity(
|
||||
opacity: opacityAnimation,
|
||||
duration: const Duration(
|
||||
milliseconds: 150,
|
||||
),
|
||||
child: UploadTaskTile(task: task),
|
||||
);
|
||||
}, childCount: activeTasks.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Add MouseRegion for desktop hover
|
||||
if (!isMobile) {
|
||||
content = MouseRegion(
|
||||
onEnter: (_) => onCompactChanged?.call(false),
|
||||
onExit: (_) => onCompactChanged?.call(true),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompact) {
|
||||
content = Center(child: content);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isMobile ? 16 : 24,
|
||||
left: isMobile ? 16 : 0,
|
||||
right: isMobile ? 16 : 24,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => onExpansionChanged?.call(!isExpanded),
|
||||
child: AnimatedBuilder(
|
||||
animation: animationController,
|
||||
builder: (context, child) {
|
||||
return Material(
|
||||
elevation: 8 + (opacityAnimation * 4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: isMobile ? MediaQuery.of(context).size.width - 32 : 320,
|
||||
height: heightAnimation,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Collapsed Header
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Upload icon with animation
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
key: ValueKey(isExpanded),
|
||||
isExpanded
|
||||
? Symbols.list_rounded
|
||||
: _getOverallStatusIcon(activeTasks),
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and count
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isExpanded
|
||||
? 'uploadTasks'.tr()
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isExpanded && activeTasks.isNotEmpty)
|
||||
Text(
|
||||
_getOverallProgressText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Progress indicator (collapsed)
|
||||
if (!isExpanded)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Expand/collapse button
|
||||
IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: opacityAnimation * 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
isExpanded
|
||||
? Symbols.expand_more
|
||||
: Symbols.chevron_right,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
onExpansionChanged?.call(!isExpanded),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded content
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width:
|
||||
1 /
|
||||
MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Clear completed tasks button
|
||||
if (_hasCompletedTasks(activeTasks))
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: const Text('clearCompleted').tr(),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearCompletedTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Clear all tasks button
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status != DriveTaskStatus.completed,
|
||||
))
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: const Text('Clear All'),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearAllTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Task list
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final task = activeTasks[index];
|
||||
return AnimatedOpacity(
|
||||
opacity: opacityAnimation,
|
||||
duration: const Duration(
|
||||
milliseconds: 150,
|
||||
),
|
||||
child: UploadTaskTile(task: task),
|
||||
);
|
||||
}, childCount: activeTasks.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
double? _getTaskProgress(DriveTask task) {
|
||||
if (task.status == DriveTaskStatus.completed || (task.uploadedBytes >= task.fileSize && task.fileSize > 0)) return 1.0;
|
||||
if (task.status != DriveTaskStatus.inProgress) return 0.0;
|
||||
|
||||
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
|
||||
}
|
||||
|
||||
double _getOverallProgress(List<DriveTask> tasks) {
|
||||
if (tasks.isEmpty) return 0.0;
|
||||
final totalProgress = tasks.fold<double>(
|
||||
|
||||
final progressValues = tasks.map((task) => _getTaskProgress(task));
|
||||
final determinateProgresses = progressValues.where((p) => p != null);
|
||||
|
||||
if (determinateProgresses.isEmpty) return 0.0;
|
||||
|
||||
final totalProgress = determinateProgresses.fold<double>(
|
||||
0.0,
|
||||
(sum, task) =>
|
||||
sum +
|
||||
(task.status == DriveTaskStatus.inProgress
|
||||
? task.progress
|
||||
: task.status == DriveTaskStatus.completed
|
||||
? 1
|
||||
: 0),
|
||||
(sum, progress) => sum + progress!,
|
||||
);
|
||||
return totalProgress / tasks.length;
|
||||
}
|
||||
@@ -431,7 +604,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
String _getOverallStatusText(List<DriveTask> tasks) {
|
||||
if (tasks.isEmpty) return '0 tasks';
|
||||
if (tasks.isEmpty) return 'tasks'.plural(0);
|
||||
|
||||
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
|
||||
final hasInProgress = tasks.any(
|
||||
@@ -469,18 +642,21 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
} else if (hasCompleted) {
|
||||
return '${tasks.length} ${'completed'.tr()}';
|
||||
} else {
|
||||
return '${tasks.length} ${'tasks'.tr()}';
|
||||
return 'tasks'.plural(tasks.length);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasCompletedTasks(List<DriveTask> tasks) {
|
||||
return tasks.any(
|
||||
(task) =>
|
||||
task.status == DriveTaskStatus.completed ||
|
||||
task.status == DriveTaskStatus.failed ||
|
||||
task.status == DriveTaskStatus.cancelled ||
|
||||
task.status == DriveTaskStatus.expired,
|
||||
);
|
||||
double _getCompactWidth(List<DriveTask> tasks) {
|
||||
// Base width for icon and padding
|
||||
double width = 16 + 12 + 12; // icon size + padding + spacing
|
||||
|
||||
// Add text width estimation
|
||||
final text = activeTasks.isEmpty ? '0 tasks' : _getOverallStatusText(tasks);
|
||||
// Rough estimation: 8px per character
|
||||
width += text.length * 8.0;
|
||||
|
||||
// Cap at reasonable maximum
|
||||
return width.clamp(200, 280);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +667,13 @@ class UploadTaskTile extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<UploadTaskTile> createState() => _UploadTaskTileState();
|
||||
|
||||
static double? _getTaskProgress(DriveTask task) {
|
||||
if (task.status == DriveTaskStatus.completed || (task.uploadedBytes >= task.fileSize && task.fileSize > 0)) return 1.0;
|
||||
if (task.status == DriveTaskStatus.inProgress) return null;
|
||||
|
||||
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
@@ -551,7 +734,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
value: widget.task.progress,
|
||||
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||
strokeWidth: 2.5,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
@@ -678,7 +861,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: widget.task.progress,
|
||||
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_session
|
||||
import connectivity_plus
|
||||
import desktop_drop
|
||||
import device_info_plus
|
||||
@@ -25,6 +26,7 @@ import gal
|
||||
import hotkey_manager_macos
|
||||
import in_app_review
|
||||
import irondash_engine_context
|
||||
import just_audio
|
||||
import livekit_client
|
||||
import local_auth_darwin
|
||||
import media_kit_libs_macos_video
|
||||
@@ -48,6 +50,7 @@ import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
@@ -68,6 +71,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- croppy (0.0.1):
|
||||
@@ -177,6 +179,9 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- irondash_engine_context (0.0.1):
|
||||
- FlutterMacOS
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- KeychainAccess (4.2.2)
|
||||
- livekit_client (2.5.4):
|
||||
- flutter_webrtc
|
||||
@@ -261,6 +266,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||
@@ -283,6 +289,7 @@ DEPENDENCIES:
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`)
|
||||
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
|
||||
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
|
||||
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
@@ -330,6 +337,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
croppy:
|
||||
@@ -374,6 +383,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||
irondash_engine_context:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
|
||||
just_audio:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
||||
livekit_client:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
|
||||
local_auth_darwin:
|
||||
@@ -418,6 +429,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43
|
||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||
@@ -454,6 +466,7 @@ SPEC CHECKSUMS:
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
livekit_client: 3df5a1787d64010ca56c4002959d9e47c03ba3fb
|
||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||
|
||||
60
pubspec.lock
60
pubspec.lock
@@ -97,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_session:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
autotrie:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -269,10 +277,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -557,10 +565,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
version: "2.0.8"
|
||||
event_bus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -589,10 +597,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1273,10 +1281,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: eefe5ee217f331627d8bbcf01f91b21c730bf66e225d6dc8a148370b0819168d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "7.0.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1541,6 +1549,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.2"
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.5"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_platform_interface
|
||||
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
just_audio_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_web
|
||||
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.16"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -3195,6 +3227,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
video_thumbnail:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_thumbnail
|
||||
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.6"
|
||||
visibility_detector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -3231,10 +3271,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
waveform_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
12
pubspec.yaml
12
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 3.5.0+161
|
||||
version: 3.5.0+162
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.0
|
||||
@@ -38,7 +38,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_hooks: ^0.21.3+1
|
||||
hooks_riverpod: ^3.1.0
|
||||
quick_actions: ^1.0.8
|
||||
quick_actions: ^1.1.0
|
||||
go_router: ^17.0.1
|
||||
styled_widget: ^0.4.1
|
||||
shared_preferences: ^2.5.4
|
||||
@@ -52,7 +52,7 @@ dependencies:
|
||||
flutter_highlight: ^0.7.0
|
||||
uuid: ^4.5.2
|
||||
url_launcher: ^6.3.2
|
||||
google_fonts: ^6.3.3
|
||||
google_fonts: ^7.0.0
|
||||
gap: ^3.0.1
|
||||
cached_network_image: ^3.4.1
|
||||
web: ^1.1.1
|
||||
@@ -148,7 +148,7 @@ dependencies:
|
||||
shelf_web_socket: ^3.0.0
|
||||
windows_notification: ^1.3.0
|
||||
win32: ^5.15.0
|
||||
ffi: ^2.1.4
|
||||
ffi: ^2.1.5
|
||||
dart_ipc: ^1.0.1
|
||||
pretty_diff_text: ^2.1.0
|
||||
window_manager: ^0.5.1
|
||||
@@ -175,6 +175,8 @@ dependencies:
|
||||
in_app_review: ^2.0.11
|
||||
snow_fall_animation: ^0.0.1+3
|
||||
flutter_app_intents: ^0.7.0
|
||||
video_thumbnail: ^0.5.6
|
||||
just_audio: ^0.10.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -213,6 +215,7 @@ flutter:
|
||||
- assets/images/oidc/
|
||||
- assets/images/stickers/
|
||||
- assets/icons/
|
||||
- assets/audio/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
@@ -275,3 +278,4 @@ msix_config:
|
||||
protocol_activation: solian, https
|
||||
app_uri_handler_hosts: solian.app
|
||||
capabilities: internetClientServer, location, microphone, webcam
|
||||
|
||||
|
||||
Reference in New Issue
Block a user