crafting a custom context-menu

This commit is contained in:
otsmr 2025-10-27 23:53:48 +01:00
parent df9b055ba7
commit 9d563793c7
27 changed files with 935 additions and 774 deletions

View file

@ -1,5 +1,18 @@
# Changelog
## 0.0.62
- Support for Groups
- Editing of text messages
- Deletion of messages
- Various UI improvements like a new context-menu
- Client-to-client (C2C) protocol converted to ProtoBuf
- Use of UUIDs in the database
- Completely new database schema
- Improved reliability of C2C messages
- Improved video handling
- Various bug fixes
## 0.0.61
- Improving image editor when changing colors

View file

@ -1,5 +1,6 @@
// ignore_for_file: unused_import
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
@ -17,6 +18,7 @@ import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.service.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
@ -54,6 +56,8 @@ void main() async {
await initFileDownloader();
unawaited(MediaFileService.purgeTempFolder());
// await twonlyDB.messagesDao.resetPendingDownloadState();
// await twonlyDB.messageRetransmissionDao.purgeOldRetransmissions();
// await twonlyDB.signalDao.purgeOutDatedPreKeys();

View file

@ -131,8 +131,8 @@ String getContactDisplayName(Contact user) {
if (user.accountDeleted) {
name = applyStrikethrough(name);
}
if (name.length > 12) {
return '${name.substring(0, 12)}...';
if (name.length > 27) {
return '${name.substring(0, 27 - 3)}...';
}
return name;
}

View file

@ -113,7 +113,6 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
Stream<List<Group>> watchGroupsForChatList() {
return (select(groups)
..where((t) => t.archived.equals(false))
..orderBy([(t) => OrderingTerm.desc(t.lastMessageExchange)]))
.watch();
}

View file

@ -22,6 +22,7 @@ Future<void> handleReaction(
groupId,
reaction.emoji,
);
return;
await twonlyDB.groupsDao
.increaseLastMessageExchange(groupId, DateTime.now());
}
}

View file

@ -56,25 +56,6 @@ ClientToServer createClientToServerFromApplicationData(
return ClientToServer()..v0 = v0;
}
Future<void> rejectAndHideContact(int contactId) async {
await sendCipherText(
contactId,
EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REJECT,
),
),
);
await twonlyDB.contactsDao.updateContact(
contactId,
const ContactsCompanion(
accepted: Value(false),
requested: Value(false),
deletedByUser: Value(true),
),
);
}
Future<void> handleMediaError(MediaFile media) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,

View file

