Compare commits

...

10 Commits

21 changed files with 677 additions and 334 deletions

View File

@ -356,7 +356,12 @@ Future<Size?> getImageSizeFromData(Uint8List bytes) async {
/// to the JID of the conversation the file comes from.
/// If the thumbnail already exists, then just its path is returned. If not, then
/// it gets generated first.
Future<String> getVideoThumbnailPath(String path, String conversationJid) async {
Future<String?> getVideoThumbnailPath(String path, String conversationJid, String mime) async {
//print('getVideoThumbnailPath: Mime type: $mime');
// Ignore mime types that may be wacky
if (mime == 'video/webm') return null;
final tempDir = await getTemporaryDirectory();
final thumbnailFilenameNoExtension = p.withoutExtension(
p.basename(path),

View File

@ -48,6 +48,10 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<MessageRetractedEvent>(_onMessageRetracted);
on<MessageEditSelectedEvent>(_onMessageEditSelected);
on<MessageEditCancelledEvent>(_onMessageEditCancelled);
on<SendButtonDragStartedEvent>(_onDragStarted);
on<SendButtonDragEndedEvent>(_onDragEnded);
on<SendButtonLockedEvent>(_onSendButtonLocked);
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
}
/// The current chat state with the conversation partner
ChatState _currentChatState;
@ -119,9 +123,12 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
quotedMessage: null,
messageEditing: false,
messageEditingOriginalBody: '',
messageText: '',
messageEditingId: null,
messageEditingSid: null,
showSendButton: false,
sendButtonState: defaultSendButtonState,
isLocked: false,
isDragging: false,
),
);
@ -167,10 +174,21 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
_startComposeTimer();
_updateChatState(ChatState.composing);
SendButtonState sendButtonState;
if (state.messageEditing) {
sendButtonState = event.value == state.messageEditingOriginalBody ?
SendButtonState.cancelCorrection :
SendButtonState.send;
} else {
sendButtonState = event.value.isEmpty ?
defaultSendButtonState :
SendButtonState.send;
}
return emit(
state.copyWith(
messageText: event.value,
showSendButton: event.value.isNotEmpty,
sendButtonState: sendButtonState,
),
);
}
@ -197,7 +215,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state.copyWith(
messageText: '',
quotedMessage: null,
showSendButton: false,
sendButtonState: defaultSendButtonState,
emojiPickerVisible: false,
messageEditing: false,
messageEditingOriginalBody: '',
@ -375,6 +393,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
messageEditingOriginalBody: event.message.body,
messageEditingId: event.message.id,
messageEditingSid: event.message.sid,
sendButtonState: SendButtonState.cancelCorrection,
),
);
}
@ -388,7 +407,33 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
messageEditingOriginalBody: '',
messageEditingId: null,
messageEditingSid: null,
showSendButton: false,
sendButtonState: defaultSendButtonState,
),
);
}
Future<void> _onDragStarted(SendButtonDragStartedEvent event, Emitter<ConversationState> emit) async {
emit(state.copyWith(isDragging: true));
}
Future<void> _onDragEnded(SendButtonDragEndedEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
isDragging: false,
isLocked: false,
),
);
}
Future<void> _onSendButtonLocked(SendButtonLockedEvent event, Emitter<ConversationState> emit) async {
emit(state.copyWith(isLocked: true));
}
Future<void> _onSendButtonLockPressed(SendButtonLockPressedEvent event, Emitter<ConversationState> emit) async {
emit(
state.copyWith(
isLocked: false,
isDragging: false,
),
);
}

View File

