431 lines
19 KiB
Dart
431 lines
19 KiB
Dart
import 'dart:math';
|
|
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:get_it/get_it.dart';
|
|
import 'package:moxxyv2/i18n/strings.g.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/controller/conversation_controller.dart';
|
|
import 'package:moxxyv2/ui/helpers.dart';
|
|
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
|
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
|
import 'package:moxxyv2/ui/service/data.dart';
|
|
import 'package:moxxyv2/ui/theme.dart';
|
|
import 'package:moxxyv2/ui/widgets/chat/message.dart';
|
|
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
|
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
|
|
|
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: 24,
|
|
color: primaryColor,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextFieldRecordButton extends StatelessWidget {
|
|
const _TextFieldRecordButton();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return 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(),
|
|
);
|
|
},
|
|
childWhenDragging: const SizedBox(),
|
|
feedback: SizedBox(
|
|
width: 45,
|
|
height: 45,
|
|
child: FloatingActionButton(
|
|
onPressed: null,
|
|
heroTag: 'fabDragged',
|
|
backgroundColor: Colors.red.shade600,
|
|
child: BlinkingIcon(
|
|
icon: Icons.mic,
|
|
duration: const Duration(milliseconds: 600),
|
|
start: Colors.white,
|
|
end: Colors.red.shade600,
|
|
),
|
|
),
|
|
),
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
child: Icon(
|
|
Icons.mic,
|
|
size: 24,
|
|
color: primaryColor,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConversationBottomRow extends StatefulWidget {
|
|
const ConversationBottomRow(
|
|
this.tabController,
|
|
this.focusNode,
|
|
this.conversationController,
|
|
this.speedDialValueNotifier, {
|
|
super.key,
|
|
});
|
|
final TabController tabController;
|
|
final FocusNode focusNode;
|
|
final ValueNotifier<bool> speedDialValueNotifier;
|
|
final BidirectionalConversationController conversationController;
|
|
|
|
@override
|
|
ConversationBottomRowState createState() => ConversationBottomRowState();
|
|
}
|
|
|
|
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
|
IconData _getSendButtonIcon(SendButtonState state) {
|
|
switch (state) {
|
|
case SendButtonState.multi:
|
|
return Icons.add;
|
|
case SendButtonState.send:
|
|
return Icons.send;
|
|
case SendButtonState.cancelCorrection:
|
|
return Icons.clear;
|
|
}
|
|
}
|
|
|
|
IconData _getPickerIcon() {
|
|
if (widget.tabController.index == 0) {
|
|
return Icons.insert_emoticon;
|
|
}
|
|
|
|
return PhosphorIcons.stickerBold;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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.isRecording != next.isRecording,
|
|
builder: (context, state) => Row(
|
|
children: [
|
|
Expanded(
|
|
child: StreamBuilder<TextFieldData>(
|
|
initialData: const TextFieldData(
|
|
true,
|
|
null,
|
|
false,
|
|
),
|
|
stream: widget
|
|
.conversationController.textFieldDataStream,
|
|
builder: (context, snapshot) {
|
|
return CustomTextField(
|
|
backgroundColor: Theme.of(context)
|
|
.extension<MoxxyThemeData>()!
|
|
.conversationTextFieldColor,
|
|
textColor: Theme.of(context)
|
|
.extension<MoxxyThemeData>()!
|
|
.conversationTextFieldTextColor,
|
|
maxLines: 5,
|
|
hintText: t.pages.conversation.messageHint,
|
|
hintTextColor: Theme.of(context)
|
|
.extension<MoxxyThemeData>()!
|
|
.conversationTextFieldHintTextColor,
|
|
isDense: true,
|
|
contentPadding: textfieldPaddingConversation,
|
|
fontSize: textFieldFontSizeConversation,
|
|
cornerRadius: textfieldRadiusConversation,
|
|
controller: widget
|
|
.conversationController.textController,
|
|
topWidget: snapshot.data!.quotedMessage != null
|
|
? buildQuoteMessageWidget(
|
|
snapshot.data!.quotedMessage!,
|
|
isSent(
|
|
snapshot.data!.quotedMessage!,
|
|
GetIt.I.get<UIDataService>().ownJid!,
|
|
),
|
|
resetQuote: widget
|
|
.conversationController.removeQuote,
|
|
)
|
|
: null,
|
|
focusNode: widget.focusNode,
|
|
shouldSummonKeyboard: () =>
|
|
!snapshot.data!.pickerVisible,
|
|
prefixIcon: IntrinsicWidth(
|
|
child: Row(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: _TextFieldIconButton(
|
|
snapshot.data!.pickerVisible
|
|
? Icons.keyboard
|
|
: _getPickerIcon(),
|
|
() {
|
|
widget.conversationController
|
|
.togglePickerVisibility(true);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
prefixIconConstraints: const BoxConstraints(
|
|
minWidth: 24,
|
|
minHeight: 24,
|
|
),
|
|
suffixIcon: snapshot.data!.isBodyEmpty &&
|
|
snapshot.data!.quotedMessage == null
|
|
? IntrinsicWidth(
|
|
child: Row(
|
|
children: const [
|
|
Padding(
|
|
padding:
|
|
EdgeInsets.only(right: 8),
|
|
child: _TextFieldRecordButton(),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null,
|
|
suffixIconConstraints: const BoxConstraints(
|
|
minWidth: 24,
|
|
minHeight: 24,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: AnimatedOpacity(
|
|
opacity: state.isRecording ? 0 : 1,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: IgnorePointer(
|
|
ignoring: state.isRecording,
|
|
child: SizedBox(
|
|
height: 45,
|
|
width: 45,
|
|
child: StreamBuilder<SendButtonState>(
|
|
initialData: defaultSendButtonState,
|
|
stream: widget
|
|
.conversationController.sendButtonStream,
|
|
builder: (context, snapshot) {
|
|
return SpeedDial(
|
|
icon: _getSendButtonIcon(snapshot.data!),
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
children: [
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.image),
|
|
onTap: () {
|
|
context
|
|
.read<ConversationBloc>()
|
|
.add(
|
|
ImagePickerRequestedEvent(),
|
|
);
|
|
},
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
label:
|
|
t.pages.conversation.sendImages,
|
|
),
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.file_present),
|
|
onTap: () {
|
|
context
|
|
.read<ConversationBloc>()
|
|
.add(
|
|
FilePickerRequestedEvent(),
|
|
);
|
|
},
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
label: t.pages.conversation.sendFiles,
|
|
),
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.photo_camera),
|
|
onTap: () {
|
|
showNotImplementedDialog(
|
|
'taking photos',
|
|
context,
|
|
);
|
|
},
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
label:
|
|
t.pages.conversation.takePhotos,
|
|
),
|
|
],
|
|
openCloseDial:
|
|
widget.speedDialValueNotifier,
|
|
onPress: () {
|
|
switch (snapshot.data!) {
|
|
case SendButtonState.cancelCorrection:
|
|
widget.conversationController
|
|
.endMessageEditing();
|
|
return;
|
|
case SendButtonState.send:
|
|
widget.conversationController
|
|
.sendMessage(
|
|
state.conversation!.encrypted,
|
|
);
|
|
return;
|
|
case SendButtonState.multi:
|
|
widget.speedDialValueNotifier
|
|
.value =
|
|
!widget.speedDialValueNotifier
|
|
.value;
|
|
return;
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
StreamBuilder<bool>(
|
|
initialData: false,
|
|
stream: widget.conversationController.pickerVisibleStream,
|
|
builder: (context, snapshot) => Offstage(
|
|
offstage: !snapshot.data!,
|
|
child: CombinedPicker(
|
|
tabController: widget.tabController,
|
|
onEmojiTapped: (emoji) {
|
|
final selection = widget
|
|
.conversationController.textController.selection;
|
|
final baseOffset = max(selection.baseOffset, 0);
|
|
final extentOffset = max(selection.extentOffset, 0);
|
|
final prefix = widget.conversationController.messageBody
|
|
.substring(0, baseOffset);
|
|
final suffix = widget.conversationController.messageBody
|
|
.substring(extentOffset);
|
|
final newText = '$prefix${emoji.emoji}$suffix';
|
|
final newValue =
|
|
baseOffset + emoji.emoji.codeUnits.length;
|
|
widget.conversationController.textController
|
|
..text = newText
|
|
..selection = TextSelection(
|
|
baseOffset: newValue,
|
|
extentOffset: newValue,
|
|
);
|
|
},
|
|
onBackspaceTapped: () {
|
|
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
|
final text = widget.conversationController.messageBody;
|
|
final selection = widget
|
|
.conversationController.textController.selection;
|
|
final cursorPosition = widget.conversationController
|
|
.textController.selection.base.offset;
|
|
|
|
if (cursorPosition < 0) {
|
|
return;
|
|
}
|
|
|
|
final newTextBeforeCursor = selection
|
|
.textBefore(text)
|
|
.characters
|
|
.skipLast(1)
|
|
.toString();
|
|
|
|
widget.conversationController.textController
|
|
..text = newTextBeforeCursor
|
|
..selection = TextSelection.fromPosition(
|
|
TextPosition(offset: newTextBeforeCursor.length),
|
|
);
|
|
},
|
|
onStickerTapped: (sticker, pack) {
|
|
widget.conversationController.sendSticker(
|
|
pack.id,
|
|
sticker.hashKey,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 8,
|
|
bottom: 8,
|
|
right: 61,
|
|
child: BlocBuilder<ConversationBloc, ConversationState>(
|
|
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
|
builder: (context, state) {
|
|
return AnimatedOpacity(
|
|
opacity: state.isRecording ? 1 : 0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: IgnorePointer(
|
|
ignoring: !state.isRecording,
|
|
child: SizedBox(
|
|
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(textfieldRadiusConversation),
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
),
|
|
// NOTE: We use a comprehension here so that the widget gets
|
|
// created and destroyed to prevent the timer from running
|
|
// until the user closes the page.
|
|
child: state.isRecording
|
|
? const Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(left: 16),
|
|
child: TimerWidget(),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|