fix: database error and some ui improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-28 00:09:12 +02:00
parent c7826ad6dd
commit 872592af21
19 changed files with 325 additions and 104 deletions

View file

@ -102,7 +102,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
t.openedAt.isNull() | t.openedAt.isNull() |
t.mediaStored.equals(true)) & t.mediaStored.equals(true)) &
(t.isDeletedFromSender.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.media.name).not()) |
(t.type.equals(MessageType.text.name) & (t.type.equals(MessageType.text.name) &
t.content.isNotNull()) | t.content.isNotNull()) |
@ -156,8 +156,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
await (delete(messages)..where( await (delete(messages)..where(
(m) => (m) =>
m.groupId.isIn(groupIds) & m.groupId.isIn(groupIds) &
(m.mediaStored.equals(true) & ((m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) | m.isDeletedFromSender.equals(true)) |
m.mediaStored.equals(false)) & 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.. // 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) | (m.openedByAll.isSmallerThanValue(deletionTime) |
@ -255,21 +255,26 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
) async { ) async {
for (final messageId in messageIds) { for (final messageId in messageIds) {
try { 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( await into(messageActions).insertOnConflictUpdate(
MessageActionsCompanion( MessageActionsCompanion(
messageId: Value(messageId), messageId: Value(messageId),
contactId: contactId, contactId: contactId,
type: const Value(MessageActionType.openedAt), type: const Value(MessageActionType.openedAt),
actionAt: Value(timestamp), actionAt: Value(actionTimestamp),
), ),
); );
} catch (e) {
Log.error('handleMessagesOpened insert failed for $messageId: $e');
}
}
for (final messageId in messageIds) {
try {
final isOpenedByAll = await haveAllMembers( final isOpenedByAll = await haveAllMembers(
messageId, messageId,
MessageActionType.openedAt, MessageActionType.openedAt,
@ -279,15 +284,15 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
messages, messages,
)..where((tbl) => tbl.messageId.equals(messageId))).write( )..where((tbl) => tbl.messageId.equals(messageId))).write(
MessagesCompanion( MessagesCompanion(
openedAt: Value(timestamp), openedAt: Value(actionTimestamp),
openedByAll: Value(isOpenedByAll ? timestamp : null), openedByAll: Value(isOpenedByAll ? actionTimestamp : null),
), ),
); );
Log.info( Log.info(
'handleMessagesOpened updated $rowsUpdated rows for message $messageId', 'handleMessagesOpened updated $rowsUpdated rows for message $messageId',
); );
} catch (e) { } catch (e) {
Log.error('handleMessagesOpened update failed: $e'); Log.error('handleMessagesOpened failed for $messageId: $e');
} }
} }
} }

View file

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

View file

@ -3493,6 +3493,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Verify now'** /// **'Verify now'**
String get unverifiedWarningButton; 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 class _AppLocalizationsDelegate

View file

@ -1988,4 +1988,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get unverifiedWarningButton => 'Jetzt verifizieren'; 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 @override
String get unverifiedWarningButton => 'Verify now'; 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

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

View file

@ -148,8 +148,6 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
message.encryptedContent, message.encryptedContent,
); );
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
Uint8List? pushData; Uint8List? pushData;
if (receipt.retryCount == 0) { if (receipt.retryCount == 0) {
final pushNotification = await getPushNotificationFromEncryptedContent( final pushNotification = await getPushNotificationFromEncryptedContent(
@ -194,9 +192,12 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
} }
if (onlyReturnEncryptedData) { if (onlyReturnEncryptedData) {
Log.info('Returning message with receiptID ${receipt.receiptId}.');
return (message.writeToBuffer(), pushData); return (message.writeToBuffer(), pushData);
} }
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
final resp = await apiService.sendTextMessage( final resp = await apiService.sendTextMessage(
receipt.contactId, receipt.contactId,
message.writeToBuffer(), message.writeToBuffer(),

View file

@ -72,6 +72,13 @@ class MediaFileService {
delete = false; 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] ?? []; final messages = messageMap[mediaId] ?? [];
// in case messages in empty the file will be deleted, as delete is true by default // 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:async';
import 'dart:convert'; import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:intl/intl.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) { if (kDebugMode) {
assert( assert(
AppState.latestAppVersionId == 116, AppState.latestAppVersionId == 116,

View file

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

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/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.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/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/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_group_action.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.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> class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver { with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>(); HashSet<int> alreadyReportedOpened = HashSet<int>();
bool _hasReceivedFirstMessageBatch = false;
final HashSet<String> _knownMessageIds = HashSet<String>();
final HashSet<String> _animateMessageIds = HashSet<String>();
StreamSubscription<Group?>? userSub; StreamSubscription<Group?>? userSub;
StreamSubscription<List<Message>>? messageSub; StreamSubscription<List<Message>>? messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub; StreamSubscription<List<GroupHistory>>? groupActionsSub;
@ -131,6 +137,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
allMessages = update; allMessages = update;
await protectMessageUpdating.protect(() async { await protectMessageUpdating.protect(() async {
await setMessages(update, groupActions); await setMessages(update, groupActions);
_hasReceivedFirstMessageBatch = true;
}); });
}); });
@ -161,6 +168,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
await flutterLocalNotificationsPlugin.cancelAll(); 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 chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[]; final storedMediaFiles = <Message>[];
@ -337,24 +353,32 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} else { } else {
final chatMessage = messages[i].message!; final chatMessage = messages[i].message!;
return BlinkWidget( return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i, enabled: focusedScrollItem == i,
child: ChatListEntry( child: AnimatedNewMessage(
key: Key(chatMessage.messageId), key: Key('anim_${chatMessage.messageId}'),
message: messages[i].message!, messageId: chatMessage.messageId,
nextMessage: (i > 0) ? messages[i - 1].message : null, animateIds: _animateMessageIds,
prevMessage: ((i + 1) < messages.length) child: ChatListEntry(
? messages[i + 1].message key: Key(chatMessage.messageId),
: null, message: messages[i].message!,
group: group, nextMessage: (i > 0)
galleryItems: galleryItems, ? messages[i - 1].message
userIdToContact: userIdToContact, : null,
scrollToMessage: scrollToMessage, prevMessage: ((i + 1) < messages.length)
onResponseTriggered: () { ? messages[i + 1].message
setState(() { : null,
quotesMessage = chatMessage; group: group,
}); galleryItems: galleryItems,
textFieldFocus?.requestFocus(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formattedDate = final now = DateTime.now();
'${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}'; 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( return Center(
child: Container( child: Container(

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
@ -9,12 +10,15 @@ import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:restart_app/restart_app.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/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.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/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
@ -293,6 +297,30 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onTap: () => onTap: () =>
context.navPush(const UserDiscoveryDeveloperView()), 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( ListTile(
title: const Text('Toggle Video Stabilization'), title: const Text('Toggle Video Stabilization'),
onTap: toggleVideoStabilization, onTap: toggleVideoStabilization,

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.2.21+130 version: 0.2.22+131
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0