@ -131,3 +131,15 @@ class MessageEditSelectedEvent extends ConversationEvent {
class MessageEditCancelledEvent extends ConversationEvent {
MessageEditCancelledEvent();
}
/// Triggered when the dragging began
class SendButtonDragStartedEvent extends ConversationEvent {}
/// Triggered when the dragging ended
class SendButtonDragEndedEvent extends ConversationEvent {}
/// Triggered when the dragging ended
class SendButtonLockedEvent extends ConversationEvent {}
/// Triggered when the FAB has been locked
class SendButtonLockPressedEvent extends ConversationEvent {}

View File

@ -1,12 +1,19 @@
part of 'conversation_bloc.dart';
enum SendButtonState {
audio,
send,
cancelCorrection,
}
const defaultSendButtonState = SendButtonState.audio;
@freezed
class ConversationState with _$ConversationState {
factory ConversationState({
// Our own JID
@Default('') String jid,
@Default('') String messageText,
@Default(false) bool showSendButton,
@Default(defaultSendButtonState) SendButtonState sendButtonState,
@Default(null) Message? quotedMessage,
@Default(<Message>[]) List<Message> messages,
@Default(null) Conversation? conversation,
@ -16,5 +23,9 @@ class ConversationState with _$ConversationState {
@Default('') String messageEditingOriginalBody,
@Default(null) String? messageEditingSid,
@Default(null) int? messageEditingId,
// For recording
@Default(false) bool isDragging,
@Default(false) bool isLocked,
}) = _ConversationState;
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
class BlinkingMicrophoneIcon extends StatefulWidget {
const BlinkingMicrophoneIcon({ super.key });
@override
BlinkingMicrophoneIconState createState() => BlinkingMicrophoneIconState();
}
class BlinkingMicrophoneIconState extends State<BlinkingMicrophoneIcon> with TickerProviderStateMixin {
late final AnimationController _recordingBlinkController;
late final Animation<Color?> _recordingBlink;
bool _blinkForward = true;
@override
void initState() {
super.initState();
_recordingBlinkController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_recordingBlink = ColorTween(
begin: Colors.white,
end: Colors.red.shade600,
).animate(_recordingBlinkController)
..addStatusListener((status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
if (_blinkForward) {
_recordingBlinkController.reverse();
} else {
_recordingBlinkController.forward();
}
_blinkForward = !_blinkForward;
}
});
_recordingBlinkController.forward();
}
@override
void dispose() {
_recordingBlinkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _recordingBlink,
builder: (_, __) {
return Icon(
Icons.mic,
color: _recordingBlink.value,
);
},
);
}
}

View File

@ -2,20 +2,52 @@ import 'dart:math';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationBottomRow extends StatelessWidget {
const ConversationBottomRow(this.controller, this.isSpeedDialOpen, { super.key });
final TextEditingController controller;
final ValueNotifier<bool> isSpeedDialOpen;
class _TextFieldIconButton extends StatelessWidget {
const _TextFieldIconButton(this.icon, this.onTap);
final void Function() onTap;
final IconData icon;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
icon,
size: 20,
color: primaryColor,
),
),
);
}
}
class ConversationBottomRow extends StatefulWidget {
const ConversationBottomRow(
this.controller,
this.focusNode, {
super.key,
}
);
final TextEditingController controller;
final FocusNode focusNode;
@override
ConversationBottomRowState createState() => ConversationBottomRowState();
}
class ConversationBottomRowState extends State<ConversationBottomRow> {
Color _getTextColor(BuildContext context) {
// TODO(Unknown): Work on the colors
if (MediaQuery.of(context).platformBrightness == Brightness.dark) {
@ -25,238 +57,288 @@ class ConversationBottomRow extends StatelessWidget {
return Colors.black;
}
bool _shouldCancelEdit(ConversationState state) {
return state.messageEditing && controller.text == state.messageEditingOriginalBody;
}
IconData _getSpeeddialIcon(ConversationState state) {
if (_shouldCancelEdit(state)) return Icons.clear;
return state.showSendButton ? Icons.send : Icons.add;
IconData _getSendButtonIcon(ConversationState state) {
switch (state.sendButtonState) {
case SendButtonState.audio: return Icons.mic;
case SendButtonState.send: return Icons.send;
case SendButtonState.cancelCorrection: return Icons.clear;
}
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color.fromRGBO(0, 0, 0, 0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.showSendButton != next.showSendButton || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
builder: (context, state) => Row(
children: [
Expanded(
child: CustomTextField(
// TODO(Unknown): Work on the colors
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
textColor: _getTextColor(context),
enableBoxShadow: true,
maxLines: 5,
hintText: 'Send a message...',
isDense: true,
onChanged: (value) {
context.read<ConversationBloc>().add(
MessageTextChangedEvent(value),
);
},
contentPadding: textfieldPaddingConversation,
cornerRadius: textfieldRadiusConversation,
controller: controller,
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
state.quotedMessage!,
isSent(state.quotedMessage!, state.jid),
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
) : null,
shouldSummonKeyboard: () => !state.emojiPickerVisible,
prefixIcon: IntrinsicWidth(
child: Row(
children: [
InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
state.emojiPickerVisible ?
Icons.keyboard :
Icons.insert_emoticon,
color: primaryColor,
size: 24,
),
),
onTap: () {
context.read<ConversationBloc>().add(EmojiPickerToggledEvent());
},
),
Visibility(
visible: state.messageText.isEmpty,
child: InkWell(
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
PhosphorIcons.stickerBold,
size: 24,
color: primaryColor,
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
child: ColoredBox(
color: Colors.transparent,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
builder: (context, state) => Row(
children: [
Expanded(
child: CustomTextField(
// TODO(Unknown): Work on the colors
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
textColor: _getTextColor(context),
enableBoxShadow: true,
maxLines: 5,
hintText: 'Send a message...',
isDense: true,
onChanged: (value) {
context.read<ConversationBloc>().add(
MessageTextChangedEvent(value),
);
},
contentPadding: textfieldPaddingConversation,
cornerRadius: textfieldRadiusConversation,
controller: widget.controller,
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
state.quotedMessage!,
isSent(state.quotedMessage!, state.jid),
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
) : null,
focusNode: widget.focusNode,
shouldSummonKeyboard: () => !state.emojiPickerVisible,
prefixIcon: IntrinsicWidth(
child: Row(
children: [
InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
state.emojiPickerVisible ?
Icons.keyboard :
Icons.insert_emoticon,
color: primaryColor,
size: 24,
),
),
onTap: () {
context.read<ConversationBloc>().add(EmojiPickerToggledEvent());
},
),
),
onTap: () {
showNotImplementedDialog('stickers', context);
},
Visibility(
visible: state.messageText.isEmpty && state.quotedMessage == null,
child: InkWell(
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
PhosphorIcons.stickerBold,
size: 24,
color: primaryColor,
),
),
onTap: () {
showNotImplementedDialog('stickers', context);
},
),
),
],
),
),
],
),
),
prefixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
suffixIcon: state.messageText.isEmpty ?
InkWell(
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.mic_rounded,
color: primaryColor,
size: 24,
prefixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
suffixIcon: state.messageText.isEmpty && state.quotedMessage == null ?
IntrinsicWidth(
child: Row(
children: [
_TextFieldIconButton(
Icons.attach_file,
() {
context.read<ConversationBloc>().add(
FilePickerRequestedEvent(),
);
},
),
_TextFieldIconButton(
Icons.photo_camera,
() {
showNotImplementedDialog(
'taking photos',
context,
);
},
),
_TextFieldIconButton(
Icons.image,
() {
context.read<ConversationBloc>().add(
ImagePickerRequestedEvent(),
);
},
),
],
),
) :
null,
suffixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
onTap: () {
showNotImplementedDialog('audio recording', context);
},
) :
null,
suffixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
const Padding(
padding: EdgeInsets.only(left: 8),
child: SizedBox(
height: 45,
width: 45,
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
// NOTE: https://stackoverflow.com/a/52786741
// Thank you kind sir
),
BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
builder: (context, state) => Offstage(
offstage: !state.emojiPickerVisible,
child: SizedBox(
height: 45,
width: 45,
child: FittedBox(
child: SpeedDial(
icon: _getSpeeddialIcon(state),
curve: Curves.bounceInOut,
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
openCloseDial: isSpeedDialOpen,
onPress: () {
if (_shouldCancelEdit(state)) {
context.read<ConversationBloc>().add(MessageEditCancelledEvent());
controller.text = '';
return;
}
height: 250,
child: EmojiPicker(
onEmojiSelected: (_, emoji) {
final bloc = context.read<ConversationBloc>();
final selection = widget.controller.selection;
final baseOffset = max(selection.baseOffset, 0);
final extentOffset = max(selection.extentOffset, 0);
final prefix = bloc.state.messageText.substring(0, baseOffset);
final suffix = bloc.state.messageText.substring(extentOffset);
final newText = '$prefix${emoji.emoji}$suffix';
final newValue = baseOffset + emoji.emoji.codeUnits.length;
bloc.add(MessageTextChangedEvent(newText));
widget.controller
..text = newText
..selection = TextSelection(
baseOffset: newValue,
extentOffset: newValue,
);
},
onBackspacePressed: () {
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
final bloc = context.read<ConversationBloc>();
final text = bloc.state.messageText;
final selection = widget.controller.selection;
final cursorPosition = widget.controller.selection.base.offset;
if (state.showSendButton) {
context.read<ConversationBloc>().add(MessageSentEvent());
controller.text = '';
} else {
isSpeedDialOpen.value = true;
}
},
children: [
SpeedDialChild(
child: const Icon(Icons.image),
onTap: () {
context.read<ConversationBloc>().add(ImagePickerRequestedEvent());
},
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
label: 'Send Images',
),
SpeedDialChild(
child: const Icon(Icons.photo_camera),
onTap: () {
showNotImplementedDialog('taking photos', context);
},
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
label: 'Take photo',
),
SpeedDialChild(
child: const Icon(Icons.attach_file),
onTap: () {
context.read<ConversationBloc>().add(FilePickerRequestedEvent());
},
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,
label: 'Send files',
)
],
if (cursorPosition < 0) {
return;
}
final newTextBeforeCursor = selection
.textBefore(text).characters
.skipLast(1)
.toString();
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
widget.controller
..text = newTextBeforeCursor
..selection = TextSelection.fromPosition(
TextPosition(offset: newTextBeforeCursor.length),
);
},
config: Config(
bgColor: Theme.of(context).scaffoldBackgroundColor,
),
),
),
)
],
),
),
),
BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
builder: (context, state) => Offstage(
offstage: !state.emojiPickerVisible,
child: SizedBox(
height: 250,
child: EmojiPicker(
onEmojiSelected: (_, emoji) {
final bloc = context.read<ConversationBloc>();
final selection = controller.selection;
final baseOffset = max(selection.baseOffset, 0);
final extentOffset = max(selection.extentOffset, 0);
final prefix = bloc.state.messageText.substring(0, baseOffset);
final suffix = bloc.state.messageText.substring(extentOffset);
final newText = '$prefix${emoji.emoji}$suffix';
final newValue = baseOffset + emoji.emoji.codeUnits.length;
bloc.add(MessageTextChangedEvent(newText));
controller
..text = newText
..selection = TextSelection(
baseOffset: newValue,
extentOffset: newValue,
);
},
onBackspacePressed: () {
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
final bloc = context.read<ConversationBloc>();
final text = bloc.state.messageText;
final selection = controller.selection;
final cursorPosition = controller.selection.base.offset;
if (cursorPosition < 0) {
return;
}
final newTextBeforeCursor = selection
.textBefore(text).characters
.skipLast(1)
.toString();
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
controller
..text = newTextBeforeCursor
..selection = TextSelection.fromPosition(
TextPosition(offset: newTextBeforeCursor.length),
);
},
config: Config(
bgColor: Theme.of(context).scaffoldBackgroundColor,
),
),
),
],
),
),
],
),
),
Positioned(
right: 8,
bottom: 8,
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState ||
prev.isDragging != next.isDragging ||
prev.isLocked != next.isLocked,
builder: (context, state) {
return Visibility(
visible: !state.isDragging && !state.isLocked,
child: LongPressDraggable<int>(
data: 1,
axis: Axis.vertical,
onDragStarted: () {
Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragStartedEvent(),
);
},
onDraggableCanceled: (_, __) {
Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragEndedEvent(),
);
},
onDragCompleted: () {
context.read<ConversationBloc>().add(
SendButtonLockedEvent(),
);
},
feedback: SizedBox(
height: 45,
width: 45,
child: FloatingActionButton(
onPressed: null,
heroTag: 'fabDragged',
backgroundColor: Colors.red.shade600,
child: const BlinkingMicrophoneIcon(),
),
),
childWhenDragging: SizedBox(
height: 45,
width: 45,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(45),
),
),
),
child: SizedBox(
height: 45,
width: 45,
child: FloatingActionButton(
heroTag: 'fabRest',
onPressed: () {
switch (state.sendButtonState) {
case SendButtonState.audio: return;
case SendButtonState.cancelCorrection:
context.read<ConversationBloc>().add(
MessageEditCancelledEvent(),
);
widget.controller.text = '';
return;
case SendButtonState.send:
context.read<ConversationBloc>().add(
MessageSentEvent(),
);
widget.controller.text = '';
return;
}
},
child: Icon(
_getSendButtonIcon(state),
color: Colors.white,
),
),
),
),
);
},
),
),
],
);
}
}

