431 lines
15 KiB
Dart
431 lines
15 KiB
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:get_it/get_it.dart';
|
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
|
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
|
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
|
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
|
import 'package:moxxyv2/ui/constants.dart';
|
|
import 'package:moxxyv2/ui/helpers.dart';
|
|
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
|
import 'package:moxxyv2/ui/widgets/context_menu.dart';
|
|
import 'package:moxxyv2/ui/widgets/conversation.dart';
|
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
|
|
|
enum ConversationsOptions { settings }
|
|
|
|
class ConversationsRowDismissible extends StatefulWidget {
|
|
const ConversationsRowDismissible({
|
|
required this.item,
|
|
required this.child,
|
|
super.key,
|
|
});
|
|
final Conversation item;
|
|
final Widget child;
|
|
|
|
@override
|
|
ConversationsRowDismissibleState createState() =>
|
|
ConversationsRowDismissibleState();
|
|
}
|
|
|
|
class ConversationsRowDismissibleState
|
|
extends State<ConversationsRowDismissible> {
|
|
DismissDirection direction = DismissDirection.none;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dismissible(
|
|
key: ValueKey('conversation;${widget.item}'),
|
|
// TODO(Unknown): Show a snackbar allowing the user to revert the action
|
|
onDismissed: (direction) => context.read<ConversationsBloc>().add(
|
|
ConversationClosedEvent(widget.item.jid),
|
|
),
|
|
onUpdate: (details) {
|
|
if (details.direction != direction) {
|
|
setState(() {
|
|
direction = details.direction;
|
|
});
|
|
}
|
|
},
|
|
background: ColoredBox(
|
|
color: Colors.red,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Visibility(
|
|
visible: direction == DismissDirection.startToEnd,
|
|
child: const Icon(Icons.delete),
|
|
),
|
|
const Spacer(),
|
|
Visibility(
|
|
visible: direction == DismissDirection.endToStart,
|
|
child: const Icon(Icons.delete),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConversationsPage extends StatefulWidget {
|
|
const ConversationsPage({super.key});
|
|
|
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
|
builder: (context) => const ConversationsPage(),
|
|
settings: const RouteSettings(
|
|
name: conversationsRoute,
|
|
),
|
|
);
|
|
|
|
@override
|
|
ConversationsPageState createState() => ConversationsPageState();
|
|
}
|
|
|
|
class ConversationsPageState extends State<ConversationsPage>
|
|
with TickerProviderStateMixin {
|
|
/// The JID of the currently selected conversation.
|
|
Conversation? _selectedConversation;
|
|
|
|
/// Data for the context menu animation
|
|
late final AnimationController _contextMenuController;
|
|
late final Animation<double> _contextMenuAnimation;
|
|
final Map<String, GlobalKey> _conversationKeys = {};
|
|
|
|
/// The required offset from the top of the stack for the context menu.
|
|
double _topStackOffset = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_contextMenuController = AnimationController(
|
|
duration: const Duration(milliseconds: 250),
|
|
vsync: this,
|
|
);
|
|
_contextMenuAnimation = Tween<double>(
|
|
begin: 0,
|
|
end: 1,
|
|
).animate(
|
|
CurvedAnimation(
|
|
parent: _contextMenuController,
|
|
curve: Curves.easeInOutCubic,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_contextMenuController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void dismissContextMenu() {
|
|
_contextMenuController.reverse();
|
|
setState(() {
|
|
_selectedConversation = null;
|
|
});
|
|
}
|
|
|
|
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
|
if (state.conversations.isNotEmpty) {
|
|
return ListView.builder(
|
|
itemCount: state.conversations.length,
|
|
itemBuilder: (context, index) {
|
|
final item = state.conversations[index];
|
|
|
|
GlobalKey key;
|
|
if (_conversationKeys.containsKey(item.jid)) {
|
|
key = _conversationKeys[item.jid]!;
|
|
} else {
|
|
key = GlobalKey();
|
|
_conversationKeys[item.jid] = key;
|
|
}
|
|
|
|
final row = ConversationsListRow(
|
|
item,
|
|
true,
|
|
enableAvatarOnTap: true,
|
|
isSelected: _selectedConversation?.jid == item.jid,
|
|
onPressed: () => GetIt.I.get<ConversationBloc>().add(
|
|
RequestedConversationEvent(
|
|
item.jid,
|
|
item.title,
|
|
item.avatarPath,
|
|
),
|
|
),
|
|
key: key,
|
|
);
|
|
|
|
return ConversationsRowDismissible(
|
|
item: item,
|
|
child: GestureDetector(
|
|
onLongPressStart: (event) async {
|
|
Vibrate.feedback(FeedbackType.medium);
|
|
|
|
final widgetRect = getWidgetPositionOnScreen(key);
|
|
final height = MediaQuery.of(context).size.height;
|
|
|
|
setState(() {
|
|
_selectedConversation = item;
|
|
|
|
final numberOptions = item.numberContextMenuOptions;
|
|
if (height - widgetRect.bottom >
|
|
40 + numberOptions * ContextMenuItem.height) {
|
|
// In this case, we have enough space below the conversation item,
|
|
// so we say that the top of the context menu is
|
|
// widgetRect.bottom (Bottom y coordinate of the conversation item)
|
|
// minus 20 (padding so we're not directly against the conversation
|
|
// item) - the height of the top bar.
|
|
_topStackOffset = widgetRect.bottom -
|
|
20 -
|
|
BorderlessTopbar.topbarPreferredHeight;
|
|
} else {
|
|
// In this case we don't have sufficient space below the conversation
|
|
// item, so we place the context menu above it.
|
|
// The computation is the same as in the above branch, but now
|
|
// we position the context menu above and thus also substract the
|
|
// height of the context menu
|
|
// (numberOptions * ContextMenuItem.height).
|
|
_topStackOffset = widgetRect.top -
|
|
20 -
|
|
numberOptions * ContextMenuItem.height -
|
|
BorderlessTopbar.topbarPreferredHeight;
|
|
}
|
|
});
|
|
|
|
await _contextMenuController.forward();
|
|
},
|
|
child: row,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
// TODO(Unknown): Maybe somehow render the svg
|
|
child: Image.asset('assets/images/begin_chat.png'),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(t.pages.conversations.noOpenChats),
|
|
),
|
|
TextButton(
|
|
child: Text(t.pages.conversations.startChat),
|
|
onPressed: () => Navigator.pushNamed(context, newConversationRoute),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<ConversationsBloc, ConversationsState>(
|
|
builder: (BuildContext context, ConversationsState state) => Scaffold(
|
|
appBar: BorderlessTopbar(
|
|
showBackButton: false,
|
|
children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () {
|
|
// Dismiss the selection, if we have an active one
|
|
if (_selectedConversation != null) {
|
|
dismissContextMenu();
|
|
}
|
|
|
|
GetIt.I.get<profile.ProfileBloc>().add(
|
|
profile.ProfilePageRequestedEvent(
|
|
true,
|
|
jid: state.jid,
|
|
avatarUrl: state.avatarPath,
|
|
displayName: state.displayName,
|
|
),
|
|
);
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Hero(
|
|
tag: 'self_profile_picture',
|
|
child: Material(
|
|
color: const Color.fromRGBO(0, 0, 0, 0),
|
|
child: AvatarWrapper(
|
|
radius: 20,
|
|
avatarUrl: state.avatarPath,
|
|
altIcon: Icons.person,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Text(
|
|
state.displayName,
|
|
style: const TextStyle(
|
|
fontSize: fontsizeAppbar,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: PopupMenuButton(
|
|
onSelected: (ConversationsOptions result) {
|
|
switch (result) {
|
|
case ConversationsOptions.settings:
|
|
Navigator.pushNamed(context, settingsRoute);
|
|
break;
|
|
}
|
|
},
|
|
icon: const Icon(Icons.more_vert),
|
|
itemBuilder: (BuildContext context) => [
|
|
PopupMenuItem(
|
|
value: ConversationsOptions.settings,
|
|
child: Text(t.pages.conversations.overlaySettings),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
_listWrapper(context, state),
|
|
if (_selectedConversation != null)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: GestureDetector(
|
|
onTap: dismissContextMenu,
|
|
// NOTE: We must set the color to Colors.transparent because the container
|
|
// would otherwise not span the entire screen (or Scaffold body to be
|
|
// more precise).
|
|
child: const ColoredBox(
|
|
color: Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: _topStackOffset,
|
|
left: 8,
|
|
child: AnimatedBuilder(
|
|
animation: _contextMenuAnimation,
|
|
builder: (context, child) => IgnorePointer(
|
|
ignoring: _selectedConversation == null,
|
|
child: Opacity(
|
|
opacity: _contextMenuAnimation.value,
|
|
child: child,
|
|
),
|
|
),
|
|
child: ContextMenu(
|
|
children: [
|
|
if ((_selectedConversation?.unreadCounter ?? 0) > 0)
|
|
ContextMenuItem(
|
|
icon: Icons.done_all,
|
|
text: t.pages.conversations.markAsRead,
|
|
onPressed: () {
|
|
context.read<ConversationsBloc>().add(
|
|
ConversationMarkedAsReadEvent(
|
|
_selectedConversation!.jid,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
ContextMenuItem(
|
|
icon: Icons.close,
|
|
text: t.pages.conversations.closeChat,
|
|
onPressed: () async {
|
|
// ignore: use_build_context_synchronously
|
|
final result = await showConfirmationDialog(
|
|
t.pages.conversations.closeChat,
|
|
t.pages.conversations.closeChatBody(
|
|
conversationTitle:
|
|
_selectedConversation?.title ?? '',
|
|
),
|
|
context,
|
|
);
|
|
|
|
if (result) {
|
|
// TODO(Unknown): Show a snackbar allowing the user to revert the action
|
|
// ignore: use_build_context_synchronously
|
|
context.read<ConversationsBloc>().add(
|
|
ConversationClosedEvent(
|
|
_selectedConversation!.jid,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: SpeedDial(
|
|
icon: Icons.chat,
|
|
curve: Curves.bounceInOut,
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
children: [
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.notes),
|
|
onTap: () {
|
|
context.read<NewConversationBloc>().add(
|
|
NewConversationAddedEvent(
|
|
'',
|
|
t.pages.conversations.speeddialAddNoteToSelf,
|
|
'',
|
|
ConversationType.note,
|
|
),
|
|
);
|
|
},
|
|
backgroundColor: primaryColor,
|
|
// TODO(Unknown): Theme dependent?
|
|
foregroundColor: Colors.white,
|
|
label: t.pages.conversations.speeddialAddNoteToSelf,
|
|
),
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.group),
|
|
onTap: () => showNotImplementedDialog('groupchat', context),
|
|
backgroundColor: primaryColor,
|
|
// TODO(Unknown): Theme dependent?
|
|
foregroundColor: Colors.white,
|
|
label: t.pages.conversations.speeddialJoinGroupchat,
|
|
),
|
|
SpeedDialChild(
|
|
child: const Icon(Icons.person_add),
|
|
onTap: () => Navigator.pushNamed(context, newConversationRoute),
|
|
backgroundColor: primaryColor,
|
|
// TODO(Unknown): Theme dependent?
|
|
foregroundColor: Colors.white,
|
|
label: t.pages.conversations.speeddialNewChat,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|