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:
PapaTutuWawa 2023-09-20 20:23:20 +00:00
commit 9ea5d41096
35 changed files with 1132 additions and 320 deletions

View File

@ -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": {

View File

@ -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.

View File

@ -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');

View File

@ -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(

View File

@ -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',

View File

@ -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();

View File

@ -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: {

View File

@ -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 {

View File

@ -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) {

View File

@ -8,7 +8,7 @@ class Preference with _$Preference {
factory Preference(
String key,
int type,
String value,
String? value,
) = _Preference;
const Preference._();

View File

@ -158,7 +158,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state.conversation!.contactId != null,
),
],
SendFilesType.image,
SendFilesType.media,
),
);
}

View File

@ -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);

View File

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

View File

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

View File

@ -236,7 +236,7 @@ class ShareSelectionBloc
);
}).toList(),
// TODO(PapaTutuWawa): Fix
SendFilesType.image,
SendFilesType.media,
paths: state.paths,
popEntireStack: true,
),

View File

@ -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';

View File

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

View File

@ -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

View File

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

View File

@ -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(),
),
],
),
),
],
],
),
),
),
),

View File

@ -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'),
),
),
],

View File

@ -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,

View File

@ -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();

View File

@ -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();

View File

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

View File

@ -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,

View File

@ -52,11 +52,7 @@ class VideoThumbnail extends StatelessWidget {
child: const ShimmerWidget(),
);
}
return ClipRRect(
borderRadius: borderRadius,
child: widget,
);
return widget;
},
);
}

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

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

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

View File

@ -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),

View File

@ -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(

View File

@ -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:

View File

@ -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,6 +98,7 @@ 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:
@ -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

View File

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