Merge pull request 'Implement media viewers' (#344) from feat/media-viewers into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/344
This commit is contained in:
commit
9ea5d41096
@ -196,6 +196,7 @@
|
||||
"retract": "Retract message",
|
||||
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
|
||||
"forward": "Forward",
|
||||
"share": "Share",
|
||||
"edit": "Edit",
|
||||
"quote": "Quote",
|
||||
"copy": "Copy content",
|
||||
@ -219,7 +220,7 @@
|
||||
"other": "Multiple devices have been added"
|
||||
},
|
||||
"messageHint": "Send a message…",
|
||||
"sendImages": "Send images",
|
||||
"sendMedia": "Send media",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos",
|
||||
"voiceRecording": {
|
||||
|
@ -47,8 +47,7 @@ class AvatarService {
|
||||
_requestedInStream.clear();
|
||||
}
|
||||
|
||||
String _computeAvatarPath(String hash) =>
|
||||
p.join(_avatarCacheDir, '$hash.png');
|
||||
String _computeAvatarPath(String hash) => p.join(_avatarCacheDir, hash);
|
||||
|
||||
/// Returns whether we can remove the avatar file at [path] by checking if the
|
||||
/// avatar is referenced by any other conversation. If [ignoreSelf] is true, then
|
||||
@ -254,15 +253,31 @@ class AvatarService {
|
||||
}
|
||||
|
||||
// Find the first metadata item that advertises a PNG avatar.
|
||||
final id = rawMetadata
|
||||
.get<List<UserAvatarMetadata>>()
|
||||
.firstWhereOrNull((element) => element.type == 'image/png')
|
||||
?.id;
|
||||
final metadata = rawMetadata.get<List<UserAvatarMetadata>>();
|
||||
var id =
|
||||
metadata.firstWhereOrNull((element) => element.type == 'image/png')?.id;
|
||||
if (id == null) {
|
||||
_log.warning(
|
||||
'$jid does not advertise an avatar of type image/png, which violates XEP-0084',
|
||||
);
|
||||
return null;
|
||||
|
||||
if (metadata.isEmpty) {
|
||||
_log.warning(
|
||||
'$jid does not advertise any metadata.',
|
||||
);
|
||||
_requestedInStream.remove(jid);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If other avatar types are present, sort the list to make the selection stable
|
||||
// and then just pick the first one.
|
||||
final typeSortedMetadata = List.of(metadata)
|
||||
..sort((a, b) => a.type.compareTo(b.type));
|
||||
final firstMetadata = typeSortedMetadata.first;
|
||||
_log.warning(
|
||||
'Falling back to ${firstMetadata.id} (${firstMetadata.type})',
|
||||
);
|
||||
id = firstMetadata.id;
|
||||
}
|
||||
|
||||
// Check if the id changed.
|
||||
|
@ -10,3 +10,8 @@ Future<String> computeCacheDirectoryPath(String subdirectory) async {
|
||||
subdirectory,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrapper around [computeCacheDirectoryPath] for the cache directory that contains
|
||||
/// copies of picked files.
|
||||
Future<String> computePickedFileCachePath() async =>
|
||||
computeCacheDirectoryPath('cache');
|
||||
|
@ -307,7 +307,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
CREATE TABLE $preferenceTable (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
value TEXT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
@ -379,7 +379,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
Preference(
|
||||
'backgroundPath',
|
||||
typeString,
|
||||
'',
|
||||
null,
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
|
@ -7,7 +7,7 @@ import 'package:path/path.dart' as p;
|
||||
Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||
final (db, logger) = data;
|
||||
|
||||
// Make avatarPath and avatarHash nullable
|
||||
// Make avatarPath, avatarHash, and backgroundPath nullable
|
||||
// 1) Roster items
|
||||
await db.execute(
|
||||
'''
|
||||
@ -70,6 +70,31 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable',
|
||||
);
|
||||
// 3) Preferences
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${preferenceTable}_new (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
value TEXT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'INSERT INTO ${preferenceTable}_new SELECT * FROM $preferenceTable',
|
||||
);
|
||||
await db.execute('DROP TABLE $preferenceTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${preferenceTable}_new RENAME TO $preferenceTable');
|
||||
|
||||
// In case the backgroundPath is set to "", migrate it to null
|
||||
await db.update(
|
||||
preferenceTable,
|
||||
{
|
||||
'value': null,
|
||||
},
|
||||
where: 'key = ? AND value = ?',
|
||||
whereArgs: ['backgroundPath', ''],
|
||||
);
|
||||
|
||||
// Find all conversations and roster items that have an avatar.
|
||||
final conversations = await db.query(
|
||||
@ -100,7 +125,8 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||
final oldPath = avatar['value']! as String;
|
||||
final newPath = p.join(
|
||||
cachePath,
|
||||
p.basename(oldPath),
|
||||
// Remove the ".png" at the end
|
||||
p.basename(oldPath).split('.').first,
|
||||
);
|
||||
|
||||
logger.finest('Migrating account avatar $oldPath');
|
||||
@ -150,7 +176,7 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||
}
|
||||
|
||||
try {
|
||||
final newPath = p.join(cachePath, '$hash.png');
|
||||
final newPath = p.join(cachePath, hash);
|
||||
|
||||
logger.finest(
|
||||
'Migrating conversation avatar $path',
|
||||
@ -213,7 +239,7 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||
}
|
||||
|
||||
try {
|
||||
final newPath = p.join(cachePath, '$hash.png');
|
||||
final newPath = p.join(cachePath, hash);
|
||||
|
||||
logger.finest(
|
||||
'Migrating roster avatar $path',
|
||||
|
@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/cache.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
@ -121,12 +122,18 @@ class HttpFileTransferService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _copyFile(
|
||||
Future<void> _copyUploadedFile(
|
||||
FileUploadJob job,
|
||||
String to,
|
||||
) async {
|
||||
if (!File(to).existsSync()) {
|
||||
await File(job.path).copy(to);
|
||||
|
||||
// Remove the original file
|
||||
await safelyRemovePickedFile(
|
||||
job.path,
|
||||
await computePickedFileCachePath(),
|
||||
);
|
||||
} else {
|
||||
_log.finest(
|
||||
'Skipping file copy on upload as file is already at media location',
|
||||
@ -311,7 +318,7 @@ class HttpFileTransferService {
|
||||
_log.fine(
|
||||
'Uploaded file $filename is already tracked but has no path. Copying...',
|
||||
);
|
||||
await _copyFile(job, filePath);
|
||||
await _copyUploadedFile(job, filePath);
|
||||
metadata = await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||
metadata.id,
|
||||
path: filePath,
|
||||
@ -319,7 +326,7 @@ class HttpFileTransferService {
|
||||
}
|
||||
} else {
|
||||
_log.fine('Uploaded file $filename not tracked. Copying...');
|
||||
await _copyFile(job, metadataWrapper.fileMetadata.path!);
|
||||
await _copyUploadedFile(job, metadataWrapper.fileMetadata.path!);
|
||||
}
|
||||
|
||||
const uuid = Uuid();
|
||||
|
@ -286,6 +286,12 @@ class NotificationsService {
|
||||
// Workaround for Android to show the thumbnail in the notification
|
||||
filePath = thumbnailPath;
|
||||
fileMime = 'image/jpeg';
|
||||
} else {
|
||||
_log.warning(
|
||||
'Thumbnail $thumbnailPath for video $filePath does not exist.',
|
||||
);
|
||||
filePath = null;
|
||||
fileMime = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,6 +321,33 @@ class NotificationsService {
|
||||
return Permission.notification.isGranted;
|
||||
}
|
||||
|
||||
/// Checks if the notification [notification] already appears inside [notifications].
|
||||
/// If yes, then that occurence is replaced by [notification]. If not, then [notification]
|
||||
/// is simply appended to [notifications].
|
||||
List<native.NotificationMessage> _replaceOrAppendNotification(
|
||||
List<modeln.Notification> notifications,
|
||||
modeln.Notification notification,
|
||||
) {
|
||||
final index = notifications.indexWhere(
|
||||
(n) =>
|
||||
n.id == notification.id &&
|
||||
n.conversationJid == notification.conversationJid &&
|
||||
n.senderJid == notification.senderJid &&
|
||||
n.timestamp == notification.timestamp,
|
||||
);
|
||||
final notificationsList =
|
||||
notifications.map((n) => n.toNotificationMessage()).toList();
|
||||
if (index == -1) {
|
||||
return [
|
||||
...notificationsList,
|
||||
notification.toNotificationMessage(),
|
||||
];
|
||||
} else {
|
||||
notificationsList[index] = notification.toNotificationMessage();
|
||||
return notificationsList.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// When a notification is already visible, then build a new notification based on [c] and [m],
|
||||
/// update the database state and tell the OS to show the notification again.
|
||||
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
|
||||
@ -350,17 +383,10 @@ class NotificationsService {
|
||||
id: id,
|
||||
channelId: messageNotificationChannelId,
|
||||
jid: c.jid,
|
||||
messages: notifications.map((n) {
|
||||
// Based on the table's composite primary key
|
||||
if (n.id == notification.id &&
|
||||
n.conversationJid == notification.conversationJid &&
|
||||
n.senderJid == notification.senderJid &&
|
||||
n.timestamp == notification.timestamp) {
|
||||
return notification.toNotificationMessage();
|
||||
}
|
||||
|
||||
return n.toNotificationMessage();
|
||||
}).toList(),
|
||||
messages: _replaceOrAppendNotification(
|
||||
notifications,
|
||||
notification,
|
||||
),
|
||||
isGroupchat: c.isGroupchat,
|
||||
groupId: messageNotificationGroupId,
|
||||
extra: {
|
||||
|
@ -1356,11 +1356,6 @@ class XmppService {
|
||||
// Indicates if we should auto-download the file, if a file is specified in the message
|
||||
final shouldDownload = isFileEmbedded &&
|
||||
await _shouldDownloadFile(conversationJid, accountJid);
|
||||
// Indicates if a notification should be created for the message.
|
||||
// The way this variable works is that if we can download the file, then the
|
||||
// notification will be created later by the [DownloadService]. If we don't want the
|
||||
// download to happen automatically, then the notification should happen immediately.
|
||||
var shouldNotify = !(isInRoster && shouldDownload);
|
||||
// A guess for the Mime type of the embedded file.
|
||||
var mimeGuess = _getMimeGuess(event);
|
||||
|
||||
@ -1486,9 +1481,6 @@ class XmppService {
|
||||
mimeGuess,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Make sure we create the notification
|
||||
shouldNotify = true;
|
||||
}
|
||||
} else {
|
||||
if (fileMetadata?.retrieved ?? false) {
|
||||
@ -1567,22 +1559,26 @@ class XmppService {
|
||||
preRun: (c) async {
|
||||
isMuted = c != null ? c.muted : prefs.defaultMuteState;
|
||||
sendNotification = !sent &&
|
||||
shouldNotify &&
|
||||
(!isConversationOpened ||
|
||||
!GetIt.I.get<LifecycleService>().isActive) &&
|
||||
isConversationOpened &&
|
||||
!GetIt.I.get<LifecycleService>().isActive) &&
|
||||
!isMuted;
|
||||
},
|
||||
);
|
||||
|
||||
// Update the share handler
|
||||
await GetIt.I.get<ShareService>().recordSentMessage(
|
||||
conversation!,
|
||||
);
|
||||
try {
|
||||
await GetIt.I.get<ShareService>().recordSentMessage(
|
||||
conversation!,
|
||||
);
|
||||
} catch (ex) {
|
||||
_log.warning('Error while creating direct share shortcut: $ex');
|
||||
}
|
||||
|
||||
// Create the notification if we the user does not already know about the message
|
||||
if (sendNotification) {
|
||||
await ns.showNotification(
|
||||
conversation,
|
||||
conversation!,
|
||||
message,
|
||||
accountJid,
|
||||
isInRoster ? conversation.title : conversationJid,
|
||||
@ -1684,7 +1680,6 @@ class XmppService {
|
||||
// using hashes and thus have to create hash pointers.
|
||||
fileMetadata == null,
|
||||
_getMimeGuess(event),
|
||||
shouldShowNotification: false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/cache.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -387,6 +389,32 @@ Future<String> getContactProfilePicturePath(String id) async {
|
||||
return p.join(avatarDir, id);
|
||||
}
|
||||
|
||||
/// Removes the path [path] only if it's within [cachePath] and the current platform
|
||||
/// is Android.
|
||||
Future<void> safelyRemovePickedFile(String path, String cachePath) async {
|
||||
// Only do this on Android.
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith(cachePath)) {
|
||||
unawaited(File(path).delete());
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the file paths [paths] if they are within [cacheDir]. Wrapper around
|
||||
/// [safelyRemovePickedFile].
|
||||
Future<void> safelyRemovePickedFiles(
|
||||
List<String> paths,
|
||||
String? cacheDir,
|
||||
) async {
|
||||
final cachePath = cacheDir ?? await computePickedFileCachePath();
|
||||
|
||||
for (final path in paths) {
|
||||
await safelyRemovePickedFile(path, cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepend [item] to [list], but ensure that the resulting list's size is
|
||||
/// smaller than or equal to [maxSize].
|
||||
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
|
||||
|
@ -8,7 +8,7 @@ class Preference with _$Preference {
|
||||
factory Preference(
|
||||
String key,
|
||||
int type,
|
||||
String value,
|
||||
String? value,
|
||||
) = _Preference;
|
||||
|
||||
const Preference._();
|
||||
|
@ -158,7 +158,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state.conversation!.contactId != null,
|
||||
),
|
||||
],
|
||||
SendFilesType.image,
|
||||
SendFilesType.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@ -16,44 +15,6 @@ part 'cropbackground_bloc.freezed.dart';
|
||||
part 'cropbackground_event.dart';
|
||||
part 'cropbackground_state.dart';
|
||||
|
||||
// This function in an isolate allows to perform the cropping without blocking the UI
|
||||
// at all. Sending the image data to the isolate would result in UI blocking.
|
||||
// TODO(Unknown): Maybe make use of image's executeThread method to replace our own
|
||||
// isolate code.
|
||||
Future<void> _cropImage(List<dynamic> data) async {
|
||||
final port = data[0] as SendPort;
|
||||
final originalPath = data[1] as String;
|
||||
final destination = data[2] as String;
|
||||
final q = data[3] as double;
|
||||
final x = data[4] as double;
|
||||
final y = data[5] as double;
|
||||
final vw = data[6] as double;
|
||||
final vh = data[7] as double;
|
||||
final blur = data[8] as bool;
|
||||
|
||||
final inverse = 1 / q;
|
||||
final xp = (x.abs() * inverse).toInt();
|
||||
final yp = (y.abs() * inverse).toInt();
|
||||
|
||||
final cmd = Command()
|
||||
..decodeImageFile(originalPath)
|
||||
..copyCrop(
|
||||
x: xp,
|
||||
y: yp,
|
||||
width: (vw * inverse).toInt(),
|
||||
height: (vh * inverse).toInt(),
|
||||
);
|
||||
|
||||
if (blur) {
|
||||
cmd.gaussianBlur(radius: 10);
|
||||
}
|
||||
|
||||
cmd.writeToFile(destination);
|
||||
|
||||
await cmd.execute();
|
||||
port.send(true);
|
||||
}
|
||||
|
||||
class CropBackgroundBloc
|
||||
extends Bloc<CropBackgroundEvent, CropBackgroundState> {
|
||||
CropBackgroundBloc() : super(CropBackgroundState()) {
|
||||
@ -124,22 +85,25 @@ class CropBackgroundBloc
|
||||
final appDir = await MoxxyPlatformApi().getPersistentDataPath();
|
||||
final backgroundPath = path.join(appDir, 'background_image.png');
|
||||
|
||||
final port = ReceivePort();
|
||||
await Isolate.spawn(
|
||||
_cropImage,
|
||||
[
|
||||
port.sendPort,
|
||||
state.imagePath,
|
||||
backgroundPath,
|
||||
event.q,
|
||||
event.x,
|
||||
event.y,
|
||||
event.viewportWidth,
|
||||
event.viewportHeight,
|
||||
state.blurEnabled,
|
||||
],
|
||||
);
|
||||
await port.first;
|
||||
// Compute values for cropping the image.
|
||||
final inverse = 1 / event.q;
|
||||
final xp = (event.x.abs() * inverse).toInt();
|
||||
final yp = (event.y.abs() * inverse).toInt();
|
||||
|
||||
// Compute the crop and optional blur.
|
||||
final cmd = Command()
|
||||
..decodeImageFile(state.imagePath!)
|
||||
..copyCrop(
|
||||
x: xp,
|
||||
y: yp,
|
||||
width: (event.viewportWidth * inverse).toInt(),
|
||||
height: (event.viewportHeight * inverse).toInt(),
|
||||
);
|
||||
if (state.blurEnabled) {
|
||||
cmd.gaussianBlur(radius: 10);
|
||||
}
|
||||
cmd.writeToFile(backgroundPath);
|
||||
await cmd.executeThread();
|
||||
|
||||
_resetState(emit);
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:move_to_background/move_to_background.dart';
|
||||
import 'package:moxxy_native/moxxy_native.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
@ -20,14 +22,21 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
on<AddFilesRequestedEvent>(_onAddFilesRequested);
|
||||
on<FileSendingRequestedEvent>(_onFileSendingRequested);
|
||||
on<ItemRemovedEvent>(_onItemRemoved);
|
||||
on<RemovedCacheFilesEvent>(_onCacheFilesRemoved);
|
||||
}
|
||||
|
||||
/// Whether a single [RemovedCacheFilesEvent] event should be ignored.
|
||||
bool _shouldIgnoreDeletionRequest = false;
|
||||
|
||||
/// Logger.
|
||||
final Logger _log = Logger('SendFilesBloc');
|
||||
|
||||
/// Pick files. Returns either a list of paths to attach or null if the process has
|
||||
/// been cancelled.
|
||||
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
||||
final result = await safePickFiles(
|
||||
type == SendFilesType.image
|
||||
? FilePickerType.image
|
||||
type == SendFilesType.media
|
||||
? FilePickerType.imageAndVideo
|
||||
: FilePickerType.generic,
|
||||
);
|
||||
|
||||
@ -50,6 +59,7 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
files = pickedFiles;
|
||||
}
|
||||
|
||||
_shouldIgnoreDeletionRequest = false;
|
||||
emit(
|
||||
state.copyWith(
|
||||
files: files,
|
||||
@ -108,6 +118,7 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
_shouldIgnoreDeletionRequest = true;
|
||||
|
||||
// Return to the last page
|
||||
final bloc = GetIt.I.get<NavigationBloc>();
|
||||
@ -152,4 +163,17 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCacheFilesRemoved(
|
||||
RemovedCacheFilesEvent event,
|
||||
Emitter<SendFilesState> _,
|
||||
) async {
|
||||
if (_shouldIgnoreDeletionRequest) {
|
||||
_log.finest('Ignoring RemovedCacheFilesEvent.');
|
||||
_shouldIgnoreDeletionRequest = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await safelyRemovePickedFiles(state.files, null);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
part of 'sendfiles_bloc.dart';
|
||||
|
||||
enum SendFilesType {
|
||||
image,
|
||||
media,
|
||||
generic,
|
||||
}
|
||||
|
||||
@ -37,3 +37,8 @@ class ItemRemovedEvent extends SendFilesEvent {
|
||||
ItemRemovedEvent(this.index);
|
||||
final int index;
|
||||
}
|
||||
|
||||
/// Triggered by the UI when the temporary files should be removed, i.e. all
|
||||
/// files that are currently selected for sending. This is only useful on systems like
|
||||
/// Android that only give us access using content URIs.
|
||||
class RemovedCacheFilesEvent extends SendFilesEvent {}
|
||||
|
@ -236,7 +236,7 @@ class ShareSelectionBloc
|
||||
);
|
||||
}).toList(),
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
SendFilesType.image,
|
||||
SendFilesType.media,
|
||||
paths: state.paths,
|
||||
popEntireStack: true,
|
||||
),
|
||||
|
@ -137,6 +137,9 @@ const Color highlightSkimColor = Color(0xff000000);
|
||||
/// The width of the bar used to indicate a legacy quote.
|
||||
const double textMessageQuoteBarWidth = 3;
|
||||
|
||||
/// The duration of the animations in the media viewers.
|
||||
const Duration mediaViewerAnimationDuration = Duration(milliseconds: 150);
|
||||
|
||||
/// Navigation constants
|
||||
const String cropRoute = '/crop';
|
||||
const String introRoute = '/intro';
|
||||
|
@ -12,6 +12,8 @@ import 'package:hex/hex.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxy_native/moxxy_native.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
||||
@ -512,3 +514,35 @@ Rect getWidgetPositionOnScreen(GlobalKey key) {
|
||||
final offset = Offset(translation.x, translation.y);
|
||||
return renderObject.paintBounds.shift(offset);
|
||||
}
|
||||
|
||||
/// Format a duration [duration] in the format of "mm:ss".
|
||||
String formatDuration(Duration duration) {
|
||||
final minutes = duration.inSeconds ~/ 60;
|
||||
final seconds = duration.inSeconds - minutes * 60;
|
||||
return '${padInt(minutes)}:${padInt(seconds)}';
|
||||
}
|
||||
|
||||
void shareMessage(Message message) {
|
||||
if (message.fileMetadata?.path != null &&
|
||||
message.fileMetadata?.mimeType != null) {
|
||||
MoxxyPlatformApi().shareItems(
|
||||
[
|
||||
ShareItem(
|
||||
path: message.fileMetadata!.path,
|
||||
mime: message.fileMetadata!.mimeType!,
|
||||
),
|
||||
],
|
||||
message.fileMetadata!.mimeType!,
|
||||
);
|
||||
} else {
|
||||
MoxxyPlatformApi().shareItems(
|
||||
[
|
||||
ShareItem(
|
||||
mime: 'text/plain',
|
||||
text: message.body,
|
||||
),
|
||||
],
|
||||
'text/plain',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:keyboard_height_plugin/keyboard_height_plugin.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
|
||||
/// A triple of data for the child widget wrapper widget.
|
||||
@ -47,19 +47,25 @@ class KeyboardReplacerController {
|
||||
);
|
||||
});
|
||||
|
||||
_keyboardHeightPlugin.onKeyboardHeightChanged((height) {
|
||||
// Only update when the height actually changed
|
||||
if (height == 0 || height == _keyboardHeight) return;
|
||||
_keyboardHeightSubscription =
|
||||
const EventChannel('org.moxxy.moxxyv2/keyboard_stream')
|
||||
.receiveBroadcastStream()
|
||||
.cast<double>()
|
||||
.listen(
|
||||
(height) {
|
||||
// Only update when the height actually changed
|
||||
if (height == 0 || height == _keyboardHeight) return;
|
||||
|
||||
_keyboardHeight = height;
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
_keyboardVisible,
|
||||
height,
|
||||
_widgetVisible,
|
||||
),
|
||||
);
|
||||
});
|
||||
_keyboardHeight = height;
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
_keyboardVisible,
|
||||
height,
|
||||
_widgetVisible,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// State of the child widget's visibility.
|
||||
@ -74,7 +80,7 @@ class KeyboardReplacerController {
|
||||
late bool _keyboardVisible;
|
||||
|
||||
/// Data for keeping track of the keyboard height.
|
||||
final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin();
|
||||
late final StreamSubscription<double> _keyboardHeightSubscription;
|
||||
|
||||
/// The currently tracked keyboard height.
|
||||
/// NOTE: The value is a random keyboard height I got on my test device.
|
||||
@ -95,7 +101,7 @@ class KeyboardReplacerController {
|
||||
|
||||
void dispose() {
|
||||
_keyboardVisibilitySubscription.cancel();
|
||||
_keyboardHeightPlugin.dispose();
|
||||
_keyboardHeightSubscription.cancel();
|
||||
}
|
||||
|
||||
/// Show the child widget in the child wrapper. If the soft-keyboard is currently
|
||||
|
@ -282,15 +282,12 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (message.isQuotable && message.conversationJid != '')
|
||||
if (message.isQuotable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.forward,
|
||||
text: t.pages.conversation.forward,
|
||||
icon: Icons.share,
|
||||
text: t.pages.conversation.share,
|
||||
onPressed: () {
|
||||
showNotImplementedDialog(
|
||||
'sharing',
|
||||
context,
|
||||
);
|
||||
shareMessage(message);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
@ -11,6 +12,9 @@ import 'package:moxxyv2/ui/widgets/cancel_button.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/video.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
|
||||
Widget _deleteIconWithShadow() {
|
||||
@ -133,19 +137,17 @@ class SendFilesPage extends StatelessWidget {
|
||||
|
||||
if (mime.startsWith('image/')) {
|
||||
// Render the image
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.contain,
|
||||
return ImageViewer(
|
||||
path: path,
|
||||
controller: ViewerUIVisibilityController(),
|
||||
);
|
||||
} /*else if (mime.startsWith('video/')) {
|
||||
// Render the video thumbnail
|
||||
// TODO(PapaTutuWawa): Maybe allow playing the video back inline
|
||||
return VideoThumbnailWidget(
|
||||
path,
|
||||
Image.memory,
|
||||
} else if (mime.startsWith('video/')) {
|
||||
return VideoViewer(
|
||||
path: path,
|
||||
controller: ViewerUIVisibilityController(),
|
||||
showScrubBar: false,
|
||||
);
|
||||
}*/
|
||||
else {
|
||||
} else {
|
||||
// Generic file
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return Center(
|
||||
@ -163,119 +165,134 @@ class SendFilesPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeRemoveTemporaryFiles() {
|
||||
if (Platform.isAndroid) {
|
||||
// Remove temporary files.
|
||||
GetIt.I.get<SendFilesBloc>().add(RemovedCacheFilesEvent());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const barPadding = 8.0;
|
||||
|
||||
// TODO(Unknown): Fix the typography
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
body: BlocBuilder<SendFilesBloc, SendFilesState>(
|
||||
builder: (context, state) => Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: _renderBackground(context, state.files[state.index]),
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
_maybeRemoveTemporaryFiles();
|
||||
return true;
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Scaffold(
|
||||
body: BlocBuilder<SendFilesBloc, SendFilesState>(
|
||||
builder: (context, state) => Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: _renderBackground(context, state.files[state.index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// TODO(Unknown): Add a TextField for entering a message
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 72,
|
||||
child: SizedBox(
|
||||
height: sharedMediaContainerDimension + 2 * barPadding,
|
||||
child: ColoredBox(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.7),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(barPadding),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.files.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < state.files.length) {
|
||||
final item = state.files[index];
|
||||
// TODO(Unknown): Add a TextField for entering a message
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 72,
|
||||
child: SizedBox(
|
||||
height: sharedMediaContainerDimension + 2 * barPadding,
|
||||
child: ColoredBox(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0.7),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(barPadding),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.files.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < state.files.length) {
|
||||
final item = state.files[index];
|
||||
|
||||
return _renderPreview(
|
||||
context,
|
||||
item,
|
||||
index == state.index,
|
||||
index,
|
||||
);
|
||||
} else {
|
||||
return SharedMediaContainer(
|
||||
const Icon(Icons.attach_file),
|
||||
color: sharedMediaItemBackgroundColor,
|
||||
onTap: () => context.read<SendFilesBloc>().add(
|
||||
AddFilesRequestedEvent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _renderPreview(
|
||||
context,
|
||||
item,
|
||||
index == state.index,
|
||||
index,
|
||||
);
|
||||
} else {
|
||||
return SharedMediaContainer(
|
||||
const Icon(Icons.attach_file),
|
||||
color: sharedMediaItemBackgroundColor,
|
||||
onTap: () => context.read<SendFilesBloc>().add(
|
||||
AddFilesRequestedEvent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: FittedBox(
|
||||
// Without wrapping the button in a Material, the image will be drawn
|
||||
// over the button, partly or entirely hiding it.
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: Ink(
|
||||
decoration: const ShapeDecoration(
|
||||
color: primaryColor,
|
||||
shape: CircleBorder(),
|
||||
),
|
||||
child: IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => context
|
||||
.read<SendFilesBloc>()
|
||||
.add(FileSendingRequestedEvent()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
CancelButton(
|
||||
onPressed: () {
|
||||
_maybeRemoveTemporaryFiles();
|
||||
|
||||
// If we do a direct share and the user presses the "x" button, then it
|
||||
// happens that just popping the stack results in just a gray screen.
|
||||
// By using `SystemNavigator.pop`, we can tell the Flutter to "pop the
|
||||
// entire app".
|
||||
context.read<NavigationBloc>().add(
|
||||
PoppedRouteWithOptionalSystemNavigatorEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.hasRecipientData)
|
||||
ConversationIndicator(state.recipients)
|
||||
else
|
||||
FetchingConversationIndicator(
|
||||
state.recipients.map((r) => r.jid).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: FittedBox(
|
||||
// Without wrapping the button in a Material, the image will be drawn
|
||||
// over the button, partly or entirely hiding it.
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: Ink(
|
||||
decoration: const ShapeDecoration(
|
||||
color: primaryColor,
|
||||
shape: CircleBorder(),
|
||||
),
|
||||
child: IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => context
|
||||
.read<SendFilesBloc>()
|
||||
.add(FileSendingRequestedEvent()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
CancelButton(
|
||||
onPressed: () {
|
||||
// If we do a direct share and the user presses the "x" button, then it
|
||||
// happens that just popping the stack results in just a gray screen.
|
||||
// By using `SystemNavigator.pop`, we can tell the Flutter to "pop the
|
||||
// entire app".
|
||||
context
|
||||
.read<NavigationBloc>()
|
||||
.add(PoppedRouteWithOptionalSystemNavigatorEvent());
|
||||
},
|
||||
),
|
||||
if (state.hasRecipientData)
|
||||
ConversationIndicator(state.recipients)
|
||||
else
|
||||
FetchingConversationIndicator(
|
||||
state.recipients.map((r) => r.jid).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -124,8 +124,7 @@ class SettingsAboutPageState extends State<SettingsAboutPage> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ElevatedButton(
|
||||
child: Text(t.pages.settings.about.viewSourceCode),
|
||||
onPressed: () =>
|
||||
_openUrl('https://github.com/PapaTutuWawa/moxxyv2'),
|
||||
onPressed: () => _openUrl('https://codeberg.org/moxxy/moxxy'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -65,7 +65,7 @@ class UISharingService {
|
||||
false,
|
||||
),
|
||||
],
|
||||
isMedia ? SendFilesType.image : SendFilesType.generic,
|
||||
isMedia ? SendFilesType.media : SendFilesType.generic,
|
||||
paths:
|
||||
attachments.map((attachment) => attachment!.path).toList(),
|
||||
hasRecipientData: false,
|
||||
|
@ -2,13 +2,13 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bottom.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/image.dart';
|
||||
|
||||
class ImageChatWidget extends StatelessWidget {
|
||||
const ImageChatWidget(
|
||||
@ -64,7 +64,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// The image exists locally
|
||||
Widget _buildImage() {
|
||||
Widget _buildImage(BuildContext context) {
|
||||
final size = getMediaSize(message, maxWidth);
|
||||
|
||||
Widget image;
|
||||
@ -95,7 +95,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
sent,
|
||||
),
|
||||
radius,
|
||||
onTap: () => openFile(message.fileMetadata!.path!),
|
||||
onTap: () {
|
||||
showImageViewer(
|
||||
context,
|
||||
message.timestamp,
|
||||
message.fileMetadata!.path!,
|
||||
message.fileMetadata!.mimeType!,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -146,7 +153,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||
if (message.fileMetadata!.path != null &&
|
||||
File(message.fileMetadata!.path!).existsSync()) {
|
||||
return _buildImage();
|
||||
return _buildImage(context);
|
||||
}
|
||||
|
||||
return _buildDownloadable();
|
||||
|
@ -2,7 +2,6 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bottom.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/helpers.dart';
|
||||
@ -11,6 +10,7 @@ import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/playbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/video.dart';
|
||||
|
||||
class VideoChatWidget extends StatelessWidget {
|
||||
const VideoChatWidget(
|
||||
@ -75,7 +75,7 @@ class VideoChatWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// The video exists locally
|
||||
Widget _buildVideo() {
|
||||
Widget _buildVideo(BuildContext context) {
|
||||
return MediaBaseChatWidget(
|
||||
VideoThumbnail(
|
||||
path: message.fileMetadata!.path!,
|
||||
@ -92,7 +92,15 @@ class VideoChatWidget extends StatelessWidget {
|
||||
sent,
|
||||
),
|
||||
radius,
|
||||
onTap: () => openFile(message.fileMetadata!.path!),
|
||||
//onTap: () => openFile(message.fileMetadata!.path!),
|
||||
onTap: () {
|
||||
showVideoViewer(
|
||||
context,
|
||||
message.timestamp,
|
||||
message.fileMetadata!.path!,
|
||||
message.fileMetadata!.mimeType!,
|
||||
);
|
||||
},
|
||||
extra: const PlayButton(),
|
||||
);
|
||||
}
|
||||
@ -144,7 +152,7 @@ class VideoChatWidget extends StatelessWidget {
|
||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||
if (message.fileMetadata!.path != null &&
|
||||
File(message.fileMetadata!.path!).existsSync()) {
|
||||
return _buildVideo();
|
||||
return _buildVideo(context);
|
||||
}
|
||||
|
||||
return _buildDownloadable();
|
||||
|
@ -12,7 +12,7 @@ typedef SharedMediaWidgetCallback = void Function(FileMetadata);
|
||||
Widget buildSharedMediaWidget(
|
||||
FileMetadata metadata,
|
||||
String conversationJid,
|
||||
SharedMediaWidgetCallback onTap, {
|
||||
VoidCallback onTap, {
|
||||
SharedMediaWidgetCallback? onLongPress,
|
||||
}) {
|
||||
// Prevent having the phone vibrate if no onLongPress is passed
|
||||
@ -22,7 +22,7 @@ Widget buildSharedMediaWidget(
|
||||
if (metadata.mimeType!.startsWith('image/')) {
|
||||
return SharedImageWidget(
|
||||
metadata.path!,
|
||||
onTap: () => onTap(metadata),
|
||||
onTap: onTap,
|
||||
onLongPress: longPressCallback,
|
||||
);
|
||||
} else if (metadata.mimeType!.startsWith('video/')) {
|
||||
@ -30,21 +30,21 @@ Widget buildSharedMediaWidget(
|
||||
metadata.path!,
|
||||
conversationJid,
|
||||
metadata.mimeType!,
|
||||
onTap: () => onTap(metadata),
|
||||
onTap: onTap,
|
||||
onLongPress: () => onLongPress?.call(metadata),
|
||||
child: const PlayButton(size: 32),
|
||||
);
|
||||
} else if (metadata.mimeType!.startsWith('audio/')) {
|
||||
return SharedAudioWidget(
|
||||
metadata.path!,
|
||||
onTap: () => onTap(metadata),
|
||||
onTap: onTap,
|
||||
onLongPress: longPressCallback,
|
||||
);
|
||||
}
|
||||
|
||||
return SharedFileWidget(
|
||||
metadata.path!,
|
||||
onTap: () => onTap(metadata),
|
||||
onTap: onTap,
|
||||
onLongPress: longPressCallback,
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/playbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
||||
|
||||
@ -28,15 +29,40 @@ class SharedVideoWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SharedMediaContainer(
|
||||
VideoThumbnail(
|
||||
path: path,
|
||||
conversationJid: conversationJid,
|
||||
mime: mime,
|
||||
size: Size(
|
||||
size,
|
||||
size,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: VideoThumbnail(
|
||||
path: path,
|
||||
conversationJid: conversationJid,
|
||||
mime: mime,
|
||||
size: Size(
|
||||
size,
|
||||
size,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
assert(
|
||||
constraints.maxWidth == constraints.maxHeight,
|
||||
'The widget must be square',
|
||||
);
|
||||
|
||||
return PlayButton(
|
||||
// Ensure that the button always fits but never gets bigger than
|
||||
// its default size.
|
||||
size: (constraints.maxHeight * 0.8).clamp(
|
||||
-double.infinity,
|
||||
66,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
color: Colors.transparent,
|
||||
|
@ -52,11 +52,7 @@ class VideoThumbnail extends StatelessWidget {
|
||||
child: const ShimmerWidget(),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: widget,
|
||||
);
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
168
lib/ui/widgets/chat/viewers/base.dart
Normal file
168
lib/ui/widgets/chat/viewers/base.dart
Normal file
@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxy_native/moxxy_native.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
/// This controller deals with showing/hiding the UI elements and handling the timeouts
|
||||
/// for hiding the elements when they are visible.
|
||||
class ViewerUIVisibilityController {
|
||||
final ValueNotifier<bool> visible = ValueNotifier(true);
|
||||
|
||||
Timer? _hideTimer;
|
||||
|
||||
void _disposeHideTimer() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposeHideTimer();
|
||||
}
|
||||
|
||||
/// Start the hide timer.
|
||||
void startHideTimer() {
|
||||
_disposeHideTimer();
|
||||
|
||||
_hideTimer = Timer.periodic(
|
||||
const Duration(seconds: 2),
|
||||
(__) {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = null;
|
||||
|
||||
visible.value = !visible.value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Start or stop the hide timer, depending on the current visibility state.
|
||||
void handleTap() {
|
||||
if (!visible.value) {
|
||||
startHideTimer();
|
||||
} else {
|
||||
_disposeHideTimer();
|
||||
}
|
||||
|
||||
visible.value = !visible.value;
|
||||
}
|
||||
}
|
||||
|
||||
class BaseMediaViewer extends StatelessWidget {
|
||||
const BaseMediaViewer({
|
||||
required this.child,
|
||||
required this.path,
|
||||
required this.mime,
|
||||
required this.timestamp,
|
||||
required this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The child to display.
|
||||
final Widget child;
|
||||
|
||||
/// The media item's path. Used for sharing.
|
||||
final String path;
|
||||
|
||||
/// The media item's path. Used for sharing.
|
||||
final String mime;
|
||||
|
||||
/// The timestamp of the message containing the media item.
|
||||
final int timestamp;
|
||||
|
||||
final ViewerUIVisibilityController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Compute a nice display of the message's timestamp.
|
||||
final timestampDateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final dateFormat = formatDateBubble(
|
||||
timestampDateTime,
|
||||
DateTime.now(),
|
||||
);
|
||||
final timeFormat =
|
||||
'${timestampDateTime.hour}:${padInt(timestampDateTime.minute)}';
|
||||
final timestampFormat = '$dateFormat, $timeFormat';
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: child,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: controller.visible,
|
||||
builder: (context, value, child) {
|
||||
return AnimatedPositioned(
|
||||
top: value ? 0 : -kToolbarHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: mediaViewerAnimationDuration,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: ColoredBox(
|
||||
color: Colors.black45,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const CloseButton(),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
timestampFormat,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Sharing is only really applicable on Android and iOS
|
||||
if (Platform.isAndroid || Platform.isIOS)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: () {
|
||||
MoxxyPlatformApi().shareItems(
|
||||
[
|
||||
ShareItem(
|
||||
path: path,
|
||||
mime: mime,
|
||||
),
|
||||
],
|
||||
mime,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef MediaViewerBuilder = Widget Function(
|
||||
BuildContext,
|
||||
ViewerUIVisibilityController,
|
||||
);
|
||||
|
||||
/// A wrapper function that shows a dialog to be used as a media viewer. This function
|
||||
/// handles creation of the UI visibility controller, showing the dialog, and disposing
|
||||
/// of the controller.
|
||||
Future<void> showMediaViewer(
|
||||
BuildContext context,
|
||||
MediaViewerBuilder builder,
|
||||
) async {
|
||||
final controller = ViewerUIVisibilityController();
|
||||
await showDialog<void>(
|
||||
barrierColor: Colors.black87,
|
||||
context: context,
|
||||
builder: (context) => builder(context, controller),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
52
lib/ui/widgets/chat/viewers/image.dart
Normal file
52
lib/ui/widgets/chat/viewers/image.dart
Normal file
@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/base.dart';
|
||||
|
||||
class ImageViewer extends StatelessWidget {
|
||||
const ImageViewer({
|
||||
required this.path,
|
||||
required this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controller for UI visibility.
|
||||
final ViewerUIVisibilityController controller;
|
||||
|
||||
/// The path to display.
|
||||
final String path;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: controller.handleTap,
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(File(path)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a dialog using [context] that allows the user to view an image at path
|
||||
/// [path] and optionally share it. [mime] is the image's exact mime type.
|
||||
Future<void> showImageViewer(
|
||||
BuildContext context,
|
||||
int timestamp,
|
||||
String path,
|
||||
String mime,
|
||||
) async {
|
||||
return showMediaViewer(
|
||||
context,
|
||||
(context, controller) {
|
||||
return BaseMediaViewer(
|
||||
path: path,
|
||||
mime: mime,
|
||||
timestamp: timestamp,
|
||||
controller: controller,
|
||||
child: ImageViewer(
|
||||
path: path,
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
294
lib/ui/widgets/chat/viewers/video.dart
Normal file
294
lib/ui/widgets/chat/viewers/video.dart
Normal file
@ -0,0 +1,294 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/base.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// A UI element that allows the user to play/pause the video.
|
||||
class VideoViewerPlayButton extends StatefulWidget {
|
||||
const VideoViewerPlayButton({
|
||||
required this.videoController,
|
||||
required this.uiController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controller controlling the video player.
|
||||
final VideoPlayerController videoController;
|
||||
|
||||
/// The controller controlling the visibility of UI elements.
|
||||
final ViewerUIVisibilityController uiController;
|
||||
|
||||
@override
|
||||
VideoViewerPlayButtonState createState() => VideoViewerPlayButtonState();
|
||||
}
|
||||
|
||||
class VideoViewerPlayButtonState extends State<VideoViewerPlayButton> {
|
||||
late bool _showPlayButton;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_showPlayButton = widget.uiController.visible.value;
|
||||
widget.uiController.visible.addListener(_handleValueChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
widget.uiController.visible.removeListener(_handleValueChange);
|
||||
}
|
||||
|
||||
void _handleValueChange() {
|
||||
setState(() {
|
||||
_showPlayButton = widget.uiController.visible.value;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.videoController,
|
||||
builder: (context, value, _) {
|
||||
return AnimatedOpacity(
|
||||
opacity: _showPlayButton ? 1 : 0,
|
||||
duration: mediaViewerAnimationDuration,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showPlayButton,
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
child: SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Center(
|
||||
child: InkWell(
|
||||
child: Icon(
|
||||
value.isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
size: 64,
|
||||
),
|
||||
onTap: () {
|
||||
if (value.isPlaying) {
|
||||
widget.videoController.pause();
|
||||
} else {
|
||||
widget.videoController.play();
|
||||
}
|
||||
|
||||
widget.uiController.startHideTimer();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// UI element that displays the current timecode and duration of the video, and
|
||||
/// allows the user to scrub through the video.
|
||||
class VideoViewerScrubber extends StatefulWidget {
|
||||
const VideoViewerScrubber({
|
||||
required this.videoController,
|
||||
required this.uiController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controller controlling the video player.
|
||||
final VideoPlayerController videoController;
|
||||
|
||||
/// The controller controlling the visibility of UI elements.
|
||||
final ViewerUIVisibilityController uiController;
|
||||
|
||||
@override
|
||||
VideoViewerScrubberState createState() => VideoViewerScrubberState();
|
||||
}
|
||||
|
||||
class VideoViewerScrubberState extends State<VideoViewerScrubber> {
|
||||
late bool _showScrubBar;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_showScrubBar = widget.uiController.visible.value;
|
||||
widget.uiController.visible.addListener(_handleValueChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.uiController.visible.removeListener(_handleValueChange);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleValueChange() {
|
||||
setState(() {
|
||||
_showScrubBar = widget.uiController.visible.value;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
ignoring: !_showScrubBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showScrubBar ? 1 : 0,
|
||||
duration: mediaViewerAnimationDuration,
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 8,
|
||||
),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: widget.videoController,
|
||||
builder: (context, value, _) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
formatDuration(value.position),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: value.position.inSeconds.toDouble(),
|
||||
max: value.duration.inSeconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
widget.videoController.seekTo(
|
||||
Duration(seconds: value.toInt()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDuration(value.duration),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoViewer extends StatefulWidget {
|
||||
const VideoViewer({
|
||||
required this.path,
|
||||
required this.controller,
|
||||
this.showScrubBar = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controller controlling UI element visibility.
|
||||
final ViewerUIVisibilityController controller;
|
||||
|
||||
/// The path to the video we're showing.
|
||||
final String path;
|
||||
|
||||
/// Flag whether to show the scrub bar or not.
|
||||
final bool showScrubBar;
|
||||
|
||||
@override
|
||||
VideoViewerState createState() => VideoViewerState();
|
||||
}
|
||||
|
||||
class VideoViewerState extends State<VideoViewer> {
|
||||
late final VideoPlayerController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = VideoPlayerController.contentUri(
|
||||
Uri.file(widget.path),
|
||||
)..initialize().then((_) {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_controller.value.isInitialized) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: _controller.value.aspectRatio,
|
||||
child: GestureDetector(
|
||||
onTap: widget.controller.handleTap,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: VideoPlayer(_controller),
|
||||
),
|
||||
VideoViewerPlayButton(
|
||||
videoController: _controller,
|
||||
uiController: widget.controller,
|
||||
),
|
||||
if (widget.showScrubBar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: VideoViewerScrubber(
|
||||
videoController: _controller,
|
||||
uiController: widget.controller,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a dialog using [context] that allows the user to view an image at path
|
||||
/// [path] and optionally share it. [mime] is the image's exact mime type.
|
||||
Future<void> showVideoViewer(
|
||||
BuildContext context,
|
||||
int timestamp,
|
||||
String path,
|
||||
String mime,
|
||||
) async {
|
||||
await showMediaViewer(
|
||||
context,
|
||||
(context, controller) {
|
||||
return BaseMediaViewer(
|
||||
path: path,
|
||||
mime: mime,
|
||||
timestamp: timestamp,
|
||||
controller: controller,
|
||||
child: VideoViewer(
|
||||
path: path,
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
@ -90,7 +90,7 @@ class SendButtonWidgetState extends State<SendButton> {
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendImages,
|
||||
label: t.pages.conversation.sendMedia,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.file_present),
|
||||
|
@ -3,8 +3,11 @@ import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/viewers/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
|
||||
|
||||
/// A widget that displays a lazily-loaded list of media files in a grid, grouped
|
||||
@ -126,7 +129,27 @@ class SharedMediaView extends StatelessWidget {
|
||||
itemBuilder: (_, message) => buildSharedMediaWidget(
|
||||
message.fileMetadata!,
|
||||
message.conversationJid,
|
||||
onTap,
|
||||
() {
|
||||
if (message.fileMetadata!.mimeType!
|
||||
.startsWith('image/')) {
|
||||
showImageViewer(
|
||||
context,
|
||||
message.timestamp,
|
||||
message.fileMetadata!.path!,
|
||||
message.fileMetadata!.mimeType!,
|
||||
);
|
||||
} else if (message.fileMetadata!.mimeType!
|
||||
.startsWith('video/')) {
|
||||
showVideoViewer(
|
||||
context,
|
||||
message.timestamp,
|
||||
message.fileMetadata!.path!,
|
||||
message.fileMetadata!.mimeType!,
|
||||
);
|
||||
} else {
|
||||
openFile(message.fileMetadata!.path!);
|
||||
}
|
||||
},
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
separatorBuilder: (_, timestamp) => Padding(
|
||||
|
76
pubspec.lock
76
pubspec.lock
@ -305,6 +305,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
csv:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -361,14 +369,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.1"
|
||||
external_path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: external_path
|
||||
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -728,6 +728,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.4"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -872,14 +880,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.7.1"
|
||||
keyboard_height_plugin:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: keyboard_height_plugin
|
||||
sha256: "7b0d62ebde9525ee53e02072ae85ad21a6cd503e6ceaff9b17161bef642573ab"
|
||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||
source: hosted
|
||||
version: "0.0.5"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -998,10 +998,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: moxxy_native
|
||||
sha256: "6f6dac5e61f5bf5e58357376569e6e02e93cf18317e93f3381e02ff890d35d2f"
|
||||
sha256: "4ded09f7abfb4b14b5b0d21b13e77afc64bd00133d572717796238532d4781fd"
|
||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.3.2"
|
||||
moxxyv2_builders:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1767,6 +1767,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "74b86e63529cf5885130c639d74cd2f9232e7c8a66cbecbddd1dcb9dbd060d1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.2"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: "3fe89ab07fdbce786e7eb25b58532d6eaf189ceddc091cb66cba712f8d9e8e55"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: c3b123a5a56c9812b9029f840c65b92fd65083eb08d69be016b01e8aa018f77d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: be72301bf2c0150ab35a8c34d66e5a99de525f6de1e8d27c0672b836fe48f73a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: "66fc0d56554143fee4c623f70e45e4272b94fd246283cb67edabb9d1e4122a4f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
visibility_detector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
12
pubspec.yaml
12
pubspec.yaml
@ -24,7 +24,6 @@ dependencies:
|
||||
dart_emoji: 0.2.0+2
|
||||
decorated_icon: 1.2.1
|
||||
emoji_picker_flutter: ^1.6.1
|
||||
external_path: ^1.0.3
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.3
|
||||
@ -47,7 +46,6 @@ dependencies:
|
||||
hex: 0.2.0
|
||||
image: ^4.0.17
|
||||
json_annotation: ^4.8.1
|
||||
keyboard_height_plugin: 0.0.4
|
||||
logging: ^1.2.0
|
||||
meta: ^1.7.0
|
||||
mime: ^1.0.4
|
||||
@ -69,7 +67,7 @@ dependencies:
|
||||
version: 0.3.1
|
||||
moxxy_native:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.2.0
|
||||
version: 0.3.2
|
||||
moxxyv2_builders:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.2.0
|
||||
@ -100,8 +98,9 @@ dependencies:
|
||||
url_launcher: ^6.1.14
|
||||
#unifiedpush: 3.0.1
|
||||
uuid: ^3.0.7
|
||||
video_player: ^2.7.2
|
||||
visibility_detector: 0.4.0+2
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.6
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
@ -118,11 +117,6 @@ dev_dependencies:
|
||||
very_good_analysis: ^5.1.0
|
||||
|
||||
dependency_overrides:
|
||||
# A fork of keyboard_height_plugin that lowers the required Dart SDK version
|
||||
keyboard_height_plugin:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.0.5
|
||||
|
||||
# NOTE: Leave here for development purposes
|
||||
# moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
|
@ -62,8 +62,7 @@ class StubXmppStateService extends XmppStateService {
|
||||
@override
|
||||
Future<XmppState> get state async => XmppState(
|
||||
avatarHash: '9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||
avatarUrl:
|
||||
'./cache/avatars/9f26dcd75b630308df29214880a4e26fe5ef3a43.png',
|
||||
avatarUrl: './cache/avatars/9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||
);
|
||||
|
||||
@override
|
||||
@ -133,21 +132,26 @@ class StubUserAvatarManager extends UserAvatarManager {
|
||||
|
||||
String currentAvatarHash = '';
|
||||
|
||||
String currentAvatarType = 'image/png';
|
||||
|
||||
List<UserAvatarMetadata>? metadataList;
|
||||
|
||||
@override
|
||||
Future<Result<AvatarError, List<UserAvatarMetadata>>> getLatestMetadata(
|
||||
JID jid,
|
||||
) async {
|
||||
return Result<AvatarError, List<UserAvatarMetadata>>(
|
||||
[
|
||||
UserAvatarMetadata(
|
||||
currentAvatarHash,
|
||||
42,
|
||||
null,
|
||||
null,
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
],
|
||||
metadataList ??
|
||||
[
|
||||
UserAvatarMetadata(
|
||||
currentAvatarHash,
|
||||
42,
|
||||
null,
|
||||
null,
|
||||
currentAvatarType,
|
||||
null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -159,8 +163,8 @@ class StubUserAvatarManager extends UserAvatarManager {
|
||||
getUserAvatarCalled++;
|
||||
return Result<AvatarError, UserAvatarData>(
|
||||
UserAvatarData(
|
||||
_avatars[currentAvatarHash]!,
|
||||
currentAvatarHash,
|
||||
_avatars[id]!,
|
||||
id,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -222,6 +226,8 @@ Future<void> main() async {
|
||||
setUp(() async {
|
||||
stubUserAvatarManager
|
||||
..currentAvatarHash = ''
|
||||
..currentAvatarType = 'image/png'
|
||||
..metadataList = null
|
||||
..getUserAvatarCalled = 0;
|
||||
scs.conversation = null;
|
||||
|
||||
@ -273,7 +279,7 @@ Future<void> main() async {
|
||||
'',
|
||||
'',
|
||||
null,
|
||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'),
|
||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'),
|
||||
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||
'user@example.org',
|
||||
null,
|
||||
@ -308,7 +314,7 @@ Future<void> main() async {
|
||||
|
||||
// The first avatar should not exist anymore.
|
||||
expect(
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
false,
|
||||
);
|
||||
@ -327,7 +333,7 @@ Future<void> main() async {
|
||||
'',
|
||||
'',
|
||||
null,
|
||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'),
|
||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'),
|
||||
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||
'user@example.org',
|
||||
null,
|
||||
@ -362,7 +368,7 @@ Future<void> main() async {
|
||||
|
||||
// The first avatar should still exist.
|
||||
expect(
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
true,
|
||||
);
|
||||
@ -372,7 +378,7 @@ Future<void> main() async {
|
||||
() async {
|
||||
// The avatar must not exist already.
|
||||
assert(
|
||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
'The avatar must not already exist',
|
||||
);
|
||||
@ -388,7 +394,7 @@ Future<void> main() async {
|
||||
// The first avatar should now exist.
|
||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||
expect(
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
true,
|
||||
);
|
||||
@ -399,7 +405,7 @@ Future<void> main() async {
|
||||
() async {
|
||||
// The avatar must not exist already.
|
||||
assert(
|
||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
'The avatar must not already exist',
|
||||
);
|
||||
@ -412,9 +418,55 @@ Future<void> main() async {
|
||||
// The first avatar should now exist.
|
||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||
expect(
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Test fetching avatars with no advertised PNG avatar', () async {
|
||||
// The avatar must not exist already.
|
||||
assert(
|
||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||
.existsSync(),
|
||||
'The avatar must not already exist',
|
||||
);
|
||||
|
||||
// Get the avatar.
|
||||
stubUserAvatarManager.metadataList = const [
|
||||
UserAvatarMetadata(
|
||||
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||
42,
|
||||
null,
|
||||
null,
|
||||
'image/tiff',
|
||||
null,
|
||||
),
|
||||
UserAvatarMetadata(
|
||||
'd6d556595ff55705010e44c9aab1079dbf5b4fb9',
|
||||
42,
|
||||
null,
|
||||
null,
|
||||
'image/jpeg',
|
||||
null,
|
||||
),
|
||||
];
|
||||
await srv.requestAvatar(JID.fromString('user@example.org'), null);
|
||||
|
||||
// The avatar jpeg avatar should now exist.
|
||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||
expect(
|
||||
File(p.join(cacheDir, 'd6d556595ff55705010e44c9aab1079dbf5b4fb9'))
|
||||
.existsSync(),
|
||||
true,
|
||||
);
|
||||
|
||||
// Request the avatar again.
|
||||
await srv.requestAvatar(
|
||||
JID.fromString('user@example.org'),
|
||||
'd6d556595ff55705010e44c9aab1079dbf5b4fb9',
|
||||
);
|
||||
// If the sorting is stable, then getUserAvatar should only be called once.
|
||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user