View File

@ -12,6 +12,7 @@ import 'package:moxxyv2/shared/warning_types.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
import 'package:moxxyv2/ui/pages/conversation/bottom.dart';
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
@ -34,23 +35,23 @@ class ConversationPage extends StatefulWidget {
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
ConversationPageState() :
_isSpeedDialOpen = ValueNotifier(false),
_controller = TextEditingController(),
_scrollController = ScrollController(),
_scrolledToBottomState = true,
super();
final TextEditingController _controller;
final ValueNotifier<bool> _isSpeedDialOpen;
final ScrollController _scrollController;
late final AnimationController _animationController;
late final AnimationController _overviewAnimationController;
late Animation<double> _overviewMsgAnimation;
late final Animation<double> _scrollToBottom;
bool _scrolledToBottomState;
late FocusNode _textfieldFocus;
@override
void initState() {
super.initState();
_textfieldFocus = FocusNode();
_scrollController.addListener(_onScroll);
_overviewAnimationController = AnimationController(
@ -77,7 +78,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
..dispose();
_animationController.dispose();
_overviewAnimationController.dispose();
_textfieldFocus.dispose();
super.dispose();
}
@ -327,9 +328,16 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
@override
Widget build(BuildContext context) {
final maxWidth = MediaQuery.of(context).size.width * 0.6;
return WillPopScope(
onWillPop: () async {
// TODO(PapaTutuWawa): Check if we are recording an audio message and handle
// that accordingly
if (_textfieldFocus.hasFocus) {
_textfieldFocus.unfocus();
return false;
}
final bloc = GetIt.I.get<ConversationBloc>();
if (bloc.state.emojiPickerVisible) {
@ -417,7 +425,10 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
),
),
ConversationBottomRow(_controller, _isSpeedDialOpen)
ConversationBottomRow(
_controller,
_textfieldFocus,
),
],
),
),
@ -446,6 +457,44 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
),
),
),
Positioned(
right: 8,
bottom: 300,
child: BlocBuilder<ConversationBloc, ConversationState>(
builder: (context, state) {
return DragTarget<int>(
onWillAccept: (data) => true,
builder: (context, _, __) {
return AnimatedScale(
scale: state.isDragging || state.isLocked ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: 45,
width: 45,
child: FloatingActionButton(
heroTag: 'fabLock',
onPressed: state.isLocked ?
() {
context.read<ConversationBloc>().add(
SendButtonLockPressedEvent(),
);
} :
null,
backgroundColor: state.isLocked ?
Colors.red.shade600 :
Colors.grey,
child: state.isLocked ?
const BlinkingMicrophoneIcon() :
const Icon(Icons.lock, color: Colors.white),
),
),
);
},
);
},
),
),
],
),
);

