Merge pull request #416 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled

- Improves: Smaller UI changes
- Fix: Some messages were not marked as opened.
This commit is contained in:
Tobi 2026-05-28 02:32:47 +02:00 committed by GitHub
commit 0602f043d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 398 additions and 129 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## 0.2.23
- Improves: Smaller UI changes
- Fix: Some messages were not marked as opened.
## 0.2.20
- New: Adds an "Ask a Friend" button to new contact suggestions.

View file

@ -102,7 +102,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
t.openedAt.isNull() |
t.mediaStored.equals(true)) &
(t.isDeletedFromSender.equals(true) |
(t.type.equals(MessageType.text.name).not() |
(t.type.equals(MessageType.text.name).not() &
t.type.equals(MessageType.media.name).not()) |
(t.type.equals(MessageType.text.name) &
t.content.isNotNull()) |
@ -156,8 +156,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
await (delete(messages)..where(
(m) =>
m.groupId.isIn(groupIds) &
(m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) |
((m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true)) |
m.mediaStored.equals(false)) &
// Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later..
(m.openedByAll.isSmallerThanValue(deletionTime) |
@ -253,41 +253,46 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
List<String> messageIds,
DateTime timestamp,
) async {
try {
await twonlyDB.batch((batch) async {
for (final messageId in messageIds) {
batch.insert(
messageActions,
MessageActionsCompanion(
messageId: Value(messageId),
contactId: contactId,
type: const Value(MessageActionType.openedAt),
actionAt: Value(timestamp),
),
mode: InsertMode.insertOrReplace,
);
}
});
} catch (e) {
Log.error(e);
}
for (final messageId in messageIds) {
try {
var actionTimestamp = timestamp;
final msg = await getMessageById(messageId).getSingleOrNull();
if (msg != null && actionTimestamp.isBefore(msg.createdAt)) {
Log.warn(
'Receiver clock skew detected for message $messageId. '
'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. '
'Clamping to creation time.',
);
actionTimestamp = msg.createdAt;
}
await into(messageActions).insertOnConflictUpdate(
MessageActionsCompanion(
messageId: Value(messageId),
contactId: contactId,
type: const Value(MessageActionType.openedAt),
actionAt: Value(actionTimestamp),
),
);
final isOpenedByAll = await haveAllMembers(
messageId,
MessageActionType.openedAt,
);
await (update(
messages,
)..where((tbl) => tbl.messageId.equals(messageId))).write(
MessagesCompanion(
openedAt: Value(timestamp),
openedByAll: Value(isOpenedByAll ? timestamp : null),
),
final rowsUpdated =
await (update(
messages,
)..where((tbl) => tbl.messageId.equals(messageId))).write(
MessagesCompanion(
openedAt: Value(actionTimestamp),
openedByAll: Value(isOpenedByAll ? actionTimestamp : null),
),
);
Log.info(
'handleMessagesOpened updated $rowsUpdated rows for message $messageId',
);
} catch (e) {
Log.error(e);
Log.error('handleMessagesOpened failed for $messageId: $e');
}
}
}

View file

@ -29,7 +29,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
final msg = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (msg == null || msg.groupId != groupId) return;
if (msg == null) {
Log.error('updateReaction: Message $messageId not found!');
return;
}
if (msg.groupId != groupId) {
Log.error('updateReaction: Message groupId ${msg.groupId} != $groupId');
return;
}
try {
if (remove) {

View file

@ -92,6 +92,7 @@ class TwonlyDB extends _$TwonlyDB {
setup: (rawDb) {
rawDb
..execute('PRAGMA journal_mode=WAL;')
..execute('PRAGMA synchronous=FULL;')
..execute('PRAGMA busy_timeout=5000;');
},
),

View file

@ -3493,6 +3493,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Verify now'**
String get unverifiedWarningButton;
/// No description provided for @today.
///
/// In en, this message translates to:
/// **'Today'**
String get today;
/// No description provided for @yesterday.
///
/// In en, this message translates to:
/// **'Yesterday'**
String get yesterday;
}
class _AppLocalizationsDelegate

View file

@ -1988,4 +1988,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get unverifiedWarningButton => 'Jetzt verifizieren';
@override
String get today => 'Heute';
@override
String get yesterday => 'Gestern';
}

View file

@ -1972,4 +1972,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get unverifiedWarningButton => 'Verify now';
@override
String get today => 'Today';
@override
String get yesterday => 'Yesterday';
}

@ -1 +1 @@
Subproject commit c33a4c3be99b38596abd0cfa91333db3a340dee2
Subproject commit 9a538845a340f41ee8d2d23bc4c3e8fd73797203

View file

@ -10,7 +10,10 @@ Future<void> handleReaction(
EncryptedContent_Reaction reaction,
String receiptId,
) async {
Log.info('[$receiptId] Got a reaction from $fromUserId (remove=${reaction.remove})');
Log.info(
'[$receiptId] Got a reaction from for ${reaction.targetMessageId} (remove=${reaction.remove})',
);
await twonlyDB.reactionsDao.updateReaction(
fromUserId,
reaction.targetMessageId,

View file

@ -413,6 +413,9 @@ Future<void> insertMediaFileInMessagesTable(
);
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
if (message != null) {
Log.info(
'Created message ${message.messageId} for media ${message.mediaId}',
);
// de-archive contact when sending a new message
await twonlyDB.groupsDao.updateGroup(
message.groupId,
@ -444,6 +447,10 @@ Future<void> _startBackgroundMediaUploadInternal(
if (mediaService.mediaFile.uploadState == UploadState.initialized ||
mediaService.mediaFile.uploadState == UploadState.preprocessing) {
Log.info(
'Hanlding media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}',
);
await mediaService.setUploadState(UploadState.preprocessing);
if (!mediaService.tempPath.existsSync()) {

View file

@ -148,8 +148,6 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
message.encryptedContent,
);
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
Uint8List? pushData;
if (receipt.retryCount == 0) {
final pushNotification = await getPushNotificationFromEncryptedContent(
@ -194,9 +192,12 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
}
if (onlyReturnEncryptedData) {
Log.info('Returning message with receiptID ${receipt.receiptId}.');
return (message.writeToBuffer(), pushData);
}
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
final resp = await apiService.sendTextMessage(
receipt.contactId,
message.writeToBuffer(),
@ -350,7 +351,9 @@ Future<void> insertAndSendAskAboutUserMessage(
) async {
final directChat = await twonlyDB.groupsDao.createOrGetDirectChat(contactId);
if (directChat == null) {
Log.error('Failed to get or create direct chat group for contact $contactId');
Log.error(
'Failed to get or create direct chat group for contact $contactId',
);
return;
}
@ -483,6 +486,17 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
);
if (receipt != null) {
try {
final typeKeys = _getEncryptedContentTypes(encryptedContent);
Log.info(
'sendCipherText: type=[$typeKeys] messageId=$messageId receiptId=${receipt.receiptId}',
);
} catch (_) {
Log.info(
'sendCipherText: messageId=$messageId receiptId=${receipt.receiptId}',
);
}
final tmp = tryToSendCompleteMessage(
receipt: receipt,
onlyReturnEncryptedData: onlyReturnEncryptedData,
@ -568,3 +582,21 @@ Future<void> sendContactMyProfileData(int contactId) async {
);
await sendCipherText(contactId, encryptedContent, blocking: false);
}
String _getEncryptedContentTypes(pb.EncryptedContent content) {
final ignoredFields = {
'groupId',
'isDirectChat',
'senderProfileCounter',
'senderUserDiscoveryVersion',
};
final types = <String>[];
for (final field in content.info_.byName.values) {
if (content.hasField(field.tagNumber) &&
!ignoredFields.contains(field.name)) {
types.add(field.name);
}
}
return types.join(', ');
}

View file

@ -72,6 +72,13 @@ class MediaFileService {
delete = false;
}
// Never purge temp files while an upload is still in progress.
// The temp file is actively needed for encryption/upload.
if (mediaFile.uploadState != UploadState.uploaded &&
mediaFile.uploadState != UploadState.fileLimitReached) {
delete = false;
}
final messages = messageMap[mediaId] ?? [];
// in case messages in empty the file will be deleted, as delete is true by default

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
@ -145,37 +144,6 @@ Future<void> runMigrations() async {
}
}
if (userService.currentUser.appVersion < 116) {
// Because of a Bug in the handleMessagesOpened function, some messages where not marked as opened. So use the logs,
// to mark the files as opened.
final logs = await loadLogFile();
final openedMessages = logs.split(
'messages.c2c.dart:12 > Opened message [',
);
for (final opened in openedMessages) {
final messageIds = opened.split(']');
if (messageIds.isNotEmpty) {
final now = clock.now();
for (final messageId in messageIds.first.split(',')) {
await (twonlyDB.update(
twonlyDB.messages,
)..where(
(tbl) =>
tbl.messageId.equals(messageId) &
(tbl.openedByAll.isNull() | tbl.openedAt.isNull()),
))
.write(
MessagesCompanion(
openedAt: Value(now),
openedByAll: Value(now),
),
);
}
}
}
await UserService.update((u) => u.appVersion = 116);
}
if (kDebugMode) {
assert(
AppState.latestAppVersionId == 116,

View file

@ -28,7 +28,7 @@ class StartupGuard {
final stat = file.statSync();
final diff = DateTime.now().difference(stat.modified);
final starting = diff.inSeconds < 30;
final starting = diff.inSeconds < 5;
if (starting) {
Log.info(
'Startup guard: App is currently starting (${diff.inSeconds}s ago).',

View file

@ -212,6 +212,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
// Maybe this is the reason?
return;
} else {
await androidVolumeDownSub?.cancel();
androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((
event,
) {
@ -233,6 +234,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
if (Platform.isAndroid) {
await androidVolumeDownSub?.cancel();
androidVolumeDownSub = null;
}
}

View file

@ -20,6 +20,7 @@ import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/themes/colors.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/animated_new_message.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.component.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
@ -40,6 +41,11 @@ class ChatMessagesView extends StatefulWidget {
class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>();
bool _hasReceivedFirstMessageBatch = false;
final HashSet<String> _knownMessageIds = HashSet<String>();
final HashSet<String> _animateMessageIds = HashSet<String>();
StreamSubscription<Group?>? userSub;
StreamSubscription<List<Message>>? messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub;
@ -131,6 +137,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
allMessages = update;
await protectMessageUpdating.protect(() async {
await setMessages(update, groupActions);
_hasReceivedFirstMessageBatch = true;
});
});
@ -161,6 +168,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
await flutterLocalNotificationsPlugin.cancelAll();
}
for (final msg in newMessages) {
if (_hasReceivedFirstMessageBatch &&
!_knownMessageIds.contains(msg.messageId) &&
msg.senderId == null) {
_animateMessageIds.add(msg.messageId);
}
_knownMessageIds.add(msg.messageId);
}
final chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[];
@ -337,24 +353,32 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} else {
final chatMessage = messages[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
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,
userIdToContact: userIdToContact,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animateMessageIds,
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,
userIdToContact: userIdToContact,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
),
),
);
}

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
class AnimatedNewMessage extends StatefulWidget {
const AnimatedNewMessage({
required this.child,
required this.messageId,
required this.animateIds,
super.key,
});
final Widget child;
final String messageId;
final Set<String> animateIds;
@override
State<AnimatedNewMessage> createState() => _AnimatedNewMessageState();
}
class _AnimatedNewMessageState extends State<AnimatedNewMessage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
bool _didAnimate = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation =
Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.decelerate,
),
);
_opacityAnimation =
Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
),
);
if (widget.animateIds.contains(widget.messageId)) {
widget.animateIds.remove(widget.messageId);
_didAnimate = true;
_controller.forward();
} else {
_controller.value = 1.0;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_didAnimate) {
return widget.child;
}
return SizeTransition(
sizeFactor: CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
),
axisAlignment: 1,
child: ScaleTransition(
scale: _scaleAnimation,
alignment: Alignment.bottomRight,
child: FadeTransition(
opacity: _opacityAnimation,
child: widget.child,
),
),
);
}
}

View file

@ -9,8 +9,24 @@ class ChatDateChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final formattedDate =
'${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}';
final now = DateTime.now();
final date = item.date!;
final locale = Localizations.localeOf(context).toLanguageTag();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final itemDay = DateTime(date.year, date.month, date.day);
String formattedDate;
if (itemDay == today) {
formattedDate = context.lang.today;
} else if (itemDay == yesterday) {
formattedDate = context.lang.yesterday;
} else if (date.year == now.year) {
formattedDate = DateFormat('E, d. MMM', locale).format(date);
} else {
formattedDate = DateFormat('E, d. MMM y', locale).format(date);
}
return Center(
child: Container(

View file

@ -86,7 +86,8 @@ class _MessageInputState extends State<MessageInput> {
) async {
if (widget.textFieldFocus.hasFocus &&
_lastTextChangeTime != null &&
DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) {
DateTime.now().difference(_lastTextChangeTime!) <=
const Duration(seconds: 6)) {
await sendTypingIndication(widget.group.groupId, true);
}
});
@ -210,7 +211,9 @@ class _MessageInputState extends State<MessageInput> {
Future<void> _loadContactId() async {
if (widget.group.isDirectChat) {
final members = await twonlyDB.groupsDao.getGroupContact(widget.group.groupId);
final members = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (members.isNotEmpty && mounted) {
setState(() {
_contactId = members.first.userId;
@ -240,18 +243,14 @@ class _MessageInputState extends State<MessageInput> {
UnverifiedContactWarningComp(
group: widget.group,
child: Padding(
padding: const EdgeInsets.only(
bottom: 10,
left: 10,
top: 5,
),
padding: const EdgeInsets.only(left: 10, bottom: 10),
child: Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 3,
),
// padding: const EdgeInsets.symmetric(
// horizontal: 3,
// ),
decoration: BoxDecoration(
color: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(20),
@ -281,7 +280,9 @@ class _MessageInputState extends State<MessageInput> {
),
child: FaIcon(
size: 20,
_emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
_emojiShowing
? FontAwesomeIcons.keyboard
: FontAwesomeIcons.faceSmile,
),
),
),
@ -293,7 +294,8 @@ class _MessageInputState extends State<MessageInput> {
controller: _textFieldController,
focusNode: widget.textFieldFocus,
keyboardType: TextInputType.multiline,
showCursor: _recordingState != RecordingState.recording,
showCursor:
_recordingState != RecordingState.recording,
maxLines: 4,
minLines: 1,
onChanged: (value) async {
@ -344,16 +346,21 @@ class _MessageInputState extends State<MessageInput> {
_currentDuration,
),
style: TextStyle(
color: isDarkMode(context) ? Colors.white : Colors.black,
color: isDarkMode(context)
? Colors.white
: Colors.black,
fontSize: 12,
),
),
if (!_audioRecordingLock) ...[
SizedBox(
width: (100 - _cancelSlideOffset) % 101,
width:
(100 - _cancelSlideOffset) % 101,
),
Text(
context.lang.voiceMessageSlideToCancel,
context
.lang
.voiceMessageSlideToCancel,
),
] else ...[
Expanded(
@ -394,13 +401,17 @@ class _MessageInputState extends State<MessageInput> {
GestureDetector(
onLongPressMoveUpdate: (details) {
if (_audioRecordingLock) return;
if (_recordingOffset.dy - details.localPosition.dy >= 100) {
if (_recordingOffset.dy -
details.localPosition.dy >=
100) {
HapticFeedback.heavyImpact();
setState(() {
_audioRecordingLock = true;
});
}
if (_recordingOffset.dx - details.localPosition.dx >= 90 &&
if (_recordingOffset.dx -
details.localPosition.dx >=
90 &&
_recordingState == RecordingState.recording) {
_recordingState = RecordingState.none;
HapticFeedback.heavyImpact();
@ -408,9 +419,13 @@ class _MessageInputState extends State<MessageInput> {
}
setState(() {
final a = _recordingOffset.dx - details.localPosition.dx;
final a =
_recordingOffset.dx -
details.localPosition.dx;
if (a > 0 && a <= 90) {
_cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx;
_cancelSlideOffset =
_recordingOffset.dx -
details.localPosition.dx;
}
});
},
@ -430,7 +445,9 @@ class _MessageInputState extends State<MessageInput> {
child: Stack(
clipBehavior: Clip.none,
children: [
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
if (_recordingState ==
RecordingState.recording &&
!_audioRecordingLock)
Positioned.fill(
top: -120,
left: -5,
@ -440,8 +457,12 @@ class _MessageInputState extends State<MessageInput> {
padding: const EdgeInsets.only(top: 13),
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(90),
color: isDarkMode(context) ? Colors.black : Colors.white,
borderRadius: BorderRadius.circular(
90,
),
color: isDarkMode(context)
? Colors.black
: Colors.white,
),
child: const Center(
child: Column(
@ -461,7 +482,9 @@ class _MessageInputState extends State<MessageInput> {
),
),
),
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
if (_recordingState ==
RecordingState.recording &&
!_audioRecordingLock)
Positioned.fill(
top: -20,
left: -25,
@ -488,10 +511,15 @@ class _MessageInputState extends State<MessageInput> {
),
child: FaIcon(
size: 20,
color: (_recordingState == RecordingState.recording) ? Colors.white : null,
color:
(_recordingState ==
RecordingState.recording)
? Colors.white
: null,
(_recordingState == RecordingState.none)
? FontAwesomeIcons.microphone
: (_recordingState == RecordingState.recording)
: (_recordingState ==
RecordingState.recording)
? FontAwesomeIcons.stop
: FontAwesomeIcons.play,
),
@ -511,7 +539,9 @@ class _MessageInputState extends State<MessageInput> {
color: context.color.primary,
FontAwesomeIcons.solidPaperPlane,
),
onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
onPressed: _audioRecordingLock
? _stopAudioRecording
: _sendMessage,
)
else
IconButton(

View file

@ -24,15 +24,15 @@ enum MessageSendState {
MessageSendState messageSendStateFromMessage(Message msg) {
if (msg.senderId == null) {
if (msg.openedByAll != null || msg.openedAt != null) {
return MessageSendState.sendOpened;
}
/// messages was send by me, look up if every messages was received by the server...
if (msg.ackByServer == null) {
return MessageSendState.sending;
}
if (msg.openedAt != null) {
return MessageSendState.sendOpened;
} else {
return MessageSendState.send;
}
return MessageSendState.send;
}
// message received

View file

@ -23,11 +23,16 @@ class UnverifiedContactWarningComp extends StatelessWidget {
return StreamBuilder<void>(
stream: userService.onUserUpdated,
builder: (context, _) {
if (!userService.currentUser.securityProfile.showWarningForNonVerifiedContacts) {
if (!userService
.currentUser
.securityProfile
.showWarningForNonVerifiedContacts) {
return child;
}
return StreamBuilder<VerificationStatus>(
stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified(group.groupId),
stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified(
group.groupId,
),
builder: (context, snapshot) {
final status = snapshot.data;
if (status == null || status == VerificationStatus.trusted) {
@ -39,7 +44,9 @@ class UnverifiedContactWarningComp extends StatelessWidget {
decoration: BoxDecoration(
color: context.color.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: context.color.error.withValues(alpha: 0.5)),
border: Border.all(
color: context.color.error.withValues(alpha: 0.5),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -93,14 +100,23 @@ class UnverifiedContactWarningComp extends StatelessWidget {
style: FilledButton.styleFrom(
backgroundColor: context.color.onErrorContainer,
foregroundColor: context.color.errorContainer,
padding: const EdgeInsets.symmetric(horizontal: 10),
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
onPressed: () async {
if (group.isDirectChat) {
await context.push(Routes.settingsHelpFaqVerifyBadge);
await context.push(
Routes.settingsHelpFaqVerifyBadge,
);
} else {
await context.push(Routes.profileGroup(group.groupId));
await context.push(
Routes.profileGroup(group.groupId),
);
}
},
child: Text(context.lang.unverifiedWarningButton),
@ -109,7 +125,12 @@ class UnverifiedContactWarningComp extends StatelessWidget {
],
),
),
child,
Padding(
padding: const EdgeInsets.only(
top: 5,
),
child: child,
),
],
),
);

View file

@ -62,7 +62,6 @@ class _MutualGroupsExpansionTileCompState
shape: const RoundedRectangleBorder(),
backgroundColor: context.color.surfaceContainer,
collapsedShape: const RoundedRectangleBorder(),
initiallyExpanded: _groups.length < 5,
onExpansionChanged: (expanded) {
setState(() {});
},

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:clock/clock.dart';
@ -9,12 +10,15 @@ import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:restart_app/restart_app.dart';
import 'package:share_plus/share_plus.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
@ -293,6 +297,30 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onTap: () =>
context.navPush(const UserDiscoveryDeveloperView()),
),
ListTile(
title: const Text('Share local database'),
onTap: () async {
final dbCopyPath =
'${AppEnvironment.cacheDir}/twonly_copy.sqlite';
final dbCopyFile = File(dbCopyPath);
if (dbCopyFile.existsSync()) {
dbCopyFile.deleteSync();
}
try {
await twonlyDB.customStatement("VACUUM INTO '$dbCopyPath'");
if (dbCopyFile.existsSync()) {
await SharePlus.instance.share(
ShareParams(
files: [XFile(dbCopyPath)],
text: 'Twonly Database',
),
);
}
} catch (e) {
Log.error('Failed to create database copy: $e');
}
},
),
ListTile(
title: const Text('Toggle Video Stabilization'),
onTap: toggleVideoStabilization,

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.2.20+129
version: 0.2.23+132
environment:
sdk: ^3.11.0