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:
PapaTutuWawa 2022-08-28 11:55:46 +02:00
commit a49bce8292
31 changed files with 859 additions and 302 deletions

View File

@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <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 <!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->

View File

@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.moxxyv2"> package="org.moxxy.moxxyv2">
<application <application
android:label="Moxxy" android:label="Moxxy"
android:name="${applicationName}" android:name="${applicationName}"
@ -7,7 +7,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@ -18,16 +18,26 @@
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme" />
/>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" android:value="2" />
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </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> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,4 +1,4 @@
package com.example.moxxyv2 package org.moxxy.moxxyv2
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <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 <!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->

View File

@ -216,7 +216,7 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
jid: String recipients: List<String>
body: String body: String
chatState: String chatState: String
quotedMessage: quotedMessage:
@ -227,7 +227,7 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
jid: String recipients: List<String>
paths: List<String> paths: List<String>
- name: BlockJidCommand - name: BlockJidCommand
extends: BackgroundCommand extends: BackgroundCommand

View File

@ -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/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart'; import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_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/bloc/sharedmedia_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/events.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/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart'; import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.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/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart'; import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/service/data.dart'; import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/progress.dart'; import 'package:moxxyv2/ui/service/progress.dart';
import 'package:moxxyv2/ui/service/thumbnail.dart'; import 'package:moxxyv2/ui/service/thumbnail.dart';
import 'package:page_transition/page_transition.dart'; import 'package:page_transition/page_transition.dart';
import 'package:share_handler/share_handler.dart';
void setupLogging() { void setupLogging() {
Logger.root.level = Level.ALL; Logger.root.level = Level.ALL;
@ -64,7 +67,7 @@ Future<void> setupUIServices() async {
GetIt.I.registerSingleton<UIProgressService>(UIProgressService()); GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
GetIt.I.registerSingleton<UIDataService>(UIDataService()); GetIt.I.registerSingleton<UIDataService>(UIDataService());
GetIt.I.registerSingleton<ThumbnailCacheService>(ThumbnailCacheService()); GetIt.I.registerSingleton<ThumbnailCacheService>(ThumbnailCacheService());
await GetIt.I.get<UIDataService>().init();} }
void setupBlocs(GlobalKey<NavigatorState> navKey) { void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: 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<CropBloc>(CropBloc());
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc()); GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc()); GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
GetIt.I.registerSingleton<ShareSelectionBloc>(ShareSelectionBloc());
} }
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a // TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
@ -138,7 +142,10 @@ void main() async {
), ),
BlocProvider<CropBackgroundBloc>( BlocProvider<CropBackgroundBloc>(
create: (_) => GetIt.I.get<CropBackgroundBloc>(), create: (_) => GetIt.I.get<CropBackgroundBloc>(),
) ),
BlocProvider<ShareSelectionBloc>(
create: (_) => GetIt.I.get<ShareSelectionBloc>(),
),
], ],
child: MyApp(navKey), child: MyApp(navKey),
), ),
@ -164,6 +171,42 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
// Lift the UI block // Lift the UI block
GetIt.I.get<Completer<void>>().complete(); 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 @override
@ -263,6 +306,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case cropRoute: return CropPage.route; case cropRoute: return CropPage.route;
case sendFilesRoute: return SendFilesPage.route; case sendFilesRoute: return SendFilesPage.route;
case backgroundCroppingRoute: return CropBackgroundPage.route; case backgroundCroppingRoute: return CropBackgroundPage.route;
case shareSelectionRoute: return ShareSelectionPage.route;
} }
return null; return null;

View File

@ -220,7 +220,7 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async { Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppService>().sendMessage( await GetIt.I.get<XmppService>().sendMessage(
body: command.body, body: command.body,
jid: command.jid, recipients: command.recipients,
chatState: command.chatState.isNotEmpty chatState: command.chatState.isNotEmpty
? chatStateFromString(command.chatState) ? chatStateFromString(command.chatState)
: null, : null,
@ -425,5 +425,5 @@ Future<void> performSignOut(SignOutCommand command, { dynamic extra }) async {
} }
Future<void> performSendFiles(SendFilesCommand 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);
} }