@ -32,6 +32,61 @@ class MediaFileService {
);
}
static Future<void> purgeTempFolder() async {
final tempDirectory = MediaFileService._buildDirectoryPath(
'tmp',
await getApplicationSupportDirectory(),
);
final files = tempDirectory.listSync();
for (final file in files) {
final mediaId = basename(file.path).split('.').first;
var delete = true;
final service = await MediaFileService.fromMediaId(mediaId);
if (service == null) {
Log.error(
'Purging media file, as it is not in the database $mediaId.',
);
} else {
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(mediaId);
for (final message in messages) {
if (message.senderId == null) {
// Media was send by me
if (message.openedAt == null) {
// Message was not yet opened from all persons, so wait...
delete = false;
} else if (service.mediaFile.requiresAuthentication ||
service.mediaFile.displayLimitInMilliseconds != null) {
// Message was opened by all persons, and they can not reopen the image.
// delete = true; // do not overwrite a previous delete = false
// this is just to make it easier to understand :)
} else if (message.openedAt!
.isAfter(DateTime.now().subtract(const Duration(days: 2)))) {
// Message was opened by all persons, as it can be reopened and then stored by a other person keep it for
// two day just to be sure.
delete = false;
}
} else {
// this media was received from another person
if (message.openedAt == null) {
// Message was not yet opened, so do not remove it.
delete = false;
}
}
}
}
if (delete) {
Log.info('Purging media file $mediaId');
file.deleteSync();
}
}
}
Future<void> updateFromDB() async {
final updated =
await twonlyDB.mediaFilesDao.getMediaFileById(mediaFile.mediaId);
@ -89,7 +144,8 @@ class MediaFileService {
}
switch (mediaFile.type) {
case MediaType.image:
await createThumbnailsForImage(storedPath, thumbnailPath);
// all images are already compress..
break;
case MediaType.video:
await createThumbnailsForVideo(storedPath, thumbnailPath);
case MediaType.gif:
@ -163,11 +219,10 @@ class MediaFileService {
await updateFromDB();
}
File _buildFilePath(
String directory, {
String namePrefix = '',
String extensionParam = '',
}) {
static Directory _buildDirectoryPath(
String directory,
Directory applicationSupportDirectory,
) {
final mediaBaseDir = Directory(
join(
applicationSupportDirectory.path,
@ -178,6 +233,14 @@ class MediaFileService {
if (!mediaBaseDir.existsSync()) {
mediaBaseDir.createSync(recursive: true);
}
return mediaBaseDir;
}
File _buildFilePath(
String directory, {
String namePrefix = '',
String extensionParam = '',
}) {
var extension = extensionParam;
if (extension == '') {
switch (mediaFile.type) {
@ -189,6 +252,8 @@ class MediaFileService {
extension = 'gif';
}
}
final mediaBaseDir =
_buildDirectoryPath(directory, applicationSupportDirectory);
return File(
join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'),
);

View file

@ -1,37 +1,7 @@
import 'dart:io';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
Future<void> createThumbnailsForImage(
File sourceFile,
File destinationFile,
) async {
final fileExtension = sourceFile.path.split('.').last.toLowerCase();
if (fileExtension != 'png') {
Log.error('Could not create thumbnail for image. $fileExtension != png');
return;
}
try {
final imageBytesCompressed = await FlutterImageCompress.compressWithFile(
minHeight: 800,
minWidth: 450,
sourceFile.path,
format: CompressFormat.webp,
quality: 50,
);
if (imageBytesCompressed == null) {
Log.error('Could not compress the image');
return;
}
await destinationFile.writeAsBytes(imageBytesCompressed);
} catch (e) {
Log.error('Could not compress the image got :$e');
}
}
Future<void> createThumbnailsForVideo(
File sourceFile,
File destinationFile,

View file

@ -8,7 +8,6 @@ import 'package:gal/gal.dart';
import 'package:intl/intl.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:local_auth/local_auth.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
@ -270,31 +269,6 @@ Uint8List hexToUint8List(String hex) => Uint8List.fromList(
),
);
PieTheme getPieCanvasTheme(BuildContext context) {
return PieTheme(
brightness: Theme.of(context).brightness,
rightClickShowsMenu: true,
radius: 70,
buttonTheme: PieButtonTheme(
backgroundColor: Theme.of(context).colorScheme.tertiary,
iconColor: Theme.of(context).colorScheme.surfaceBright,
),
buttonThemeHovered: PieButtonTheme(
backgroundColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.surfaceBright,
),
tooltipPadding: const EdgeInsets.all(20),
overlayColor: isDarkMode(context)
? const Color.fromARGB(69, 0, 0, 0)
: const Color.fromARGB(40, 0, 0, 0),
// spacing: 0,
tooltipTextStyle: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
),
);
}
Color getMessageColorFromType(
Message message,
MediaFile? mediaFile,

View file

@ -8,7 +8,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/misc.dart';
@ -223,7 +222,22 @@ class ContactsListView extends StatelessWidget {
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () async {
await rejectAndHideContact(contact.userId);
await sendCipherText(
contact.userId,
EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REJECT,
),
),
);
await twonlyDB.contactsDao.updateContact(
contact.userId,
const ContactsCompanion(
accepted: Value(false),
requested: Value(false),
deletedByUser: Value(true),
),
);
},
),
),

View file

@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart';
class ArchivedChatsView extends StatefulWidget {
const ArchivedChatsView({super.key});
@override
State<ArchivedChatsView> createState() => _ArchivedChatsViewState();
}
class _ArchivedChatsViewState extends State<ArchivedChatsView> {
List<Group> _groupsArchived = [];
late StreamSubscription<List<Group>> _contactsSub;
@override
void initState() {
initAsync();
super.initState();
}
Future<void> initAsync() async {
final stream = twonlyDB.groupsDao.watchGroupsForChatList();
_contactsSub = stream.listen((groups) {
setState(() {
_groupsArchived = groups.where((x) => x.archived).toList();
});
});
}
@override
void dispose() {
_contactsSub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Archivierte Chats"),
),
body: ListView(
children: _groupsArchived.map((group) {
return GroupListItem(
group: group,
);
}).toList(),
),
);
}
}

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/chats/add_new_user.view.dart';
import 'package:twonly/src/views/chats/archived_chats.view.dart';
import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart';
import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart';
import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart';
@ -33,8 +34,8 @@ class _ChatListViewState extends State<ChatListView> {
late StreamSubscription<List<Group>> _contactsSub;
List<Group> _groupsNotPinned = [];
List<Group> _groupsPinned = [];
List<Group> _groupsArchived = [];
GlobalKey firstUserListItemKey = GlobalKey();
GlobalKey searchForOtherUsers = GlobalKey();
Timer? tutorial;
bool showFeedbackShortcut = false;
@ -49,8 +50,10 @@ class _ChatListViewState extends State<ChatListView> {
final stream = twonlyDB.groupsDao.watchGroupsForChatList();
_contactsSub = stream.listen((groups) {
setState(() {
_groupsNotPinned = groups.where((x) => !x.pinned).toList();
_groupsPinned = groups.where((x) => x.pinned).toList();
_groupsNotPinned =
groups.where((x) => !x.pinned && !x.archived).toList();
_groupsPinned = groups.where((x) => x.pinned && !x.archived).toList();
_groupsArchived = groups.where((x) => x.archived).toList();
});
});
@ -59,9 +62,9 @@ class _ChatListViewState extends State<ChatListView> {
if (!mounted) return;
await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers);
if (!mounted) return;
if (_groupsNotPinned.isNotEmpty) {
await showChatListTutorialContextMenu(context, firstUserListItemKey);
}
// if (_groupsNotPinned.isNotEmpty) {
// await showChatListTutorialContextMenu(context, firstUserListItemKey);
// }
});
final changeLog = await rootBundle.loadString('CHANGELOG.md');
@ -102,193 +105,219 @@ class _ChatListViewState extends State<ChatListView> {
Widget build(BuildContext context) {
final isConnected = context.watch<CustomChangeProvider>().isConnected;
final planId = context.watch<CustomChangeProvider>().plan;
return Scaffold(
appBar: AppBar(
title: Row(
children: [
GestureDetector(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const ProfileView();
},
),
);
if (!mounted) return;
setState(() {}); // gUser has updated
},
child: AvatarIcon(
userData: gUser,
fontSize: 14,
color: context.color.onSurface.withAlpha(20),
),
),
const SizedBox(width: 10),
const Text('twonly '),
if (planId != 'Free')
return Container(
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
GestureDetector(
onTap: () {
Navigator.push(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const SubscriptionView();
return const ProfileView();
},
),
);
if (!mounted) return;
setState(() {}); // gUser has updated
},
child: Container(
decoration: BoxDecoration(
color: context.color.primary,
borderRadius: BorderRadius.circular(15),
),
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Text(
planId,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isDarkMode(context) ? Colors.black : Colors.white,
),
),
child: AvatarIcon(
userData: gUser,
fontSize: 14,
color: context.color.onSurface.withAlpha(20),
),
),
],
),
actions: [
const FeedbackIconButton(),
StreamBuilder(
stream: twonlyDB.contactsDao.watchContactsRequested(),
builder: (context, snapshot) {
var count = 0;
if (snapshot.hasData && snapshot.data != null) {
count = snapshot.data!;
}
return NotificationBadge(
count: count.toString(),
child: IconButton(
key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () {
const SizedBox(width: 10),
const Text('twonly '),
if (planId != 'Free')
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddNewUserView(),
builder: (context) {
return const SubscriptionView();
},
),
);
},
child: Container(
decoration: BoxDecoration(
color: context.color.primary,
borderRadius: BorderRadius.circular(15),
),
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Text(
planId,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color:
isDarkMode(context) ? Colors.black : Colors.white,
),
),
),
),
],
),
actions: [
const FeedbackIconButton(),
StreamBuilder(
stream: twonlyDB.contactsDao.watchContactsRequested(),
builder: (context, snapshot) {
var count = 0;
if (snapshot.hasData && snapshot.data != null) {
count = snapshot.data!;
}
return NotificationBadge(
count: count.toString(),
child: IconButton(
key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddNewUserView(),
),
);
},
),
);
},
),
IconButton(
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsMainView(),
),
);
if (!mounted) return;
setState(() {}); // gUser may has changed...
},
icon: const FaIcon(FontAwesomeIcons.gear, size: 19),
),
],
),
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: isConnected ? Container() : const ConnectionInfo(),
),
Positioned.fill(
child: Container(
child: RefreshIndicator(
onRefresh: () async {
await apiService.close(() {});
await apiService.connect(force: true);
await Future.delayed(const Duration(seconds: 1));
},
child: (_groupsNotPinned.isEmpty &&
_groupsPinned.isEmpty &&
_groupsArchived.isEmpty)
? Center(
child: Padding(
padding: const EdgeInsets.all(10),
child: OutlinedButton.icon(
icon: const Icon(Icons.person_add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const AddNewUserView(),
),
);
},
label: Text(
context.lang.chatListViewSearchUserNameBtn),
),
),
)
: ListView.builder(
itemCount: _groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0) +
_groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >=
_groupsNotPinned.length +
_groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0)) {
if (_groupsArchived.isEmpty) return Container();
return ListTile(
title: Text(
"Archivierte Chats (${_groupsArchived.length})",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ArchivedChatsView();
},
),
);
},
);
}
// Check if the index is for the pinned users
if (index < _groupsPinned.length) {
final group = _groupsPinned[index];
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
);
}
// If there are pinned users, account for the Divider
var adjustedIndex = index - _groupsPinned.length;
if (_groupsPinned.isNotEmpty &&
adjustedIndex == 0) {
return const Divider();
}
// Adjust the index for the contacts list
adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0);
// Get the contacts that are not pinned
final group = _groupsNotPinned.elementAt(
adjustedIndex,
);
return GroupListItem(
key: ValueKey(group.groupId), group: group);
},
),
),
),
),
],
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 30),
child: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const StartNewChatView();
},
),
);
},
child: const FaIcon(FontAwesomeIcons.penToSquare),
),
IconButton(
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsMainView(),
),
);
if (!mounted) return;
setState(() {}); // gUser may has changed...
},
icon: const FaIcon(FontAwesomeIcons.gear, size: 19),
),
],
),
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: isConnected ? Container() : const ConnectionInfo(),
),
Positioned.fill(
child: RefreshIndicator(
onRefresh: () async {
await apiService.close(() {});
await apiService.connect(force: true);
await Future.delayed(const Duration(seconds: 1));
},
child: (_groupsNotPinned.isEmpty && _groupsPinned.isEmpty)
? Center(
child: Padding(
padding: const EdgeInsets.all(10),
child: OutlinedButton.icon(
icon: const Icon(Icons.person_add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddNewUserView(),
),
);
},
label:
Text(context.lang.chatListViewSearchUserNameBtn),
),
),
)
: ListView.builder(
itemCount: _groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0) +
_groupsNotPinned.length,
itemBuilder: (context, index) {
// Check if the index is for the pinned users
if (index < _groupsPinned.length) {
final group = _groupsPinned[index];
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
firstUserListItemKey: (index == 0 || index == 1)
? firstUserListItemKey
: null,
);
}
// If there are pinned users, account for the Divider
var adjustedIndex = index - _groupsPinned.length;
if (_groupsPinned.isNotEmpty && adjustedIndex == 0) {
return const Divider();
}
// Adjust the index for the contacts list
adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0);
// Get the contacts that are not pinned
final group = _groupsNotPinned.elementAt(
adjustedIndex,
);
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
firstUserListItemKey:
(index == 0) ? firstUserListItemKey : null,
);
},
),
),
),
],
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 30),
child: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const StartNewChatView();
},
),
);
},
child: const FaIcon(FontAwesomeIcons.penToSquare),
),
),
);

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:mutex/mutex.dart';
@ -20,32 +21,29 @@ import 'package:twonly/src/views/components/group_context_menu.component.dart';
class GroupListItem extends StatefulWidget {
const GroupListItem({
required this.group,
required this.firstUserListItemKey,
super.key,
});
final Group group;
final GlobalKey? firstUserListItemKey;
@override
State<GroupListItem> createState() => _UserListItem();
}
class _UserListItem extends State<GroupListItem> {
MessageSendState state = MessageSendState.send;
Message? currentMessage;
Message? _currentMessage;
List<Message> messagesNotOpened = [];
late StreamSubscription<List<Message>> messagesNotOpenedStream;
List<Message> _messagesNotOpened = [];
late StreamSubscription<List<Message>> _messagesNotOpenedStream;
Message? lastMessage;
Reaction? lastReaction;
late StreamSubscription<Message?> lastMessageStream;
late StreamSubscription<Reaction?> lastReactionStream;
late StreamSubscription<List<MediaFile>> lastMediaFilesStream;
Message? _lastMessage;
Reaction? _lastReaction;
late StreamSubscription<Message?> _lastMessageStream;
late StreamSubscription<Reaction?> _lastReactionStream;
late StreamSubscription<List<MediaFile>> _lastMediaFilesStream;
List<Message> previewMessages = [];
List<MediaFile> previewMediaFiles = [];
bool hasNonOpenedMediaFile = false;
List<Message> _previewMessages = [];
final List<MediaFile> _previewMediaFiles = [];
bool _hasNonOpenedMediaFile = false;
@override
void initState() {
@ -55,48 +53,48 @@ class _UserListItem extends State<GroupListItem> {
@override
void dispose() {
messagesNotOpenedStream.cancel();
lastReactionStream.cancel();
lastMessageStream.cancel();
lastMediaFilesStream.cancel();
_messagesNotOpenedStream.cancel();
_lastReactionStream.cancel();
_lastMessageStream.cancel();
_lastMediaFilesStream.cancel();
super.dispose();
}
void initStreams() {
lastMessageStream = twonlyDB.messagesDao
_lastMessageStream = twonlyDB.messagesDao
.watchLastMessage(widget.group.groupId)
.listen((update) {
protectUpdateState.protect(() async {
await updateState(update, messagesNotOpened);
await updateState(update, _messagesNotOpened);
});
});
lastReactionStream = twonlyDB.reactionsDao
_lastReactionStream = twonlyDB.reactionsDao
.watchLastReactions(widget.group.groupId)
.listen((update) {
setState(() {
lastReaction = update;
_lastReaction = update;
});
// protectUpdateState.protect(() async {
// await updateState(lastMessage, update, messagesNotOpened);
// });
});
messagesNotOpenedStream = twonlyDB.messagesDao
_messagesNotOpenedStream = twonlyDB.messagesDao
.watchMessageNotOpened(widget.group.groupId)
.listen((update) {
protectUpdateState.protect(() async {
await updateState(lastMessage, update);
await updateState(_lastMessage, update);
});
});
lastMediaFilesStream =
_lastMediaFilesStream =
twonlyDB.mediaFilesDao.watchNewestMediaFiles().listen((mediaFiles) {
for (final mediaFile in mediaFiles) {
final index =
previewMediaFiles.indexWhere((t) => t.mediaId == mediaFile.mediaId);
final index = _previewMediaFiles
.indexWhere((t) => t.mediaId == mediaFile.mediaId);
if (index >= 0) {
previewMediaFiles[index] = mediaFile;
_previewMediaFiles[index] = mediaFile;
}
}
setState(() {});
@ -111,55 +109,55 @@ class _UserListItem extends State<GroupListItem> {
) async {
if (newLastMessage == null) {
// there are no messages at all
currentMessage = null;
previewMessages = [];
_currentMessage = null;
_previewMessages = [];
} else if (newMessagesNotOpened.isNotEmpty) {
// Filter for the preview non opened messages. First messages which where send but not yet opened by the other side.
final receivedMessages =
newMessagesNotOpened.where((x) => x.senderId != null).toList();
if (receivedMessages.isNotEmpty) {
previewMessages = receivedMessages;
currentMessage = receivedMessages.first;
_previewMessages = receivedMessages;
_currentMessage = receivedMessages.first;
} else {
previewMessages = newMessagesNotOpened;
currentMessage = newMessagesNotOpened.first;
_previewMessages = newMessagesNotOpened;
_currentMessage = newMessagesNotOpened.first;
}
} else {
// there are no not opened messages show just the last message in the table
currentMessage = newLastMessage;
previewMessages = [newLastMessage];
_currentMessage = newLastMessage;
_previewMessages = [newLastMessage];
}
final msgs =
previewMessages.where((x) => x.type == MessageType.media).toList();
_previewMessages.where((x) => x.type == MessageType.media).toList();
if (msgs.isNotEmpty &&
msgs.first.type == MessageType.media &&
msgs.first.senderId != null &&
msgs.first.openedAt == null) {
hasNonOpenedMediaFile = true;
_hasNonOpenedMediaFile = true;
} else {
hasNonOpenedMediaFile = false;
_hasNonOpenedMediaFile = false;
}
for (final message in previewMessages) {
for (final message in _previewMessages) {
if (message.mediaId != null &&
!previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
!_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
if (mediaFile != null) {
previewMediaFiles.add(mediaFile);
_previewMediaFiles.add(mediaFile);
}
}
}
lastMessage = newLastMessage;
messagesNotOpened = newMessagesNotOpened;
_lastMessage = newLastMessage;
_messagesNotOpened = newMessagesNotOpened;
if (mounted) setState(() {});
}
Future<void> onTap() async {
if (currentMessage == null) {
if (_currentMessage == null) {
await Navigator.push(
context,
MaterialPageRoute(
@ -171,9 +169,9 @@ class _UserListItem extends State<GroupListItem> {
return;
}
if (hasNonOpenedMediaFile) {
if (_hasNonOpenedMediaFile) {
final msgs =
previewMessages.where((x) => x.type == MessageType.media).toList();
_previewMessages.where((x) => x.type == MessageType.media).toList();
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(msgs.first.mediaId!);
if (mediaFile?.downloadState == null) return;
@ -207,70 +205,56 @@ class _UserListItem extends State<GroupListItem> {
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
top: 0,
bottom: 0,
left: 50,
child: SizedBox(
key: widget.firstUserListItemKey,
height: 20,
width: 20,
),
return GroupContextMenu(
group: widget.group,
child: ListTile(
title: Text(
widget.group.groupName,
),
GroupContextMenu(
group: widget.group,
child: ListTile(
title: Text(
widget.group.groupName,
),
subtitle: (currentMessage == null)
? Text(context.lang.chatsTapToSend)
: Row(
children: [
MessageSendStateIcon(
previewMessages,
previewMediaFiles,
lastReaction: lastReaction,
),
const Text(''),
const SizedBox(width: 5),
if (currentMessage != null)
LastMessageTime(message: currentMessage!),
FlameCounterWidget(
groupId: widget.group.groupId,
prefix: true,
),
],
subtitle: (_currentMessage == null)
? Text(context.lang.chatsTapToSend)
: Row(
children: [
MessageSendStateIcon(
_previewMessages,
_previewMediaFiles,
lastReaction: _lastReaction,
),
leading: AvatarIcon(group: widget.group),
trailing: IconButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
if (hasNonOpenedMediaFile) {
return ChatMessagesView(widget.group);
} else {
return CameraSendToView(widget.group);
}
},
const Text(''),
const SizedBox(width: 5),
if (_currentMessage != null)
LastMessageTime(message: _currentMessage!),
FlameCounterWidget(
groupId: widget.group.groupId,
prefix: true,
),
);
},
icon: FaIcon(
hasNonOpenedMediaFile
? FontAwesomeIcons.solidComments
: FontAwesomeIcons.camera,
color: context.color.outline.withAlpha(150),
],
),
),
onTap: onTap,
leading: AvatarIcon(group: widget.group),
trailing: IconButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
if (_hasNonOpenedMediaFile) {
return ChatMessagesView(widget.group);
} else {
return CameraSendToView(widget.group);
}
},
),
);
},
icon: FaIcon(
_hasNonOpenedMediaFile
? FontAwesomeIcons.solidComments
: FontAwesomeIcons.camera,
color: context.color.outline.withAlpha(150),
),
),
],
onTap: onTap,
),
);
}
}

View file

@ -3,7 +3,6 @@ import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:mutex/mutex.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
@ -255,96 +254,64 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
),
),
),
body: PieCanvas(
theme: getPieCanvasTheme(context),
child: SafeArea(
child: Column(
children: [
Expanded(
child: ScrollablePositionedList.builder(
reverse: true,
itemCount: messages.length + 1,
itemScrollController: itemScrollController,
itemBuilder: (context, i) {
if (i == messages.length) {
return const Padding(
padding: EdgeInsetsGeometry.only(top: 10),
);
}
if (messages[i].isDate) {
return ChatDateChip(
item: messages[i],
);
} else {
final chatMessage = messages[i].message!;
return Transform.translate(
offset: Offset(
(focusedScrollItem == i)
? (chatMessage.senderId == null)
? -8
: 8
: 0,
0,
),
child: Transform.scale(
scale: (focusedScrollItem == i) ? 1.05 : 1,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: messages[i].message!,
nextMessage:
(i > 0) ? messages[i - 1].message : null,
prevMessage: ((i + 1) < messages.length)
? messages[i + 1].message
: null,
group: group,
galleryItems: galleryItems,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus.requestFocus();
},
),
),
);
}
},
),
),
if (quotesMessage != null)
Container(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 10,
),
child: Row(
children: [
Expanded(
child: ResponsePreview(
message: quotesMessage,
showBorder: true,
body: SafeArea(
child: Column(
children: [
Expanded(
child: ScrollablePositionedList.builder(
reverse: true,
itemCount: messages.length + 1,
itemScrollController: itemScrollController,
itemBuilder: (context, i) {
if (i == messages.length) {
return const Padding(
padding: EdgeInsetsGeometry.only(top: 10),
);
}
if (messages[i].isDate) {
return ChatDateChip(
item: messages[i],
);
} else {
final chatMessage = messages[i].message!;
return Transform.translate(
offset: Offset(
(focusedScrollItem == i)
? (chatMessage.senderId == null)
? -8
: 8
: 0,
0,
),
child: Transform.scale(
scale: (focusedScrollItem == i) ? 1.05 : 1,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: messages[i].message!,
nextMessage:
(i > 0) ? messages[i - 1].message : null,
prevMessage: ((i + 1) < messages.length)
? messages[i + 1].message
: null,
group: group,
galleryItems: galleryItems,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus.requestFocus();
},
),
),
IconButton(
onPressed: () {
setState(() {
quotesMessage = null;
});
},
icon: const FaIcon(
FontAwesomeIcons.xmark,
size: 16,
),
),
],
),
),
Padding(
);
}
},
),
),
if (quotesMessage != null)
Container(
padding: const EdgeInsets.only(
bottom: 30,
left: 20,
right: 20,
top: 10,
@ -352,50 +319,79 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
child: Row(
children: [
Expanded(
child: TextField(
controller: newMessageController,
focusNode: textFieldFocus,
keyboardType: TextInputType.multiline,
maxLines: 4,
minLines: 1,
onChanged: (value) {
currentInputText = value;
setState(() {});
},
onSubmitted: (_) {
_sendMessage();
},
decoration: inputTextMessageDeco(context),
child: ResponsePreview(
message: quotesMessage,
showBorder: true,
group: group,
),
),
if (currentInputText != '')
IconButton(
padding: const EdgeInsets.all(15),
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
),
onPressed: _sendMessage,
)
else
IconButton(
icon: const FaIcon(FontAwesomeIcons.camera),
padding: const EdgeInsets.all(15),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return CameraSendToView(widget.group);
},
),
);
},
IconButton(
onPressed: () {
setState(() {
quotesMessage = null;
});
},
icon: const FaIcon(
FontAwesomeIcons.xmark,
size: 16,
),
),
],
),
),
],
),
Padding(
padding: const EdgeInsets.only(
bottom: 30,
left: 20,
right: 20,
top: 10,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: newMessageController,
focusNode: textFieldFocus,
keyboardType: TextInputType.multiline,
maxLines: 4,
minLines: 1,
onChanged: (value) {
currentInputText = value;
setState(() {});
},
onSubmitted: (_) {
_sendMessage();
},
decoration: inputTextMessageDeco(context),
),
),
if (currentInputText != '')
IconButton(
padding: const EdgeInsets.all(15),
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
),
onPressed: _sendMessage,
)
else
IconButton(
icon: const FaIcon(FontAwesomeIcons.camera),
padding: const EdgeInsets.all(15),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return CameraSendToView(widget.group);
},
),
);
},
),
],
),
),
],
),
),
),

