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",
|
"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.",
|
"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",
|
"forward": "Forward",
|
||||||
|
"share": "Share",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"quote": "Quote",
|
"quote": "Quote",
|
||||||
"copy": "Copy content",
|
"copy": "Copy content",
|
||||||
@ -219,7 +220,7 @@
|
|||||||
"other": "Multiple devices have been added"
|
"other": "Multiple devices have been added"
|
||||||
},
|
},
|
||||||
"messageHint": "Send a message…",
|
"messageHint": "Send a message…",
|
||||||
"sendImages": "Send images",
|
"sendMedia": "Send media",
|
||||||
"sendFiles": "Send files",
|
"sendFiles": "Send files",
|
||||||
"takePhotos": "Take photos",
|
"takePhotos": "Take photos",
|
||||||
"voiceRecording": {
|
"voiceRecording": {
|
||||||
|
@ -47,8 +47,7 @@ class AvatarService {
|
|||||||
_requestedInStream.clear();
|
_requestedInStream.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _computeAvatarPath(String hash) =>
|
String _computeAvatarPath(String hash) => p.join(_avatarCacheDir, hash);
|
||||||
p.join(_avatarCacheDir, '$hash.png');
|
|
||||||
|
|
||||||
/// Returns whether we can remove the avatar file at [path] by checking if the
|
/// 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
|
/// 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.
|
// Find the first metadata item that advertises a PNG avatar.
|
||||||
final id = rawMetadata
|
final metadata = rawMetadata.get<List<UserAvatarMetadata>>();
|
||||||
.get<List<UserAvatarMetadata>>()
|
var id =
|
||||||
.firstWhereOrNull((element) => element.type == 'image/png')
|
metadata.firstWhereOrNull((element) => element.type == 'image/png')?.id;
|
||||||
?.id;
|
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
'$jid does not advertise an avatar of type image/png, which violates XEP-0084',
|
'$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.
|
// Check if the id changed.
|
||||||
|
@ -10,3 +10,8 @@ Future<String> computeCacheDirectoryPath(String subdirectory) async {
|
|||||||
subdirectory,
|
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 (
|
CREATE TABLE $preferenceTable (
|
||||||
key TEXT NOT NULL PRIMARY KEY,
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
type INTEGER NOT NULL,
|
type INTEGER NOT NULL,
|
||||||
value TEXT NOT NULL
|
value TEXT NULL
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -379,7 +379,7 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
Preference(
|
Preference(
|
||||||
'backgroundPath',
|
'backgroundPath',
|
||||||
typeString,
|
typeString,
|
||||||
'',
|
null,
|
||||||
).toDatabaseJson(),
|
).toDatabaseJson(),
|
||||||
);
|
);
|
||||||
await db.insert(
|
await db.insert(
|
||||||
|
@ -7,7 +7,7 @@ import 'package:path/path.dart' as p;
|
|||||||
Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
||||||
final (db, logger) = data;
|
final (db, logger) = data;
|
||||||
|
|
||||||
// Make avatarPath and avatarHash nullable
|
// Make avatarPath, avatarHash, and backgroundPath nullable
|
||||||
// 1) Roster items
|
// 1) Roster items
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@ -70,6 +70,31 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable',
|
'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.
|
// Find all conversations and roster items that have an avatar.
|
||||||
final conversations = await db.query(
|
final conversations = await db.query(
|
||||||
@ -100,7 +125,8 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
|||||||
final oldPath = avatar['value']! as String;
|
final oldPath = avatar['value']! as String;
|
||||||
final newPath = p.join(
|
final newPath = p.join(
|
||||||
cachePath,
|
cachePath,
|
||||||
p.basename(oldPath),
|
// Remove the ".png" at the end
|
||||||
|
p.basename(oldPath).split('.').first,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.finest('Migrating account avatar $oldPath');
|
logger.finest('Migrating account avatar $oldPath');
|
||||||
@ -150,7 +176,7 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newPath = p.join(cachePath, '$hash.png');
|
final newPath = p.join(cachePath, hash);
|
||||||
|
|
||||||
logger.finest(
|
logger.finest(
|
||||||
'Migrating conversation avatar $path',
|
'Migrating conversation avatar $path',
|
||||||
@ -213,7 +239,7 @@ Future<void> upgradeFromV47ToV48(DatabaseMigrationData data) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newPath = p.join(cachePath, '$hash.png');
|
final newPath = p.join(cachePath, hash);
|
||||||
|
|
||||||
logger.finest(
|
logger.finest(
|
||||||
'Migrating roster avatar $path',
|
'Migrating roster avatar $path',
|
||||||
|
@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/cache.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
@ -121,12 +122,18 @@ class HttpFileTransferService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyFile(
|
Future<void> _copyUploadedFile(
|
||||||
FileUploadJob job,
|
FileUploadJob job,
|
||||||
String to,
|
String to,
|
||||||
) async {
|
) async {
|
||||||
if (!File(to).existsSync()) {
|
if (!File(to).existsSync()) {
|
||||||
await File(job.path).copy(to);
|
await File(job.path).copy(to);
|
||||||
|
|
||||||
|
// Remove the original file
|
||||||
|
await safelyRemovePickedFile(
|
||||||
|
job.path,
|
||||||
|
await computePickedFileCachePath(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.finest(
|
_log.finest(
|
||||||
'Skipping file copy on upload as file is already at media location',
|
'Skipping file copy on upload as file is already at media location',
|
||||||
@ -311,7 +318,7 @@ class HttpFileTransferService {
|
|||||||
_log.fine(
|
_log.fine(
|
||||||
'Uploaded file $filename is already tracked but has no path. Copying...',
|
'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 = await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||||
metadata.id,
|
metadata.id,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
@ -319,7 +326,7 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.fine('Uploaded file $filename not tracked. Copying...');
|
_log.fine('Uploaded file $filename not tracked. Copying...');
|
||||||
await _copyFile(job, metadataWrapper.fileMetadata.path!);
|
await _copyUploadedFile(job, metadataWrapper.fileMetadata.path!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uuid = Uuid();
|
const uuid = Uuid();
|
||||||
|
@ -286,6 +286,12 @@ class NotificationsService {
|
|||||||
// Workaround for Android to show the thumbnail in the notification
|
// Workaround for Android to show the thumbnail in the notification
|
||||||
filePath = thumbnailPath;
|
filePath = thumbnailPath;
|
||||||
fileMime = 'image/jpeg';
|
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;
|
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],
|
/// 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.
|
/// 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)?
|
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
|
||||||
@ -350,17 +383,10 @@ class NotificationsService {
|
|||||||
id: id,
|
id: id,
|
||||||
channelId: messageNotificationChannelId,
|
channelId: messageNotificationChannelId,
|
||||||
jid: c.jid,
|
jid: c.jid,
|
||||||
messages: notifications.map((n) {
|
messages: _replaceOrAppendNotification(
|
||||||
// Based on the table's composite primary key
|
notifications,
|
||||||
if (n.id == notification.id &&
|
notification,
|
||||||
n.conversationJid == notification.conversationJid &&
|
),
|
||||||
n.senderJid == notification.senderJid &&
|
|
||||||
n.timestamp == notification.timestamp) {
|
|
||||||
return notification.toNotificationMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return n.toNotificationMessage();
|
|
||||||
}).toList(),
|
|
||||||
isGroupchat: c.isGroupchat,
|
isGroupchat: c.isGroupchat,
|
||||||
groupId: messageNotificationGroupId,
|
groupId: messageNotificationGroupId,
|
||||||
extra: {
|
extra: {
|
||||||
|
@ -1356,11 +1356,6 @@ class XmppService {
|
|||||||
// Indicates if we should auto-download the file, if a file is specified in the message
|
// Indicates if we should auto-download the file, if a file is specified in the message
|
||||||
final shouldDownload = isFileEmbedded &&
|
final shouldDownload = isFileEmbedded &&
|
||||||
await _shouldDownloadFile(conversationJid, accountJid);
|
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.
|
// A guess for the Mime type of the embedded file.
|
||||||
var mimeGuess = _getMimeGuess(event);
|
var mimeGuess = _getMimeGuess(event);
|
||||||
|
|
||||||
@ -1486,9 +1481,6 @@ class XmppService {
|
|||||||
mimeGuess,
|
mimeGuess,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Make sure we create the notification
|
|
||||||
shouldNotify = true;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (fileMetadata?.retrieved ?? false) {
|
if (fileMetadata?.retrieved ?? false) {
|
||||||
@ -1567,22 +1559,26 @@ class XmppService {
|
|||||||
preRun: (c) async {
|
preRun: (c) async {
|
||||||
isMuted = c != null ? c.muted : prefs.defaultMuteState;
|
isMuted = c != null ? c.muted : prefs.defaultMuteState;
|
||||||
sendNotification = !sent &&
|
sendNotification = !sent &&
|
||||||
shouldNotify &&
|
|
||||||
(!isConversationOpened ||
|
(!isConversationOpened ||
|
||||||
!GetIt.I.get<LifecycleService>().isActive) &&
|
isConversationOpened &&
|
||||||
|
!GetIt.I.get<LifecycleService>().isActive) &&
|
||||||
!isMuted;
|
!isMuted;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the share handler
|
// Update the share handler
|
||||||
await GetIt.I.get<ShareService>().recordSentMessage(
|
try {
|
||||||
conversation!,
|
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
|
// Create the notification if we the user does not already know about the message
|
||||||
if (sendNotification) {
|
if (sendNotification) {
|
||||||
await ns.showNotification(
|
await ns.showNotification(
|
||||||
conversation,
|
conversation!,
|
||||||
message,
|
message,
|
||||||
accountJid,
|
accountJid,
|
||||||
isInRoster ? conversation.title : conversationJid,
|
isInRoster ? conversation.title : conversationJid,
|
||||||
@ -1684,7 +1680,6 @@ class XmppService {
|
|||||||
// using hashes and thus have to create hash pointers.
|
// using hashes and thus have to create hash pointers.
|
||||||
fileMetadata == null,
|
fileMetadata == null,
|
||||||
_getMimeGuess(event),
|
_getMimeGuess(event),
|
||||||
shouldShowNotification: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
|
import 'package:moxxyv2/service/cache.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@ -387,6 +389,32 @@ Future<String> getContactProfilePicturePath(String id) async {
|
|||||||
return p.join(avatarDir, id);
|
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
|
/// Prepend [item] to [list], but ensure that the resulting list's size is
|
||||||
/// smaller than or equal to [maxSize].
|
/// smaller than or equal to [maxSize].
|
||||||
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
|
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
|
||||||
|
@ -8,7 +8,7 @@ class Preference with _$Preference {
|
|||||||
factory Preference(
|
factory Preference(
|
||||||
String key,
|
String key,
|
||||||
int type,
|
int type,
|
||||||
String value,
|
String? value,
|
||||||
) = _Preference;
|
) = _Preference;
|
||||||
|
|
||||||
const Preference._();
|
const Preference._();
|
||||||
|
@ -158,7 +158,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
state.conversation!.contactId != null,
|
state.conversation!.contactId != null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
SendFilesType.image,
|
SendFilesType.media,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
@ -16,44 +15,6 @@ part 'cropbackground_bloc.freezed.dart';
|
|||||||
part 'cropbackground_event.dart';
|
part 'cropbackground_event.dart';
|
||||||
part 'cropbackground_state.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
|
class CropBackgroundBloc
|
||||||
extends Bloc<CropBackgroundEvent, CropBackgroundState> {
|
extends Bloc<CropBackgroundEvent, CropBackgroundState> {
|
||||||
CropBackgroundBloc() : super(CropBackgroundState()) {
|
CropBackgroundBloc() : super(CropBackgroundState()) {
|
||||||
@ -124,22 +85,25 @@ class CropBackgroundBloc
|
|||||||
final appDir = await MoxxyPlatformApi().getPersistentDataPath();
|
final appDir = await MoxxyPlatformApi().getPersistentDataPath();
|
||||||
final backgroundPath = path.join(appDir, 'background_image.png');
|
final backgroundPath = path.join(appDir, 'background_image.png');
|
||||||
|
|
||||||
final port = ReceivePort();
|
// Compute values for cropping the image.
|
||||||
await Isolate.spawn(
|
final inverse = 1 / event.q;
|
||||||
_cropImage,
|
final xp = (event.x.abs() * inverse).toInt();
|
||||||
[
|
final yp = (event.y.abs() * inverse).toInt();
|
||||||
port.sendPort,
|
|
||||||
state.imagePath,
|
// Compute the crop and optional blur.
|
||||||
backgroundPath,
|
final cmd = Command()
|
||||||
event.q,
|
..decodeImageFile(state.imagePath!)
|
||||||
event.x,
|
..copyCrop(
|
||||||
event.y,
|
x: xp,
|
||||||
event.viewportWidth,
|
y: yp,
|
||||||
event.viewportHeight,
|
width: (event.viewportWidth * inverse).toInt(),
|
||||||
state.blurEnabled,
|
height: (event.viewportHeight * inverse).toInt(),
|
||||||
],
|
);
|
||||||
);
|
if (state.blurEnabled) {
|
||||||
await port.first;
|
cmd.gaussianBlur(radius: 10);
|
||||||
|
}
|
||||||
|
cmd.writeToFile(backgroundPath);
|
||||||
|
await cmd.executeThread();
|
||||||
|
|
||||||
_resetState(emit);
|
_resetState(emit);
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
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:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:move_to_background/move_to_background.dart';
|
import 'package:move_to_background/move_to_background.dart';
|
||||||
import 'package:moxxy_native/moxxy_native.dart';
|
import 'package:moxxy_native/moxxy_native.dart';
|
||||||
import 'package:moxxyv2/shared/commands.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/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
@ -20,14 +22,21 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
|||||||
on<AddFilesRequestedEvent>(_onAddFilesRequested);
|
on<AddFilesRequestedEvent>(_onAddFilesRequested);
|
||||||
on<FileSendingRequestedEvent>(_onFileSendingRequested);
|
on<FileSendingRequestedEvent>(_onFileSendingRequested);
|
||||||
on<ItemRemovedEvent>(_onItemRemoved);
|
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
|
/// Pick files. Returns either a list of paths to attach or null if the process has
|
||||||
/// been cancelled.
|
/// been cancelled.
|
||||||
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
||||||
final result = await safePickFiles(
|
final result = await safePickFiles(
|
||||||
type == SendFilesType.image
|
type == SendFilesType.media
|
||||||
? FilePickerType.image
|
? FilePickerType.imageAndVideo
|
||||||
: FilePickerType.generic,
|
: FilePickerType.generic,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -50,6 +59,7 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
|||||||
files = pickedFiles;
|
files = pickedFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_shouldIgnoreDeletionRequest = false;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
files: files,
|
files: files,
|
||||||
@ -108,6 +118,7 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
|||||||
),
|
),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
|
_shouldIgnoreDeletionRequest = true;
|
||||||
|
|
||||||
// Return to the last page
|
// Return to the last page
|
||||||
final bloc = GetIt.I.get<NavigationBloc>();
|
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';
|
part of 'sendfiles_bloc.dart';
|
||||||
|
|
||||||
enum SendFilesType {
|
enum SendFilesType {
|
||||||
image,
|
media,
|
||||||
generic,
|
generic,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,3 +37,8 @@ class ItemRemovedEvent extends SendFilesEvent {
|
|||||||
ItemRemovedEvent(this.index);
|
ItemRemovedEvent(this.index);
|
||||||
final int 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(),
|
}).toList(),
|
||||||
// TODO(PapaTutuWawa): Fix
|
// TODO(PapaTutuWawa): Fix
|
||||||
SendFilesType.image,
|
SendFilesType.media,
|
||||||
paths: state.paths,
|
paths: state.paths,
|
||||||
popEntireStack: true,
|
popEntireStack: true,
|
||||||
),
|
),
|
||||||
|
@ -137,6 +137,9 @@ const Color highlightSkimColor = Color(0xff000000);
|
|||||||
/// The width of the bar used to indicate a legacy quote.
|
/// The width of the bar used to indicate a legacy quote.
|
||||||
const double textMessageQuoteBarWidth = 3;
|
const double textMessageQuoteBarWidth = 3;
|
||||||
|
|
||||||
|
/// The duration of the animations in the media viewers.
|
||||||
|
const Duration mediaViewerAnimationDuration = Duration(milliseconds: 150);
|
||||||
|
|
||||||
/// Navigation constants
|
/// Navigation constants
|
||||||
const String cropRoute = '/crop';
|
const String cropRoute = '/crop';
|
||||||
const String introRoute = '/intro';
|
const String introRoute = '/intro';
|
||||||
|
@ -12,6 +12,8 @@ import 'package:hex/hex.dart';
|
|||||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
import 'package:moxxy_native/moxxy_native.dart';
|
import 'package:moxxy_native/moxxy_native.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.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/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sticker_pack_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);
|
final offset = Offset(translation.x, translation.y);
|
||||||
return renderObject.paintBounds.shift(offset);
|
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 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
|
|
||||||
/// A triple of data for the child widget wrapper widget.
|
/// A triple of data for the child widget wrapper widget.
|
||||||
@ -47,19 +47,25 @@ class KeyboardReplacerController {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
_keyboardHeightPlugin.onKeyboardHeightChanged((height) {
|
_keyboardHeightSubscription =
|
||||||
// Only update when the height actually changed
|
const EventChannel('org.moxxy.moxxyv2/keyboard_stream')
|
||||||
if (height == 0 || height == _keyboardHeight) return;
|
.receiveBroadcastStream()
|
||||||
|
.cast<double>()
|
||||||
|
.listen(
|
||||||
|
(height) {
|
||||||
|
// Only update when the height actually changed
|
||||||
|
if (height == 0 || height == _keyboardHeight) return;
|
||||||
|
|
||||||
_keyboardHeight = height;
|
_keyboardHeight = height;
|
||||||
_streamController.add(
|
_streamController.add(
|
||||||
KeyboardReplacerData(
|
KeyboardReplacerData(
|
||||||
_keyboardVisible,
|
_keyboardVisible,
|
||||||
height,
|
height,
|
||||||
_widgetVisible,
|
_widgetVisible,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of the child widget's visibility.
|
/// State of the child widget's visibility.
|
||||||
@ -74,7 +80,7 @@ class KeyboardReplacerController {
|
|||||||
late bool _keyboardVisible;
|
late bool _keyboardVisible;
|
||||||
|
|
||||||
/// Data for keeping track of the keyboard height.
|
/// Data for keeping track of the keyboard height.
|
||||||
final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin();
|
late final StreamSubscription<double> _keyboardHeightSubscription;
|
||||||
|
|
||||||
/// The currently tracked keyboard height.
|
/// The currently tracked keyboard height.
|
||||||
/// NOTE: The value is a random keyboard height I got on my test device.
|
/// NOTE: The value is a random keyboard height I got on my test device.
|
||||||
@ -95,7 +101,7 @@ class KeyboardReplacerController {
|
|||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_keyboardVisibilitySubscription.cancel();
|
_keyboardVisibilitySubscription.cancel();
|
||||||
_keyboardHeightPlugin.dispose();
|
_keyboardHeightSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the child widget in the child wrapper. If the soft-keyboard is currently
|
/// 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(
|
ContextMenuItem(
|
||||||
icon: Icons.forward,
|
icon: Icons.share,
|
||||||
text: t.pages.conversation.forward,
|
text: t.pages.conversation.share,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showNotImplementedDialog(
|
shareMessage(message);
|
||||||
'sharing',
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'package:decorated_icon/decorated_icon.dart';
|
import 'package:decorated_icon/decorated_icon.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sendfiles_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/base.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/shared/image.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/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;
|
import 'package:path/path.dart' as pathlib;
|
||||||
|
|
||||||
Widget _deleteIconWithShadow() {
|
Widget _deleteIconWithShadow() {
|
||||||
@ -133,19 +137,17 @@ class SendFilesPage extends StatelessWidget {
|
|||||||
|
|
||||||
if (mime.startsWith('image/')) {
|
if (mime.startsWith('image/')) {
|
||||||
// Render the image
|
// Render the image
|
||||||
return Image.file(
|
return ImageViewer(
|
||||||
File(path),
|
path: path,
|
||||||
fit: BoxFit.contain,
|
controller: ViewerUIVisibilityController(),
|
||||||
);
|
);
|
||||||
} /*else if (mime.startsWith('video/')) {
|
} else if (mime.startsWith('video/')) {
|
||||||
// Render the video thumbnail
|
return VideoViewer(
|
||||||
// TODO(PapaTutuWawa): Maybe allow playing the video back inline
|
path: path,
|
||||||
return VideoThumbnailWidget(
|
controller: ViewerUIVisibilityController(),
|
||||||
path,
|
showScrubBar: false,
|
||||||
Image.memory,
|
|
||||||
);
|
);
|
||||||
}*/
|
} else {
|
||||||
else {
|
|
||||||
// Generic file
|
// Generic file
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
return Center(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const barPadding = 8.0;
|
const barPadding = 8.0;
|
||||||
|
|
||||||
// TODO(Unknown): Fix the typography
|
// TODO(Unknown): Fix the typography
|
||||||
return SafeArea(
|
return WillPopScope(
|
||||||
child: Scaffold(
|
onWillPop: () async {
|
||||||
body: BlocBuilder<SendFilesBloc, SendFilesState>(
|
_maybeRemoveTemporaryFiles();
|
||||||
builder: (context, state) => Stack(
|
return true;
|
||||||
children: [
|
},
|
||||||
Positioned(
|
child: SafeArea(
|
||||||
top: 0,
|
child: Scaffold(
|
||||||
left: 0,
|
body: BlocBuilder<SendFilesBloc, SendFilesState>(
|
||||||
right: 0,
|
builder: (context, state) => Stack(
|
||||||
bottom: 0,
|
children: [
|
||||||
child: Center(
|
Positioned(
|
||||||
child: _renderBackground(context, state.files[state.index]),
|
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
|
||||||
// TODO(Unknown): Add a TextField for entering a message
|
Positioned(
|
||||||
Positioned(
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
bottom: 72,
|
||||||
bottom: 72,
|
child: SizedBox(
|
||||||
child: SizedBox(
|
height: sharedMediaContainerDimension + 2 * barPadding,
|
||||||
height: sharedMediaContainerDimension + 2 * barPadding,
|
child: ColoredBox(
|
||||||
child: ColoredBox(
|
color: const Color.fromRGBO(0, 0, 0, 0.7),
|
||||||
color: const Color.fromRGBO(0, 0, 0, 0.7),
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(barPadding),
|
||||||
padding: const EdgeInsets.all(barPadding),
|
child: ListView.builder(
|
||||||
child: ListView.builder(
|
shrinkWrap: true,
|
||||||
shrinkWrap: true,
|
scrollDirection: Axis.horizontal,
|
||||||
scrollDirection: Axis.horizontal,
|
itemCount: state.files.length + 1,
|
||||||
itemCount: state.files.length + 1,
|
itemBuilder: (context, index) {
|
||||||
itemBuilder: (context, index) {
|
if (index < state.files.length) {
|
||||||
if (index < state.files.length) {
|
final item = state.files[index];
|
||||||
final item = state.files[index];
|
|
||||||
|
|
||||||
return _renderPreview(
|
return _renderPreview(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
index == state.index,
|
index == state.index,
|
||||||
index,
|
index,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return SharedMediaContainer(
|
return SharedMediaContainer(
|
||||||
const Icon(Icons.attach_file),
|
const Icon(Icons.attach_file),
|
||||||
color: sharedMediaItemBackgroundColor,
|
color: sharedMediaItemBackgroundColor,
|
||||||
onTap: () => context.read<SendFilesBloc>().add(
|
onTap: () => context.read<SendFilesBloc>().add(
|
||||||
AddFilesRequestedEvent(),
|
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),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
child: Text(t.pages.settings.about.viewSourceCode),
|
child: Text(t.pages.settings.about.viewSourceCode),
|
||||||
onPressed: () =>
|
onPressed: () => _openUrl('https://codeberg.org/moxxy/moxxy'),
|
||||||
_openUrl('https://github.com/PapaTutuWawa/moxxyv2'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -65,7 +65,7 @@ class UISharingService {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
isMedia ? SendFilesType.image : SendFilesType.generic,
|
isMedia ? SendFilesType.media : SendFilesType.generic,
|
||||||
paths:
|
paths:
|
||||||
attachments.map((attachment) => attachment!.path).toList(),
|
attachments.map((attachment) => attachment!.path).toList(),
|
||||||
hasRecipientData: false,
|
hasRecipientData: false,
|
||||||
|
@ -2,13 +2,13 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.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/bottom.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/helpers.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/base.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||||
|
import 'package:moxxyv2/ui/widgets/chat/viewers/image.dart';
|
||||||
|
|
||||||
class ImageChatWidget extends StatelessWidget {
|
class ImageChatWidget extends StatelessWidget {
|
||||||
const ImageChatWidget(
|
const ImageChatWidget(
|
||||||
@ -64,7 +64,7 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The image exists locally
|
/// The image exists locally
|
||||||
Widget _buildImage() {
|
Widget _buildImage(BuildContext context) {
|
||||||
final size = getMediaSize(message, maxWidth);
|
final size = getMediaSize(message, maxWidth);
|
||||||
|
|
||||||
Widget image;
|
Widget image;
|
||||||
@ -95,7 +95,14 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
sent,
|
sent,
|
||||||
),
|
),
|
||||||
radius,
|
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
|
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||||
if (message.fileMetadata!.path != null &&
|
if (message.fileMetadata!.path != null &&
|
||||||
File(message.fileMetadata!.path!).existsSync()) {
|
File(message.fileMetadata!.path!).existsSync()) {
|
||||||
return _buildImage();
|
return _buildImage(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildDownloadable();
|
return _buildDownloadable();
|
||||||
|
@ -2,7 +2,6 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.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/bottom.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
import 'package:moxxyv2/ui/widgets/chat/downloadbutton.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/helpers.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/playbutton.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
||||||
|
import 'package:moxxyv2/ui/widgets/chat/viewers/video.dart';
|
||||||
|
|
||||||
class VideoChatWidget extends StatelessWidget {
|
class VideoChatWidget extends StatelessWidget {
|
||||||
const VideoChatWidget(
|
const VideoChatWidget(
|
||||||
@ -75,7 +75,7 @@ class VideoChatWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The video exists locally
|
/// The video exists locally
|
||||||
Widget _buildVideo() {
|
Widget _buildVideo(BuildContext context) {
|
||||||
return MediaBaseChatWidget(
|
return MediaBaseChatWidget(
|
||||||
VideoThumbnail(
|
VideoThumbnail(
|
||||||
path: message.fileMetadata!.path!,
|
path: message.fileMetadata!.path!,
|
||||||
@ -92,7 +92,15 @@ class VideoChatWidget extends StatelessWidget {
|
|||||||
sent,
|
sent,
|
||||||
),
|
),
|
||||||
radius,
|
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(),
|
extra: const PlayButton(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -144,7 +152,7 @@ class VideoChatWidget extends StatelessWidget {
|
|||||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||||
if (message.fileMetadata!.path != null &&
|
if (message.fileMetadata!.path != null &&
|
||||||
File(message.fileMetadata!.path!).existsSync()) {
|
File(message.fileMetadata!.path!).existsSync()) {
|
||||||
return _buildVideo();
|
return _buildVideo(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildDownloadable();
|
return _buildDownloadable();
|
||||||
|
@ -12,7 +12,7 @@ typedef SharedMediaWidgetCallback = void Function(FileMetadata);
|
|||||||
Widget buildSharedMediaWidget(
|
Widget buildSharedMediaWidget(
|
||||||
FileMetadata metadata,
|
FileMetadata metadata,
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
SharedMediaWidgetCallback onTap, {
|
VoidCallback onTap, {
|
||||||
SharedMediaWidgetCallback? onLongPress,
|
SharedMediaWidgetCallback? onLongPress,
|
||||||
}) {
|
}) {
|
||||||
// Prevent having the phone vibrate if no onLongPress is passed
|
// Prevent having the phone vibrate if no onLongPress is passed
|
||||||
@ -22,7 +22,7 @@ Widget buildSharedMediaWidget(
|
|||||||
if (metadata.mimeType!.startsWith('image/')) {
|
if (metadata.mimeType!.startsWith('image/')) {
|
||||||
return SharedImageWidget(
|
return SharedImageWidget(
|
||||||
metadata.path!,
|
metadata.path!,
|
||||||
onTap: () => onTap(metadata),
|
onTap: onTap,
|
||||||
onLongPress: longPressCallback,
|
onLongPress: longPressCallback,
|
||||||
);
|
);
|
||||||
} else if (metadata.mimeType!.startsWith('video/')) {
|
} else if (metadata.mimeType!.startsWith('video/')) {
|
||||||
@ -30,21 +30,21 @@ Widget buildSharedMediaWidget(
|
|||||||
metadata.path!,
|
metadata.path!,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
metadata.mimeType!,
|
metadata.mimeType!,
|
||||||
onTap: () => onTap(metadata),
|
onTap: onTap,
|
||||||
onLongPress: () => onLongPress?.call(metadata),
|
onLongPress: () => onLongPress?.call(metadata),
|
||||||
child: const PlayButton(size: 32),
|
child: const PlayButton(size: 32),
|
||||||
);
|
);
|
||||||
} else if (metadata.mimeType!.startsWith('audio/')) {
|
} else if (metadata.mimeType!.startsWith('audio/')) {
|
||||||
return SharedAudioWidget(
|
return SharedAudioWidget(
|
||||||
metadata.path!,
|
metadata.path!,
|
||||||
onTap: () => onTap(metadata),
|
onTap: onTap,
|
||||||
onLongPress: longPressCallback,
|
onLongPress: longPressCallback,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SharedFileWidget(
|
return SharedFileWidget(
|
||||||
metadata.path!,
|
metadata.path!,
|
||||||
onTap: () => onTap(metadata),
|
onTap: onTap,
|
||||||
onLongPress: longPressCallback,
|
onLongPress: longPressCallback,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/shared/base.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
|
||||||
|
|
||||||
@ -28,15 +29,40 @@ class SharedVideoWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SharedMediaContainer(
|
return SharedMediaContainer(
|
||||||
VideoThumbnail(
|
Stack(
|
||||||
path: path,
|
children: [
|
||||||
conversationJid: conversationJid,
|
Positioned.fill(
|
||||||
mime: mime,
|
child: VideoThumbnail(
|
||||||
size: Size(
|
path: path,
|
||||||
size,
|
conversationJid: conversationJid,
|
||||||
size,
|
mime: mime,
|
||||||
),
|
size: Size(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
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,
|
borderRadius: borderRadius,
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
@ -52,11 +52,7 @@ class VideoThumbnail extends StatelessWidget {
|
|||||||
child: const ShimmerWidget(),
|
child: const ShimmerWidget(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return widget;
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
child: 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,
|
backgroundColor: primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
label: t.pages.conversation.sendImages,
|
label: t.pages.conversation.sendMedia,
|
||||||
),
|
),
|
||||||
SpeedDialChild(
|
SpeedDialChild(
|
||||||
child: const Icon(Icons.file_present),
|
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/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/controller/shared_media_controller.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/bubbles/date.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/shared.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';
|
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
|
||||||
|
|
||||||
/// A widget that displays a lazily-loaded list of media files in a grid, grouped
|
/// 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(
|
itemBuilder: (_, message) => buildSharedMediaWidget(
|
||||||
message.fileMetadata!,
|
message.fileMetadata!,
|
||||||
message.conversationJid,
|
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,
|
onLongPress: onLongPress,
|
||||||
),
|
),
|
||||||
separatorBuilder: (_, timestamp) => Padding(
|
separatorBuilder: (_, timestamp) => Padding(
|
||||||
|
76
pubspec.lock
76
pubspec.lock
@ -305,6 +305,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.0"
|
version: "2.6.0"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
csv:
|
csv:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -361,14 +369,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.1"
|
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:
|
ffi:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -728,6 +728,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.4"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -872,14 +880,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.7.1"
|
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:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -998,10 +998,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: moxxy_native
|
name: moxxy_native
|
||||||
sha256: "6f6dac5e61f5bf5e58357376569e6e02e93cf18317e93f3381e02ff890d35d2f"
|
sha256: "4ded09f7abfb4b14b5b0d21b13e77afc64bd00133d572717796238532d4781fd"
|
||||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.3.2"
|
||||||
moxxyv2_builders:
|
moxxyv2_builders:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1767,6 +1767,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.0"
|
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:
|
visibility_detector:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
10
pubspec.yaml
10
pubspec.yaml
@ -24,7 +24,6 @@ dependencies:
|
|||||||
dart_emoji: 0.2.0+2
|
dart_emoji: 0.2.0+2
|
||||||
decorated_icon: 1.2.1
|
decorated_icon: 1.2.1
|
||||||
emoji_picker_flutter: ^1.6.1
|
emoji_picker_flutter: ^1.6.1
|
||||||
external_path: ^1.0.3
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.3
|
flutter_bloc: ^8.1.3
|
||||||
@ -47,7 +46,6 @@ dependencies:
|
|||||||
hex: 0.2.0
|
hex: 0.2.0
|
||||||
image: ^4.0.17
|
image: ^4.0.17
|
||||||
json_annotation: ^4.8.1
|
json_annotation: ^4.8.1
|
||||||
keyboard_height_plugin: 0.0.4
|
|
||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
meta: ^1.7.0
|
meta: ^1.7.0
|
||||||
mime: ^1.0.4
|
mime: ^1.0.4
|
||||||
@ -69,7 +67,7 @@ dependencies:
|
|||||||
version: 0.3.1
|
version: 0.3.1
|
||||||
moxxy_native:
|
moxxy_native:
|
||||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||||
version: 0.2.0
|
version: 0.3.2
|
||||||
moxxyv2_builders:
|
moxxyv2_builders:
|
||||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
@ -100,6 +98,7 @@ dependencies:
|
|||||||
url_launcher: ^6.1.14
|
url_launcher: ^6.1.14
|
||||||
#unifiedpush: 3.0.1
|
#unifiedpush: 3.0.1
|
||||||
uuid: ^3.0.7
|
uuid: ^3.0.7
|
||||||
|
video_player: ^2.7.2
|
||||||
visibility_detector: 0.4.0+2
|
visibility_detector: 0.4.0+2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@ -118,11 +117,6 @@ dev_dependencies:
|
|||||||
very_good_analysis: ^5.1.0
|
very_good_analysis: ^5.1.0
|
||||||
|
|
||||||
dependency_overrides:
|
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
|
# NOTE: Leave here for development purposes
|
||||||
# moxxmpp:
|
# moxxmpp:
|
||||||
# path: ../moxxmpp/packages/moxxmpp
|
# path: ../moxxmpp/packages/moxxmpp
|
||||||
|
@ -62,8 +62,7 @@ class StubXmppStateService extends XmppStateService {
|
|||||||
@override
|
@override
|
||||||
Future<XmppState> get state async => XmppState(
|
Future<XmppState> get state async => XmppState(
|
||||||
avatarHash: '9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
avatarHash: '9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||||
avatarUrl:
|
avatarUrl: './cache/avatars/9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||||
'./cache/avatars/9f26dcd75b630308df29214880a4e26fe5ef3a43.png',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -133,21 +132,26 @@ class StubUserAvatarManager extends UserAvatarManager {
|
|||||||
|
|
||||||
String currentAvatarHash = '';
|
String currentAvatarHash = '';
|
||||||
|
|
||||||
|
String currentAvatarType = 'image/png';
|
||||||
|
|
||||||
|
List<UserAvatarMetadata>? metadataList;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<AvatarError, List<UserAvatarMetadata>>> getLatestMetadata(
|
Future<Result<AvatarError, List<UserAvatarMetadata>>> getLatestMetadata(
|
||||||
JID jid,
|
JID jid,
|
||||||
) async {
|
) async {
|
||||||
return Result<AvatarError, List<UserAvatarMetadata>>(
|
return Result<AvatarError, List<UserAvatarMetadata>>(
|
||||||
[
|
metadataList ??
|
||||||
UserAvatarMetadata(
|
[
|
||||||
currentAvatarHash,
|
UserAvatarMetadata(
|
||||||
42,
|
currentAvatarHash,
|
||||||
null,
|
42,
|
||||||
null,
|
null,
|
||||||
'image/png',
|
null,
|
||||||
null,
|
currentAvatarType,
|
||||||
),
|
null,
|
||||||
],
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,8 +163,8 @@ class StubUserAvatarManager extends UserAvatarManager {
|
|||||||
getUserAvatarCalled++;
|
getUserAvatarCalled++;
|
||||||
return Result<AvatarError, UserAvatarData>(
|
return Result<AvatarError, UserAvatarData>(
|
||||||
UserAvatarData(
|
UserAvatarData(
|
||||||
_avatars[currentAvatarHash]!,
|
_avatars[id]!,
|
||||||
currentAvatarHash,
|
id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -222,6 +226,8 @@ Future<void> main() async {
|
|||||||
setUp(() async {
|
setUp(() async {
|
||||||
stubUserAvatarManager
|
stubUserAvatarManager
|
||||||
..currentAvatarHash = ''
|
..currentAvatarHash = ''
|
||||||
|
..currentAvatarType = 'image/png'
|
||||||
|
..metadataList = null
|
||||||
..getUserAvatarCalled = 0;
|
..getUserAvatarCalled = 0;
|
||||||
scs.conversation = null;
|
scs.conversation = null;
|
||||||
|
|
||||||
@ -273,7 +279,7 @@ Future<void> main() async {
|
|||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
null,
|
null,
|
||||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'),
|
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'),
|
||||||
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||||
'user@example.org',
|
'user@example.org',
|
||||||
null,
|
null,
|
||||||
@ -308,7 +314,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
// The first avatar should not exist anymore.
|
// The first avatar should not exist anymore.
|
||||||
expect(
|
expect(
|
||||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@ -327,7 +333,7 @@ Future<void> main() async {
|
|||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
null,
|
null,
|
||||||
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'),
|
p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'),
|
||||||
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
'9f26dcd75b630308df29214880a4e26fe5ef3a43',
|
||||||
'user@example.org',
|
'user@example.org',
|
||||||
null,
|
null,
|
||||||
@ -362,7 +368,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
// The first avatar should still exist.
|
// The first avatar should still exist.
|
||||||
expect(
|
expect(
|
||||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@ -372,7 +378,7 @@ Future<void> main() async {
|
|||||||
() async {
|
() async {
|
||||||
// The avatar must not exist already.
|
// The avatar must not exist already.
|
||||||
assert(
|
assert(
|
||||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
'The avatar must not already exist',
|
'The avatar must not already exist',
|
||||||
);
|
);
|
||||||
@ -388,7 +394,7 @@ Future<void> main() async {
|
|||||||
// The first avatar should now exist.
|
// The first avatar should now exist.
|
||||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||||
expect(
|
expect(
|
||||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@ -399,7 +405,7 @@ Future<void> main() async {
|
|||||||
() async {
|
() async {
|
||||||
// The avatar must not exist already.
|
// The avatar must not exist already.
|
||||||
assert(
|
assert(
|
||||||
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
!File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
'The avatar must not already exist',
|
'The avatar must not already exist',
|
||||||
);
|
);
|
||||||
@ -412,9 +418,55 @@ Future<void> main() async {
|
|||||||
// The first avatar should now exist.
|
// The first avatar should now exist.
|
||||||
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
expect(stubUserAvatarManager.getUserAvatarCalled, 1);
|
||||||
expect(
|
expect(
|
||||||
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43.png'))
|
File(p.join(cacheDir, '9f26dcd75b630308df29214880a4e26fe5ef3a43'))
|
||||||
.existsSync(),
|
.existsSync(),
|
||||||
true,
|
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