View File

@ -113,18 +113,26 @@ class HttpFileTransferService {
} }
} }
Future<void> _copyFile(String fromPath, String toPath, int msgId) async { Future<void> _copyFile(FileUploadJob job) async {
await File(fromPath).copy(toPath); for (final recipient in job.recipients) {
final newPath = await getDownloadPath(
pathlib.basename(job.path),
recipient,
job.mime,
);
await File(job.path).copy(newPath);
// Let the media scanner index the file // Let the media scanner index the file
MoxplatformPlugin.media.scanFile(toPath); MoxplatformPlugin.media.scanFile(newPath);
// Update the message // Update the message
await GetIt.I.get<MessageService>().updateMessage( await GetIt.I.get<MessageService>().updateMessage(
msgId, job.messageMap[recipient]!.id,
mediaUrl: toPath, mediaUrl: newPath,
); );
} }
}
/// Actually attempt to upload the file described by the job [job]. /// Actually attempt to upload the file described by the job [job].
Future<void> _performFileUpload(FileUploadJob job) async { Future<void> _performFileUpload(FileUploadJob job) async {
@ -148,8 +156,6 @@ class HttpFileTransferService {
} }
final slot = slotResult.getValue(); final slot = slotResult.getValue();
final fileMime = lookupMimeType(job.path);
try { try {
final response = await dio.Dio().putUri<dynamic>( final response = await dio.Dio().putUri<dynamic>(
Uri.parse(slot.putUrl), Uri.parse(slot.putUrl),
@ -160,13 +166,17 @@ class HttpFileTransferService {
), ),
data: data, data: data,
onSendProgress: (count, total) { onSendProgress: (count, total) {
// 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(); final progress = count.toDouble() / total.toDouble();
sendEvent( sendEvent(
ProgressEvent( ProgressEvent(
id: job.message.id, id: job.messageMap.values.first.id,
progress: progress == 1 ? 0.99 : progress, progress: progress == 1 ? 0.99 : progress,
), ),
); );
}
}, },
); );
@ -176,8 +186,9 @@ class HttpFileTransferService {
_log.severe('Upload failed'); _log.severe('Upload failed');
// Notify UI of upload failure // Notify UI of upload failure
for (final recipient in job.recipients) {
final msg = await ms.updateMessage( final msg = await ms.updateMessage(
job.message.id, job.messageMap[recipient]!.id,
errorType: fileUploadFailedError, errorType: fileUploadFailedError,
); );
sendEvent( sendEvent(
@ -185,11 +196,13 @@ class HttpFileTransferService {
message: msg.copyWith(isUploading: false), message: msg.copyWith(isUploading: false),
), ),
); );
}
} else { } else {
_log.fine('Upload was successful'); _log.fine('Upload was successful');
for (final recipient in job.recipients) {
// Notify UI of upload completion // Notify UI of upload completion
var msg = job.message; var msg = job.messageMap[recipient]!;
// Reset a stored error, if there was one // Reset a stored error, if there was one
if (msg.errorType != null) { if (msg.errorType != null) {
@ -207,14 +220,14 @@ class HttpFileTransferService {
// Send the message to the recipient // Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails( MessageDetails(
to: job.recipient, to: recipient,
body: slot.getUrl, body: slot.getUrl,
requestDeliveryReceipt: true, requestDeliveryReceipt: true,
id: job.message.sid, id: msg.sid,
originId: job.message.originId, originId: msg.originId,
sfs: StatelessFileSharingData( sfs: StatelessFileSharingData(
FileMetadataData( FileMetadataData(
mediaType: fileMime, mediaType: job.mime,
size: stat.size, size: stat.size,
name: pathlib.basename(job.path), name: pathlib.basename(job.path),
thumbnails: job.thumbnails, thumbnails: job.thumbnails,
@ -223,14 +236,13 @@ class HttpFileTransferService {
), ),
), ),
); );
_log.finest('Sent message with file upload for ${job.path}'); _log.finest('Sent message with file upload for ${job.path} to $recipient');
final isMultiMedia = fileMime != null ? final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
fileMime.startsWith('image/') || fileMime.startsWith('video/') :
false;
if (isMultiMedia) { if (isMultiMedia) {
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...'); _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)); unawaited(_copyFile(job));
}
} }
} }
} on dio.DioError { } on dio.DioError {

View File

@ -6,25 +6,26 @@ import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
@immutable @immutable
class FileUploadJob { 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 path;
final String recipient; final String? mime;
final Message message; // Recipient -> Message
final String copyToPath; final Map<String, Message> messageMap;
final List<Thumbnail> thumbnails; final List<Thumbnail> thumbnails;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is FileUploadJob && return other is FileUploadJob &&
recipient == other.recipient && recipients == other.recipients &&
path == other.path && path == other.path &&
message == other.message && messageMap == other.messageMap &&
copyToPath == other.copyToPath && mime == other.mime &&
thumbnails == other.thumbnails; thumbnails == other.thumbnails;
} }
@override @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. /// A job describing the upload of a file.

View File

@ -189,10 +189,10 @@ class XmppService {
/// Returns the JID of the chat that is currently opened. Null, if none is open. /// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid; 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({ Future<void> sendMessage({
required String body, required String body,
required String jid, required List<String> recipients,
Message? quotedMessage, Message? quotedMessage,
String? commandId, String? commandId,
ChatState? chatState, ChatState? chatState,
@ -201,13 +201,15 @@ class XmppService {
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
for (final recipient in recipients) {
final sid = conn.generateId(); final sid = conn.generateId();
final originId = conn.generateId(); final originId = conn.generateId();
final message = await ms.addMessageFromData( final message = await ms.addMessageFromData(
body, body,
timestamp, timestamp,
conn.getConnectionSettings().jid.toString(), conn.getConnectionSettings().jid.toString(),
jid, recipient,
false, false,
sid, sid,
false, false,
@ -215,16 +217,15 @@ class XmppService {
quoteId: quotedMessage?.originId ?? quotedMessage?.sid, quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
); );
if (commandId != null) { // Using the same ID should be fine.
sendEvent( sendEvent(
MessageAddedEvent(message: message), MessageAddedEvent(message: message),
id: commandId, id: commandId,
); );
}
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails( MessageDetails(
to: jid, to: recipient,
body: body, body: body,
requestDeliveryReceipt: true, requestDeliveryReceipt: true,
id: sid, id: sid,
@ -236,7 +237,7 @@ class XmppService {
), ),
); );
final conversation = await cs.getConversationByJid(jid); final conversation = await cs.getConversationByJid(recipient);
final newConversation = await cs.updateConversation( final newConversation = await cs.updateConversation(
conversation!.id, conversation!.id,
lastMessageBody: body, lastMessageBody: body,
@ -247,6 +248,7 @@ class XmppService {
ConversationUpdatedEvent(conversation: newConversation), ConversationUpdatedEvent(conversation: newConversation),
); );
} }
}
String? _getMessageSrcUrl(MessageEvent event) { String? _getMessageSrcUrl(MessageEvent event) {
if (event.sfs != null) { if (event.sfs != null) {
@ -323,21 +325,13 @@ class XmppService {
return GetIt.I.get<XmppConnection>().connectAwaitable(lastResource: lastResource); 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 // Create a new message
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
// TODO(Unknown): This has a huge issue. The messages should get sent to the UI // Path -> Recipient -> Message
// as soon as possible to indicate to the user that we are working on final messages = <String, Map<String, Message>>{};
// 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 -> Thumbnails // Path -> Thumbnails
final thumbnails = <String, List<Thumbnail>>{}; final thumbnails = <String, List<Thumbnail>>{};
@ -345,6 +339,8 @@ class XmppService {
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
for (final path in paths) { for (final path in paths) {
final pathMime = lookupMimeType(path); final pathMime = lookupMimeType(path);
for (final recipient in recipients) {
final msg = await ms.addMessageFromData( final msg = await ms.addMessageFromData(
'', '',
DateTime.now().millisecondsSinceEpoch, DateTime.now().millisecondsSinceEpoch,
@ -357,12 +353,78 @@ class XmppService {
mediaType: pathMime, mediaType: pathMime,
originId: conn.generateId(), originId: conn.generateId(),
); );
messages[path] = msg; if (messages.containsKey(path)) {
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true))); messages[path]![recipient] = msg;
} else {
messages[path] = { recipient: msg };
}
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true)));
}
}
// Create the shared media entries
// 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),
);
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): Do this for videos
// TODO(PapaTutuWawa): Maybe do this in a separate isolate // TODO(PapaTutuWawa): Maybe do this in a separate isolate
if ((pathMime ?? '').startsWith('image/')) { if ((pathMime ?? '').startsWith('image/')) {
// Generate a thumbnail only when we have to
if (!thumbnails.containsKey(path)) {
final image = decodeImage((await File(path).readAsBytes()).toList()); final image = decodeImage((await File(path).readAsBytes()).toList());
if (image != null) { if (image != null) {
thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)]; thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)];
@ -370,6 +432,7 @@ class XmppService {
_log.warning('Failed to generate thumbnail for $path'); _log.warning('Failed to generate thumbnail for $path');
} }
} }
}
// Send an upload notification // Send an upload notification
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
@ -386,38 +449,11 @@ class XmppService {
); );
} }
// 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(
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));
// Requesting Upload slots and uploading
final hfts = GetIt.I.get<HttpFileTransferService>();
for (final path in paths) {
final pathMime = lookupMimeType(path);
await hfts.uploadFile( await hfts.uploadFile(
FileUploadJob( FileUploadJob(
recipient, recipients,
path, path,
await getDownloadPath(pathlib.basename(path), recipient, pathMime), pathMime,
messages[path]!, messages[path]!,
thumbnails[path] ?? [], thumbnails[path] ?? [],
), ),

View File

@ -176,7 +176,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData( final result = await MoxplatformPlugin.handler.getDataSender().sendData(
SendMessageCommand( SendMessageCommand(
jid: state.conversation!.jid, recipients: [state.conversation!.jid],
body: state.messageText, body: state.messageText,
quotedMessage: state.quotedMessage, quotedMessage: state.quotedMessage,
chatState: chatStateToString(ChatState.active), chatState: chatStateToString(ChatState.active),
@ -301,13 +301,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
Future<void> _onImagePickerRequested(ImagePickerRequestedEvent event, Emitter<ConversationState> emit) async { Future<void> _onImagePickerRequested(ImagePickerRequestedEvent event, Emitter<ConversationState> emit) async {
GetIt.I.get<SendFilesBloc>().add( 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 { Future<void> _onFilePickerRequested(FilePickerRequestedEvent event, Emitter<ConversationState> emit) async {
GetIt.I.get<SendFilesBloc>().add( GetIt.I.get<SendFilesBloc>().add(
SendFilesPageRequestedEvent(state.conversation!.jid, SendFilesType.generic), SendFilesPageRequestedEvent([state.conversation!.jid], SendFilesType.generic),
); );
} }

View File

@ -1,8 +1,10 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
part 'conversations_bloc.freezed.dart'; part 'conversations_bloc.freezed.dart';
part 'conversations_event.dart'; part 'conversations_event.dart';
@ -30,16 +32,21 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
Future<void> _onConversationsAdded(ConversationsAddedEvent event, Emitter<ConversationsState> emit) async { Future<void> _onConversationsAdded(ConversationsAddedEvent event, Emitter<ConversationsState> emit) async {
// TODO(Unknown): Should we guard against adding the same conversation multiple times? // TODO(Unknown): Should we guard against adding the same conversation multiple times?
return emit( emit(
state.copyWith( state.copyWith(
conversations: List.from(<Conversation>[ ...state.conversations, event.conversation ]) conversations: List.from(<Conversation>[ ...state.conversations, event.conversation ])
..sort(compareConversation), ..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 { Future<void> _onConversationsUpdated(ConversationsUpdatedEvent event, Emitter<ConversationsState> emit) async {
return emit( emit(
state.copyWith( state.copyWith(
conversations: List.from(state.conversations.map((c) { conversations: List.from(state.conversations.map((c) {
if (c.jid == event.conversation.jid) return event.conversation; if (c.jid == event.conversation.jid) return event.conversation;
@ -48,6 +55,11 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
}).toList()..sort(compareConversation),), }).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 { Future<void> _onAvatarChanged(AvatarChangedEvent event, Emitter<ConversationsState> emit) async {

View File

@ -9,6 +9,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
part 'login_bloc.freezed.dart'; part 'login_bloc.freezed.dart';
part 'login_event.dart'; part 'login_event.dart';
@ -72,6 +73,7 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
); );
if (result is LoginSuccessfulEvent) { if (result is LoginSuccessfulEvent) {
GetIt.I.get<UIDataService>().isLoggedIn = true;
emit(state.copyWith(working: false)); emit(state.copyWith(working: false));
GetIt.I.get<ConversationsBloc>().add( GetIt.I.get<ConversationsBloc>().add(
@ -91,6 +93,7 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
), ),
); );
} else if (result is LoginFailureEvent) { } else if (result is LoginFailureEvent) {
GetIt.I.get<UIDataService>().isLoggedIn = false;
return emit( return emit(
state.copyWith( state.copyWith(
working: false, working: false,

View File

@ -41,4 +41,8 @@ class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
Future<void> _onPoppedRoute(PoppedRouteEvent event, Emitter<NavigationState> emit) async { Future<void> _onPoppedRoute(PoppedRouteEvent event, Emitter<NavigationState> emit) async {
navigationKey.currentState!.pop(); navigationKey.currentState!.pop();
} }
bool canPop() {
return navigationKey.currentState!.canPop();
}
} }

View File

@ -9,6 +9,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
part 'newconversation_bloc.freezed.dart'; part 'newconversation_bloc.freezed.dart';
part 'newconversation_event.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)); emit(state.copyWith(roster: roster));
} }
} }

View File

@ -11,6 +11,7 @@ import 'package:moxxyv2/shared/preferences.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
part 'preferences_event.dart'; part 'preferences_event.dart';
@ -43,6 +44,8 @@ class PreferencesBloc extends Bloc<PreferencesEvent, PreferencesState> {
} }
Future<void> _onSignedOut(SignedOutEvent event, Emitter<PreferencesState> emit) async { Future<void> _onSignedOut(SignedOutEvent event, Emitter<PreferencesState> emit) async {
GetIt.I.get<UIDataService>().isLoggedIn = false;
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
SignOutCommand(), SignOutCommand(),
); );

View File

@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:move_to_background/move_to_background.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
@ -32,26 +33,41 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
} }
Future<void> _sendFilesRequested(SendFilesPageRequestedEvent event, Emitter<SendFilesState> emit) async { Future<void> _sendFilesRequested(SendFilesPageRequestedEvent event, Emitter<SendFilesState> emit) async {
final files = await _pickFiles(event.type); List<String> files;
if (files == null) return; if (event.paths != null) {
files = event.paths!;
} else {
final pickedFiles = await _pickFiles(event.type);
if (pickedFiles == null) return;
files = pickedFiles;
}
emit( emit(
state.copyWith( state.copyWith(
files: files, files: files,
index: 0, index: 0,
conversationJid: event.jid, recipients: event.recipients,
), ),
); );
GetIt.I.get<NavigationBloc>().add( NavigationEvent navEvent;
PushedNamedEvent( if (event.popEntireStack) {
navEvent = PushedNamedAndRemoveUntilEvent(
const NavigationDestination(sendFilesRoute),
(_) => false,
);
} else {
navEvent = PushedNamedEvent(
const NavigationDestination( const NavigationDestination(
sendFilesRoute, sendFilesRoute,
), ),
),
); );
} }
GetIt.I.get<NavigationBloc>().add(navEvent);
}
Future<void> _onIndexSet(IndexSetEvent event, Emitter<SendFilesState> emit) async { Future<void> _onIndexSet(IndexSetEvent event, Emitter<SendFilesState> emit) async {
emit(state.copyWith(index: event.index)); emit(state.copyWith(index: event.index));
} }
@ -71,13 +87,26 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
SendFilesCommand( SendFilesCommand(
paths: state.files, paths: state.files,
jid: state.conversationJid!, recipients: state.recipients,
), ),
awaitable: false, awaitable: false,
); );
// Return to the last page // 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 { Future<void> _onItemRemoved(ItemRemovedEvent event, Emitter<SendFilesState> emit) async {

View File

@ -9,9 +9,11 @@ abstract class SendFilesEvent {}
class SendFilesPageRequestedEvent extends SendFilesEvent { class SendFilesPageRequestedEvent extends SendFilesEvent {
SendFilesPageRequestedEvent(this.jid, this.type); SendFilesPageRequestedEvent(this.recipients, this.type, { this.paths, this.popEntireStack = false });
final String jid; final List<String> recipients;
final SendFilesType type; final SendFilesType type;
final List<String>? paths;
final bool popEntireStack;
} }
class IndexSetEvent extends SendFilesEvent { class IndexSetEvent extends SendFilesEvent {
@ -22,7 +24,9 @@ class IndexSetEvent extends SendFilesEvent {
class AddFilesRequestedEvent extends SendFilesEvent {} class AddFilesRequestedEvent extends SendFilesEvent {}
class FileSendingRequestedEvent extends SendFilesEvent {} class FileSendingRequestedEvent extends SendFilesEvent {
FileSendingRequestedEvent();
}
class ItemRemovedEvent extends SendFilesEvent { class ItemRemovedEvent extends SendFilesEvent {

View File

@ -8,6 +8,6 @@ class SendFilesState with _$SendFilesState {
// The currently selected path // The currently selected path
@Default(0) int index, @Default(0) int index,
// The chat that is currently active // The chat that is currently active
@Default(null) String? conversationJid, @Default(<String>[]) List<String> recipients,
}) = _SendFilesState; }) = _SendFilesState;
} }

View 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);
}
}

View 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 {}

View 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;
}

View File

@ -11,10 +11,10 @@ const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
const int primaryColorHexRGBO = 0xffcf4aff; const int primaryColorHexRGBO = 0xffcf4aff;
const Color primaryColor = Color(primaryColorHexRGBO); const Color primaryColor = Color(primaryColorHexRGBO);
const Color bubbleColorSent = Color(0xffac70ca); const Color bubbleColorSent = Color(0xffa139f0);
const Color bubbleColorSentQuoted = Color(0xff964db3); const Color bubbleColorSentQuoted = bubbleColorSent;
const Color bubbleColorReceived = Color(0xff222222); const Color bubbleColorReceived = Color(0xff222222);
const Color bubbleColorReceivedQuoted = Color(0xff2c3e50); const Color bubbleColorReceivedQuoted = bubbleColorReceived;
const double paddingVeryLarge = 64; const double paddingVeryLarge = 64;
@ -51,3 +51,4 @@ const String networkRoute = '$settingsRoute/network';
const String appearanceRoute = '$settingsRoute/appearance'; const String appearanceRoute = '$settingsRoute/appearance';
const String backgroundCroppingRoute = '$settingsRoute/appearance/background'; const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String blocklistRoute = '/blocklist'; const String blocklistRoute = '/blocklist';
const String shareSelectionRoute = '/share_selection';

View File

@ -38,6 +38,7 @@ PopupMenuItem<dynamic> popupItemWithIcon(dynamic value, String text, IconData ic
/// A custom version of the Topbar NameAndAvatar style to integrate with /// A custom version of the Topbar NameAndAvatar style to integrate with
/// bloc. /// bloc.
// TODO(Unknown): If the display name is too long, then it will cause an overflow.
class ConversationTopbarWidget extends StatelessWidget { class ConversationTopbarWidget extends StatelessWidget {
const ConversationTopbarWidget({ Key? key }) : super(key: key); const ConversationTopbarWidget({ Key? key }) : super(key: key);

View 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,
),
),
);
}
}

View File

@ -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/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart'; import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_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/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:share_handler/share_handler.dart';
/// Handler for when we received a [PreStartDoneEvent]. /// Handler for when we received a [PreStartDoneEvent].
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async { Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
@ -21,6 +24,7 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
); );
if (result.state == preStartLoggedInState) { if (result.state == preStartLoggedInState) {
GetIt.I.get<UIDataService>().isLoggedIn = true;
GetIt.I.get<ConversationsBloc>().add( GetIt.I.get<ConversationsBloc>().add(
ConversationsInitEvent( ConversationsInitEvent(
result.displayName!, 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<ConversationBloc>().add(OwnJidReceivedEvent(result.jid!));
GetIt.I.get<Logger>().finest('Navigating to conversations'); GetIt.I.get<Logger>().finest('Navigating to conversations');
// 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( GetIt.I.get<NavigationBloc>().add(
PushedNamedAndRemoveUntilEvent( PushedNamedAndRemoveUntilEvent(
const NavigationDestination(conversationsRoute), const NavigationDestination(conversationsRoute),
(_) => false, (_) => false,
), ),
); );
}
GetIt.I.get<ShareSelectionBloc>().add(
ShareSelectionInitEvent(
result.conversations!,
result.roster!,
),
);
} else if (result.state == preStartNotLoggedInState) { } else if (result.state == preStartNotLoggedInState) {
GetIt.I.get<UIDataService>().isLoggedIn = false;
GetIt.I.get<Logger>().finest('Navigating to intro'); GetIt.I.get<Logger>().finest('Navigating to intro');
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedAndRemoveUntilEvent( PushedNamedAndRemoveUntilEvent(

View File

@ -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 { class UIDataService {
UIDataService(); UIDataService() : isLoggedIn = false;
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,
);
bool isLoggedIn;
} }

View File

@ -38,15 +38,14 @@ class TextChatWidget extends StatelessWidget {
child: ParsedText( child: ParsedText(
text: message.body, text: message.body,
style: TextStyle( style: TextStyle(
color: Colors.white, color: const Color(0xf9ebffff),
fontSize: fontsize, fontSize: fontsize,
), ),
parse: [ parse: [
MatchText( MatchText(
type: ParsedType.URL, type: ParsedType.URL,
style: const TextStyle( style: const TextStyle(
// TODO(Unknown): Work on the color decoration: TextDecoration.underline,
color: Colors.blue,
), ),
onTap: (url) async { onTap: (url) async {
await launchUrl( await launchUrl(

View File

@ -19,6 +19,7 @@ class ConversationsListRow extends StatefulWidget {
this.lastChangeTimestamp, this.lastChangeTimestamp,
this.update, { this.update, {
this.typingIndicator = false, this.typingIndicator = false,
this.extra,
Key? key, Key? key,
} }
) : super(key: key); ) : super(key: key);
@ -30,6 +31,7 @@ class ConversationsListRow extends StatefulWidget {
final int lastChangeTimestamp; final int lastChangeTimestamp;
final bool update; // Should a timer run to update the timestamp final bool update; // Should a timer run to update the timestamp
final bool typingIndicator; final bool typingIndicator;
final Widget? extra;
@override @override
ConversationsListRowState createState() => ConversationsListRowState(); ConversationsListRowState createState() => ConversationsListRowState();
@ -98,8 +100,12 @@ class ConversationsListRowState extends State<ConversationsListRow> {
final badgeText = widget.unreadCount > 99 ? '99+' : widget.unreadCount.toString(); final badgeText = widget.unreadCount > 99 ? '99+' : widget.unreadCount.toString();
// TODO(Unknown): Maybe turn this into an attribute of the widget to prevent calling this // TODO(Unknown): Maybe turn this into an attribute of the widget to prevent calling this
// for every conversation // 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( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
@ -111,58 +117,60 @@ class ConversationsListRowState extends State<ConversationsListRow> {
), ),
Padding( Padding(
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: LimitedBox(
maxWidth: width,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Row(
width: width - 70.0 - 16.0 - 8.0, mainAxisSize: MainAxisSize.min,
child: Row(
children: [ children: [
Container( Text(
constraints: BoxConstraints(
maxWidth: widget.maxTextWidth,
),
child: Text(
widget.name, widget.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 17), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 17),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
const Spacer(),
Visibility( Visibility(
visible: widget.lastChangeTimestamp != timestampNever, visible: showTimestamp,
child: const Spacer(),
),
Visibility(
visible: showTimestamp,
child: Text(_timestampString), child: Text(_timestampString),
) ),
], ],
), ),
),
SizedBox( Row(
width: width - 70.0 - 16.0 - 8.0, mainAxisSize: MainAxisSize.min,
child: Row(
children: [ children: [
// TODO(Unknown): Change color and font size LimitedBox(
Container( maxWidth: textWidth,
constraints: BoxConstraints(
maxWidth: widget.maxTextWidth,
),
// TODO(Unknown): Colors
child: _buildLastMessageBody(), child: _buildLastMessageBody(),
), ),
const Spacer(), Visibility(
visible: showBadge,
child: const Spacer(),
),
Visibility( Visibility(
visible: widget.unreadCount > 0, visible: widget.unreadCount > 0,
child: Badge( child: Badge(
badgeContent: Text(badgeText), badgeContent: Text(badgeText),
badgeColor: bubbleColorSent, badgeColor: bubbleColorSent,
), ),
)
],
),
), ),
], ],
), ),
) ],
),
),
),
Visibility(
visible: widget.extra != null,
child: const Spacer(),
),
...widget.extra != null ? [widget.extra!] : [],
], ],
), ),
); );

View File

@ -664,6 +664,15 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.2" 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: moxdns:
dependency: "direct main" dependency: "direct main"
description: description:
@ -965,6 +974,34 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" 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: shared_preferences:
dependency: transitive dependency: transitive
description: description:

View File

@ -46,6 +46,10 @@ dependencies:
json_annotation: 4.6.0 json_annotation: 4.6.0
logging: 1.0.2 logging: 1.0.2
mime: 1.0.2 mime: 1.0.2
move_to_background:
git:
url: https://github.com/ViliusP/move_to_background.git
ref: e5cc2eefd1667e8ef22f21f41b0ef012b060be6c
moxdns: moxdns:
hosted: https://git.polynom.me/api/packages/Moxxy/pub hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.1.3 version: 0.1.3
@ -69,6 +73,7 @@ dependencies:
random_string: 2.3.1 random_string: 2.3.1
saslprep: 1.0.2 saslprep: 1.0.2
settings_ui: 2.0.2 settings_ui: 2.0.2
share_handler: 0.0.16
#scrollable_positioned_list: 0.2.3 #scrollable_positioned_list: 0.2.3
stack_blur: 0.2.2 stack_blur: 0.2.2
swipeable_tile: swipeable_tile: