Merge pull request 'Implement sharing media with Moxxy' (#98) from feat/sharing-intent into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/98
This commit is contained in:
commit
a49bce8292
@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.moxxyv2">
|
||||
package="org.moxxy.moxxyv2">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
|
@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.moxxyv2">
|
||||
package="org.moxxy.moxxyv2">
|
||||
<application
|
||||
android:label="Moxxy"
|
||||
android:name="${applicationName}"
|
||||
@ -7,7 +7,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@ -18,16 +18,26 @@
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
android:resource="@style/NormalTheme" />
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2"
|
||||
/>
|
||||
android:value="2" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow receiving share intents for all kinds of things -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
@ -1,4 +1,4 @@
|
||||
package com.example.moxxyv2
|
||||
package org.moxxy.moxxyv2
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.moxxyv2">
|
||||
package="org.moxxy.moxxyv2">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
|
@ -216,7 +216,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
recipients: List<String>
|
||||
body: String
|
||||
chatState: String
|
||||
quotedMessage:
|
||||
@ -227,7 +227,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
recipients: List<String>
|
||||
paths: List<String>
|
||||
- name: BlockJidCommand
|
||||
extends: BackgroundCommand
|
||||
|
@ -19,6 +19,7 @@ import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/events.dart';
|
||||
@ -44,12 +45,14 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/thumbnail.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
void setupLogging() {
|
||||
Logger.root.level = Level.ALL;
|
||||
@ -64,7 +67,7 @@ Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<ThumbnailCacheService>(ThumbnailCacheService());
|
||||
await GetIt.I.get<UIDataService>().init();}
|
||||
}
|
||||
|
||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
|
||||
@ -79,6 +82,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||
GetIt.I.registerSingleton<ShareSelectionBloc>(ShareSelectionBloc());
|
||||
}
|
||||
|
||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
||||
@ -138,7 +142,10 @@ void main() async {
|
||||
),
|
||||
BlocProvider<CropBackgroundBloc>(
|
||||
create: (_) => GetIt.I.get<CropBackgroundBloc>(),
|
||||
)
|
||||
),
|
||||
BlocProvider<ShareSelectionBloc>(
|
||||
create: (_) => GetIt.I.get<ShareSelectionBloc>(),
|
||||
),
|
||||
],
|
||||
child: MyApp(navKey),
|
||||
),
|
||||
@ -164,6 +171,42 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
|
||||
// Lift the UI block
|
||||
GetIt.I.get<Completer<void>>().complete();
|
||||
|
||||
_setupSharingHandler();
|
||||
}
|
||||
|
||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setupSharingHandler() async {
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
final media = await handler.getInitialSharedMedia();
|
||||
|
||||
// Shared while the app was closed
|
||||
if (media != null) {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
}
|
||||
|
||||
// Shared while the app is stil running
|
||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -263,6 +306,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case cropRoute: return CropPage.route;
|
||||
case sendFilesRoute: return SendFilesPage.route;
|
||||
case backgroundCroppingRoute: return CropBackgroundPage.route;
|
||||
case shareSelectionRoute: return ShareSelectionPage.route;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -220,7 +220,7 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
|
||||
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
|
||||
await GetIt.I.get<XmppService>().sendMessage(
|
||||
body: command.body,
|
||||
jid: command.jid,
|
||||
recipients: command.recipients,
|
||||
chatState: command.chatState.isNotEmpty
|
||||
? chatStateFromString(command.chatState)
|
||||
: null,
|
||||
@ -425,5 +425,5 @@ Future<void> performSignOut(SignOutCommand command, { dynamic extra }) async {
|
||||
}
|
||||
|
||||
Future<void> performSendFiles(SendFilesCommand command, { dynamic extra }) async {
|
||||
await GetIt.I.get<XmppService>().sendFiles(command.paths, command.jid);
|
||||
await GetIt.I.get<XmppService>().sendFiles(command.paths, command.recipients);
|
||||
}
|
||||
|
@ -113,17 +113,25 @@ class HttpFileTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyFile(String fromPath, String toPath, int msgId) async {
|
||||
await File(fromPath).copy(toPath);
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
for (final recipient in job.recipients) {
|
||||
final newPath = await getDownloadPath(
|
||||
pathlib.basename(job.path),
|
||||
recipient,
|
||||
job.mime,
|
||||
);
|
||||
|
||||
// Let the media scanner index the file
|
||||
MoxplatformPlugin.media.scanFile(toPath);
|
||||
await File(job.path).copy(newPath);
|
||||
|
||||
// Update the message
|
||||
await GetIt.I.get<MessageService>().updateMessage(
|
||||
msgId,
|
||||
mediaUrl: toPath,
|
||||
);
|
||||
// Let the media scanner index the file
|
||||
MoxplatformPlugin.media.scanFile(newPath);
|
||||
|
||||
// Update the message
|
||||
await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaUrl: newPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually attempt to upload the file described by the job [job].
|
||||
@ -148,8 +156,6 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
final slot = slotResult.getValue();
|
||||
final fileMime = lookupMimeType(job.path);
|
||||
|
||||
try {
|
||||
final response = await dio.Dio().putUri<dynamic>(
|
||||
Uri.parse(slot.putUrl),
|
||||
@ -160,13 +166,17 @@ class HttpFileTransferService {
|
||||
),
|
||||
data: data,
|
||||
onSendProgress: (count, total) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.message.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -176,61 +186,63 @@ class HttpFileTransferService {
|
||||
_log.severe('Upload failed');
|
||||
|
||||
// Notify UI of upload failure
|
||||
final msg = await ms.updateMessage(
|
||||
job.message.id,
|
||||
errorType: fileUploadFailedError,
|
||||
);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: msg.copyWith(isUploading: false),
|
||||
),
|
||||
);
|
||||
for (final recipient in job.recipients) {
|
||||
final msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
errorType: fileUploadFailedError,
|
||||
);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: msg.copyWith(isUploading: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
// Notify UI of upload completion
|
||||
var msg = job.message;
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = job.messageMap[recipient]!;
|
||||
|
||||
// Reset a stored error, if there was one
|
||||
if (msg.errorType != null) {
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
errorType: noError,
|
||||
);
|
||||
}
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: msg.copyWith(isUploading: false),
|
||||
),
|
||||
);
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: job.recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: job.message.sid,
|
||||
originId: job.message.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: fileMime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
),
|
||||
slot.getUrl,
|
||||
// Reset a stored error, if there was one
|
||||
if (msg.errorType != null) {
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
errorType: noError,
|
||||
);
|
||||
}
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: msg.copyWith(isUploading: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path}');
|
||||
);
|
||||
|
||||
final isMultiMedia = fileMime != null ?
|
||||
fileMime.startsWith('image/') || fileMime.startsWith('video/') :
|
||||
false;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job.path, job.copyToPath, msg.id));
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
),
|
||||
slot.getUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
} on dio.DioError {
|
||||
|
@ -6,25 +6,26 @@ import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
|
||||
@immutable
|
||||
class FileUploadJob {
|
||||
|
||||
const FileUploadJob(this.recipient, this.path, this.copyToPath, this.message, this.thumbnails);
|
||||
const FileUploadJob(this.recipients, this.path, this.mime, this.messageMap, this.thumbnails);
|
||||
final List<String> recipients;
|
||||
final String path;
|
||||
final String recipient;
|
||||
final Message message;
|
||||
final String copyToPath;
|
||||
final String? mime;
|
||||
// Recipient -> Message
|
||||
final Map<String, Message> messageMap;
|
||||
final List<Thumbnail> thumbnails;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FileUploadJob &&
|
||||
recipient == other.recipient &&
|
||||
recipients == other.recipients &&
|
||||
path == other.path &&
|
||||
message == other.message &&
|
||||
copyToPath == other.copyToPath &&
|
||||
messageMap == other.messageMap &&
|
||||
mime == other.mime &&
|
||||
thumbnails == other.thumbnails;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => path.hashCode ^ recipient.hashCode ^ message.hashCode ^ copyToPath.hashCode ^ thumbnails.hashCode;
|
||||
int get hashCode => path.hashCode ^ recipients.hashCode ^ messageMap.hashCode ^ mime.hashCode ^ thumbnails.hashCode;
|
||||
}
|
||||
|
||||
/// A job describing the upload of a file.
|
||||
|
@ -189,10 +189,10 @@ class XmppService {
|
||||
/// Returns the JID of the chat that is currently opened. Null, if none is open.
|
||||
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
|
||||
|
||||
/// Sends a message to [jid] with the body of [body].
|
||||
/// Sends a message to JIDs in [recipients] with the body of [body].
|
||||
Future<void> sendMessage({
|
||||
required String body,
|
||||
required String jid,
|
||||
required List<String> recipients,
|
||||
Message? quotedMessage,
|
||||
String? commandId,
|
||||
ChatState? chatState,
|
||||
@ -201,51 +201,53 @@ class XmppService {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final sid = conn.generateId();
|
||||
final originId = conn.generateId();
|
||||
final message = await ms.addMessageFromData(
|
||||
body,
|
||||
timestamp,
|
||||
conn.getConnectionSettings().jid.toString(),
|
||||
jid,
|
||||
false,
|
||||
sid,
|
||||
false,
|
||||
originId: originId,
|
||||
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
|
||||
);
|
||||
|
||||
if (commandId != null) {
|
||||
for (final recipient in recipients) {
|
||||
final sid = conn.generateId();
|
||||
final originId = conn.generateId();
|
||||
final message = await ms.addMessageFromData(
|
||||
body,
|
||||
timestamp,
|
||||
conn.getConnectionSettings().jid.toString(),
|
||||
recipient,
|
||||
false,
|
||||
sid,
|
||||
false,
|
||||
originId: originId,
|
||||
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
|
||||
);
|
||||
|
||||
// Using the same ID should be fine.
|
||||
sendEvent(
|
||||
MessageAddedEvent(message: message),
|
||||
id: commandId,
|
||||
);
|
||||
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: body,
|
||||
requestDeliveryReceipt: true,
|
||||
id: sid,
|
||||
originId: originId,
|
||||
quoteBody: quotedMessage?.body,
|
||||
quoteFrom: quotedMessage?.sender,
|
||||
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
|
||||
chatState: chatState,
|
||||
),
|
||||
);
|
||||
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversation!.id,
|
||||
lastMessageBody: body,
|
||||
lastChangeTimestamp: timestamp,
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(conversation: newConversation),
|
||||
);
|
||||
}
|
||||
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: jid,
|
||||
body: body,
|
||||
requestDeliveryReceipt: true,
|
||||
id: sid,
|
||||
originId: originId,
|
||||
quoteBody: quotedMessage?.body,
|
||||
quoteFrom: quotedMessage?.sender,
|
||||
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
|
||||
chatState: chatState,
|
||||
),
|
||||
);
|
||||
|
||||
final conversation = await cs.getConversationByJid(jid);
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversation!.id,
|
||||
lastMessageBody: body,
|
||||
lastChangeTimestamp: timestamp,
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(conversation: newConversation),
|
||||
);
|
||||
}
|
||||
|
||||
String? _getMessageSrcUrl(MessageEvent event) {
|
||||
@ -323,21 +325,13 @@ class XmppService {
|
||||
return GetIt.I.get<XmppConnection>().connectAwaitable(lastResource: lastResource);
|
||||
}
|
||||
|
||||
Future<void> sendFiles(List<String> paths, String recipient) async {
|
||||
Future<void> sendFiles(List<String> paths, List<String> recipients) async {
|
||||
// Create a new message
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
// TODO(Unknown): This has a huge issue. The messages should get sent to the UI
|
||||
// as soon as possible to indicate to the user that we are working on
|
||||
// them. But if the files are big, then copying them might take a little
|
||||
// while. The solution might be to use the real path before copying as
|
||||
// the messages initial mediaUrl attribute and once the file has been
|
||||
// copied replace it with the new path. Meanwhile, the file can also be
|
||||
// uploaded from its original location.
|
||||
|
||||
// Path -> Message
|
||||
final messages = <String, Message>{};
|
||||
// Path -> Recipient -> Message
|
||||
final messages = <String, Map<String, Message>>{};
|
||||
// Path -> Thumbnails
|
||||
final thumbnails = <String, List<Thumbnail>>{};
|
||||
|
||||
@ -345,79 +339,121 @@ class XmppService {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
for (final path in paths) {
|
||||
final pathMime = lookupMimeType(path);
|
||||
final msg = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
conn.getConnectionSettings().jid.toString(),
|
||||
recipient,
|
||||
true,
|
||||
conn.generateId(),
|
||||
false,
|
||||
mediaUrl: path,
|
||||
mediaType: pathMime,
|
||||
originId: conn.generateId(),
|
||||
);
|
||||
messages[path] = msg;
|
||||
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true)));
|
||||
|
||||
// TODO(PapaTutuWawa): Do this for videos
|
||||
// TODO(PapaTutuWawa): Maybe do this in a separate isolate
|
||||
if ((pathMime ?? '').startsWith('image/')) {
|
||||
final image = decodeImage((await File(path).readAsBytes()).toList());
|
||||
if (image != null) {
|
||||
thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)];
|
||||
for (final recipient in recipients) {
|
||||
final msg = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
conn.getConnectionSettings().jid.toString(),
|
||||
recipient,
|
||||
true,
|
||||
conn.generateId(),
|
||||
false,
|
||||
mediaUrl: path,
|
||||
mediaType: pathMime,
|
||||
originId: conn.generateId(),
|
||||
);
|
||||
if (messages.containsKey(path)) {
|
||||
messages[path]![recipient] = msg;
|
||||
} else {
|
||||
_log.warning('Failed to generate thumbnail for $path');
|
||||
messages[path] = { recipient: msg };
|
||||
}
|
||||
}
|
||||
|
||||
// Send an upload notification
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
fun: FileMetadataData(
|
||||
// TODO(Unknown): Maybe add media type specific metadata
|
||||
mediaType: lookupMimeType(path),
|
||||
name: pathlib.basename(path),
|
||||
size: File(path).statSync().size,
|
||||
thumbnails: thumbnails[path] ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true)));
|
||||
}
|
||||
}
|
||||
|
||||
// Create the shared media entries
|
||||
final sharedMedia = List<DBSharedMedium>.empty(growable: true);
|
||||
for (final path in paths) {
|
||||
sharedMedia.add(
|
||||
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
// Recipient -> [Shared Medium]
|
||||
final sharedMediaMap = <String, List<DBSharedMedium>>{};
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
for (final recipient in recipients) {
|
||||
for (final path in paths) {
|
||||
final medium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
path,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
mime: lookupMimeType(path),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Update conversation
|
||||
final lastFileMime = lookupMimeType(paths.last);
|
||||
final conversationId = (await cs.getConversationByJid(recipient))!.id;
|
||||
final updatedConversation = await cs.updateConversation(
|
||||
conversationId,
|
||||
lastMessageBody: mimeTypeToConversationBody(lastFileMime),
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
sharedMedia: sharedMedia,
|
||||
);
|
||||
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||
if (sharedMediaMap.containsKey(recipient)) {
|
||||
sharedMediaMap[recipient]!.add(medium);
|
||||
} else {
|
||||
sharedMediaMap[recipient] = List<DBSharedMedium>.from([medium]);
|
||||
}
|
||||
}
|
||||
|
||||
final lastFileMime = lookupMimeType(paths.last);
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
if (conversation != null) {
|
||||
// Update conversation
|
||||
final updatedConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
lastMessageBody: mimeTypeToConversationBody(lastFileMime),
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
sharedMedia: sharedMediaMap[recipient],
|
||||
open: true,
|
||||
);
|
||||
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||
} else {
|
||||
// Create conversation
|
||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
// TODO(Unknown): Should we use the JID parser?
|
||||
rosterItem?.title ?? recipient.split('@').first,
|
||||
mimeTypeToConversationBody(lastFileMime),
|
||||
rosterItem?.avatarUrl ?? '',
|
||||
recipient,
|
||||
0,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
sharedMediaMap[recipient]!,
|
||||
true,
|
||||
);
|
||||
|
||||
// Notify the UI
|
||||
sendEvent(ConversationAddedEvent(conversation: newConversation));
|
||||
}
|
||||
}
|
||||
|
||||
// Requesting Upload slots and uploading
|
||||
final hfts = GetIt.I.get<HttpFileTransferService>();
|
||||
for (final path in paths) {
|
||||
final pathMime = lookupMimeType(path);
|
||||
|
||||
for (final recipient in recipients) {
|
||||
// TODO(PapaTutuWawa): Do this for videos
|
||||
// TODO(PapaTutuWawa): Maybe do this in a separate isolate
|
||||
if ((pathMime ?? '').startsWith('image/')) {
|
||||
// Generate a thumbnail only when we have to
|
||||
if (!thumbnails.containsKey(path)) {
|
||||
final image = decodeImage((await File(path).readAsBytes()).toList());
|
||||
if (image != null) {
|
||||
thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)];
|
||||
} else {
|
||||
_log.warning('Failed to generate thumbnail for $path');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send an upload notification
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
fun: FileMetadataData(
|
||||
// TODO(Unknown): Maybe add media type specific metadata
|
||||
mediaType: lookupMimeType(path),
|
||||
name: pathlib.basename(path),
|
||||
size: File(path).statSync().size,
|
||||
thumbnails: thumbnails[path] ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await hfts.uploadFile(
|
||||
FileUploadJob(
|
||||
recipient,
|
||||
recipients,
|
||||
path,
|
||||
await getDownloadPath(pathlib.basename(path), recipient, pathMime),
|
||||
pathMime,
|
||||
messages[path]!,
|
||||
thumbnails[path] ?? [],
|
||||
),
|
||||
|
@ -176,7 +176,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendMessageCommand(
|
||||
jid: state.conversation!.jid,
|
||||
recipients: [state.conversation!.jid],
|
||||
body: state.messageText,
|
||||
quotedMessage: state.quotedMessage,
|
||||
chatState: chatStateToString(ChatState.active),
|
||||
@ -301,13 +301,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
|
||||
Future<void> _onImagePickerRequested(ImagePickerRequestedEvent event, Emitter<ConversationState> emit) async {
|
||||
GetIt.I.get<SendFilesBloc>().add(
|
||||
SendFilesPageRequestedEvent(state.conversation!.jid, SendFilesType.image),
|
||||
SendFilesPageRequestedEvent([state.conversation!.jid], SendFilesType.image),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onFilePickerRequested(FilePickerRequestedEvent event, Emitter<ConversationState> emit) async {
|
||||
GetIt.I.get<SendFilesBloc>().add(
|
||||
SendFilesPageRequestedEvent(state.conversation!.jid, SendFilesType.generic),
|
||||
SendFilesPageRequestedEvent([state.conversation!.jid], SendFilesType.generic),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
|
||||
part 'conversations_bloc.freezed.dart';
|
||||
part 'conversations_event.dart';
|
||||
@ -30,16 +32,21 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
|
||||
Future<void> _onConversationsAdded(ConversationsAddedEvent event, Emitter<ConversationsState> emit) async {
|
||||
// TODO(Unknown): Should we guard against adding the same conversation multiple times?
|
||||
return emit(
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversations: List.from(<Conversation>[ ...state.conversations, event.conversation ])
|
||||
..sort(compareConversation),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(Unknown): Doing it from here feels absolutely not clean. Maybe change that.
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ConversationsModified(state.conversations),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConversationsUpdated(ConversationsUpdatedEvent event, Emitter<ConversationsState> emit) async {
|
||||
return emit(
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversations: List.from(state.conversations.map((c) {
|
||||
if (c.jid == event.conversation.jid) return event.conversation;
|
||||
@ -48,6 +55,11 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
}).toList()..sort(compareConversation),),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(Unknown): Doing it from here feels absolutely not clean. Maybe change that.
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ConversationsModified(state.conversations),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAvatarChanged(AvatarChangedEvent event, Emitter<ConversationsState> emit) async {
|
||||
|
@ -9,6 +9,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
|
||||
part 'login_bloc.freezed.dart';
|
||||
part 'login_event.dart';
|
||||
@ -72,6 +73,7 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
|
||||
);
|
||||
|
||||
if (result is LoginSuccessfulEvent) {
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = true;
|
||||
emit(state.copyWith(working: false));
|
||||
|
||||
GetIt.I.get<ConversationsBloc>().add(
|
||||
@ -91,6 +93,7 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
|
||||
),
|
||||
);
|
||||
} else if (result is LoginFailureEvent) {
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
return emit(
|
||||
state.copyWith(
|
||||
working: false,
|
||||
|
@ -41,4 +41,8 @@ class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
|
||||
Future<void> _onPoppedRoute(PoppedRouteEvent event, Emitter<NavigationState> emit) async {
|
||||
navigationKey.currentState!.pop();
|
||||
}
|
||||
|
||||
bool canPop() {
|
||||
return navigationKey.currentState!.canPop();
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
|
||||
part 'newconversation_bloc.freezed.dart';
|
||||
part 'newconversation_event.dart';
|
||||
@ -99,6 +100,11 @@ class NewConversationBloc extends Bloc<NewConversationEvent, NewConversationStat
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Unknown): Doing it from here feels absolutely not clean. Maybe change that.
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
RosterModifiedEvent(roster),
|
||||
);
|
||||
|
||||
emit(state.copyWith(roster: roster));
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import 'package:moxxyv2/shared/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
|
||||
part 'preferences_event.dart';
|
||||
|
||||
@ -43,6 +44,8 @@ class PreferencesBloc extends Bloc<PreferencesEvent, PreferencesState> {
|
||||
}
|
||||
|
||||
Future<void> _onSignedOut(SignedOutEvent event, Emitter<PreferencesState> emit) async {
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SignOutCommand(),
|
||||
);
|
||||
|
@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:move_to_background/move_to_background.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
@ -32,24 +33,39 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
}
|
||||
|
||||
Future<void> _sendFilesRequested(SendFilesPageRequestedEvent event, Emitter<SendFilesState> emit) async {
|
||||
final files = await _pickFiles(event.type);
|
||||
if (files == null) return;
|
||||
List<String> files;
|
||||
if (event.paths != null) {
|
||||
files = event.paths!;
|
||||
} else {
|
||||
final pickedFiles = await _pickFiles(event.type);
|
||||
if (pickedFiles == null) return;
|
||||
|
||||
files = pickedFiles;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
files: files,
|
||||
index: 0,
|
||||
conversationJid: event.jid,
|
||||
recipients: event.recipients,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedEvent(
|
||||
NavigationEvent navEvent;
|
||||
if (event.popEntireStack) {
|
||||
navEvent = PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(sendFilesRoute),
|
||||
(_) => false,
|
||||
);
|
||||
} else {
|
||||
navEvent = PushedNamedEvent(
|
||||
const NavigationDestination(
|
||||
sendFilesRoute,
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(navEvent);
|
||||
}
|
||||
|
||||
Future<void> _onIndexSet(IndexSetEvent event, Emitter<SendFilesState> emit) async {
|
||||
@ -71,13 +87,26 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendFilesCommand(
|
||||
paths: state.files,
|
||||
jid: state.conversationJid!,
|
||||
recipients: state.recipients,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
|
||||
// Return to the last page
|
||||
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
||||
final bloc = GetIt.I.get<NavigationBloc>();
|
||||
final canPop = bloc.canPop();
|
||||
NavigationEvent navEvent;
|
||||
if (canPop) {
|
||||
navEvent = PoppedRouteEvent();
|
||||
} else {
|
||||
navEvent = PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
(_) => false,
|
||||
);
|
||||
}
|
||||
|
||||
bloc.add(navEvent);
|
||||
if (!canPop) await MoveToBackground.moveTaskToBack();
|
||||
}
|
||||
|
||||
Future<void> _onItemRemoved(ItemRemovedEvent event, Emitter<SendFilesState> emit) async {
|
||||
|
@ -9,9 +9,11 @@ abstract class SendFilesEvent {}
|
||||
|
||||
class SendFilesPageRequestedEvent extends SendFilesEvent {
|
||||
|
||||
SendFilesPageRequestedEvent(this.jid, this.type);
|
||||
final String jid;
|
||||
SendFilesPageRequestedEvent(this.recipients, this.type, { this.paths, this.popEntireStack = false });
|
||||
final List<String> recipients;
|
||||
final SendFilesType type;
|
||||
final List<String>? paths;
|
||||
final bool popEntireStack;
|
||||
}
|
||||
|
||||
class IndexSetEvent extends SendFilesEvent {
|
||||
@ -22,7 +24,9 @@ class IndexSetEvent extends SendFilesEvent {
|
||||
|
||||
class AddFilesRequestedEvent extends SendFilesEvent {}
|
||||
|
||||
class FileSendingRequestedEvent extends SendFilesEvent {}
|
||||
class FileSendingRequestedEvent extends SendFilesEvent {
|
||||
FileSendingRequestedEvent();
|
||||
}
|
||||
|
||||
class ItemRemovedEvent extends SendFilesEvent {
|
||||
|
||||
|
@ -8,6 +8,6 @@ class SendFilesState with _$SendFilesState {
|
||||
// The currently selected path
|
||||
@Default(0) int index,
|
||||
// The chat that is currently active
|
||||
@Default(null) String? conversationJid,
|
||||
@Default(<String>[]) List<String> recipients,
|
||||
}) = _SendFilesState;
|
||||
}
|
||||
|
192
lib/ui/bloc/share_selection_bloc.dart
Normal file
192
lib/ui/bloc/share_selection_bloc.dart
Normal file
@ -0,0 +1,192 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:move_to_background/move_to_background.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
||||
|
||||
part 'share_selection_bloc.freezed.dart';
|
||||
part 'share_selection_event.dart';
|
||||
part 'share_selection_state.dart';
|
||||
|
||||
/// The type of data we try to share
|
||||
enum ShareSelectionType {
|
||||
media,
|
||||
text,
|
||||
}
|
||||
|
||||
/// Create a common ground between Conversations and RosterItems
|
||||
class ShareListItem {
|
||||
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation);
|
||||
final String avatarPath;
|
||||
final String jid;
|
||||
final String title;
|
||||
final bool isConversation;
|
||||
}
|
||||
|
||||
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
||||
ShareSelectionBloc() : super(ShareSelectionState()) {
|
||||
on<ShareSelectionInitEvent>(_onShareSelectionInit);
|
||||
on<ConversationsModified>(_onConversationsModified);
|
||||
on<RosterModifiedEvent>(_onRosterModified);
|
||||
on<ShareSelectionRequestedEvent>(_onRequested);
|
||||
on<SelectionToggledEvent>(_onSelectionToggled);
|
||||
on<SubmittedEvent>(_onSubmit);
|
||||
on<ResetEvent>(_onReset);
|
||||
}
|
||||
|
||||
/// Resets user controllable data, i.e. paths, selections and text
|
||||
void _resetState(Emitter<ShareSelectionState> emit) {
|
||||
emit(state.copyWith(selection: [], paths: [], text: null));
|
||||
}
|
||||
|
||||
/// Returns the list of JIDs that are selected.
|
||||
List<String> _getRecipients() {
|
||||
return state.selection
|
||||
.map((i) => state.items[i].jid)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _updateItems(List<Conversation> conversations, List<RosterItem> rosterItems, Emitter<ShareSelectionState> emit) {
|
||||
// Use all conversations as a base
|
||||
final items = List<ShareListItem>.from(
|
||||
conversations.map((c) {
|
||||
return ShareListItem(
|
||||
c.avatarUrl,
|
||||
c.jid,
|
||||
c.title,
|
||||
true,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Only add roster items with a JID which we don't already have in items.
|
||||
for (final rosterItem in rosterItems) {
|
||||
// We look for the index because this way we can update the roster items
|
||||
final index = items.lastIndexWhere((ShareListItem e) => e.jid == rosterItem.jid);
|
||||
if (index == -1) {
|
||||
items.add(
|
||||
ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
items[index] = ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
Future<void> _onShareSelectionInit(ShareSelectionInitEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
_updateItems(event.conversations, event.rosterItems, emit);
|
||||
}
|
||||
|
||||
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type));
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(shareSelectionRoute),
|
||||
(_) => false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConversationsModified(ConversationsModified event, Emitter<ShareSelectionState> emit) async {
|
||||
_updateItems(
|
||||
event.conversations,
|
||||
GetIt.I.get<NewConversationBloc>().state.roster,
|
||||
emit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRosterModified(RosterModifiedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
_updateItems(
|
||||
GetIt.I.get<ConversationsBloc>().state.conversations,
|
||||
event.rosterItems,
|
||||
emit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSubmit(SubmittedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
if (state.type == ShareSelectionType.text) {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendMessageCommand(
|
||||
recipients: _getRecipients(),
|
||||
body: state.text!,
|
||||
chatState: chatStateToString(ChatState.gone),
|
||||
),
|
||||
);
|
||||
|
||||
// Navigate to the conversations page...
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
(_) => false,
|
||||
),
|
||||
);
|
||||
// ...reset the state...
|
||||
_resetState(emit);
|
||||
// ...and put the app back into the background
|
||||
await MoveToBackground.moveTaskToBack();
|
||||
} else {
|
||||
GetIt.I.get<SendFilesBloc>().add(
|
||||
SendFilesPageRequestedEvent(
|
||||
state.selection
|
||||
.map((i) => state.items[i].jid)
|
||||
.toList(),
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
SendFilesType.image,
|
||||
paths: state.paths,
|
||||
popEntireStack: true,
|
||||
),
|
||||
);
|
||||
|
||||
_resetState(emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelectionToggled(SelectionToggledEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
if (state.selection.contains(event.index)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selection: List.from(
|
||||
state.selection
|
||||
.where((s) => s != event.index)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selection: List.from(
|
||||
[...state.selection, event.index],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReset(ResetEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
_resetState(emit);
|
||||
}
|
||||
}
|
43
lib/ui/bloc/share_selection_event.dart
Normal file
43
lib/ui/bloc/share_selection_event.dart
Normal file
@ -0,0 +1,43 @@
|
||||
part of 'share_selection_bloc.dart';
|
||||
|
||||
abstract class ShareSelectionEvent {}
|
||||
|
||||
/// Triggered when we receive the initial data, i.e. open conversations and the roster
|
||||
class ShareSelectionInitEvent extends ShareSelectionEvent {
|
||||
ShareSelectionInitEvent(this.conversations, this.rosterItems);
|
||||
final List<Conversation> conversations;
|
||||
final List<RosterItem> rosterItems;
|
||||
}
|
||||
|
||||
/// Triggered when the share page has been requested. [paths] refers to the paths that
|
||||
/// we want to share with the JID or the JIDs.
|
||||
class ShareSelectionRequestedEvent extends ShareSelectionEvent {
|
||||
ShareSelectionRequestedEvent(this.paths, this.text, this.type);
|
||||
final List<String> paths;
|
||||
final String? text;
|
||||
final ShareSelectionType type;
|
||||
}
|
||||
|
||||
/// Triggered when we want to toggle the selection of a list item
|
||||
class SelectionToggledEvent extends ShareSelectionEvent {
|
||||
SelectionToggledEvent(this.index);
|
||||
final int index;
|
||||
}
|
||||
|
||||
/// Triggered when a conversation gets added or removed
|
||||
class ConversationsModified extends ShareSelectionEvent {
|
||||
ConversationsModified(this.conversations);
|
||||
final List<Conversation> conversations;
|
||||
}
|
||||
|
||||
/// Triggered when the roster gets modified
|
||||
class RosterModifiedEvent extends ShareSelectionEvent {
|
||||
RosterModifiedEvent(this.rosterItems);
|
||||
final List<RosterItem> rosterItems;
|
||||
}
|
||||
|
||||
/// Triggered when the user confirms their selection.
|
||||
class SubmittedEvent extends ShareSelectionEvent {}
|
||||
|
||||
/// Triggered when we should reset the paths and the selection
|
||||
class ResetEvent extends ShareSelectionEvent {}
|
17
lib/ui/bloc/share_selection_state.dart
Normal file
17
lib/ui/bloc/share_selection_state.dart
Normal file
@ -0,0 +1,17 @@
|
||||
part of 'share_selection_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class ShareSelectionState with _$ShareSelectionState {
|
||||
factory ShareSelectionState({
|
||||
// A deduplicated combination of the conversation and roster list
|
||||
@Default(<ShareListItem>[]) List<ShareListItem> items,
|
||||
// List of paths that we want to share
|
||||
@Default(<String>[]) List<String> paths,
|
||||
// The text we want to share
|
||||
@Default(null) String? text,
|
||||
// List of selected items in items
|
||||
@Default(<int>[]) List<int> selection,
|
||||
// The type of data we try to share
|
||||
@Default(ShareSelectionType.media) ShareSelectionType type,
|
||||
}) = _ShareSelectionState;
|
||||
}
|
@ -11,10 +11,10 @@ const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
||||
const int primaryColorHexRGBO = 0xffcf4aff;
|
||||
const Color primaryColor = Color(primaryColorHexRGBO);
|
||||
|
||||
const Color bubbleColorSent = Color(0xffac70ca);
|
||||
const Color bubbleColorSentQuoted = Color(0xff964db3);
|
||||
const Color bubbleColorSent = Color(0xffa139f0);
|
||||
const Color bubbleColorSentQuoted = bubbleColorSent;
|
||||
const Color bubbleColorReceived = Color(0xff222222);
|
||||
const Color bubbleColorReceivedQuoted = Color(0xff2c3e50);
|
||||
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
||||
|
||||
const double paddingVeryLarge = 64;
|
||||
|
||||
@ -51,3 +51,4 @@ const String networkRoute = '$settingsRoute/network';
|
||||
const String appearanceRoute = '$settingsRoute/appearance';
|
||||
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
||||
const String blocklistRoute = '/blocklist';
|
||||
const String shareSelectionRoute = '/share_selection';
|
||||
|
@ -38,6 +38,7 @@ PopupMenuItem<dynamic> popupItemWithIcon(dynamic value, String text, IconData ic
|
||||
|
||||
/// A custom version of the Topbar NameAndAvatar style to integrate with
|
||||
/// bloc.
|
||||
// TODO(Unknown): If the display name is too long, then it will cause an overflow.
|
||||
class ConversationTopbarWidget extends StatelessWidget {
|
||||
const ConversationTopbarWidget({ Key? key }) : super(key: key);
|
||||
|
||||
|
103
lib/ui/pages/share_selection.dart
Normal file
103
lib/ui/pages/share_selection.dart
Normal file
@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:move_to_background/move_to_background.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart' as navigation;
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/conversation.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
class ShareSelectionPage extends StatelessWidget {
|
||||
const ShareSelectionPage({ Key? key }) : super(key: key);
|
||||
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
builder: (_) => const ShareSelectionPage(),
|
||||
settings: const RouteSettings(
|
||||
name: shareSelectionRoute,
|
||||
),
|
||||
);
|
||||
|
||||
bool _buildWhen(ShareSelectionState prev, ShareSelectionState next) {
|
||||
// Prevent rebuilding when items changes. This prevents us from having to deal with
|
||||
// a roster update coming in while we are selecting JIDs to share to.
|
||||
// TODO(Unknown): But does it work?
|
||||
return prev.selection != next.selection ||
|
||||
prev.paths != next.paths ||
|
||||
prev.text != next.text ||
|
||||
prev.type != next.type;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
|
||||
|
||||
// Navigate to the conversations page...
|
||||
GetIt.I.get<navigation.NavigationBloc>().add(
|
||||
navigation.PushedNamedAndRemoveUntilEvent(
|
||||
const navigation.NavigationDestination(conversationsRoute),
|
||||
(_) => false,
|
||||
),
|
||||
);
|
||||
// ...and put the app back into the background
|
||||
await MoveToBackground.moveTaskToBack();
|
||||
|
||||
return false;
|
||||
},
|
||||
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
|
||||
buildWhen: _buildWhen,
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple('Share with...'),
|
||||
body: ListView.builder(
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
final isSelected = state.selection.contains(index);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
context.read<ShareSelectionBloc>().add(
|
||||
SelectionToggledEvent(index),
|
||||
);
|
||||
},
|
||||
child: ConversationsListRow(
|
||||
item.avatarPath,
|
||||
item.title,
|
||||
item.jid,
|
||||
0,
|
||||
maxTextWidth,
|
||||
timestampNever,
|
||||
false,
|
||||
extra: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) {
|
||||
context.read<ShareSelectionBloc>().add(
|
||||
SelectionToggledEvent(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: state.selection.isNotEmpty ?
|
||||
FloatingActionButton(
|
||||
onPressed: () {
|
||||
context.read<ShareSelectionBloc>().add(SubmittedEvent());
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
),
|
||||
) :
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -8,7 +8,10 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
/// Handler for when we received a [PreStartDoneEvent].
|
||||
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
@ -21,6 +24,7 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
);
|
||||
|
||||
if (result.state == preStartLoggedInState) {
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = true;
|
||||
GetIt.I.get<ConversationsBloc>().add(
|
||||
ConversationsInitEvent(
|
||||
result.displayName!,
|
||||
@ -37,13 +41,26 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
GetIt.I.get<ConversationBloc>().add(OwnJidReceivedEvent(result.jid!));
|
||||
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
(_) => false,
|
||||
|
||||
// Only go to the Conversations page when we did not start due to a sharing intent
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
if (await handler.getInitialSharedMedia() == null) {
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
(_) => false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
} else if (result.state == preStartNotLoggedInState) {
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
GetIt.I.get<Logger>().finest('Navigating to intro');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
@ -1,36 +1,6 @@
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
|
||||
class UIDataService {
|
||||
|
||||
UIDataService();
|
||||
late String _thumbnailBase;
|
||||
|
||||
Future<void> init() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final base = await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_PICTURES);
|
||||
_thumbnailBase = pathlib.join(base, 'Moxxy', '.thumbnail');
|
||||
}
|
||||
|
||||
// The base path for thumbnails
|
||||
String get thumbnailBase => _thumbnailBase;
|
||||
|
||||
/// Returns the path of a possible thumbnail for the video. Does not imply that the file
|
||||
/// exists.
|
||||
String getThumbnailPath(Message message) => getThumbnailPathFull(
|
||||
message.conversationJid,
|
||||
pathlib.basename(message.mediaUrl!),
|
||||
);
|
||||
|
||||
/// Returns the path of a possible thumbnail for the video. Does not imply that the file
|
||||
/// exists.
|
||||
String getThumbnailPathFull(String conversationJid, String filename) => pathlib.join(
|
||||
_thumbnailBase,
|
||||
conversationJid,
|
||||
filename,
|
||||
);
|
||||
UIDataService() : isLoggedIn = false;
|
||||
|
||||
bool isLoggedIn;
|
||||
}
|
||||
|
@ -38,15 +38,14 @@ class TextChatWidget extends StatelessWidget {
|
||||
child: ParsedText(
|
||||
text: message.body,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: const Color(0xf9ebffff),
|
||||
fontSize: fontsize,
|
||||
),
|
||||
parse: [
|
||||
MatchText(
|
||||
type: ParsedType.URL,
|
||||
style: const TextStyle(
|
||||
// TODO(Unknown): Work on the color
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
onTap: (url) async {
|
||||
await launchUrl(
|
||||
|
@ -19,6 +19,7 @@ class ConversationsListRow extends StatefulWidget {
|
||||
this.lastChangeTimestamp,
|
||||
this.update, {
|
||||
this.typingIndicator = false,
|
||||
this.extra,
|
||||
Key? key,
|
||||
}
|
||||
) : super(key: key);
|
||||
@ -30,6 +31,7 @@ class ConversationsListRow extends StatefulWidget {
|
||||
final int lastChangeTimestamp;
|
||||
final bool update; // Should a timer run to update the timestamp
|
||||
final bool typingIndicator;
|
||||
final Widget? extra;
|
||||
|
||||
@override
|
||||
ConversationsListRowState createState() => ConversationsListRowState();
|
||||
@ -54,18 +56,18 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
// conversation screen open for hours on end?
|
||||
if (widget.update && widget.lastChangeTimestamp > -1 && _now - widget.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
|
||||
_updateTimer = Timer.periodic(const Duration(minutes: 1), (timer) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
setState(() {
|
||||
_timestampString = formatConversationTimestamp(
|
||||
widget.lastChangeTimestamp,
|
||||
now,
|
||||
);
|
||||
});
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
setState(() {
|
||||
_timestampString = formatConversationTimestamp(
|
||||
widget.lastChangeTimestamp,
|
||||
now,
|
||||
);
|
||||
});
|
||||
|
||||
if (now - widget.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
|
||||
_updateTimer!.cancel();
|
||||
_updateTimer = null;
|
||||
}
|
||||
if (now - widget.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
|
||||
_updateTimer!.cancel();
|
||||
_updateTimer = null;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_updateTimer = null;
|
||||
@ -98,8 +100,12 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
final badgeText = widget.unreadCount > 99 ? '99+' : widget.unreadCount.toString();
|
||||
// TODO(Unknown): Maybe turn this into an attribute of the widget to prevent calling this
|
||||
// for every conversation
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final width = screenWidth - 24 - 70;
|
||||
final textWidth = screenWidth * 0.6;
|
||||
|
||||
final showTimestamp = widget.lastChangeTimestamp != timestampNever;
|
||||
final showBadge = widget.unreadCount > 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
@ -111,58 +117,60 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: width - 70.0 - 16.0 - 8.0,
|
||||
child: Row(
|
||||
child: LimitedBox(
|
||||
maxWidth: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxTextWidth,
|
||||
),
|
||||
child: Text(
|
||||
widget.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 17),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
widget.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 17),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Spacer(),
|
||||
Visibility(
|
||||
visible: widget.lastChangeTimestamp != timestampNever,
|
||||
visible: showTimestamp,
|
||||
child: const Spacer(),
|
||||
),
|
||||
Visibility(
|
||||
visible: showTimestamp,
|
||||
child: Text(_timestampString),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: width - 70.0 - 16.0 - 8.0,
|
||||
child: Row(
|
||||
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// TODO(Unknown): Change color and font size
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxTextWidth,
|
||||
),
|
||||
// TODO(Unknown): Colors
|
||||
LimitedBox(
|
||||
maxWidth: textWidth,
|
||||
child: _buildLastMessageBody(),
|
||||
),
|
||||
const Spacer(),
|
||||
Visibility(
|
||||
visible: showBadge,
|
||||
child: const Spacer(),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.unreadCount > 0,
|
||||
child: Badge(
|
||||
badgeContent: Text(badgeText),
|
||||
badgeColor: bubbleColorSent,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.extra != null,
|
||||
child: const Spacer(),
|
||||
),
|
||||
...widget.extra != null ? [widget.extra!] : [],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
37
pubspec.lock
37
pubspec.lock
@ -664,6 +664,15 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
move_to_background:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: e5cc2eefd1667e8ef22f21f41b0ef012b060be6c
|
||||
resolved-ref: e5cc2eefd1667e8ef22f21f41b0ef012b060be6c
|
||||
url: "https://github.com/ViliusP/move_to_background.git"
|
||||
source: git
|
||||
version: "2.0.0"
|
||||
moxdns:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -965,6 +974,34 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
share_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.16"
|
||||
share_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_handler_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
share_handler_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_handler_ios
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.9"
|
||||
share_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_handler_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.5"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -46,6 +46,10 @@ dependencies:
|
||||
json_annotation: 4.6.0
|
||||
logging: 1.0.2
|
||||
mime: 1.0.2
|
||||
move_to_background:
|
||||
git:
|
||||
url: https://github.com/ViliusP/move_to_background.git
|
||||
ref: e5cc2eefd1667e8ef22f21f41b0ef012b060be6c
|
||||
moxdns:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.1.3
|
||||
@ -69,6 +73,7 @@ dependencies:
|
||||
random_string: 2.3.1
|
||||
saslprep: 1.0.2
|
||||
settings_ui: 2.0.2
|
||||
share_handler: 0.0.16
|
||||
#scrollable_positioned_list: 0.2.3
|
||||
stack_blur: 0.2.2
|
||||
swipeable_tile:
|
||||
|
Loading…
Reference in New Issue
Block a user