View File

@ -34,12 +34,18 @@ class ConversationsPage extends StatefulWidget {
}
class ConversationsPageState extends State<ConversationsPage> with TickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
late final AnimationController _controller;
late Animation<double> _convY;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
}
@override
void dispose() {
_controller.dispose();

View File

@ -65,6 +65,7 @@ class SendFilesPage extends StatelessWidget {
path,
// TODO(PapaTutuWawa): Fix
'sendfiles',
mime,
onTap: () {
if (selected) {
// The trash can icon has been tapped

View File

@ -28,6 +28,85 @@ enum _AudioPlaybackState {
stopped
}
class _AudioWidget extends StatelessWidget {
const _AudioWidget(
this.maxWidth,
this.isDownloading,
this.onTap,
this.icon,
this.onChanged,
this.duration,
this.position,
this.messageId,
);
final double maxWidth;
final bool isDownloading;
final void Function() onTap;
final void Function(double) onChanged;
final double? duration;
final double? position;
final Widget? icon;
final int messageId;
Widget _getLeftWidget() {
if (isDownloading) {
return SizedBox(
width: 48,
height: 48,
child: ProgressWidget(id: messageId),
);
}
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 4,
),
child: icon,
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: maxWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_getLeftWidget(),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Slider(
onChanged: onChanged,
value: (position != null && duration != null) ?
(position! / duration!).clamp(0, 1) :
0,
),
SizedBox(
width: maxWidth - 80,
child: Row(
children: [
Text(doubleToTimestamp(position ?? 0)),
const Spacer(),
Text(doubleToTimestamp(duration ?? 0)),
],
),
),
const SizedBox(height: 20),
],
),
],
),
);
}
}
class AudioChatWidget extends StatefulWidget {
const AudioChatWidget(
this.message,
@ -94,100 +173,79 @@ class AudioChatState extends State<AudioChatWidget> {
}
Widget _buildUploading() {
// TODO(PapaTutuWawa): Fix
return FileChatWidget(
widget.message,
return MediaBaseChatWidget(
_AudioWidget(
widget.maxWidth,
true,
() {},
null,
(_) {},
null,
null,
widget.message.id,
),
MessageBubbleBottom(widget.message, widget.sent),
widget.radius,
widget.sent,
extra: ProgressWidget(id: widget.message.id),
gradient: false,
);
}
Widget _buildDownloading() {
// TODO(PapaTutuWawa): Fix
return FileChatBaseWidget(
widget.message,
Icons.image,
widget.message.isFileUploadNotification ?
(widget.message.filename ?? '') :
filenameFromUrl(widget.message.srcUrl!),
return MediaBaseChatWidget(
_AudioWidget(
widget.maxWidth,
true,
() {},
null,
(_) {},
null,
null,
widget.message.id,
),
MessageBubbleBottom(widget.message, widget.sent),
widget.radius,
widget.sent,
extra: ProgressWidget(id: widget.message.id),
gradient: false,
);
}
/// The audio file exists locally
Widget _buildAudio() {
return MediaBaseChatWidget(
SizedBox(
width: widget.maxWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
if (_playState != _AudioPlaybackState.playing) {
if (_playState == _AudioPlaybackState.paused) {
_audioFile?.resume();
} else if (_playState == _AudioPlaybackState.stopped) {
_audioFile?.play();
}
_AudioWidget(
widget.maxWidth,
false,
() {
if (_playState != _AudioPlaybackState.playing) {
if (_playState == _AudioPlaybackState.paused) {
_audioFile?.resume();
} else if (_playState == _AudioPlaybackState.stopped) {
_audioFile?.play();
}
setState(() {
_playState = _AudioPlaybackState.playing;
});
} else {
_audioFile?.pause();
setState(() {
_playState = _AudioPlaybackState.paused;
});
}
setState(() {
_playState = _AudioPlaybackState.playing;
});
} else {
_audioFile?.pause();
setState(() {
_playState = _AudioPlaybackState.paused;
});
}
},
_playState == _AudioPlaybackState.playing ?
const Icon(Icons.pause) :
const Icon(Icons.play_arrow),
(p) {
if (_duration == null || _audioFile == null) return;
},
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 4,
),
child: _playState == _AudioPlaybackState.playing ?
const Icon(Icons.pause) :
const Icon(Icons.play_arrow),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Slider(
onChanged: (p) {
if (_duration == null || _audioFile == null) return;
setState(() {
_position = p * _duration!;
_audioFile!.seek(_position!);
});
},
value: (_position != null && _duration != null) ?
(_position! / _duration!).clamp(0, 1) :
0,
),
SizedBox(
width: widget.maxWidth - 80,
child: Row(
children: [
Text(doubleToTimestamp(_position ?? 0)),
const Spacer(),
Text(doubleToTimestamp(_duration ?? 0)),
],
),
),
const SizedBox(height: 20),
],
),
],
),
setState(() {
_position = p * _duration!;
_audioFile!.seek(_position!);
});
},
_duration,
_position,
widget.message.id,
),
MessageBubbleBottom(widget.message, widget.sent),
widget.radius,
@ -196,6 +254,7 @@ class AudioChatState extends State<AudioChatWidget> {
}
Widget _buildDownloadable() {
// TODO(Unknown): Implement
return FileChatBaseWidget(
widget.message,
Icons.image,
@ -222,7 +281,7 @@ class AudioChatState extends State<AudioChatWidget> {
// TODO(PapaTutuWawa): Maybe use an async builder
if (widget.message.mediaUrl != null && File(widget.message.mediaUrl!).existsSync()) return _buildAudio();
return _buildDownloadable();
}
}

View File

@ -104,11 +104,7 @@ Widget buildQuoteMessageWidget(Message message, bool sent, { void Function()? re
}
Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) {
if (medium.mime == null) {
return SharedFileWidget(
medium.path,
);
} else if (medium.mime!.startsWith('image/')) {
if (medium.mime!.startsWith('image/')) {
return SharedImageWidget(
medium.path,
onTap: () => OpenFile.open(medium.path),
@ -117,6 +113,7 @@ Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) {
return SharedVideoWidget(
medium.path,
conversationJid,
medium.mime!,
onTap: () => OpenFile.open(medium.path),
child: const PlayButton(size: 32),
);
@ -127,5 +124,8 @@ Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) {
);
}
return SharedFileWidget(medium.path);
return SharedFileWidget(
medium.path,
onTap: () => OpenFile.open(medium.path),
);
}

View File

@ -35,6 +35,7 @@ class VideoChatWidget extends StatelessWidget {
VideoThumbnail(
path: message.mediaUrl!,
conversationJid: message.conversationJid,
mime: message.mediaType!,
size: Size(
maxWidth,
0.6 * maxWidth,
@ -83,6 +84,7 @@ class VideoChatWidget extends StatelessWidget {
VideoThumbnail(
path: message.mediaUrl!,
conversationJid: message.conversationJid,
mime: message.mediaType!,
size: Size(
maxWidth,
0.6 * maxWidth,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/widgets/chat/quote/media.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/audio.dart';
@ -25,7 +26,7 @@ class QuotedAudioWidget extends StatelessWidget {
borderRadius: 8,
),
// TODO(Unknown): Include the audio messages duration here
'Audio',
t.messages.audio,
sent,
resetQuote: resetQuote,
);

View File

@ -22,7 +22,6 @@ class QuotedFileWidget extends StatelessWidget {
message,
SharedFileWidget(
message.mediaUrl!,
enableOnTap: false,
size: 48,
borderRadius: 8,
),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/widgets/chat/quote/media.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
@ -24,7 +25,7 @@ class QuotedImageWidget extends StatelessWidget {
size: 48,
borderRadius: 8,
),
'Image',
t.messages.image,
sent,
resetQuote: resetQuote,
);

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/widgets/chat/quote/media.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
@ -22,10 +23,11 @@ class QuotedVideoWidget extends StatelessWidget {
SharedVideoWidget(
message.mediaUrl!,
message.conversationJid,
message.mediaType!,
size: 48,
borderRadius: 8,
),
'Video',
t.messages.video,
sent,
resetQuote: resetQuote,
);

View File

@ -1,18 +1,17 @@
import 'package:better_open_file/better_open_file.dart';
import 'package:flutter/material.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
class SharedFileWidget extends StatelessWidget {
const SharedFileWidget(
this.path, {
this.enableOnTap = true,
this.onTap,
this.borderRadius = 10,
this.size = sharedMediaContainerDimension,
super.key,
}
);
final String path;
final bool enableOnTap;
final void Function()? onTap;
final double borderRadius;
final double size;
@ -30,9 +29,7 @@ class SharedFileWidget extends StatelessWidget {
),
),
size: size,
onTap: enableOnTap ?
() => OpenFile.open(path) :
null,
onTap: onTap,
);
}
}

View File

@ -5,7 +5,8 @@ import 'package:moxxyv2/ui/widgets/chat/video_thumbnail.dart';
class SharedVideoWidget extends StatelessWidget {
const SharedVideoWidget(
this.path,
this.conversationJid, {
this.conversationJid,
this.mime, {
this.onTap,
this.borderColor,
this.child,
@ -16,6 +17,7 @@ class SharedVideoWidget extends StatelessWidget {
);
final String path;
final String conversationJid;
final String mime;
final Color? borderColor;
final void Function()? onTap;
final Widget? child;
@ -28,6 +30,7 @@ class SharedVideoWidget extends StatelessWidget {
VideoThumbnail(
path: path,
conversationJid: conversationJid,
mime: mime,
size: Size(
size,
size,

View File

@ -9,25 +9,27 @@ class VideoThumbnail extends StatelessWidget {
required this.conversationJid,
required this.size,
required this.borderRadius,
required this.mime,
super.key,
});
final String path;
final String conversationJid;
final Size size;
final BorderRadius borderRadius;
final String mime;
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: getVideoThumbnailPath(path, conversationJid),
return FutureBuilder<String?>(
future: getVideoThumbnailPath(path, conversationJid, mime),
builder: (context, snapshot) {
Widget widget;
if (snapshot.hasData) {
if (snapshot.hasData && snapshot.data != null) {
widget = Image.file(
File(snapshot.data!),
fit: BoxFit.cover,
);
} else if (snapshot.hasError) {
} else if (snapshot.hasError || snapshot.hasData && snapshot.data == null) {
widget = SizedBox(
width: size.width,
height: size.height,

View File

@ -112,6 +112,7 @@ class ConversationsListRowState extends State<ConversationsListRow> {
preview = SharedVideoWidget(
widget.conversation.lastMessage!.mediaUrl!,
widget.conversation.jid,
widget.conversation.lastMessage!.mediaType!,
borderRadius: 5,
size: 30,
);

View File

@ -30,6 +30,7 @@ class CustomTextField extends StatelessWidget {
this.suffixIconConstraints,
this.onTap,
this.shouldSummonKeyboard,
this.focusNode,
super.key,
});
final double cornerRadius;
@ -59,6 +60,7 @@ class CustomTextField extends StatelessWidget {
final ValueChanged<String>? onChanged;
final void Function()? onTap;
final bool Function()? shouldSummonKeyboard;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
@ -91,6 +93,7 @@ class CustomTextField extends StatelessWidget {
style: style,
onTap: onTap,
shouldSummonKeyboard: shouldSummonKeyboard,
focusNode: focusNode,
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,