diff --git a/CHANGELOG.md b/CHANGELOG.md index 544acb7..a2cbad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 - Fix: Reupload of media files was not working properly diff --git a/lib/main.dart b/lib/main.dart index f148c74..bfc9b38 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:io'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -33,9 +35,20 @@ void main() async { globalApplicationSupportDirectory = (await getApplicationSupportDirectory()).path; + initLogger(); 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) { gUser = user; @@ -57,8 +70,6 @@ void main() async { await deleteLocalUserData(); } - initLogger(); - final settingsController = SettingsChangeProvider(); await settingsController.loadSettings(); diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 01752ed..5ff7cb6 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -24,13 +24,13 @@ class MediaFilesDao extends DatabaseAccessor ); } - final rowId = await into( - mediaFiles, - ).insertOnConflictUpdate(insertMediaFile); + await into(mediaFiles).insertOnConflictUpdate(insertMediaFile); + + final mediaId = insertMediaFile.mediaId.value; return await (select( mediaFiles, - )..where((t) => t.rowId.equals(rowId))).getSingle(); + )..where((t) => t.mediaId.equals(mediaId))).getSingle(); } catch (e) { Log.error('Could not insert media file: $e'); return null; diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 7a1c36a..64b91b4 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -64,18 +64,41 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { return query.map((row) => row.readTable(messages)).watch(); } - Stream watchLastMessage(String groupId) { + Future> watchLastMessage(String groupId) async { + final group = await twonlyDB.groupsDao.getGroup(groupId); + final deletionTime = clock.now().subtract( + Duration( + milliseconds: group!.deleteMessagesAfterMilliseconds, + ), + ); 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)]) ..limit(1)) .watchSingleOrNull(); } - Stream> watchByGroupId(String groupId) { + Future>> watchByGroupId(String groupId) async { + final group = await twonlyDB.groupsDao.getGroup(groupId); + final deletionTime = clock.now().subtract( + Duration( + milliseconds: group!.deleteMessagesAfterMilliseconds, + ), + ); return ((select(messages)..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()) & (t.isDeletedFromSender.equals(true) | (t.type.equals(MessageType.text.name).not() | t.type.equals(MessageType.media.name).not()) | @@ -127,7 +150,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { (m.mediaStored.equals(true) & m.isDeletedFromSender.equals(true) | 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.createdAt.isSmallerThanValue(deletionTime))), )) diff --git a/lib/src/providers/purchases.provider.dart b/lib/src/providers/purchases.provider.dart index fd37b17..25cf150 100644 --- a/lib/src/providers/purchases.provider.dart +++ b/lib/src/providers/purchases.provider.dart @@ -70,11 +70,11 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { }; final response = await iapConnection.queryProductDetails(ids); if (response.notFoundIDs.isNotEmpty) { - Log.error(response.notFoundIDs); + Log.warn(response.notFoundIDs); } products = response.productDetails.map(PurchasableProduct.new).toList(); 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; notifyListeners(); diff --git a/lib/src/services/api/client2client/media.c2c.dart b/lib/src/services/api/client2client/media.c2c.dart index 4f08d91..496ac0b 100644 --- a/lib/src/services/api/client2client/media.c2c.dart +++ b/lib/src/services/api/client2client/media.c2c.dart @@ -117,8 +117,8 @@ Future handleMedia( } } - late MediaFile? mediaFile; - late Message? message; + MediaFile? mediaFile; + Message? message; await twonlyDB.transaction(() async { mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia( @@ -163,7 +163,7 @@ Future handleMedia( ); }); - if (message != null) { + if (message != null && mediaFile != null) { await twonlyDB.groupsDao.increaseLastMessageExchange( groupId, fromTimestamp(media.timestamp), @@ -176,6 +176,10 @@ Future handleMedia( ); unawaited(startDownloadMedia(mediaFile!, false)); + } else { + Log.error( + 'Could not insert new message as both the message and mediaFile are empty.', + ); } } diff --git a/lib/src/services/api/mediafiles/upload.service.dart b/lib/src/services/api/mediafiles/upload.service.dart index 806bce5..db48596 100644 --- a/lib/src/services/api/mediafiles/upload.service.dart +++ b/lib/src/services/api/mediafiles/upload.service.dart @@ -102,6 +102,12 @@ Future reuploadMediaFiles() async { .getMessageById(messageId) .getSingleOrNull(); 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( 'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).', ); diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index 07865ec..e678e46 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -102,21 +102,23 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ message.encryptedContent, ); - final pushNotification = await getPushNotificationFromEncryptedContent( - receipt.contactId, - receipt.messageId, - encryptedContent, - ); - - Log.info('Uploading $receiptId. (${pushNotification?.kind})'); + Log.info('Uploading $receiptId.'); Uint8List? pushData; - if (pushNotification != null && receipt.retryCount <= 1) { - // Only show the push notification the first two time. - pushData = await encryptPushNotification( + if (receipt.retryCount == 0) { + final pushNotification = await getPushNotificationFromEncryptedContent( receipt.contactId, - pushNotification, + receipt.messageId, + encryptedContent, ); + + if (pushNotification != null) { + // Only show the push notification the first two time. + pushData = await encryptPushNotification( + receipt.contactId, + pushNotification, + ); + } } if (message.type == pb.Message_Type.TEST_NOTIFICATION) { diff --git a/lib/src/services/notifications/background.notifications.dart b/lib/src/services/notifications/background.notifications.dart index 50d7820..3a1e682 100644 --- a/lib/src/services/notifications/background.notifications.dart +++ b/lib/src/services/notifications/background.notifications.dart @@ -214,6 +214,8 @@ Future showLocalPushNotification( pushNotification.kind == PushKind.reactionToText || pushNotification.kind == PushKind.reactionToAudio)) { payload = Routes.chatsMessages(groupId); + } else { + payload = Routes.chats; } await flutterLocalNotificationsPlugin.show( diff --git a/lib/src/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index 361a476..00ccc1c 100644 --- a/lib/src/views/chats/chat_list_components/group_list_item.dart +++ b/lib/src/views/chats/chat_list_components/group_list_item.dart @@ -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/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/typing_indicator_subtitle.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/flame.dart'; @@ -63,9 +64,10 @@ class _UserListItem extends State { } Future initStreams() async { - _lastMessageStream = twonlyDB.messagesDao - .watchLastMessage(widget.group.groupId) - .listen((update) { + _lastMessageStream = + (await twonlyDB.messagesDao.watchLastMessage( + widget.group.groupId, + )).listen((update) { protectUpdateState.protect(() async { await updateState(update, _messagesNotOpened); }); @@ -227,6 +229,7 @@ class _UserListItem extends State { VerifiedShield( group: widget.group, showOnlyIfVerified: true, + clickable: false, size: 12, ), ], @@ -249,6 +252,9 @@ class _UserListItem extends State { ) : Row( children: [ + TypingIndicatorSubtitle( + groupId: widget.group.groupId, + ), MessageSendStateIcon( _previewMessages, _previewMediaFiles, diff --git a/lib/src/views/chats/chat_list_components/typing_indicator_subtitle.dart b/lib/src/views/chats/chat_list_components/typing_indicator_subtitle.dart new file mode 100644 index 0000000..8592f48 --- /dev/null +++ b/lib/src/views/chats/chat_list_components/typing_indicator_subtitle.dart @@ -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 createState() => + _TypingIndicatorSubtitleState(); +} + +class _TypingIndicatorSubtitleState extends State { + List _groupMembers = []; + + late StreamSubscription> 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 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, + ), + ), + ), + ); + } +} diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index dcb1c2b..8b30c0b 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -107,7 +107,7 @@ class _ChatMessagesViewState extends State { }); }); - final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId); + final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId); messageSub = msgStream.listen((update) async { allMessages = update; await protectMessageUpdating.protect(() async { diff --git a/lib/src/views/chats/chat_messages_components/typing_indicator.dart b/lib/src/views/chats/chat_messages_components/typing_indicator.dart index da1f725..b48908b 100644 --- a/lib/src/views/chats/chat_messages_components/typing_indicator.dart +++ b/lib/src/views/chats/chat_messages_components/typing_indicator.dart @@ -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/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 { const TypingIndicator({required this.group, super.key}); @@ -18,10 +40,8 @@ class TypingIndicator extends StatefulWidget { State createState() => _TypingIndicatorState(); } -class _TypingIndicatorState extends State - with SingleTickerProviderStateMixin { +class _TypingIndicatorState extends State { late AnimationController _controller; - late List> _animations; List _groupMembers = []; @@ -43,7 +63,90 @@ class _TypingIndicatorState extends State membersSub = membersStream.listen((update) { filterOpenUsers(update.map((m) => m.$2).toList()); }); + } + void filterOpenUsers(List 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 createState() => _AnimatedTypingDotsState(); +} + +class _AnimatedTypingDotsState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + late List> _animations; + + @override + void initState() { _controller = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, @@ -77,93 +180,18 @@ class _TypingIndicatorState extends State ), ); }); - } - - void filterOpenUsers(List 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; + super.initState(); } @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( - 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, - children: List.generate( - 3, - (index) => _AnimatedDot( - isTyping: isTyping(member), - animation: _animations[index], - ), - ), - ), - ), - Expanded(child: Container()), - ], - ), - ), - ) - .toList(), + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + 3, + (index) => _AnimatedDot( + isTyping: widget.isTyping, + animation: _animations[index], ), ), ); diff --git a/lib/src/views/components/verified_shield.dart b/lib/src/views/components/verified_shield.dart index 6a6cca7..293e038 100644 --- a/lib/src/views/components/verified_shield.dart +++ b/lib/src/views/components/verified_shield.dart @@ -13,12 +13,14 @@ class VerifiedShield extends StatefulWidget { super.key, this.size = 15, this.showOnlyIfVerified = false, + this.clickable = true, }); final Group? group; final Contact? contact; final double size; final bool showOnlyIfVerified; + final bool clickable; @override State createState() => _VerifiedShieldState(); @@ -61,7 +63,7 @@ class _VerifiedShieldState extends State { Widget build(BuildContext context) { if (!isVerified && widget.showOnlyIfVerified) return Container(); return GestureDetector( - onTap: (contact == null) + onTap: (contact == null || !widget.clickable) ? null : () => context.push(Routes.settingsHelpFaqVerifyBadge), child: ColoredBox( diff --git a/lib/src/views/home.view.dart b/lib/src/views/home.view.dart index 83a52c6..f208d90 100644 --- a/lib/src/views/home.view.dart +++ b/lib/src/views/home.view.dart @@ -49,11 +49,11 @@ class Shade extends StatelessWidget { } class HomeViewState extends State { - int activePageIdx = 0; + int _activePageIdx = 1; final MainCameraController _mainCameraController = MainCameraController(); - final PageController homeViewPageController = PageController(initialPage: 1); + final PageController _homeViewPageController = PageController(initialPage: 1); late StreamSubscription> _intentStreamSub; late StreamSubscription _deepLinkSub; @@ -67,10 +67,10 @@ class HomeViewState extends State { bool onPageView(ScrollNotification notification) { disableCameraTimer?.cancel(); if (notification.depth == 0 && notification is ScrollUpdateNotification) { - final page = homeViewPageController.page ?? 0; + final page = _homeViewPageController.page ?? 0; lastChange = page; setState(() { - offsetFromOne = 1.0 - (homeViewPageController.page ?? 0); + offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0); offsetRatio = offsetFromOne.abs(); }); } @@ -100,17 +100,17 @@ class HomeViewState extends State { _mainCameraController.setState = () { if (mounted) setState(() {}); }; - activePageIdx = widget.initialPage; globalUpdateOfHomeViewPageIndex = (index) { - homeViewPageController.jumpToPage(index); + _homeViewPageController.jumpToPage(index); setState(() { - activePageIdx = index; + _activePageIdx = index; }); }; selectNotificationStream.stream.listen((response) async { if (response.payload != null && - response.payload!.startsWith(Routes.chats)) { + response.payload!.startsWith(Routes.chats) && + response.payload! != Routes.chats) { await routerProvider.push(response.payload!); } globalUpdateOfHomeViewPageIndex(0); @@ -134,6 +134,11 @@ class HomeViewState extends State { context, _mainCameraController.setSharedLinkForPreview, ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.initialPage == 0) { + globalUpdateOfHomeViewPageIndex(0); + } + }); } @override @@ -196,10 +201,10 @@ class HomeViewState extends State { onNotification: onPageView, child: Positioned.fill( child: PageView( - controller: homeViewPageController, + controller: _homeViewPageController, onPageChanged: (index) { setState(() { - activePageIdx = index; + _activePageIdx = index; }); }, children: [ @@ -222,7 +227,7 @@ class HomeViewState extends State { child: CameraPreviewControllerView( mainController: _mainCameraController, isVisible: - ((1 - (offsetRatio * 4) % 1) == 1) && activePageIdx == 1, + ((1 - (offsetRatio * 4) % 1) == 1) && _activePageIdx == 1, ), ), ), @@ -253,15 +258,15 @@ class HomeViewState extends State { ), ], onTap: (index) async { - activePageIdx = index; - await homeViewPageController.animateToPage( + _activePageIdx = index; + await _homeViewPageController.animateToPage( index, duration: const Duration(milliseconds: 100), curve: Curves.bounceIn, ); if (mounted) setState(() {}); }, - currentIndex: activePageIdx, + currentIndex: _activePageIdx, ), ); } diff --git a/lib/src/views/settings/profile/profile.view.dart b/lib/src/views/settings/profile/profile.view.dart index 89ea84f..fa743e2 100644 --- a/lib/src/views/settings/profile/profile.view.dart +++ b/lib/src/views/settings/profile/profile.view.dart @@ -57,14 +57,28 @@ class _ProfileViewState extends State { } Future _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 (!mounted) return; - if (result.error == ErrorCode.UsernameAlreadyTaken) { + if (result.error == ErrorCode.UsernameAlreadyTaken || + result.error == ErrorCode.UsernameNotValid) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.lang.errorUsernameAlreadyTaken), + content: Text( + result.error == ErrorCode.UsernameAlreadyTaken + ? context.lang.errorUsernameAlreadyTaken + : context.lang.errorUsernameNotValid, + ), duration: const Duration(seconds: 3), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index d576d53..a175898 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.1.5+105 +version: 0.1.6+106 environment: sdk: ^3.11.0