mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 21:42:13 +00:00
fix: database error and some ui improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
c7826ad6dd
commit
872592af21
19 changed files with 325 additions and 104 deletions
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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).',
|
||||||
|
|
|
||||||
|
|
@ -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,11 +353,18 @@ 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: AnimatedNewMessage(
|
||||||
|
key: Key('anim_${chatMessage.messageId}'),
|
||||||
|
messageId: chatMessage.messageId,
|
||||||
|
animateIds: _animateMessageIds,
|
||||||
child: ChatListEntry(
|
child: ChatListEntry(
|
||||||
key: Key(chatMessage.messageId),
|
key: Key(chatMessage.messageId),
|
||||||
message: messages[i].message!,
|
message: messages[i].message!,
|
||||||
nextMessage: (i > 0) ? messages[i - 1].message : null,
|
nextMessage: (i > 0)
|
||||||
|
? messages[i - 1].message
|
||||||
|
: null,
|
||||||
prevMessage: ((i + 1) < messages.length)
|
prevMessage: ((i + 1) < messages.length)
|
||||||
? messages[i + 1].message
|
? messages[i + 1].message
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -356,6 +379,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
textFieldFocus?.requestFocus();
|
textFieldFocus?.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(() {});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue