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",
|
"fediversePostDescribe": "Post from the Fediverse Network",
|
||||||
"settingsShowFediverseContent": "Show Fediverse Content",
|
"settingsShowFediverseContent": "Show Fediverse Content",
|
||||||
"universalSearch": "Universal Search",
|
"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:
|
PODS:
|
||||||
- Alamofire (5.11.0)
|
- Alamofire (5.11.0)
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- Flutter
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- croppy (0.0.1):
|
- croppy (0.0.1):
|
||||||
@@ -219,6 +221,9 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- irondash_engine_context (0.0.1):
|
- irondash_engine_context (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- just_audio (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- KeychainAccess (4.2.2)
|
- KeychainAccess (4.2.2)
|
||||||
- Kingfisher (8.6.2)
|
- Kingfisher (8.6.2)
|
||||||
- KingfisherWebP (1.7.2):
|
- KingfisherWebP (1.7.2):
|
||||||
@@ -324,12 +329,16 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- video_thumbnail (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- libwebp
|
||||||
- wakelock_plus (0.0.1):
|
- wakelock_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- WebRTC-SDK (137.7151.04)
|
- WebRTC-SDK (137.7151.04)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Alamofire
|
- Alamofire
|
||||||
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/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`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||||
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
||||||
|
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||||
- Kingfisher (~> 8.0)
|
- Kingfisher (~> 8.0)
|
||||||
- KingfisherWebP
|
- KingfisherWebP
|
||||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||||
@@ -379,6 +389,7 @@ DEPENDENCIES:
|
|||||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/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`)
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -414,6 +425,8 @@ SPEC REPOS:
|
|||||||
- WebRTC-SDK
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audio_session:
|
||||||
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
croppy:
|
croppy:
|
||||||
@@ -462,6 +475,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/in_app_review/ios"
|
:path: ".symlinks/plugins/in_app_review/ios"
|
||||||
irondash_engine_context:
|
irondash_engine_context:
|
||||||
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
||||||
|
just_audio:
|
||||||
|
:path: ".symlinks/plugins/just_audio/darwin"
|
||||||
livekit_client:
|
livekit_client:
|
||||||
:path: ".symlinks/plugins/livekit_client/ios"
|
:path: ".symlinks/plugins/livekit_client/ios"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
@@ -508,11 +523,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
video_thumbnail:
|
||||||
|
:path: ".symlinks/plugins/video_thumbnail/ios"
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86
|
Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86
|
||||||
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
@@ -553,6 +571,7 @@ SPEC CHECKSUMS:
|
|||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||||
|
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||||
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
|
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
|
||||||
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
|
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
|
||||||
@@ -587,6 +606,7 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ struct CheckNotificationsIntent: AppIntent {
|
|||||||
let value = result["value"] as? String ?? "You have new notifications"
|
let value = result["value"] as? String ?? "You have new notifications"
|
||||||
return .result(
|
return .result(
|
||||||
value: value,
|
value: value,
|
||||||
dialog: "Dialog: \(value)"
|
dialog: "\(value)"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let errorMessage = result["error"] as? String ?? "Failed to check notifications"
|
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 {
|
enum AppIntentError: Error {
|
||||||
case executionFailed(String)
|
case executionFailed(String)
|
||||||
}
|
}
|
||||||
@@ -373,6 +509,42 @@ struct AppShortcuts: AppShortcutsProvider {
|
|||||||
"Get notifications using \(.applicationName)",
|
"Get notifications using \(.applicationName)",
|
||||||
"Do I have notifications in \(.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 {
|
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
|
||||||
switch content.userInfo["type"] as? String {
|
switch content.userInfo["type"] as? String {
|
||||||
case "messages.new":
|
case "messages.new":
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxMessage.caf"))
|
||||||
try handleMessagingNotification(request: request, content: content)
|
try handleMessagingNotification(request: request, content: content)
|
||||||
default:
|
default:
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxNotification.caf"))
|
||||||
try handleDefaultNotification(content: content)
|
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({
|
const factory SnScrappedLink({
|
||||||
required String type,
|
required String type,
|
||||||
required String url,
|
required String url,
|
||||||
required String title,
|
required String? title,
|
||||||
required String? description,
|
required String? description,
|
||||||
required String? imageUrl,
|
required String? imageUrl,
|
||||||
required String? faviconUrl,
|
required String? faviconUrl,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$SnScrappedLink {
|
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
|
/// Create a copy of SnScrappedLink
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res> {
|
|||||||
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
|
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SnScrappedLink
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? 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(
|
return _then(_self.copyWith(
|
||||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
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,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,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?,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?,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?,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
|
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) {
|
switch (_that) {
|
||||||
case _SnScrappedLink() when $default != null:
|
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 _:
|
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) {
|
switch (_that) {
|
||||||
case _SnScrappedLink():
|
case _SnScrappedLink():
|
||||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);}
|
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) {
|
switch (_that) {
|
||||||
case _SnScrappedLink() when $default != null:
|
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 _:
|
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 type;
|
||||||
@override final String url;
|
@override final String url;
|
||||||
@override final String title;
|
@override final String? title;
|
||||||
@override final String? description;
|
@override final String? description;
|
||||||
@override final String? imageUrl;
|
@override final String? imageUrl;
|
||||||
@override final String? faviconUrl;
|
@override final String? faviconUrl;
|
||||||
@@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo
|
|||||||
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
|
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of SnScrappedLink
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? 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(
|
return _then(_SnScrappedLink(
|
||||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
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,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,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?,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?,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?,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
|
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(
|
_SnScrappedLink(
|
||||||
type: json['type'] as String,
|
type: json['type'] as String,
|
||||||
url: json['url'] as String,
|
url: json['url'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String?,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
imageUrl: json['image_url'] as String?,
|
imageUrl: json['image_url'] as String?,
|
||||||
faviconUrl: json['favicon_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 "dart:convert";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.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/models/chat.dart";
|
||||||
import "package:island/pods/chat/chat_room.dart";
|
import "package:island/pods/chat/chat_room.dart";
|
||||||
import "package:island/pods/lifecycle.dart";
|
import "package:island/pods/lifecycle.dart";
|
||||||
@@ -198,7 +200,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
|||||||
return _typingStatuses;
|
return _typingStatuses;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onMessage(WebSocketPacket pkt) {
|
Future<void> onMessage(WebSocketPacket pkt) async {
|
||||||
if (!pkt.type.startsWith('messages')) return;
|
if (!pkt.type.startsWith('messages')) return;
|
||||||
if (['messages.read'].contains(pkt.type)) return;
|
if (['messages.read'].contains(pkt.type)) return;
|
||||||
|
|
||||||
@@ -238,6 +240,17 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
|||||||
_messagesNotifier.receiveMessage(message);
|
_messagesNotifier.receiveMessage(message);
|
||||||
// Send read receipt for new message
|
// Send read receipt for new message
|
||||||
sendReadReceipt();
|
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);
|
uniqueMessages.add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state = AsyncValue.data(uniqueMessages);
|
if (ref.mounted) state = AsyncValue.data(uniqueMessages);
|
||||||
} finally {
|
} finally {
|
||||||
_isUpdatingState = false;
|
_isUpdatingState = false;
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
take: _pageSize,
|
take: _pageSize,
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(newMessages);
|
if (ref.mounted) state = AsyncValue.data(newMessages);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +408,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
} finally {
|
} finally {
|
||||||
talker.log('Finished message sync');
|
talker.log('Finished message sync');
|
||||||
// Always reset global syncing state, regardless of disposal
|
// 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;
|
_isSyncing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,7 +500,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
|
|
||||||
_hasMore = messages.length == _pageSize;
|
_hasMore = messages.length == _pageSize;
|
||||||
|
|
||||||
state = AsyncValue.data(messages);
|
if (ref.mounted) state = AsyncValue.data(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadMore() async {
|
Future<void> loadMore() async {
|
||||||
@@ -509,7 +511,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
|
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||||
final offset = currentMessages.length;
|
final offset = currentMessages.length;
|
||||||
|
|
||||||
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
||||||
@@ -518,9 +520,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
_hasMore = false;
|
_hasMore = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = AsyncValue.data(
|
if (ref.mounted) {
|
||||||
_sortMessages([...currentMessages, ...newMessages]),
|
state = AsyncValue.data(
|
||||||
);
|
_sortMessages([...currentMessages, ...newMessages]),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err, stackTrace) {
|
} catch (err, stackTrace) {
|
||||||
talker.log(
|
talker.log(
|
||||||
'Error loading more messages',
|
'Error loading more messages',
|
||||||
@@ -531,12 +535,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
} finally {
|
} finally {
|
||||||
// Always reset global syncing state, regardless of disposal
|
// 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(
|
Future<void> sendMessage(
|
||||||
WidgetRef ref,
|
WidgetRef outerRef,
|
||||||
String content,
|
String content,
|
||||||
List<UniversalFile> attachments, {
|
List<UniversalFile> attachments, {
|
||||||
SnPoll? poll,
|
SnPoll? poll,
|
||||||
@@ -569,14 +575,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
_fileUploadProgress[localMessage.id] = {};
|
_fileUploadProgress[localMessage.id] = {};
|
||||||
await _database.saveMessageWithSender(localMessage);
|
await _database.saveMessageWithSender(localMessage);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var cloudAttachments = List.empty(growable: true);
|
var cloudAttachments = List.empty(growable: true);
|
||||||
for (var idx = 0; idx < attachments.length; idx++) {
|
for (var idx = 0; idx < attachments.length; idx++) {
|
||||||
final cloudFile = await FileUploader.createCloudFile(
|
final cloudFile = await FileUploader.createCloudFile(
|
||||||
ref: ref,
|
ref: outerRef,
|
||||||
fileData: attachments[idx],
|
fileData: attachments[idx],
|
||||||
onProgress: (progress, _) {
|
onProgress: (progress, _) {
|
||||||
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
|
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
|
||||||
@@ -619,24 +625,27 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
await _database.deleteMessage(localMessage.id);
|
await _database.deleteMessage(localMessage.id);
|
||||||
await _database.saveMessageWithSender(updatedMessage);
|
await _database.saveMessageWithSender(updatedMessage);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
if (ref.mounted) {
|
||||||
if (editingTo != null) {
|
final currentMessages = state.value ?? [];
|
||||||
final newMessages = currentMessages
|
if (editingTo != null) {
|
||||||
.where((m) => m.id != localMessage.id) // remove pending message
|
final newMessages = currentMessages
|
||||||
.map(
|
.where((m) => m.id != localMessage.id) // remove pending message
|
||||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
.map(
|
||||||
) // update original message
|
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||||
.toList();
|
) // update original message
|
||||||
state = AsyncValue.data(newMessages);
|
.toList();
|
||||||
} else {
|
state = AsyncValue.data(newMessages);
|
||||||
final newMessages = currentMessages.map((m) {
|
} else {
|
||||||
if (m.id == localMessage.id) {
|
final newMessages = currentMessages.map((m) {
|
||||||
return updatedMessage;
|
if (m.id == localMessage.id) {
|
||||||
}
|
return updatedMessage;
|
||||||
return m;
|
}
|
||||||
}).toList();
|
return m;
|
||||||
state = AsyncValue.data(newMessages);
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
talker.log('Message with nonce $nonce sent successfully');
|
talker.log('Message with nonce $nonce sent successfully');
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
talker.log(
|
talker.log(
|
||||||
@@ -651,13 +660,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
localMessage.id,
|
localMessage.id,
|
||||||
MessageStatus.failed,
|
MessageStatus.failed,
|
||||||
);
|
);
|
||||||
final newMessages = (state.value ?? []).map((m) {
|
if (ref.mounted) {
|
||||||
if (m.id == localMessage.id) {
|
final newMessages = (state.value ?? []).map((m) {
|
||||||
return m..status = MessageStatus.failed;
|
if (m.id == localMessage.id) {
|
||||||
}
|
return m..status = MessageStatus.failed;
|
||||||
return m;
|
}
|
||||||
}).toList();
|
return m;
|
||||||
state = AsyncValue.data(newMessages);
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
}
|
||||||
showErrorAlert(e);
|
showErrorAlert(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,13 +709,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
await _database.deleteMessage(pendingMessageId);
|
await _database.deleteMessage(pendingMessageId);
|
||||||
await _database.saveMessageWithSender(updatedMessage);
|
await _database.saveMessageWithSender(updatedMessage);
|
||||||
|
|
||||||
final newMessages = (state.value ?? []).map((m) {
|
if (ref.mounted) {
|
||||||
if (m.id == pendingMessageId) {
|
final newMessages = (state.value ?? []).map((m) {
|
||||||
return updatedMessage;
|
if (m.id == pendingMessageId) {
|
||||||
}
|
return updatedMessage;
|
||||||
return m;
|
}
|
||||||
}).toList();
|
return m;
|
||||||
state = AsyncValue.data(newMessages);
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
talker.log(
|
talker.log(
|
||||||
'Failed to retry message $pendingMessageId',
|
'Failed to retry message $pendingMessageId',
|
||||||
@@ -718,13 +731,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
pendingMessageId,
|
pendingMessageId,
|
||||||
MessageStatus.failed,
|
MessageStatus.failed,
|
||||||
);
|
);
|
||||||
final newMessages = (state.value ?? []).map((m) {
|
if (ref.mounted) {
|
||||||
if (m.id == pendingMessageId) {
|
final newMessages = (state.value ?? []).map((m) {
|
||||||
return m..status = MessageStatus.failed;
|
if (m.id == pendingMessageId) {
|
||||||
}
|
return m..status = MessageStatus.failed;
|
||||||
return m;
|
}
|
||||||
}).toList();
|
return m;
|
||||||
state = AsyncValue.data(_sortMessages(newMessages));
|
}).toList();
|
||||||
|
state = AsyncValue.data(_sortMessages(newMessages));
|
||||||
|
}
|
||||||
showErrorAlert(e);
|
showErrorAlert(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,21 +771,23 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
if (!isSilentMessage) {
|
if (!isSilentMessage) {
|
||||||
await _database.saveMessageWithSender(localMessage);
|
await _database.saveMessageWithSender(localMessage);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||||
final existingIndex = currentMessages.indexWhere(
|
final existingIndex = currentMessages.indexWhere(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.id == localMessage.id ||
|
m.id == localMessage.id ||
|
||||||
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (ref.mounted) {
|
||||||
final newList = [...currentMessages];
|
if (existingIndex >= 0) {
|
||||||
newList[existingIndex] = localMessage;
|
final newList = [...currentMessages];
|
||||||
state = AsyncValue.data(_sortMessages(newList));
|
newList[existingIndex] = localMessage;
|
||||||
} else {
|
state = AsyncValue.data(_sortMessages(newList));
|
||||||
state = AsyncValue.data(
|
} else {
|
||||||
_sortMessages([localMessage, ...currentMessages]),
|
state = AsyncValue.data(
|
||||||
);
|
_sortMessages([localMessage, ...currentMessages]),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,13 +854,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
|
|
||||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
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);
|
final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
|
||||||
|
|
||||||
if (index >= 0) {
|
if (ref.mounted) {
|
||||||
final newList = [...currentMessages];
|
if (index >= 0) {
|
||||||
newList[index] = updatedMessage;
|
final newList = [...currentMessages];
|
||||||
state = AsyncValue.data(_sortMessages(newList));
|
newList[index] = updatedMessage;
|
||||||
|
state = AsyncValue.data(_sortMessages(newList));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,7 +876,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
talker.log('Received message deletion $messageId');
|
talker.log('Received message deletion $messageId');
|
||||||
_pendingMessages.remove(messageId);
|
_pendingMessages.remove(messageId);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||||
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
||||||
|
|
||||||
LocalChatMessage? messageToUpdate;
|
LocalChatMessage? messageToUpdate;
|
||||||
@@ -883,10 +902,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
|
|
||||||
await _database.saveMessageWithSender(deletedMessage);
|
await _database.saveMessageWithSender(deletedMessage);
|
||||||
|
|
||||||
if (messageIndex != -1) {
|
if (ref.mounted) {
|
||||||
final newList = [...currentMessages];
|
if (messageIndex != -1) {
|
||||||
newList[messageIndex] = deletedMessage;
|
final newList = [...currentMessages];
|
||||||
state = AsyncValue.data(newList);
|
newList[messageIndex] = deletedMessage;
|
||||||
|
state = AsyncValue.data(newList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,7 +928,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
_pendingMessages.remove(messageId);
|
_pendingMessages.remove(messageId);
|
||||||
await _database.deleteMessage(messageId);
|
await _database.deleteMessage(messageId);
|
||||||
|
|
||||||
final currentMessages = state.value ?? [];
|
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||||
final newMessages = currentMessages
|
final newMessages = currentMessages
|
||||||
.where((m) => m.id != messageId)
|
.where((m) => m.id != messageId)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -1063,7 +1084,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if message is already in current state to avoid duplicate loading
|
// 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(
|
final existingIndex = currentMessages.indexWhere(
|
||||||
(m) => m.id == messageId,
|
(m) => m.id == messageId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class MessagesNotifierProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$messagesNotifierHash() => r'a721a4b92b48ee7c2289cdcd7130bbf1ca9dcb40';
|
String _$messagesNotifierHash() => r'622fed0908eb04381a971e36540c516743246dff';
|
||||||
|
|
||||||
final class MessagesNotifierFamily extends $Family
|
final class MessagesNotifierFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:island/services/analytics_service.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
@@ -256,8 +257,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
|||||||
|
|
||||||
void setThemeMode(String value) {
|
void setThemeMode(String value) {
|
||||||
final prefs = ref.read(sharedPreferencesProvider);
|
final prefs = ref.read(sharedPreferencesProvider);
|
||||||
|
final oldValue = state.themeMode;
|
||||||
prefs.setString(kAppThemeMode, value);
|
prefs.setString(kAppThemeMode, value);
|
||||||
state = state.copyWith(themeMode: value);
|
state = state.copyWith(themeMode: value);
|
||||||
|
|
||||||
|
AnalyticsService().logThemeChanged(oldValue ?? 'system', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAppTransparentBackground(double value) {
|
void setAppTransparentBackground(double value) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ final class AppSettingsNotifierProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$appSettingsNotifierHash() =>
|
String _$appSettingsNotifierHash() =>
|
||||||
r'2437c621dcb1625a120ed1f21ab5c29906ba98be';
|
r'64cf6dfda90fc634336d96bd5313b2a07b19eccb';
|
||||||
|
|
||||||
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
|
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
|
||||||
AppSettings build();
|
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) {
|
void _handleWebSocketPacket(dynamic packet) {
|
||||||
if (packet.type.startsWith('task.')) {
|
if (packet.type.startsWith('task.') || packet.type == 'upload.completed') {
|
||||||
final data = packet.data;
|
final data = packet.data;
|
||||||
if (data == null) return;
|
if (data == null && packet.type != 'upload.completed') return;
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
talker.info(
|
talker.info(
|
||||||
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
|
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
|
||||||
);
|
);
|
||||||
|
|
||||||
final taskId = data['task_id'] as String?;
|
final taskId = data != null ? (data['task_id'] as String?) : null;
|
||||||
if (taskId == null) return;
|
if (taskId == null && packet.type != 'upload.completed') return;
|
||||||
|
|
||||||
switch (packet.type) {
|
switch (packet.type) {
|
||||||
case 'task.created':
|
case 'task.created':
|
||||||
_handleTaskCreated(taskId, data);
|
_handleTaskCreated(taskId!, data);
|
||||||
break;
|
break;
|
||||||
case 'task.progress':
|
case 'task.progress':
|
||||||
_handleProgressUpdate(taskId, data);
|
_handleProgressUpdate(taskId!, data);
|
||||||
break;
|
break;
|
||||||
case 'task.completed':
|
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;
|
break;
|
||||||
case 'task.failed':
|
case 'task.failed':
|
||||||
_handleUploadFailed(taskId, data);
|
_handleUploadFailed(taskId!, data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +285,21 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
|
|||||||
}).toList();
|
}).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(
|
void updateDownloadProgress(
|
||||||
String taskId,
|
String taskId,
|
||||||
int downloadedBytes,
|
int downloadedBytes,
|
||||||
@@ -470,6 +514,7 @@ class EnhancedFileUploader extends FileUploader {
|
|||||||
|
|
||||||
// Step 2: Upload chunks
|
// Step 2: Upload chunks
|
||||||
int bytesUploaded = 0;
|
int bytesUploaded = 0;
|
||||||
|
int chunksUploaded = 0;
|
||||||
if (fileData is XFile) {
|
if (fileData is XFile) {
|
||||||
// Use stream for XFile
|
// Use stream for XFile
|
||||||
final subscription = fileData.openRead().listen(null);
|
final subscription = fileData.openRead().listen(null);
|
||||||
@@ -494,6 +539,11 @@ class EnhancedFileUploader extends FileUploader {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
bytesUploaded += chunkData.length;
|
bytesUploaded += chunkData.length;
|
||||||
|
chunksUploaded += 1;
|
||||||
|
// Update upload progress in UI
|
||||||
|
ref
|
||||||
|
.read(uploadTasksProvider.notifier)
|
||||||
|
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||||
}
|
}
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
} else if (fileData is Uint8List) {
|
} else if (fileData is Uint8List) {
|
||||||
@@ -521,6 +571,11 @@ class EnhancedFileUploader extends FileUploader {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
bytesUploaded += chunks[i].length;
|
bytesUploaded += chunks[i].length;
|
||||||
|
chunksUploaded += 1;
|
||||||
|
// Update upload progress in UI
|
||||||
|
ref
|
||||||
|
.read(uploadTasksProvider.notifier)
|
||||||
|
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw ArgumentError('Invalid fileData type');
|
throw ArgumentError('Invalid fileData type');
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:easy_localization/easy_localization.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:flutter/material.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/config.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/talker.dart';
|
import 'package:island/talker.dart';
|
||||||
|
import 'package:island/services/analytics_service.dart';
|
||||||
|
|
||||||
class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||||
@override
|
@override
|
||||||
@@ -31,9 +28,7 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
|||||||
final response = await client.get('/pass/accounts/me');
|
final response = await client.get('/pass/accounts/me');
|
||||||
final user = SnAccount.fromJson(response.data);
|
final user = SnAccount.fromJson(response.data);
|
||||||
|
|
||||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
AnalyticsService().setUserId(user.id);
|
||||||
FirebaseAnalytics.instance.setUserId(id: user.id);
|
|
||||||
}
|
|
||||||
return user;
|
return user;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
@@ -91,9 +86,8 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
|||||||
final prefs = ref.read(sharedPreferencesProvider);
|
final prefs = ref.read(sharedPreferencesProvider);
|
||||||
await prefs.remove(kTokenPairStoreKey);
|
await prefs.remove(kTokenPairStoreKey);
|
||||||
ref.invalidate(tokenProvider);
|
ref.invalidate(tokenProvider);
|
||||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
AnalyticsService().setUserId(null);
|
||||||
FirebaseAnalytics.instance.setUserId(id: 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/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/database/message.dart';
|
||||||
import 'package:island/models/chat.dart';
|
import 'package:island/models/chat.dart';
|
||||||
import 'package:island/widgets/chat/message_item.dart';
|
import 'package:island/widgets/chat/message_item.dart';
|
||||||
|
|
||||||
// Provider to track animated messages to prevent replay
|
// Provider to track animated messages to prevent replay
|
||||||
final animatedMessagesProvider =
|
final animatedMessagesProvider = NotifierProvider.autoDispose(
|
||||||
NotifierProvider<AnimatedMessagesNotifier, Set<String>>(
|
AnimatedMessagesNotifier.new,
|
||||||
AnimatedMessagesNotifier.new,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||||
@override
|
@override
|
||||||
@@ -22,7 +22,7 @@ class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessageItemWrapper extends ConsumerWidget {
|
class MessageItemWrapper extends HookConsumerWidget {
|
||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final int index;
|
final int index;
|
||||||
final bool isLastInGroup;
|
final bool isLastInGroup;
|
||||||
@@ -54,51 +54,6 @@ class MessageItemWrapper extends ConsumerWidget {
|
|||||||
required this.roomOpenTime,
|
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) {
|
Widget _buildContent(BuildContext context, SnChatMember? identity) {
|
||||||
final isSelected = selectedMessages.contains(message.id);
|
final isSelected = selectedMessages.contains(message.id);
|
||||||
final isCurrentUser = identity?.id == message.senderId;
|
final isCurrentUser = identity?.id == message.senderId;
|
||||||
@@ -116,12 +71,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
color:
|
color: isSelected
|
||||||
isSelected
|
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
? Theme.of(
|
: null,
|
||||||
context,
|
|
||||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
|
||||||
: null,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
MessageItem(
|
MessageItem(
|
||||||
@@ -130,10 +82,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
|||||||
key: ValueKey('item-${message.id}'),
|
key: ValueKey('item-${message.id}'),
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser: isCurrentUser,
|
isCurrentUser: isCurrentUser,
|
||||||
onAction:
|
onAction: isSelectionMode
|
||||||
isSelectionMode
|
? null
|
||||||
? null
|
: (action) => onMessageAction(action, message),
|
||||||
: (action) => onMessageAction(action, message),
|
|
||||||
onJump: onJump,
|
onJump: onJump,
|
||||||
progress: attachmentProgress[message.id],
|
progress: attachmentProgress[message.id],
|
||||||
showAvatar: isLastInGroup,
|
showAvatar: isLastInGroup,
|
||||||
@@ -178,4 +129,88 @@ class MessageItemWrapper extends ConsumerWidget {
|
|||||||
onJump: (_) {},
|
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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/pods/drive/file_references.dart';
|
|
||||||
import 'package:island/services/file_download.dart';
|
import 'package:island/services/file_download.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/services/time.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
import 'package:island/widgets/content/file_viewer_contents.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 {
|
class FileDetailScreen extends HookConsumerWidget {
|
||||||
final SnCloudFile item;
|
final SnCloudFile item;
|
||||||
@@ -165,22 +160,6 @@ class FileDetailScreen extends HookConsumerWidget {
|
|||||||
break;
|
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
|
// Always add info button
|
||||||
actions.add(
|
actions.add(
|
||||||
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
|
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,
|
isCompact: true,
|
||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
|
isUploading: progressMap.containsKey(idx),
|
||||||
onRequestUpload: () async {
|
onRequestUpload: () async {
|
||||||
final config =
|
final config =
|
||||||
await showModalBottomSheet<
|
await showModalBottomSheet<
|
||||||
|
|||||||
@@ -459,63 +459,85 @@ class _PostDetailLargeScreenLayout extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final user = ref.watch(userInfoProvider);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Material(
|
child: Center(
|
||||||
color: Theme.of(context).cardTheme.color,
|
child: Padding(
|
||||||
elevation: 8,
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: CloudFileList(
|
||||||
child: Padding(
|
files: post.attachments,
|
||||||
padding: const EdgeInsets.all(24),
|
disableConstraint: true,
|
||||||
child: CloudFileList(
|
padding: EdgeInsets.zero,
|
||||||
files: post.attachments,
|
|
||||||
disableConstraint: true,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: CustomScrollView(
|
child: Stack(
|
||||||
slivers: [
|
fit: StackFit.expand,
|
||||||
SliverToBoxAdapter(
|
children: [
|
||||||
child: Padding(
|
Material(
|
||||||
padding: const EdgeInsets.all(16),
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Column(
|
elevation: 8,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: CustomScrollView(
|
||||||
children: [
|
slivers: [
|
||||||
PostHeader(
|
SliverToBoxAdapter(
|
||||||
item: post,
|
child: Padding(
|
||||||
isFullPost: true,
|
padding: const EdgeInsets.all(16),
|
||||||
isCompact: false,
|
child: Column(
|
||||||
renderingPadding: EdgeInsets.zero,
|
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(
|
PostRepliesList(postId: postId, maxWidth: 800),
|
||||||
item: post,
|
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||||
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),
|
if (user.value != null)
|
||||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
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:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:flutter_app_intents/flutter_app_intents.dart';
|
import 'package:flutter_app_intents/flutter_app_intents.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:island/models/auth.dart';
|
import 'package:island/models/auth.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
import 'package:island/talker.dart';
|
import 'package:island/talker.dart';
|
||||||
import 'package:island/route.dart';
|
import 'package:island/route.dart';
|
||||||
|
|
||||||
@@ -165,6 +167,75 @@ class AppIntentsService {
|
|||||||
.build(),
|
.build(),
|
||||||
_handleCheckNotificationsIntent,
|
_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() {
|
void dispose() {
|
||||||
@@ -232,11 +303,7 @@ class AppIntentsService {
|
|||||||
try {
|
try {
|
||||||
talker.info('[AppIntents] Opening compose screen');
|
talker.info('[AppIntents] Opening compose screen');
|
||||||
|
|
||||||
if (rootNavigatorKey.currentContext == null) {
|
eventBus.fire(ShowComposeSheetEvent());
|
||||||
return AppIntentResult.failed(error: 'App context not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
|
||||||
|
|
||||||
return AppIntentResult.successful(
|
return AppIntentResult.successful(
|
||||||
value: 'Opening compose screen',
|
value: 'Opening compose screen',
|
||||||
@@ -254,11 +321,7 @@ class AppIntentsService {
|
|||||||
try {
|
try {
|
||||||
talker.info('[AppIntents] Composing new post');
|
talker.info('[AppIntents] Composing new post');
|
||||||
|
|
||||||
if (rootNavigatorKey.currentContext == null) {
|
eventBus.fire(ShowComposeSheetEvent());
|
||||||
return AppIntentResult.failed(error: 'App context not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
|
||||||
|
|
||||||
return AppIntentResult.successful(
|
return AppIntentResult.successful(
|
||||||
value: 'Opening compose screen',
|
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
|
// Donation Methods - to be called manually from your app code
|
||||||
|
|
||||||
Future<void> donateOpenChat(String channelId) async {
|
Future<void> donateOpenChat(String channelId) async {
|
||||||
@@ -371,6 +628,7 @@ class AppIntentsService {
|
|||||||
relevanceScore: 0.8,
|
relevanceScore: 0.8,
|
||||||
context: {'feature': 'chat', 'userAction': true},
|
context: {'feature': 'chat', 'userAction': true},
|
||||||
);
|
);
|
||||||
|
_logDonation('open_chat', {'channel_id': channelId});
|
||||||
talker.info('[AppIntents] Donated open_chat intent');
|
talker.info('[AppIntents] Donated open_chat intent');
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
|
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
|
||||||
@@ -386,6 +644,7 @@ class AppIntentsService {
|
|||||||
relevanceScore: 0.8,
|
relevanceScore: 0.8,
|
||||||
context: {'feature': 'posts', 'userAction': true},
|
context: {'feature': 'posts', 'userAction': true},
|
||||||
);
|
);
|
||||||
|
_logDonation('open_post', {'post_id': postId});
|
||||||
talker.info('[AppIntents] Donated open_post intent');
|
talker.info('[AppIntents] Donated open_post intent');
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
talker.error('[AppIntents] Failed to donate open_post', e, stack);
|
talker.error('[AppIntents] Failed to donate open_post', e, stack);
|
||||||
@@ -401,6 +660,7 @@ class AppIntentsService {
|
|||||||
relevanceScore: 0.9,
|
relevanceScore: 0.9,
|
||||||
context: {'feature': 'compose', 'userAction': true},
|
context: {'feature': 'compose', 'userAction': true},
|
||||||
);
|
);
|
||||||
|
_logDonation('open_compose', {});
|
||||||
talker.info('[AppIntents] Donated compose intent');
|
talker.info('[AppIntents] Donated compose intent');
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
talker.error('[AppIntents] Failed to donate compose', e, stack);
|
talker.error('[AppIntents] Failed to donate compose', e, stack);
|
||||||
@@ -416,6 +676,7 @@ class AppIntentsService {
|
|||||||
relevanceScore: 0.7,
|
relevanceScore: 0.7,
|
||||||
context: {'feature': 'search', 'userAction': true},
|
context: {'feature': 'search', 'userAction': true},
|
||||||
);
|
);
|
||||||
|
_logDonation('search_content', {'query': query});
|
||||||
talker.info('[AppIntents] Donated search intent');
|
talker.info('[AppIntents] Donated search intent');
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
talker.error('[AppIntents] Failed to donate search', e, stack);
|
talker.error('[AppIntents] Failed to donate search', e, stack);
|
||||||
@@ -431,6 +692,7 @@ class AppIntentsService {
|
|||||||
relevanceScore: 0.6,
|
relevanceScore: 0.6,
|
||||||
context: {'feature': 'notifications', 'userAction': true},
|
context: {'feature': 'notifications', 'userAction': true},
|
||||||
);
|
);
|
||||||
|
_logDonation('check_notifications', {});
|
||||||
talker.info('[AppIntents] Donated check_notifications intent');
|
talker.info('[AppIntents] Donated check_notifications intent');
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
talker.error(
|
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:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -104,6 +105,13 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
|||||||
if (settings.notifyWithHaptic) {
|
if (settings.notifyWithHaptic) {
|
||||||
HapticFeedback.heavyImpact();
|
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(
|
showTopSnackBar(
|
||||||
globalOverlay.currentState!,
|
globalOverlay.currentState!,
|
||||||
Center(
|
Center(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:island/main.dart';
|
import 'package:island/main.dart';
|
||||||
@@ -31,9 +33,7 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
|
|||||||
|
|
||||||
Future<void> initializeLocalNotifications() async {
|
Future<void> initializeLocalNotifications() async {
|
||||||
// Initialize Windows notification for Windows platform
|
// Initialize Windows notification for Windows platform
|
||||||
windowsNotification = winty.WindowsNotification(
|
windowsNotification = winty.WindowsNotification(applicationId: "Solian");
|
||||||
applicationId: "Solian",
|
|
||||||
);
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addObserver(
|
WidgetsBinding.instance.addObserver(
|
||||||
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
|
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
|
||||||
@@ -55,6 +55,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
) {
|
) {
|
||||||
|
final settings = ref.watch(appSettingsProvider);
|
||||||
final ws = ref.watch(websocketProvider);
|
final ws = ref.watch(websocketProvider);
|
||||||
return ws.dataStream.listen((pkt) async {
|
return ws.dataStream.listen((pkt) async {
|
||||||
if (pkt.type == "notifications.new") {
|
if (pkt.type == "notifications.new") {
|
||||||
@@ -64,6 +65,16 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
|||||||
talker.info(
|
talker.info(
|
||||||
'[Notification] Showing in-app notification: ${notification.title}',
|
'[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(
|
showTopSnackBar(
|
||||||
globalOverlay.currentState!,
|
globalOverlay.currentState!,
|
||||||
Center(
|
Center(
|
||||||
@@ -139,7 +150,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
|||||||
final notificationMessage = NotificationMessage.fromPluginTemplate(
|
final notificationMessage = NotificationMessage.fromPluginTemplate(
|
||||||
notification.id, // unique id
|
notification.id, // unique id
|
||||||
notification.title,
|
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,
|
group: notification.topic,
|
||||||
image: imagePath,
|
image: imagePath,
|
||||||
largeImage: largeImagePath,
|
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:path_provider/path_provider.dart' show getTemporaryDirectory;
|
||||||
import 'package:screenshot/screenshot.dart';
|
import 'package:screenshot/screenshot.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:island/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Shares a post as a screenshot image
|
/// Shares a post as a screenshot image
|
||||||
Future<void> sharePostAsScreenshot(
|
Future<void> sharePostAsScreenshot(
|
||||||
@@ -62,5 +63,9 @@ Future<void> sharePostAsScreenshot(
|
|||||||
.catchError((err) {
|
.catchError((err) {
|
||||||
if (context.mounted) hideLoadingModal(context);
|
if (context.mounted) hideLoadingModal(context);
|
||||||
showErrorAlert(err);
|
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,
|
title: null,
|
||||||
titlePadding: EdgeInsets.zero,
|
titlePadding: EdgeInsets.zero,
|
||||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
content: Column(
|
content: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Icon(
|
children: [
|
||||||
icon ?? Icons.error_outline_rounded,
|
Icon(
|
||||||
size: 48,
|
icon ?? Icons.error_outline_rounded,
|
||||||
color: Theme.of(context).colorScheme.error,
|
size: 48,
|
||||||
),
|
color: Theme.of(context).colorScheme.error,
|
||||||
const Gap(16),
|
),
|
||||||
Text(
|
const Gap(16),
|
||||||
'somethingWentWrong'.tr(),
|
Text(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
'somethingWentWrong'.tr(),
|
||||||
),
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
const Gap(8),
|
),
|
||||||
Text(text),
|
const Gap(8),
|
||||||
const Gap(8),
|
SelectableText(text),
|
||||||
],
|
const Gap(8),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import 'package:island/services/event_bus.dart';
|
|||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/cmp/pattle.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:material_symbols_icons/material_symbols_icons.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shake/shake.dart';
|
import 'package:shake/shake.dart';
|
||||||
@@ -146,16 +146,19 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ShakeDetector detector = ShakeDetector.autoStart(
|
ShakeDetector? detactor;
|
||||||
onPhoneShake: (_) {
|
if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
|
||||||
showPalette.value = true;
|
detactor = ShakeDetector.autoStart(
|
||||||
},
|
onPhoneShake: (_) {
|
||||||
);
|
showPalette.value = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return () {
|
return () {
|
||||||
hotKeyManager.unregister(popHotKey);
|
hotKeyManager.unregister(popHotKey);
|
||||||
hotKeyManager.unregister(cmpHotKey);
|
hotKeyManager.unregister(cmpHotKey);
|
||||||
detector.stopListening();
|
detactor?.stopListening();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -255,7 +258,7 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_WebSocketIndicator(),
|
_WebSocketIndicator(),
|
||||||
const UploadOverlay(),
|
const TaskOverlay(),
|
||||||
if (showPalette.value)
|
if (showPalette.value)
|
||||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||||
],
|
],
|
||||||
@@ -268,7 +271,7 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Positioned.fill(child: child),
|
Positioned.fill(child: child),
|
||||||
_WebSocketIndicator(),
|
_WebSocketIndicator(),
|
||||||
const UploadOverlay(),
|
const TaskOverlay(),
|
||||||
if (showPalette.value)
|
if (showPalette.value)
|
||||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
progress:
|
progress:
|
||||||
attachmentProgress['chat-upload']?[idx],
|
attachmentProgress['chat-upload']?[idx],
|
||||||
|
isUploading:
|
||||||
|
attachmentProgress['chat-upload']
|
||||||
|
?.containsKey(idx) ??
|
||||||
|
false,
|
||||||
onRequestUpload: () => onUploadAttachment(idx),
|
onRequestUpload: () => onUploadAttachment(idx),
|
||||||
onDelete: () => onDeleteAttachment(idx),
|
onDelete: () => onDeleteAttachment(idx),
|
||||||
onUpdate: (value) {
|
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 'dart:io';
|
||||||
|
|
||||||
import 'package:cross_file/cross_file.dart';
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -14,13 +16,12 @@ import 'package:island/services/file_uploader.dart';
|
|||||||
import 'package:island/utils/format.dart';
|
import 'package:island/utils/format.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/sensitive.dart';
|
||||||
import 'package:island/widgets/content/sheet.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:super_context_menu/super_context_menu.dart';
|
import 'package:super_context_menu/super_context_menu.dart';
|
||||||
|
|
||||||
import 'sensitive.dart';
|
|
||||||
|
|
||||||
class SensitiveMarksSelector extends StatefulWidget {
|
class SensitiveMarksSelector extends StatefulWidget {
|
||||||
final List<int> initial;
|
final List<int> initial;
|
||||||
final ValueChanged<List<int>>? onChanged;
|
final ValueChanged<List<int>>? onChanged;
|
||||||
@@ -85,6 +86,7 @@ class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
|
|||||||
class AttachmentPreview extends HookConsumerWidget {
|
class AttachmentPreview extends HookConsumerWidget {
|
||||||
final UniversalFile item;
|
final UniversalFile item;
|
||||||
final double? progress;
|
final double? progress;
|
||||||
|
final bool isUploading;
|
||||||
final Function(int)? onMove;
|
final Function(int)? onMove;
|
||||||
final Function? onDelete;
|
final Function? onDelete;
|
||||||
final Function? onInsert;
|
final Function? onInsert;
|
||||||
@@ -96,6 +98,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
this.progress,
|
this.progress,
|
||||||
|
this.isUploading = false,
|
||||||
this.onRequestUpload,
|
this.onRequestUpload,
|
||||||
this.onMove,
|
this.onMove,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
@@ -125,79 +128,72 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
builder:
|
builder: (context) => SheetScaffold(
|
||||||
(context) => SheetScaffold(
|
heightFactor: 0.6,
|
||||||
heightFactor: 0.6,
|
titleText: 'rename'.tr(),
|
||||||
titleText: 'rename'.tr(),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
padding: const EdgeInsets.symmetric(
|
child: TextField(
|
||||||
horizontal: 24,
|
controller: nameController,
|
||||||
vertical: 24,
|
decoration: InputDecoration(
|
||||||
),
|
labelText: 'fileName'.tr(),
|
||||||
child: TextField(
|
border: const OutlineInputBorder(),
|
||||||
controller: nameController,
|
errorText: errorMessage,
|
||||||
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(
|
await showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder:
|
builder: (context) => SheetScaffold(
|
||||||
(context) => SheetScaffold(
|
heightFactor: 0.6,
|
||||||
heightFactor: 0.6,
|
titleText: 'markAsSensitive'.tr(),
|
||||||
titleText: 'markAsSensitive'.tr(),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
padding: const EdgeInsets.symmetric(
|
child: Column(
|
||||||
horizontal: 24,
|
children: [
|
||||||
vertical: 24,
|
// 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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var ratio =
|
var ratio = item.isOnCloud
|
||||||
item.isOnCloud
|
? (item.data.fileMeta?['ratio'] is num
|
||||||
? (item.data.fileMeta?['ratio'] is num
|
? item.data.fileMeta!['ratio'].toDouble()
|
||||||
? item.data.fileMeta!['ratio'].toDouble()
|
: null)
|
||||||
: 1.0)
|
: null;
|
||||||
: 1.0;
|
|
||||||
if (ratio == 0) ratio = 1.0;
|
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(
|
final contentWidget = ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -298,128 +462,13 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
if (ratio != null)
|
||||||
aspectRatio: ratio,
|
AspectRatio(
|
||||||
child: Stack(
|
aspectRatio: ratio,
|
||||||
fit: StackFit.expand,
|
child: innerContentWidget,
|
||||||
children: [
|
).center()
|
||||||
Builder(
|
else
|
||||||
key: ValueKey(item.hashCode),
|
IntrinsicHeight(child: innerContentWidget),
|
||||||
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(),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -497,8 +546,9 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
if (onRequestUpload != null)
|
if (onRequestUpload != null)
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap:
|
onTap: item.isOnCloud
|
||||||
item.isOnCloud ? null : () => onRequestUpload?.call(),
|
? null
|
||||||
|
: () => onRequestUpload?.call(),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -507,40 +557,39 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
child:
|
child: (item.isOnCloud)
|
||||||
(item.isOnCloud)
|
? Row(
|
||||||
? Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Icon(
|
||||||
Icon(
|
Symbols.cloud,
|
||||||
Symbols.cloud,
|
size: 16,
|
||||||
size: 16,
|
color: Colors.white,
|
||||||
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(
|
: Row(
|
||||||
'attachmentOnCloud'.tr(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(color: Colors.white),
|
children: [
|
||||||
),
|
Icon(
|
||||||
],
|
Symbols.cloud_off,
|
||||||
)
|
size: 16,
|
||||||
: Row(
|
color: Colors.white,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
if (!isCompact) const Gap(8),
|
||||||
Icon(
|
if (!isCompact)
|
||||||
Symbols.cloud_off,
|
Text(
|
||||||
size: 16,
|
'attachmentOnDevice'.tr(),
|
||||||
color: Colors.white,
|
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(
|
return ContextMenuWidget(
|
||||||
menuProvider:
|
menuProvider: (MenuRequest request) => Menu(
|
||||||
(MenuRequest request) => Menu(
|
children: [
|
||||||
children: [
|
if (item.isOnDevice && item.type == UniversalFileType.image)
|
||||||
if (item.isOnDevice && item.type == UniversalFileType.image)
|
MenuAction(
|
||||||
MenuAction(
|
title: 'crop'.tr(),
|
||||||
title: 'crop'.tr(),
|
image: MenuImage.icon(Symbols.crop),
|
||||||
image: MenuImage.icon(Symbols.crop),
|
callback: () async {
|
||||||
callback: () async {
|
final result = await cropImage(
|
||||||
final result = await cropImage(
|
context,
|
||||||
context,
|
image: item.data,
|
||||||
image: item.data,
|
replacePath: true,
|
||||||
replacePath: true,
|
);
|
||||||
);
|
if (result == null) return;
|
||||||
if (result == null) return;
|
onUpdate?.call(item.copyWith(data: result));
|
||||||
onUpdate?.call(item.copyWith(data: result));
|
},
|
||||||
},
|
),
|
||||||
),
|
if (item.isOnDevice)
|
||||||
if (item.isOnDevice)
|
MenuAction(
|
||||||
MenuAction(
|
title: 'rename'.tr(),
|
||||||
title: 'rename'.tr(),
|
image: MenuImage.icon(Symbols.edit),
|
||||||
image: MenuImage.icon(Symbols.edit),
|
callback: () async {
|
||||||
callback: () async {
|
await _showRenameSheet(context, ref);
|
||||||
await _showRenameSheet(context, ref);
|
},
|
||||||
},
|
),
|
||||||
),
|
if (item.isOnCloud)
|
||||||
if (item.isOnCloud)
|
MenuAction(
|
||||||
MenuAction(
|
title: 'rename'.tr(),
|
||||||
title: 'rename'.tr(),
|
image: MenuImage.icon(Symbols.edit),
|
||||||
image: MenuImage.icon(Symbols.edit),
|
callback: () async {
|
||||||
callback: () async {
|
await _showRenameSheet(context, ref);
|
||||||
await _showRenameSheet(context, ref);
|
},
|
||||||
},
|
),
|
||||||
),
|
if (item.isOnCloud)
|
||||||
if (item.isOnCloud)
|
MenuAction(
|
||||||
MenuAction(
|
title: 'markAsSensitive'.tr(),
|
||||||
title: 'markAsSensitive'.tr(),
|
image: MenuImage.icon(Symbols.no_adult_content),
|
||||||
image: MenuImage.icon(Symbols.no_adult_content),
|
callback: () async {
|
||||||
callback: () async {
|
await _showSensitiveDialog(context, ref);
|
||||||
await _showSensitiveDialog(context, ref);
|
},
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
|
||||||
child: contentWidget,
|
child: contentWidget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,22 +244,20 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
minWidth: minWidth ?? 0,
|
minWidth: minWidth ?? 0,
|
||||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||||
),
|
),
|
||||||
child:
|
child: (ratio == null && isImage)
|
||||||
(ratio == null && isImage)
|
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
: (ratio == null && isAudio)
|
||||||
: (ratio == null && isAudio)
|
? IntrinsicHeight(child: widgetItem)
|
||||||
? IntrinsicHeight(child: widgetItem)
|
: AspectRatio(
|
||||||
: AspectRatio(
|
aspectRatio: ratio?.toDouble() ?? 1,
|
||||||
aspectRatio: ratio?.toDouble() ?? 1,
|
child: widgetItem,
|
||||||
child: widgetItem,
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final allImages =
|
final allImages = !files.any(
|
||||||
!files.any(
|
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (allImages) {
|
if (allImages) {
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
@@ -270,10 +268,9 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
padding: padding ?? EdgeInsets.zero,
|
padding: padding ?? EdgeInsets.zero,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final availableWidth =
|
final availableWidth = constraints.maxWidth.isFinite
|
||||||
constraints.maxWidth.isFinite
|
? constraints.maxWidth
|
||||||
? constraints.maxWidth
|
: MediaQuery.of(context).size.width;
|
||||||
: MediaQuery.of(context).size.width;
|
|
||||||
final itemExtent = math.min(
|
final itemExtent = math.min(
|
||||||
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
||||||
640.0,
|
640.0,
|
||||||
@@ -339,10 +336,9 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
padding: padding,
|
padding: padding,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio:
|
aspectRatio: files[index].fileMeta?['ratio'] is num
|
||||||
files[index].fileMeta?['ratio'] is num
|
? files[index].fileMeta!['ratio'].toDouble()
|
||||||
? files[index].fileMeta!['ratio'].toDouble()
|
: 1.0,
|
||||||
: 1.0,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
@@ -440,40 +436,68 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
||||||
Widget fg =
|
Widget fg = fullyUnlocked
|
||||||
fullyUnlocked
|
? (isImage
|
||||||
? (isImage
|
? CloudFileWidget(
|
||||||
? CloudFileWidget(
|
|
||||||
item: file,
|
item: file,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
noBlurhash: true,
|
noBlurhash: true,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
useInternalGate: false,
|
useInternalGate: false,
|
||||||
)
|
)
|
||||||
: CloudFileWidget(
|
: CloudFileWidget(
|
||||||
item: file,
|
item: file,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
useInternalGate: false,
|
useInternalGate: false,
|
||||||
))
|
))
|
||||||
: const SizedBox.shrink();
|
: const SizedBox.shrink();
|
||||||
|
|
||||||
Widget overlays;
|
Widget overlays = AnimatedSwitcher(
|
||||||
if (lockedByDS) {
|
duration: const Duration(milliseconds: 300),
|
||||||
overlays = _DataSavingOverlay();
|
child: lockedByDS
|
||||||
} else if (file.sensitiveMarks.isNotEmpty) {
|
? _DataSavingOverlay(key: const ValueKey('ds'))
|
||||||
overlays = _SensitiveOverlay(
|
: (file.sensitiveMarks.isNotEmpty && !showMature.value
|
||||||
file: file,
|
? _SensitiveOverlay(
|
||||||
isRevealed: showMature.value,
|
key: const ValueKey('sensitive-blur'),
|
||||||
onHide: () => showMature.value = false,
|
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(
|
final content = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [if (isImage) Positioned.fill(child: bg), fg, overlays],
|
children: [
|
||||||
|
if (isImage) Positioned.fill(child: bg),
|
||||||
|
fg,
|
||||||
|
overlays,
|
||||||
|
hideButton,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@@ -494,41 +518,11 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
|
|
||||||
class _SensitiveOverlay extends StatelessWidget {
|
class _SensitiveOverlay extends StatelessWidget {
|
||||||
final SnCloudFile file;
|
final SnCloudFile file;
|
||||||
final VoidCallback? onHide;
|
|
||||||
final bool isRevealed;
|
|
||||||
|
|
||||||
const _SensitiveOverlay({
|
const _SensitiveOverlay({required this.file, super.key});
|
||||||
required this.file,
|
|
||||||
this.onHide,
|
|
||||||
this.isRevealed = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
|
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -549,6 +543,8 @@ class _SensitiveOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DataSavingOverlay extends StatelessWidget {
|
class _DataSavingOverlay extends StatelessWidget {
|
||||||
|
const _DataSavingOverlay({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
if (widget.link.title.isNotEmpty) ...[
|
if (widget.link.title?.isNotEmpty ?? false) ...[
|
||||||
Text(
|
Text(
|
||||||
widget.link.title,
|
widget.link.title!,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.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_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
@@ -29,8 +30,16 @@ class UniversalImage extends HookWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loaded = useState(false);
|
final loaded = useState(false);
|
||||||
|
final isCached = useState<bool?>(null);
|
||||||
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
|
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
|
||||||
|
isCached.value = fileInfo != null;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [uri]);
|
||||||
|
|
||||||
if (isSvgImage) {
|
if (isSvgImage) {
|
||||||
return SvgPicture.network(
|
return SvgPicture.network(
|
||||||
uri,
|
uri,
|
||||||
@@ -59,44 +68,69 @@ class UniversalImage extends HookWidget {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (blurHash != null) BlurHash(hash: blurHash!),
|
if (blurHash != null) BlurHash(hash: blurHash!),
|
||||||
CachedNetworkImage(
|
if (isCached.value == null)
|
||||||
imageUrl: uri,
|
Center(child: CircularProgressIndicator())
|
||||||
fit: fit,
|
else if (isCached.value!)
|
||||||
width: width,
|
CachedNetworkImage(
|
||||||
height: height,
|
imageUrl: uri,
|
||||||
memCacheHeight: cacheHeight,
|
fit: fit,
|
||||||
memCacheWidth: cacheWidth,
|
width: width,
|
||||||
progressIndicatorBuilder: (context, url, progress) {
|
height: height,
|
||||||
return Center(
|
memCacheHeight: cacheHeight,
|
||||||
child: AnimatedCircularProgressIndicator(
|
memCacheWidth: cacheWidth,
|
||||||
value: progress.progress,
|
imageBuilder: (context, imageProvider) => Image(
|
||||||
color: Colors.white.withOpacity(0.5),
|
image: imageProvider,
|
||||||
),
|
fit: fit,
|
||||||
);
|
width: width,
|
||||||
},
|
height: height,
|
||||||
imageBuilder: (context, imageProvider) {
|
),
|
||||||
Future(() {
|
errorWidget: (context, url, error) => useFallbackImage
|
||||||
if (context.mounted) return loaded.value = true;
|
? Image.asset(
|
||||||
});
|
'assets/images/media-offline.jpg',
|
||||||
return AnimatedOpacity(
|
fit: BoxFit.cover,
|
||||||
opacity: loaded.value ? 1.0 : 0.0,
|
key: Key('image-broke-$uri'),
|
||||||
duration: const Duration(milliseconds: 300),
|
)
|
||||||
child: Image(
|
: SizedBox.shrink(),
|
||||||
image: imageProvider,
|
)
|
||||||
fit: fit,
|
else
|
||||||
width: width,
|
CachedNetworkImage(
|
||||||
height: height,
|
imageUrl: uri,
|
||||||
),
|
fit: fit,
|
||||||
);
|
width: width,
|
||||||
},
|
height: height,
|
||||||
errorWidget: (context, url, error) => useFallbackImage
|
memCacheHeight: cacheHeight,
|
||||||
? Image.asset(
|
memCacheWidth: cacheWidth,
|
||||||
'assets/images/media-offline.jpg',
|
progressIndicatorBuilder: (context, url, progress) {
|
||||||
fit: BoxFit.cover,
|
return Center(
|
||||||
key: Key('image-broke-$uri'),
|
child: AnimatedCircularProgressIndicator(
|
||||||
)
|
value: progress.progress,
|
||||||
: SizedBox.shrink(),
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
|
|
||||||
typedef WidgetBuilder0 = Widget Function();
|
typedef WidgetBuilder = Widget Function();
|
||||||
|
|
||||||
class DataSavingGate extends ConsumerWidget {
|
class DataSavingGate extends ConsumerWidget {
|
||||||
final bool bypass;
|
final bool bypass;
|
||||||
final WidgetBuilder0 content;
|
final WidgetBuilder content;
|
||||||
final Widget placeholder;
|
final Widget placeholder;
|
||||||
|
|
||||||
const DataSavingGate({
|
const DataSavingGate({
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ class ComposeAttachments extends ConsumerWidget {
|
|||||||
isCompact: isCompact,
|
isCompact: isCompact,
|
||||||
item: state.attachments.value[idx],
|
item: state.attachments.value[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
|
isUploading: progressMap.containsKey(idx),
|
||||||
onRequestUpload: () async {
|
onRequestUpload: () async {
|
||||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||||
context: ref.context,
|
context: ref.context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
builder:
|
builder: (context) =>
|
||||||
(context) =>
|
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||||
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
|
||||||
);
|
);
|
||||||
if (config != null) {
|
if (config != null) {
|
||||||
await ComposeLogic.uploadAttachment(
|
await ComposeLogic.uploadAttachment(
|
||||||
@@ -146,19 +146,21 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
|||||||
isCompact: true,
|
isCompact: true,
|
||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
|
isUploading: progressMap.containsKey(idx),
|
||||||
onRequestUpload: () async {
|
onRequestUpload: () async {
|
||||||
final config = await showModalBottomSheet<
|
final config =
|
||||||
AttachmentUploadConfig
|
await showModalBottomSheet<
|
||||||
>(
|
AttachmentUploadConfig
|
||||||
context: context,
|
>(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder:
|
isScrollControlled: true,
|
||||||
(context) => AttachmentUploaderSheet(
|
builder: (context) =>
|
||||||
ref: ref,
|
AttachmentUploaderSheet(
|
||||||
state: state,
|
ref: ref,
|
||||||
index: idx,
|
state: state,
|
||||||
),
|
index: idx,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
if (config != null) {
|
if (config != null) {
|
||||||
await ComposeLogic.uploadAttachment(
|
await ComposeLogic.uploadAttachment(
|
||||||
ref,
|
ref,
|
||||||
@@ -168,24 +170,15 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate:
|
onUpdate: (value) => ComposeLogic.updateAttachment(
|
||||||
(value) => ComposeLogic.updateAttachment(
|
state,
|
||||||
state,
|
value,
|
||||||
value,
|
idx,
|
||||||
idx,
|
),
|
||||||
),
|
onDelete: () =>
|
||||||
onDelete:
|
ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
() => ComposeLogic.deleteAttachment(
|
onInsert: () =>
|
||||||
ref,
|
ComposeLogic.insertAttachment(ref, state, idx),
|
||||||
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:island/pods/drive/file_pool.dart';
|
||||||
import 'package:pasteboard/pasteboard.dart';
|
import 'package:pasteboard/pasteboard.dart';
|
||||||
import 'package:island/talker.dart';
|
import 'package:island/talker.dart';
|
||||||
|
import 'package:island/services/analytics_service.dart';
|
||||||
|
|
||||||
class ComposeState {
|
class ComposeState {
|
||||||
final TextEditingController titleController;
|
final TextEditingController titleController;
|
||||||
@@ -738,6 +739,17 @@ class ComposeLogic {
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
eventBus.fire(PostCreatedEvent());
|
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;
|
return post;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import 'package:island/widgets/post/compose_sheet.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:super_context_menu/super_context_menu.dart';
|
import 'package:super_context_menu/super_context_menu.dart';
|
||||||
|
import 'package:island/services/analytics_service.dart';
|
||||||
|
|
||||||
const kAvailableStickers = {
|
const kAvailableStickers = {
|
||||||
'angry',
|
'angry',
|
||||||
@@ -367,7 +368,15 @@ class PostItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
|
|
||||||
|
AnalyticsService().logPostReacted(
|
||||||
|
item.id,
|
||||||
|
symbol,
|
||||||
|
attitude,
|
||||||
|
isRemoving,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
reacting.value = false;
|
reacting.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -998,14 +998,15 @@ class _LinkPreview extends ConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
// Title
|
// Title
|
||||||
Text(
|
if (embed.title != null)
|
||||||
embed.title,
|
Text(
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
embed.title!,
|
||||||
fontWeight: FontWeight.w600,
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
// Description
|
// Description
|
||||||
if (embed.description != null &&
|
if (embed.description != null &&
|
||||||
embed.description!.isNotEmpty)
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
class UploadOverlay extends HookConsumerWidget {
|
class TaskOverlay extends HookConsumerWidget {
|
||||||
const UploadOverlay({super.key});
|
const TaskOverlay({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -32,7 +32,9 @@ class UploadOverlay extends HookConsumerWidget {
|
|||||||
final isVisibleOverride = useState<bool?>(null);
|
final isVisibleOverride = useState<bool?>(null);
|
||||||
final pendingHide = useState(false);
|
final pendingHide = useState(false);
|
||||||
final isExpandedLocal = useState(false);
|
final isExpandedLocal = useState(false);
|
||||||
|
final isCompactLocal = useState(true); // Start compact
|
||||||
final autoHideTimer = useState<Timer?>(null);
|
final autoHideTimer = useState<Timer?>(null);
|
||||||
|
final autoCompactTimer = useState<Timer?>(null);
|
||||||
|
|
||||||
final allFinished = activeTasks.every(
|
final allFinished = activeTasks.every(
|
||||||
(task) =>
|
(task) =>
|
||||||
@@ -69,14 +71,35 @@ class UploadOverlay extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [allFinished, activeTasks, isExpandedLocal.value, pendingHide.value]);
|
}, [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 =
|
final isVisible =
|
||||||
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
|
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
|
||||||
!pendingHide.value;
|
!pendingHide.value;
|
||||||
final slideController = useAnimationController(
|
final slideController = useAnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
);
|
);
|
||||||
|
final isTopPositioned = !isDesktop; // Mobile: top, Desktop: bottom
|
||||||
|
|
||||||
final slideAnimation = Tween<Offset>(
|
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
|
end: Offset.zero, // End at normal position
|
||||||
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
|
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
|
||||||
|
|
||||||
@@ -95,33 +118,47 @@ class UploadOverlay extends HookConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final isDesktop = isWideScreen(context);
|
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
bottom: 0,
|
top: isTopPositioned ? 0 : null,
|
||||||
|
bottom: !isTopPositioned ? 0 : null,
|
||||||
left: isDesktop ? null : 0,
|
left: isDesktop ? null : 0,
|
||||||
right: isDesktop ? 24 : 0,
|
right: isDesktop ? 24 : 0,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: slideAnimation,
|
position: slideAnimation,
|
||||||
child: _UploadOverlayContent(
|
child:
|
||||||
activeTasks: activeTasks,
|
_TaskOverlayContent(
|
||||||
isExpanded: isExpandedLocal.value,
|
activeTasks: activeTasks,
|
||||||
onExpansionChanged: (expanded) => isExpandedLocal.value = expanded,
|
isExpanded: isExpandedLocal.value,
|
||||||
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
|
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 List<DriveTask> activeTasks;
|
||||||
final bool isExpanded;
|
final bool isExpanded;
|
||||||
|
final bool isCompact;
|
||||||
final Function(bool)? onExpansionChanged;
|
final Function(bool)? onExpansionChanged;
|
||||||
|
final Function(bool)? onCompactChanged;
|
||||||
|
|
||||||
const _UploadOverlayContent({
|
const _TaskOverlayContent({
|
||||||
required this.activeTasks,
|
required this.activeTasks,
|
||||||
required this.isExpanded,
|
required this.isExpanded,
|
||||||
|
required this.isCompact,
|
||||||
this.onExpansionChanged,
|
this.onExpansionChanged,
|
||||||
|
this.onCompactChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -130,11 +167,16 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
|||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
initialValue: 0.0,
|
initialValue: 0.0,
|
||||||
);
|
);
|
||||||
final heightAnimation = useAnimation(
|
final compactHeight = 32.0;
|
||||||
Tween<double>(begin: 60, end: 400).animate(
|
final collapsedHeight = 60.0;
|
||||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
final expandedHeight = 400.0;
|
||||||
),
|
|
||||||
);
|
final currentHeight = isCompact
|
||||||
|
? compactHeight
|
||||||
|
: isExpanded
|
||||||
|
? expandedHeight
|
||||||
|
: collapsedHeight;
|
||||||
|
|
||||||
final opacityAnimation = useAnimation(
|
final opacityAnimation = useAnimation(
|
||||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||||
);
|
);
|
||||||
@@ -152,233 +194,364 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
|||||||
|
|
||||||
final taskNotifier = ref.read(uploadTasksProvider.notifier);
|
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(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: isMobile ? 16 : 24,
|
bottom: isMobile ? 16 : 24,
|
||||||
left: isMobile ? 16 : 0,
|
left: isMobile ? 16 : 0,
|
||||||
right: isMobile ? 16 : 24,
|
right: isMobile ? 16 : 24,
|
||||||
),
|
),
|
||||||
child: GestureDetector(
|
child: content,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
double _getOverallProgress(List<DriveTask> tasks) {
|
||||||
if (tasks.isEmpty) return 0.0;
|
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,
|
0.0,
|
||||||
(sum, task) =>
|
(sum, progress) => sum + progress!,
|
||||||
sum +
|
|
||||||
(task.status == DriveTaskStatus.inProgress
|
|
||||||
? task.progress
|
|
||||||
: task.status == DriveTaskStatus.completed
|
|
||||||
? 1
|
|
||||||
: 0),
|
|
||||||
);
|
);
|
||||||
return totalProgress / tasks.length;
|
return totalProgress / tasks.length;
|
||||||
}
|
}
|
||||||
@@ -431,7 +604,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getOverallStatusText(List<DriveTask> tasks) {
|
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 hasDownload = tasks.any((task) => task.type == 'FileDownload');
|
||||||
final hasInProgress = tasks.any(
|
final hasInProgress = tasks.any(
|
||||||
@@ -469,18 +642,21 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
|||||||
} else if (hasCompleted) {
|
} else if (hasCompleted) {
|
||||||
return '${tasks.length} ${'completed'.tr()}';
|
return '${tasks.length} ${'completed'.tr()}';
|
||||||
} else {
|
} else {
|
||||||
return '${tasks.length} ${'tasks'.tr()}';
|
return 'tasks'.plural(tasks.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _hasCompletedTasks(List<DriveTask> tasks) {
|
double _getCompactWidth(List<DriveTask> tasks) {
|
||||||
return tasks.any(
|
// Base width for icon and padding
|
||||||
(task) =>
|
double width = 16 + 12 + 12; // icon size + padding + spacing
|
||||||
task.status == DriveTaskStatus.completed ||
|
|
||||||
task.status == DriveTaskStatus.failed ||
|
// Add text width estimation
|
||||||
task.status == DriveTaskStatus.cancelled ||
|
final text = activeTasks.isEmpty ? '0 tasks' : _getOverallStatusText(tasks);
|
||||||
task.status == DriveTaskStatus.expired,
|
// 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
|
@override
|
||||||
State<UploadTaskTile> createState() => _UploadTaskTileState();
|
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>
|
class _UploadTaskTileState extends State<UploadTaskTile>
|
||||||
@@ -551,7 +734,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: widget.task.progress,
|
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
backgroundColor: Theme.of(
|
backgroundColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -678,7 +861,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: widget.task.progress,
|
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).colorScheme.primary,
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import audio_session
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import desktop_drop
|
import desktop_drop
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
@@ -25,6 +26,7 @@ import gal
|
|||||||
import hotkey_manager_macos
|
import hotkey_manager_macos
|
||||||
import in_app_review
|
import in_app_review
|
||||||
import irondash_engine_context
|
import irondash_engine_context
|
||||||
|
import just_audio
|
||||||
import livekit_client
|
import livekit_client
|
||||||
import local_auth_darwin
|
import local_auth_darwin
|
||||||
import media_kit_libs_macos_video
|
import media_kit_libs_macos_video
|
||||||
@@ -48,6 +50,7 @@ import wakelock_plus
|
|||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
@@ -68,6 +71,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||||
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- croppy (0.0.1):
|
- croppy (0.0.1):
|
||||||
@@ -177,6 +179,9 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- irondash_engine_context (0.0.1):
|
- irondash_engine_context (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- just_audio (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- KeychainAccess (4.2.2)
|
- KeychainAccess (4.2.2)
|
||||||
- livekit_client (2.5.4):
|
- livekit_client (2.5.4):
|
||||||
- flutter_webrtc
|
- flutter_webrtc
|
||||||
@@ -261,6 +266,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||||
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
|
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
|
||||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/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`)
|
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/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`)
|
- 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`)
|
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
|
||||||
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
|
- 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`)
|
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||||
@@ -330,6 +337,8 @@ SPEC REPOS:
|
|||||||
- WebRTC-SDK
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audio_session:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||||
croppy:
|
croppy:
|
||||||
@@ -374,6 +383,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||||
irondash_engine_context:
|
irondash_engine_context:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
|
||||||
|
just_audio:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
||||||
livekit_client:
|
livekit_client:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
@@ -418,6 +429,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||||
croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43
|
croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43
|
||||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||||
@@ -454,6 +466,7 @@ SPEC CHECKSUMS:
|
|||||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||||
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
||||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||||
|
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||||
livekit_client: 3df5a1787d64010ca56c4002959d9e47c03ba3fb
|
livekit_client: 3df5a1787d64010ca56c4002959d9e47c03ba3fb
|
||||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||||
|
|||||||
60
pubspec.lock
60
pubspec.lock
@@ -97,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
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:
|
autotrie:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -269,10 +277,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_builder
|
name: code_builder
|
||||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.11.0"
|
version: "4.11.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -557,10 +565,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: equatable
|
name: equatable
|
||||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.7"
|
version: "2.0.8"
|
||||||
event_bus:
|
event_bus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -589,10 +597,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.5"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1273,10 +1281,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
sha256: eefe5ee217f331627d8bbcf01f91b21c730bf66e225d6dc8a148370b0819168d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "7.0.0"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1541,6 +1549,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.11.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -3195,6 +3227,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
visibility_detector:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -3231,10 +3271,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.2.1"
|
||||||
waveform_flutter:
|
waveform_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
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
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 3.5.0+161
|
version: 3.5.0+162
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.0
|
sdk: ^3.8.0
|
||||||
@@ -38,7 +38,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_hooks: ^0.21.3+1
|
flutter_hooks: ^0.21.3+1
|
||||||
hooks_riverpod: ^3.1.0
|
hooks_riverpod: ^3.1.0
|
||||||
quick_actions: ^1.0.8
|
quick_actions: ^1.1.0
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
styled_widget: ^0.4.1
|
styled_widget: ^0.4.1
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
@@ -52,7 +52,7 @@ dependencies:
|
|||||||
flutter_highlight: ^0.7.0
|
flutter_highlight: ^0.7.0
|
||||||
uuid: ^4.5.2
|
uuid: ^4.5.2
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
google_fonts: ^6.3.3
|
google_fonts: ^7.0.0
|
||||||
gap: ^3.0.1
|
gap: ^3.0.1
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
@@ -148,7 +148,7 @@ dependencies:
|
|||||||
shelf_web_socket: ^3.0.0
|
shelf_web_socket: ^3.0.0
|
||||||
windows_notification: ^1.3.0
|
windows_notification: ^1.3.0
|
||||||
win32: ^5.15.0
|
win32: ^5.15.0
|
||||||
ffi: ^2.1.4
|
ffi: ^2.1.5
|
||||||
dart_ipc: ^1.0.1
|
dart_ipc: ^1.0.1
|
||||||
pretty_diff_text: ^2.1.0
|
pretty_diff_text: ^2.1.0
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
@@ -175,6 +175,8 @@ dependencies:
|
|||||||
in_app_review: ^2.0.11
|
in_app_review: ^2.0.11
|
||||||
snow_fall_animation: ^0.0.1+3
|
snow_fall_animation: ^0.0.1+3
|
||||||
flutter_app_intents: ^0.7.0
|
flutter_app_intents: ^0.7.0
|
||||||
|
video_thumbnail: ^0.5.6
|
||||||
|
just_audio: ^0.10.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -213,6 +215,7 @@ flutter:
|
|||||||
- assets/images/oidc/
|
- assets/images/oidc/
|
||||||
- assets/images/stickers/
|
- assets/images/stickers/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
- assets/audio/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
@@ -275,3 +278,4 @@ msix_config:
|
|||||||
protocol_activation: solian, https
|
protocol_activation: solian, https
|
||||||
app_uri_handler_hosts: solian.app
|
app_uri_handler_hosts: solian.app
|
||||||
capabilities: internetClientServer, location, microphone, webcam
|
capabilities: internetClientServer, location, microphone, webcam
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user