View file

@ -162,7 +162,10 @@ class _ChatListEntryState extends State<ChatListEntry> {
message: widget.message,
group: widget.group,
onResponseTriggered: widget.onResponseTriggered!,
child: child,
galleryItems: widget.galleryItems,
child: Container(
child: child,
),
);
}

View file

@ -4,10 +4,10 @@ import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
as pb;
import 'package:twonly/src/services/api/messages.dart';
@ -16,6 +16,7 @@ import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/chats/message_info.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/context_menu.component.dart';
class MessageContextMenu extends StatelessWidget {
const MessageContextMenu({
@ -23,27 +24,23 @@ class MessageContextMenu extends StatelessWidget {
required this.group,
required this.child,
required this.onResponseTriggered,
required this.galleryItems,
super.key,
});
final Group group;
final Widget child;
final Message message;
final List<MemoryItem> galleryItems;
final VoidCallback onResponseTriggered;
@override
Widget build(BuildContext context) {
return PieMenu(
onPressed: () => (),
onToggle: (menuOpen) async {
if (menuOpen) {
await HapticFeedback.heavyImpact();
}
},
actions: [
return ContextMenu(
items: [
if (!message.isDeletedFromSender)
PieAction(
tooltip: Text(context.lang.react),
onSelect: () async {
ContextMenuItem(
title: context.lang.react,
onTap: () async {
final layer = await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
@ -68,36 +65,38 @@ class MessageContextMenu extends StatelessWidget {
null,
);
},
child: const FaIcon(FontAwesomeIcons.faceLaugh),
icon: FontAwesomeIcons.faceLaugh,
),
if (!message.isDeletedFromSender)
PieAction(
tooltip: Text(context.lang.reply),
onSelect: onResponseTriggered,
child: const FaIcon(FontAwesomeIcons.reply),
ContextMenuItem(
title: context.lang.reply,
onTap: () async {
onResponseTriggered();
},
icon: FontAwesomeIcons.reply,
),
if (!message.isDeletedFromSender &&
message.senderId == null &&
message.type == MessageType.text)
PieAction(
tooltip: Text(context.lang.edit),
onSelect: () async {
ContextMenuItem(
title: context.lang.edit,
onTap: () async {
await editTextMessage(context, message);
},
child: const FaIcon(FontAwesomeIcons.pencil),
icon: FontAwesomeIcons.pencil,
),
if (message.content != null)
PieAction(
tooltip: Text(context.lang.copy),
onSelect: () async {
ContextMenuItem(
title: context.lang.copy,
onTap: () async {
await Clipboard.setData(ClipboardData(text: message.content!));
await HapticFeedback.heavyImpact();
},
child: const FaIcon(FontAwesomeIcons.solidCopy),
icon: FontAwesomeIcons.solidCopy,
),
PieAction(
tooltip: Text(context.lang.delete),
onSelect: () async {
ContextMenuItem(
title: context.lang.delete,
onTap: () async {
final delete = await showAlertDialog(
context,
context.lang.deleteTitle,
@ -130,12 +129,12 @@ class MessageContextMenu extends StatelessWidget {
}
}
},
child: const FaIcon(FontAwesomeIcons.trash),
icon: FontAwesomeIcons.trash,
),
if (!message.isDeletedFromSender)
PieAction(
tooltip: Text(context.lang.info),
onSelect: () async {
ContextMenuItem(
title: context.lang.info,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
@ -143,12 +142,13 @@ class MessageContextMenu extends StatelessWidget {
return MessageInfoView(
message: message,
group: group,
galleryItems: galleryItems,
);
},
),
);
},
child: const FaIcon(FontAwesomeIcons.circleInfo),
icon: FontAwesomeIcons.circleInfo,
),
],
child: child,

View file

@ -6,6 +6,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages_components/bottom_sheets/message_history.bottom_sheet.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart';
@ -16,11 +17,13 @@ class MessageInfoView extends StatefulWidget {
const MessageInfoView({
required this.message,
required this.group,
required this.galleryItems,
super.key,
});
final Message message;
final Group group;
final List<MemoryItem> galleryItems;
@override
State<MessageInfoView> createState() => _MessageInfoViewState();
@ -164,9 +167,24 @@ class _MessageInfoViewState extends State<MessageInfoView> {
child: ListView(
children: [
const SizedBox(height: 20),
ChatListEntry(
group: widget.group,
message: widget.message,
Stack(
children: [
ChatListEntry(
group: widget.group,
message: widget.message,
galleryItems: widget.galleryItems,
),
Positioned.fill(
child: GestureDetector(
onTap: () {
// In case in ChatListEntry is a image, this prevents to open the image preview.
},
child: Container(
color: Colors.transparent,
),
),
),
],
),
Text(
'${context.lang.sent}: ${friendlyDateTime(context, widget.message.createdAt)}',

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
@ -74,34 +73,31 @@ class _StartNewChatView extends State<StartNewChatView> {
title: Text(context.lang.startNewChatTitle),
),
body: SafeArea(
child: PieCanvas(
theme: getPieCanvasTheme(context),
child: Padding(
padding:
const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField(
onChanged: (_) async {
await filterUsers();
},
controller: searchUserName,
decoration: getInputDecoration(
context,
context.lang.shareImageSearchAllContacts,
),
child: Padding(
padding:
const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField(
onChanged: (_) async {
await filterUsers();
},
controller: searchUserName,
decoration: getInputDecoration(
context,
context.lang.shareImageSearchAllContacts,
),
),
const SizedBox(height: 10),
Expanded(
child: UserList(
contacts,
),
),
const SizedBox(height: 10),
Expanded(
child: UserList(
contacts,
),
],
),
),
],
),
),
),

View file

@ -26,7 +26,7 @@ class AvatarIcon extends StatefulWidget {
}
class _AvatarIconState extends State<AvatarIcon> {
List<String> avatarSVGs = [];
final List<String> _avatarSVGs = [];
@override
void initState() {
@ -40,20 +40,20 @@ class _AvatarIconState extends State<AvatarIcon> {
await twonlyDB.groupsDao.getGroupContact(widget.group!.groupId);
if (contacts.length == 1) {
if (contacts.first.avatarSvgCompressed != null) {
avatarSVGs.add(getAvatarSvg(contacts.first.avatarSvgCompressed!));
_avatarSVGs.add(getAvatarSvg(contacts.first.avatarSvgCompressed!));
}
} else {
for (final contact in contacts) {
if (contact.avatarSvgCompressed != null) {
avatarSVGs.add(getAvatarSvg(contact.avatarSvgCompressed!));
_avatarSVGs.add(getAvatarSvg(contact.avatarSvgCompressed!));
}
}
}
// avatarSvg = group!.avatarSvg;
} else if (widget.userData?.avatarSvg != null) {
avatarSVGs.add(widget.userData!.avatarSvg!);
_avatarSVGs.add(widget.userData!.avatarSvg!);
} else if (widget.contact?.avatarSvgCompressed != null) {
avatarSVGs.add(getAvatarSvg(widget.contact!.avatarSvgCompressed!));
_avatarSVGs.add(getAvatarSvg(widget.contact!.avatarSvgCompressed!));
}
if (mounted) setState(() {});
}
@ -77,10 +77,10 @@ class _AvatarIconState extends State<AvatarIcon> {
width: proSize,
color: widget.color,
child: Center(
child: avatarSVGs.isEmpty
child: _avatarSVGs.isEmpty
? SvgPicture.asset('assets/images/default_avatar.svg')
: SvgPicture.string(
avatarSVGs.first,
_avatarSVGs.first,
errorBuilder: (context, error, stackTrace) {
Log.error('$error');
return Container();

View file

@ -0,0 +1,99 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class ContextMenu extends StatefulWidget {
const ContextMenu({
required this.child,
required this.items,
super.key,
});
final List<ContextMenuItem> items;
final Widget child;
@override
State<ContextMenu> createState() => _ContextMenuState();
}
class _ContextMenuState extends State<ContextMenu> {
Offset? _tapPosition;
Widget _getIcon(IconData icon) {
return Padding(
padding: const EdgeInsets.only(left: 12),
child: FaIcon(
icon,
size: 20,
),
);
}
Future<void> _showCustomMenu() async {
if (_tapPosition == null) {
return;
}
final overlay = Overlay.of(context).context.findRenderObject();
if (overlay == null) {
return;
}
unawaited(HapticFeedback.heavyImpact());
await showMenu(
context: context,
menuPadding: EdgeInsetsGeometry.zero,
elevation: 1,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // corner radius
),
popUpAnimationStyle: const AnimationStyle(
duration: Duration.zero,
curve: Curves.fastOutSlowIn,
),
items: <PopupMenuEntry<int>>[
...widget.items.map(
(item) => PopupMenuItem(
padding: EdgeInsets.zero,
child: ListTile(
title: Text(item.title),
onTap: () async {
if (mounted) Navigator.pop(context);
await item.onTap();
},
leading: _getIcon(item.icon),
),
),
)
],
position: RelativeRect.fromRect(
_tapPosition! & const Size(40, 40),
Offset.zero & overlay.semanticBounds.size,
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPress: _showCustomMenu,
onTapDown: (TapDownDetails details) {
_tapPosition = details.globalPosition;
},
child: widget.child,
);
}
}
class ContextMenuItem {
ContextMenuItem({
required this.title,
required this.onTap,
required this.icon,
});
final String title;
final Future<void> Function() onTap;
final IconData icon;
}

View file

@ -1,14 +1,13 @@
import 'package:drift/drift.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/components/context_menu.component.dart';
class GroupContextMenu extends StatefulWidget {
class GroupContextMenu extends StatelessWidget {
const GroupContextMenu({
required this.group,
required this.child,
@ -17,80 +16,63 @@ class GroupContextMenu extends StatefulWidget {
final Widget child;
final Group group;
@override
State<GroupContextMenu> createState() => _GroupContextMenuState();
}
class _GroupContextMenuState extends State<GroupContextMenu> {
@override
Widget build(BuildContext context) {
return PieMenu(
onPressed: () => (),
onToggle: (menuOpen) async {
if (menuOpen) {
await HapticFeedback.heavyImpact();
}
},
actions: [
if (!widget.group.archived)
PieAction(
tooltip: Text(context.lang.contextMenuArchiveUser),
onSelect: () async {
return ContextMenu(
items: [
if (!group.archived)
ContextMenuItem(
title: context.lang.contextMenuArchiveUser,
onTap: () async {
const update = GroupsCompanion(archived: Value(true));
if (context.mounted) {
await twonlyDB.groupsDao
.updateGroup(widget.group.groupId, update);
await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
},
child: const FaIcon(FontAwesomeIcons.boxArchive),
icon: FontAwesomeIcons.boxArchive,
),
if (widget.group.archived)
PieAction(
tooltip: Text(context.lang.contextMenuUndoArchiveUser),
onSelect: () async {
if (group.archived)
ContextMenuItem(
title: context.lang.contextMenuUndoArchiveUser,
onTap: () async {
const update = GroupsCompanion(archived: Value(false));
if (context.mounted) {
await twonlyDB.groupsDao
.updateGroup(widget.group.groupId, update);
await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
},
child: const FaIcon(FontAwesomeIcons.boxOpen),
icon: FontAwesomeIcons.boxOpen,
),
PieAction(
tooltip: Text(context.lang.contextMenuOpenChat),
onSelect: () async {
ContextMenuItem(
title: context.lang.contextMenuOpenChat,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ChatMessagesView(widget.group);
return ChatMessagesView(group);
},
),
);
},
child: const FaIcon(FontAwesomeIcons.solidComments),
icon: FontAwesomeIcons.comments,
),
PieAction(
tooltip: Text(
widget.group.pinned
if (!group.archived)
ContextMenuItem(
title: group.pinned
? context.lang.contextMenuUnpin
: context.lang.contextMenuPin,
),
onSelect: () async {
final update = GroupsCompanion(pinned: Value(!widget.group.pinned));
if (context.mounted) {
await twonlyDB.groupsDao
.updateGroup(widget.group.groupId, update);
}
},
child: FaIcon(
widget.group.pinned
onTap: () async {
final update = GroupsCompanion(pinned: Value(!group.pinned));
if (context.mounted) {
await twonlyDB.groupsDao.updateGroup(group.groupId, update);
}
},
icon: group.pinned
? FontAwesomeIcons.thumbtackSlash
: FontAwesomeIcons.thumbtack,
),
),
],
child: widget.child,
child: child,
);
}
}

View file

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/context_menu.component.dart';
import 'package:twonly/src/views/contact/contact.view.dart';
class UserContextMenu extends StatefulWidget {
class UserContextMenu extends StatelessWidget {
const UserContextMenu({
required this.contact,
required this.child,
@ -14,32 +14,26 @@ class UserContextMenu extends StatefulWidget {
final Widget child;
final Contact contact;
@override
State<UserContextMenu> createState() => _UserContextMenuBlocked();
}
class _UserContextMenuBlocked extends State<UserContextMenu> {
@override
Widget build(BuildContext context) {
return PieMenu(
onPressed: () => (),
actions: [
PieAction(
tooltip: Text(context.lang.contextMenuUserProfile),
onSelect: () async {
return ContextMenu(
items: [
ContextMenuItem(
title: context.lang.contextMenuUserProfile,
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ContactView(widget.contact.userId);
return ContactView(contact.userId);
},
),
);
},
child: const FaIcon(FontAwesomeIcons.user),
icon: FontAwesomeIcons.user,
),
],
child: widget.child,
child: child,
);
}
}

View file

@ -4,7 +4,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
@ -29,8 +28,14 @@ class _ContactViewState extends State<ContactView> {
context.lang.contactRemoveBody,
);
if (remove) {
// trigger deletion for the other user...
await rejectAndHideContact(contact.userId);
await twonlyDB.contactsDao.updateContact(
contact.userId,
const ContactsCompanion(
accepted: Value(false),
requested: Value(false),
deletedByUser: Value(true),
),
);
if (mounted) {
Navigator.popUntil(context, (route) => route.isFirst);
}
@ -192,13 +197,13 @@ class _ContactViewState extends State<ContactView> {
text: context.lang.contactBlock,
onTap: () => handleUserBlockRequest(contact),
),
BetterListTile(
icon: FontAwesomeIcons.userMinus,
iconSize: 16,
color: Colors.red,
text: context.lang.contactRemove,
onTap: () => handleUserRemoveRequest(contact),
),
// BetterListTile(
// icon: FontAwesomeIcons.userMinus,
// iconSize: 16,
// color: Colors.red,
// text: context.lang.contactRemove,
// onTap: () => handleUserRemoveRequest(contact),
// ),
],
);
},

View file

@ -3,7 +3,6 @@ import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:screenshot/screenshot.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
@ -155,93 +154,90 @@ class HomeViewState extends State<HomeView> {
@override
Widget build(BuildContext context) {
return PieCanvas(
theme: getPieCanvasTheme(context),
child: Scaffold(
body: GestureDetector(
onDoubleTap: offsetRatio == 0 ? toggleSelectedCamera : null,
child: Stack(
children: <Widget>[
HomeViewCameraPreview(
controller: cameraController,
screenshotController: screenshotController,
),
Shade(
opacity: offsetRatio,
),
NotificationListener<ScrollNotification>(
onNotification: onPageView,
child: Positioned.fill(
child: PageView(
controller: homeViewPageController,
onPageChanged: (index) {
setState(() {
activePageIdx = index;
});
},
children: [
const ChatListView(),
Container(),
const MemoriesView(),
],
),
return Scaffold(
body: GestureDetector(
onDoubleTap: offsetRatio == 0 ? toggleSelectedCamera : null,
child: Stack(
children: <Widget>[
HomeViewCameraPreview(
controller: cameraController,
screenshotController: screenshotController,
),
Shade(
opacity: offsetRatio,
),
NotificationListener<ScrollNotification>(
onNotification: onPageView,
child: Positioned.fill(
child: PageView(
controller: homeViewPageController,
onPageChanged: (index) {
setState(() {
activePageIdx = index;
});
},
children: [
const ChatListView(),
Container(),
const MemoriesView(),
],
),
),
Positioned(
left: 0,
top: 0,
right: 0,
bottom: (offsetRatio > 0.25)
? MediaQuery.sizeOf(context).height * 2
: 0,
child: Opacity(
opacity: 1 - (offsetRatio * 4) % 1,
child: CameraPreviewControllerView(
cameraController: cameraController,
screenshotController: screenshotController,
selectedCameraDetails: selectedCameraDetails,
selectCamera: selectCamera,
),
),
Positioned(
left: 0,
top: 0,
right: 0,
bottom: (offsetRatio > 0.25)
? MediaQuery.sizeOf(context).height * 2
: 0,
child: Opacity(
opacity: 1 - (offsetRatio * 4) % 1,
child: CameraPreviewControllerView(
cameraController: cameraController,
screenshotController: screenshotController,
selectedCameraDetails: selectedCameraDetails,
selectCamera: selectCamera,
),
),
],
),
),
bottomNavigationBar: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
unselectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface.withAlpha(150),
),
selectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface,
),
items: const [
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.solidComments),
label: '',
),
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.camera),
label: '',
),
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.photoFilm),
label: '',
),
],
onTap: (int index) async {
activePageIdx = index;
await homeViewPageController.animateToPage(
index,
duration: const Duration(milliseconds: 100),
curve: Curves.bounceIn,
);
if (mounted) setState(() {});
},
currentIndex: activePageIdx,
),
),
bottomNavigationBar: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
unselectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface.withAlpha(150),
),
selectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface,
),
items: const [
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.solidComments),
label: '',
),
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.camera),
label: '',
),
BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.photoFilm),
label: '',
),
],
onTap: (int index) async {
activePageIdx = index;
await homeViewPageController.animateToPage(
index,
duration: const Duration(milliseconds: 100),
curve: Curves.bounceIn,
);
if (mounted) setState(() {});
},
currentIndex: activePageIdx,
),
);
}
}

View file

@ -1,6 +1,5 @@
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
@ -32,53 +31,50 @@ class _PrivacyViewBlockUsers extends State<PrivacyViewBlockUsers> {
appBar: AppBar(
title: Text(context.lang.settingsPrivacyBlockUsers),
),
body: PieCanvas(
theme: getPieCanvasTheme(context),
child: Padding(
padding:
const EdgeInsets.only(bottom: 20, left: 10, top: 20, right: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField(
onChanged: (value) => setState(() {
filter = value;
}),
decoration: getInputDecoration(
context,
context.lang.searchUsernameInput,
),
body: Padding(
padding:
const EdgeInsets.only(bottom: 20, left: 10, top: 20, right: 10),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField(
onChanged: (value) => setState(() {
filter = value;
}),
decoration: getInputDecoration(
context,
context.lang.searchUsernameInput,
),
),
const SizedBox(height: 20),
Text(
context.lang.settingsPrivacyBlockUsersDesc,
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
Expanded(
child: StreamBuilder(
stream: allUsers,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
),
const SizedBox(height: 20),
Text(
context.lang.settingsPrivacyBlockUsersDesc,
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
Expanded(
child: StreamBuilder(
stream: allUsers,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
final filteredContacts = snapshot.data!.where((contact) {
return getContactDisplayName(contact)
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
final filteredContacts = snapshot.data!.where((contact) {
return getContactDisplayName(contact)
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
return UserList(
List.from(filteredContacts),
);
},
),
return UserList(
List.from(filteredContacts),
);
},
),
],
),
),
],
),
),
);

View file

@ -1278,15 +1278,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.0"
pie_menu:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: e1ae0b2dabdfa9ad204b2cf93c48a5962e243c6c
url: "https://github.com/otsmr/flutter-pie-menu.git"
source: git
version: "3.3.2"
platform:
dependency: transitive
description:
@ -1816,10 +1807,10 @@ packages:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b"
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
version: "6.6.0"
video_player_web:
dependency: transitive
description:

View file

@ -59,9 +59,6 @@ dependencies:
path_provider: ^2.1.5
permission_handler: ^12.0.0+1
photo_view: ^0.15.0
pie_menu:
git:
url: https://github.com/otsmr/flutter-pie-menu.git
protobuf: ^4.0.0
provider: ^6.1.2
restart_app: ^1.3.2