mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-22 14:32:53 +00:00
Merge pull request #398 from twonlyapp/dev
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run
- Improved: Show input indicator in the chat overview as well - Improved: Username change error handling - Fix: Phantom push notification - Fix: Start in chat, if configured - Fix: Smaller UI fixes
This commit is contained in:
commit
e52c40d824
17 changed files with 325 additions and 136 deletions
|
|
@ -1,5 +1,13 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.6
|
||||||
|
|
||||||
|
- Improved: Show input indicator in the chat overview as well
|
||||||
|
- Improved: Username change error handling
|
||||||
|
- Fix: Phantom push notification
|
||||||
|
- Fix: Start in chat, if configured
|
||||||
|
- Fix: Smaller UI fixes
|
||||||
|
|
||||||
## 0.1.5
|
## 0.1.5
|
||||||
|
|
||||||
- Fix: Reupload of media files was not working properly
|
- Fix: Reupload of media files was not working properly
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
@ -33,9 +35,20 @@ void main() async {
|
||||||
globalApplicationSupportDirectory =
|
globalApplicationSupportDirectory =
|
||||||
(await getApplicationSupportDirectory()).path;
|
(await getApplicationSupportDirectory()).path;
|
||||||
|
|
||||||
|
initLogger();
|
||||||
await initFCMService();
|
await initFCMService();
|
||||||
|
|
||||||
final user = await getUser();
|
var user = await getUser();
|
||||||
|
|
||||||
|
if (Platform.isIOS && user != null) {
|
||||||
|
final db = File('$globalApplicationSupportDirectory/twonly.sqlite');
|
||||||
|
if (!db.existsSync()) {
|
||||||
|
Log.error('[twonly] IOS: App was removed and then reinstalled again...');
|
||||||
|
await const FlutterSecureStorage().deleteAll();
|
||||||
|
user = await getUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
gUser = user;
|
gUser = user;
|
||||||
|
|
||||||
|
|
@ -57,8 +70,6 @@ void main() async {
|
||||||
await deleteLocalUserData();
|
await deleteLocalUserData();
|
||||||
}
|
}
|
||||||
|
|
||||||
initLogger();
|
|
||||||
|
|
||||||
final settingsController = SettingsChangeProvider();
|
final settingsController = SettingsChangeProvider();
|
||||||
|
|
||||||
await settingsController.loadSettings();
|
await settingsController.loadSettings();
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final rowId = await into(
|
await into(mediaFiles).insertOnConflictUpdate(insertMediaFile);
|
||||||
mediaFiles,
|
|
||||||
).insertOnConflictUpdate(insertMediaFile);
|
final mediaId = insertMediaFile.mediaId.value;
|
||||||
|
|
||||||
return await (select(
|
return await (select(
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
)..where((t) => t.rowId.equals(rowId))).getSingle();
|
)..where((t) => t.mediaId.equals(mediaId))).getSingle();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error('Could not insert media file: $e');
|
Log.error('Could not insert media file: $e');
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -64,18 +64,41 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
return query.map((row) => row.readTable(messages)).watch();
|
return query.map((row) => row.readTable(messages)).watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<Message?> watchLastMessage(String groupId) {
|
Future<Stream<Message?>> watchLastMessage(String groupId) async {
|
||||||
|
final group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||||
|
final deletionTime = clock.now().subtract(
|
||||||
|
Duration(
|
||||||
|
milliseconds: group!.deleteMessagesAfterMilliseconds,
|
||||||
|
),
|
||||||
|
);
|
||||||
return (select(messages)
|
return (select(messages)
|
||||||
..where((t) => t.groupId.equals(groupId))
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.groupId.equals(groupId) &
|
||||||
|
// messages in groups will only be removed in case all members have received it...
|
||||||
|
// so ensuring that this message is not shown in the messages anymore
|
||||||
|
(t.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
|
t.openedAt.isNull()),
|
||||||
|
)
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
||||||
..limit(1))
|
..limit(1))
|
||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<Message>> watchByGroupId(String groupId) {
|
Future<Stream<List<Message>>> watchByGroupId(String groupId) async {
|
||||||
|
final group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||||
|
final deletionTime = clock.now().subtract(
|
||||||
|
Duration(
|
||||||
|
milliseconds: group!.deleteMessagesAfterMilliseconds,
|
||||||
|
),
|
||||||
|
);
|
||||||
return ((select(messages)..where(
|
return ((select(messages)..where(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.groupId.equals(groupId) &
|
t.groupId.equals(groupId) &
|
||||||
|
// messages in groups will only be removed in case all members have received it...
|
||||||
|
// so ensuring that this message is not shown in the messages anymore
|
||||||
|
(t.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
|
t.openedAt.isNull()) &
|
||||||
(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()) |
|
||||||
|
|
@ -127,7 +150,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
(m.mediaStored.equals(true) &
|
(m.mediaStored.equals(true) &
|
||||||
m.isDeletedFromSender.equals(true) |
|
m.isDeletedFromSender.equals(true) |
|
||||||
m.mediaStored.equals(false)) &
|
m.mediaStored.equals(false)) &
|
||||||
(m.openedAt.isSmallerThanValue(deletionTime) |
|
// 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.isDeletedFromSender.equals(true) &
|
(m.isDeletedFromSender.equals(true) &
|
||||||
m.createdAt.isSmallerThanValue(deletionTime))),
|
m.createdAt.isSmallerThanValue(deletionTime))),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,11 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
||||||
};
|
};
|
||||||
final response = await iapConnection.queryProductDetails(ids);
|
final response = await iapConnection.queryProductDetails(ids);
|
||||||
if (response.notFoundIDs.isNotEmpty) {
|
if (response.notFoundIDs.isNotEmpty) {
|
||||||
Log.error(response.notFoundIDs);
|
Log.warn(response.notFoundIDs);
|
||||||
}
|
}
|
||||||
products = response.productDetails.map(PurchasableProduct.new).toList();
|
products = response.productDetails.map(PurchasableProduct.new).toList();
|
||||||
if (products.isEmpty) {
|
if (products.isEmpty) {
|
||||||
Log.error('Could not load any products from the store!');
|
Log.warn('Could not load any products from the store!');
|
||||||
}
|
}
|
||||||
storeState = StoreState.available;
|
storeState = StoreState.available;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,8 @@ Future<void> handleMedia(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
late MediaFile? mediaFile;
|
MediaFile? mediaFile;
|
||||||
late Message? message;
|
Message? message;
|
||||||
|
|
||||||
await twonlyDB.transaction(() async {
|
await twonlyDB.transaction(() async {
|
||||||
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
|
@ -163,7 +163,7 @@ Future<void> handleMedia(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message != null) {
|
if (message != null && mediaFile != null) {
|
||||||
await twonlyDB.groupsDao.increaseLastMessageExchange(
|
await twonlyDB.groupsDao.increaseLastMessageExchange(
|
||||||
groupId,
|
groupId,
|
||||||
fromTimestamp(media.timestamp),
|
fromTimestamp(media.timestamp),
|
||||||
|
|
@ -176,6 +176,10 @@ Future<void> handleMedia(
|
||||||
);
|
);
|
||||||
|
|
||||||
unawaited(startDownloadMedia(mediaFile!, false));
|
unawaited(startDownloadMedia(mediaFile!, false));
|
||||||
|
} else {
|
||||||
|
Log.error(
|
||||||
|
'Could not insert new message as both the message and mediaFile are empty.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,12 @@ Future<void> reuploadMediaFiles() async {
|
||||||
.getMessageById(messageId)
|
.getMessageById(messageId)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (message == null || message.mediaId == null) {
|
if (message == null || message.mediaId == null) {
|
||||||
|
// The message or media file does not exists any more, so delete the receipt...
|
||||||
|
if (message != null) {
|
||||||
|
// The media file of the message does not exist anymore. Removing it...
|
||||||
|
await twonlyDB.messagesDao.deleteMessagesById(messageId);
|
||||||
|
}
|
||||||
|
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
|
||||||
Log.error(
|
Log.error(
|
||||||
'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).',
|
'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -102,22 +102,24 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
||||||
message.encryptedContent,
|
message.encryptedContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Log.info('Uploading $receiptId.');
|
||||||
|
|
||||||
|
Uint8List? pushData;
|
||||||
|
if (receipt.retryCount == 0) {
|
||||||
final pushNotification = await getPushNotificationFromEncryptedContent(
|
final pushNotification = await getPushNotificationFromEncryptedContent(
|
||||||
receipt.contactId,
|
receipt.contactId,
|
||||||
receipt.messageId,
|
receipt.messageId,
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
Log.info('Uploading $receiptId. (${pushNotification?.kind})');
|
if (pushNotification != null) {
|
||||||
|
|
||||||
Uint8List? pushData;
|
|
||||||
if (pushNotification != null && receipt.retryCount <= 1) {
|
|
||||||
// Only show the push notification the first two time.
|
// Only show the push notification the first two time.
|
||||||
pushData = await encryptPushNotification(
|
pushData = await encryptPushNotification(
|
||||||
receipt.contactId,
|
receipt.contactId,
|
||||||
pushNotification,
|
pushNotification,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type == pb.Message_Type.TEST_NOTIFICATION) {
|
if (message.type == pb.Message_Type.TEST_NOTIFICATION) {
|
||||||
pushData = (PushNotification()..kind = PushKind.testNotification)
|
pushData = (PushNotification()..kind = PushKind.testNotification)
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,8 @@ Future<void> showLocalPushNotification(
|
||||||
pushNotification.kind == PushKind.reactionToText ||
|
pushNotification.kind == PushKind.reactionToText ||
|
||||||
pushNotification.kind == PushKind.reactionToAudio)) {
|
pushNotification.kind == PushKind.reactionToAudio)) {
|
||||||
payload = Routes.chatsMessages(groupId);
|
payload = Routes.chatsMessages(groupId);
|
||||||
|
} else {
|
||||||
|
payload = Routes.chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
await flutterLocalNotificationsPlugin.show(
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
|
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
|
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
|
||||||
|
import 'package:twonly/src/views/chats/chat_list_components/typing_indicator_subtitle.dart';
|
||||||
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
|
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
|
||||||
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
||||||
import 'package:twonly/src/views/components/flame.dart';
|
import 'package:twonly/src/views/components/flame.dart';
|
||||||
|
|
@ -63,9 +64,10 @@ class _UserListItem extends State<GroupListItem> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initStreams() async {
|
Future<void> initStreams() async {
|
||||||
_lastMessageStream = twonlyDB.messagesDao
|
_lastMessageStream =
|
||||||
.watchLastMessage(widget.group.groupId)
|
(await twonlyDB.messagesDao.watchLastMessage(
|
||||||
.listen((update) {
|
widget.group.groupId,
|
||||||
|
)).listen((update) {
|
||||||
protectUpdateState.protect(() async {
|
protectUpdateState.protect(() async {
|
||||||
await updateState(update, _messagesNotOpened);
|
await updateState(update, _messagesNotOpened);
|
||||||
});
|
});
|
||||||
|
|
@ -227,6 +229,7 @@ class _UserListItem extends State<GroupListItem> {
|
||||||
VerifiedShield(
|
VerifiedShield(
|
||||||
group: widget.group,
|
group: widget.group,
|
||||||
showOnlyIfVerified: true,
|
showOnlyIfVerified: true,
|
||||||
|
clickable: false,
|
||||||
size: 12,
|
size: 12,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -249,6 +252,9 @@ class _UserListItem extends State<GroupListItem> {
|
||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
children: [
|
children: [
|
||||||
|
TypingIndicatorSubtitle(
|
||||||
|
groupId: widget.group.groupId,
|
||||||
|
),
|
||||||
MessageSendStateIcon(
|
MessageSendStateIcon(
|
||||||
_previewMessages,
|
_previewMessages,
|
||||||
_previewMediaFiles,
|
_previewMediaFiles,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:twonly/globals.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/views/chats/chat_messages.view.dart';
|
||||||
|
import 'package:twonly/src/views/chats/chat_messages_components/typing_indicator.dart';
|
||||||
|
|
||||||
|
class TypingIndicatorSubtitle extends StatefulWidget {
|
||||||
|
const TypingIndicatorSubtitle({required this.groupId, super.key});
|
||||||
|
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TypingIndicatorSubtitle> createState() =>
|
||||||
|
_TypingIndicatorSubtitleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypingIndicatorSubtitleState extends State<TypingIndicatorSubtitle> {
|
||||||
|
List<GroupMember> _groupMembers = [];
|
||||||
|
|
||||||
|
late StreamSubscription<List<(Contact, GroupMember)>> membersSub;
|
||||||
|
|
||||||
|
late Timer _periodicUpdate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
filterOpenUsers(_groupMembers);
|
||||||
|
});
|
||||||
|
|
||||||
|
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
|
||||||
|
widget.groupId,
|
||||||
|
);
|
||||||
|
membersSub = membersStream.listen((update) {
|
||||||
|
filterOpenUsers(update.map((m) => m.$2).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void filterOpenUsers(List<GroupMember> input) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_groupMembers = input.where(isTyping).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
membersSub.cancel();
|
||||||
|
_periodicUpdate.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_groupMembers.isEmpty) return Container();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 5),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: getMessageColor(true),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 0.6,
|
||||||
|
child: const AnimatedTypingDots(
|
||||||
|
isTyping: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId);
|
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
|
||||||
messageSub = msgStream.listen((update) async {
|
messageSub = msgStream.listen((update) async {
|
||||||
allMessages = update;
|
allMessages = update;
|
||||||
await protectMessageUpdating.protect(() async {
|
await protectMessageUpdating.protect(() async {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,28 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/views/chats/chat_messages.view.dart';
|
import 'package:twonly/src/views/chats/chat_messages.view.dart';
|
||||||
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
||||||
|
|
||||||
|
bool isTyping(GroupMember member) {
|
||||||
|
return member.lastTypeIndicator != null &&
|
||||||
|
clock
|
||||||
|
.now()
|
||||||
|
.difference(
|
||||||
|
member.lastTypeIndicator!,
|
||||||
|
)
|
||||||
|
.inSeconds <=
|
||||||
|
2;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasChatOpen(GroupMember member) {
|
||||||
|
return member.lastChatOpened != null &&
|
||||||
|
clock
|
||||||
|
.now()
|
||||||
|
.difference(
|
||||||
|
member.lastChatOpened!,
|
||||||
|
)
|
||||||
|
.inSeconds <=
|
||||||
|
6;
|
||||||
|
}
|
||||||
|
|
||||||
class TypingIndicator extends StatefulWidget {
|
class TypingIndicator extends StatefulWidget {
|
||||||
const TypingIndicator({required this.group, super.key});
|
const TypingIndicator({required this.group, super.key});
|
||||||
|
|
||||||
|
|
@ -18,10 +40,8 @@ class TypingIndicator extends StatefulWidget {
|
||||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TypingIndicatorState extends State<TypingIndicator>
|
class _TypingIndicatorState extends State<TypingIndicator> {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
late List<Animation<double>> _animations;
|
|
||||||
|
|
||||||
List<GroupMember> _groupMembers = [];
|
List<GroupMember> _groupMembers = [];
|
||||||
|
|
||||||
|
|
@ -43,7 +63,90 @@ class _TypingIndicatorState extends State<TypingIndicator>
|
||||||
membersSub = membersStream.listen((update) {
|
membersSub = membersStream.listen((update) {
|
||||||
filterOpenUsers(update.map((m) => m.$2).toList());
|
filterOpenUsers(update.map((m) => m.$2).toList());
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void filterOpenUsers(List<GroupMember> input) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_groupMembers = input.where(hasChatOpen).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
membersSub.cancel();
|
||||||
|
_periodicUpdate.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_groupMembers.isEmpty) return Container();
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: _groupMembers
|
||||||
|
.map(
|
||||||
|
(member) => Padding(
|
||||||
|
key: Key('typing_indicator_${member.contactId}'),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!widget.group.isDirectChat)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.push(
|
||||||
|
Routes.profileContact(member.contactId),
|
||||||
|
),
|
||||||
|
child: AvatarIcon(
|
||||||
|
contactId: member.contactId,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: getMessageColor(true),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: AnimatedTypingDots(
|
||||||
|
isTyping: isTyping(member),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: Container()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimatedTypingDots extends StatefulWidget {
|
||||||
|
const AnimatedTypingDots({required this.isTyping, super.key});
|
||||||
|
|
||||||
|
final bool isTyping;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnimatedTypingDots> createState() => _AnimatedTypingDotsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
late List<Animation<double>> _animations;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: const Duration(milliseconds: 1000),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
|
|
@ -77,95 +180,20 @@ class _TypingIndicatorState extends State<TypingIndicator>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
super.initState();
|
||||||
|
|
||||||
void filterOpenUsers(List<GroupMember> input) {
|
|
||||||
setState(() {
|
|
||||||
_groupMembers = input.where(hasChatOpen).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
membersSub.cancel();
|
|
||||||
_periodicUpdate.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isTyping(GroupMember member) {
|
|
||||||
return member.lastTypeIndicator != null &&
|
|
||||||
clock
|
|
||||||
.now()
|
|
||||||
.difference(
|
|
||||||
member.lastTypeIndicator!,
|
|
||||||
)
|
|
||||||
.inSeconds <=
|
|
||||||
2;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasChatOpen(GroupMember member) {
|
|
||||||
return member.lastChatOpened != null &&
|
|
||||||
clock
|
|
||||||
.now()
|
|
||||||
.difference(
|
|
||||||
member.lastChatOpened!,
|
|
||||||
)
|
|
||||||
.inSeconds <=
|
|
||||||
6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_groupMembers.isEmpty) return Container();
|
return Row(
|
||||||
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
children: _groupMembers
|
|
||||||
.map(
|
|
||||||
(member) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (!widget.group.isDirectChat)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => context.push(
|
|
||||||
Routes.profileContact(member.contactId),
|
|
||||||
),
|
|
||||||
child: AvatarIcon(
|
|
||||||
contactId: member.contactId,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: getMessageColor(true),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
3,
|
3,
|
||||||
(index) => _AnimatedDot(
|
(index) => _AnimatedDot(
|
||||||
isTyping: isTyping(member),
|
isTyping: widget.isTyping,
|
||||||
animation: _animations[index],
|
animation: _animations[index],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Container()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ class VerifiedShield extends StatefulWidget {
|
||||||
super.key,
|
super.key,
|
||||||
this.size = 15,
|
this.size = 15,
|
||||||
this.showOnlyIfVerified = false,
|
this.showOnlyIfVerified = false,
|
||||||
|
this.clickable = true,
|
||||||
});
|
});
|
||||||
final Group? group;
|
final Group? group;
|
||||||
final Contact? contact;
|
final Contact? contact;
|
||||||
final double size;
|
final double size;
|
||||||
|
|
||||||
final bool showOnlyIfVerified;
|
final bool showOnlyIfVerified;
|
||||||
|
final bool clickable;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VerifiedShield> createState() => _VerifiedShieldState();
|
State<VerifiedShield> createState() => _VerifiedShieldState();
|
||||||
|
|
@ -61,7 +63,7 @@ class _VerifiedShieldState extends State<VerifiedShield> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!isVerified && widget.showOnlyIfVerified) return Container();
|
if (!isVerified && widget.showOnlyIfVerified) return Container();
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: (contact == null)
|
onTap: (contact == null || !widget.clickable)
|
||||||
? null
|
? null
|
||||||
: () => context.push(Routes.settingsHelpFaqVerifyBadge),
|
: () => context.push(Routes.settingsHelpFaqVerifyBadge),
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,11 @@ class Shade extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeViewState extends State<HomeView> {
|
class HomeViewState extends State<HomeView> {
|
||||||
int activePageIdx = 0;
|
int _activePageIdx = 1;
|
||||||
|
|
||||||
final MainCameraController _mainCameraController = MainCameraController();
|
final MainCameraController _mainCameraController = MainCameraController();
|
||||||
|
|
||||||
final PageController homeViewPageController = PageController(initialPage: 1);
|
final PageController _homeViewPageController = PageController(initialPage: 1);
|
||||||
late StreamSubscription<List<SharedFile>> _intentStreamSub;
|
late StreamSubscription<List<SharedFile>> _intentStreamSub;
|
||||||
late StreamSubscription<Uri> _deepLinkSub;
|
late StreamSubscription<Uri> _deepLinkSub;
|
||||||
|
|
||||||
|
|
@ -67,10 +67,10 @@ class HomeViewState extends State<HomeView> {
|
||||||
bool onPageView(ScrollNotification notification) {
|
bool onPageView(ScrollNotification notification) {
|
||||||
disableCameraTimer?.cancel();
|
disableCameraTimer?.cancel();
|
||||||
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
|
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
|
||||||
final page = homeViewPageController.page ?? 0;
|
final page = _homeViewPageController.page ?? 0;
|
||||||
lastChange = page;
|
lastChange = page;
|
||||||
setState(() {
|
setState(() {
|
||||||
offsetFromOne = 1.0 - (homeViewPageController.page ?? 0);
|
offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
|
||||||
offsetRatio = offsetFromOne.abs();
|
offsetRatio = offsetFromOne.abs();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -100,17 +100,17 @@ class HomeViewState extends State<HomeView> {
|
||||||
_mainCameraController.setState = () {
|
_mainCameraController.setState = () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
};
|
};
|
||||||
activePageIdx = widget.initialPage;
|
|
||||||
|
|
||||||
globalUpdateOfHomeViewPageIndex = (index) {
|
globalUpdateOfHomeViewPageIndex = (index) {
|
||||||
homeViewPageController.jumpToPage(index);
|
_homeViewPageController.jumpToPage(index);
|
||||||
setState(() {
|
setState(() {
|
||||||
activePageIdx = index;
|
_activePageIdx = index;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
selectNotificationStream.stream.listen((response) async {
|
selectNotificationStream.stream.listen((response) async {
|
||||||
if (response.payload != null &&
|
if (response.payload != null &&
|
||||||
response.payload!.startsWith(Routes.chats)) {
|
response.payload!.startsWith(Routes.chats) &&
|
||||||
|
response.payload! != Routes.chats) {
|
||||||
await routerProvider.push(response.payload!);
|
await routerProvider.push(response.payload!);
|
||||||
}
|
}
|
||||||
globalUpdateOfHomeViewPageIndex(0);
|
globalUpdateOfHomeViewPageIndex(0);
|
||||||
|
|
@ -134,6 +134,11 @@ class HomeViewState extends State<HomeView> {
|
||||||
context,
|
context,
|
||||||
_mainCameraController.setSharedLinkForPreview,
|
_mainCameraController.setSharedLinkForPreview,
|
||||||
);
|
);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (widget.initialPage == 0) {
|
||||||
|
globalUpdateOfHomeViewPageIndex(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -196,10 +201,10 @@ class HomeViewState extends State<HomeView> {
|
||||||
onNotification: onPageView,
|
onNotification: onPageView,
|
||||||
child: Positioned.fill(
|
child: Positioned.fill(
|
||||||
child: PageView(
|
child: PageView(
|
||||||
controller: homeViewPageController,
|
controller: _homeViewPageController,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
activePageIdx = index;
|
_activePageIdx = index;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -222,7 +227,7 @@ class HomeViewState extends State<HomeView> {
|
||||||
child: CameraPreviewControllerView(
|
child: CameraPreviewControllerView(
|
||||||
mainController: _mainCameraController,
|
mainController: _mainCameraController,
|
||||||
isVisible:
|
isVisible:
|
||||||
((1 - (offsetRatio * 4) % 1) == 1) && activePageIdx == 1,
|
((1 - (offsetRatio * 4) % 1) == 1) && _activePageIdx == 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -253,15 +258,15 @@ class HomeViewState extends State<HomeView> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onTap: (index) async {
|
onTap: (index) async {
|
||||||
activePageIdx = index;
|
_activePageIdx = index;
|
||||||
await homeViewPageController.animateToPage(
|
await _homeViewPageController.animateToPage(
|
||||||
index,
|
index,
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
curve: Curves.bounceIn,
|
curve: Curves.bounceIn,
|
||||||
);
|
);
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
},
|
},
|
||||||
currentIndex: activePageIdx,
|
currentIndex: _activePageIdx,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,14 +57,28 @@ class _ProfileViewState extends State<ProfileView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateUsername(String username) async {
|
Future<void> _updateUsername(String username) async {
|
||||||
final result = await apiService.changeUsername(username);
|
var filteredUsername = username.replaceAll(
|
||||||
|
RegExp('[^a-zA-Z0-9._]'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredUsername.length > 12) {
|
||||||
|
filteredUsername = filteredUsername.substring(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await apiService.changeUsername(filteredUsername);
|
||||||
if (result.isError) {
|
if (result.isError) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (result.error == ErrorCode.UsernameAlreadyTaken) {
|
if (result.error == ErrorCode.UsernameAlreadyTaken ||
|
||||||
|
result.error == ErrorCode.UsernameNotValid) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.lang.errorUsernameAlreadyTaken),
|
content: Text(
|
||||||
|
result.error == ErrorCode.UsernameAlreadyTaken
|
||||||
|
? context.lang.errorUsernameAlreadyTaken
|
||||||
|
: context.lang.errorUsernameNotValid,
|
||||||
|
),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.1.5+105
|
version: 0.1.6+106
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue