diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index 2a1a48a2..07a44b81 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -140,6 +140,9 @@ class UserData { // So update data can be assigned. If set the user choose to participate. String? userStudyParticipantsToken; + @JsonKey(defaultValue: 0) + int userStudyCountNewFriendsViaSuggestion = 0; + // Once a day the anonymous data is collected and send to the server DateTime? lastUserStudyDataUpload; diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 8b590656..3b028b00 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -95,6 +95,8 @@ UserData _$UserDataFromJson(Map json) => json['askedForUserStudyPermission'] as bool? ?? false ..userStudyParticipantsToken = json['userStudyParticipantsToken'] as String? + ..userStudyCountNewFriendsViaSuggestion = + (json['userStudyCountNewFriendsViaSuggestion'] as num?)?.toInt() ?? 0 ..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null ? null : DateTime.parse(json['lastUserStudyDataUpload'] as String) @@ -151,6 +153,8 @@ Map _$UserDataToJson(UserData instance) => { 'twonlySafeBackup': instance.twonlySafeBackup, 'askedForUserStudyPermission': instance.askedForUserStudyPermission, 'userStudyParticipantsToken': instance.userStudyParticipantsToken, + 'userStudyCountNewFriendsViaSuggestion': + instance.userStudyCountNewFriendsViaSuggestion, 'lastUserStudyDataUpload': instance.lastUserStudyDataUpload ?.toIso8601String(), 'currentSetupPage': instance.currentSetupPage, diff --git a/lib/src/services/api/mediafiles/download.api.dart b/lib/src/services/api/mediafiles/download.api.dart index 89a8b757..f7e8bd27 100644 --- a/lib/src/services/api/mediafiles/download.api.dart +++ b/lib/src/services/api/mediafiles/download.api.dart @@ -318,13 +318,6 @@ Future handleEncryptedFile(String mediaId) async { return; } - await twonlyDB.mediaFilesDao.updateMedia( - mediaId, - const MediaFilesCompanion( - downloadState: Value(DownloadState.downloaded), - ), - ); - try { final chacha20 = FlutterChacha20.poly1305Aead(); final secretKeyData = SecretKeyData( diff --git a/lib/src/services/user_study.service.dart b/lib/src/services/user_study.service.dart index 36d0cce8..4507add8 100644 --- a/lib/src/services/user_study.service.dart +++ b/lib/src/services/user_study.service.dart @@ -79,6 +79,9 @@ Future handleUserStudyUpload() async { 'user_discovery_count_announced_users': udAllAnnouncedUsers.length, 'user_discovery_count_unknown_announced_users': udUnknownAnnouncedUsers, + 'user_study_count_new_friends_via_suggestion': + userService.currentUser.userStudyCountNewFriendsViaSuggestion, + 'accepted_contacts': contacts.where((c) => c.accepted).length, 'verified_contacts': verifications.length, 'verified_contacts_via_migrated_from_old_version': verifications.values diff --git a/lib/src/visual/components/flame_counter.comp.dart b/lib/src/visual/components/flame_counter.comp.dart index 7ff8ec35..73d6491f 100644 --- a/lib/src/visual/components/flame_counter.comp.dart +++ b/lib/src/visual/components/flame_counter.comp.dart @@ -77,9 +77,6 @@ class _FlameCounterWidgetState extends State { flameEmoji = '🎂'; } - // Override with hourglass when the flame is about to expire - if (isExpiring) flameEmoji = '⌛'; - return Row( children: [ if (widget.prefix) const SizedBox(width: 5), @@ -96,6 +93,13 @@ class _FlameCounterWidgetState extends State { emoji: flameEmoji, ), ), + if (isExpiring) + const SizedBox( + height: 11, + child: EmojiAnimationComp( + emoji: '⌛', + ), + ), ], ); } diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index 2b77f699..225a3195 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -203,8 +203,7 @@ class _UserListItem extends State { await startDownloadMedia(mediaFile, true); return; } - if (mediaFile.downloadState! == DownloadState.ready || - mediaFile.downloadState! == DownloadState.downloaded) { + if (mediaFile.downloadState! == DownloadState.ready) { if (!mounted) return; await context.push( Routes.chatsMediaViewer, diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart index 77d752ba..96c7607c 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart @@ -88,9 +88,7 @@ class _ChatMediaEntryState extends State { } Future onTap() async { - if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready || - widget.mediaService.mediaFile.downloadState == - DownloadState.downloaded) && + if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready) && widget.message.openedAt == null) { if (!mounted) return; await Navigator.push( diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index ee3c84cb..361ad24a 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -48,8 +48,7 @@ class MediaViewerView extends StatefulWidget { State createState() => _MediaViewerViewState(); } -class _MediaViewerViewState extends State - with WidgetsBindingObserver { +class _MediaViewerViewState extends State { Timer? nextMediaTimer; Timer? progressTimer; @@ -90,7 +89,6 @@ class _MediaViewerViewState extends State if (widget.initialMessage != null) { allMediaFiles = [widget.initialMessage!]; } - WidgetsBinding.instance.addObserver(this); asyncLoadNextMedia(true); } @@ -105,23 +103,11 @@ class _MediaViewerViewState extends State final tmp = videoController; videoController = null; tmp?.dispose(); - WidgetsBinding.instance.removeObserver(this); super.dispose(); } final Mutex _messageUpdateLock = Mutex(); - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _messageUpdateLock.protect(() async { - if (currentMedia == null && allMediaFiles.isNotEmpty) { - await loadCurrentMediaFile(); - } - }); - } - } - bool _isViewActive() { return !AppState.isAppInBackground && (ModalRoute.of(context)?.isCurrent ?? false); @@ -158,7 +144,7 @@ class _MediaViewerViewState extends State allMediaFiles.add(msg); } } - setState(() {}); + if (mounted) setState(() {}); if (firstRun) { firstRun = false; await loadCurrentMediaFile(); @@ -228,7 +214,13 @@ class _MediaViewerViewState extends State await downloadStateListener?.cancel(); downloadStateListener = stream.listen((updated) async { - if (updated == null) return; + if (updated == null) { + // Media file record no longer exists — skip to next or exit rather + // than leaving the screen permanently black with no content/loader. + await downloadStateListener?.cancel(); + await nextMediaOrExit(); + return; + } if (updated.downloadState != DownloadState.ready) { setState(() { _showDownloadingLoader = true; @@ -238,7 +230,12 @@ class _MediaViewerViewState extends State final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( allMediaFiles.first.mediaId!, ); - if (mediaFile == null) return; + if (mediaFile == null) { + // DB record gone — skip to next or exit. + await downloadStateListener?.cancel(); + await nextMediaOrExit(); + return; + } await startDownloadMedia(mediaFile, true); unawaited(tryDownloadAllMediaFiles(force: true)); } @@ -246,7 +243,12 @@ class _MediaViewerViewState extends State } await downloadStateListener?.cancel(); - await handleNextDownloadedMedia(showTwonly); + try { + await handleNextDownloadedMedia(showTwonly); + } catch (e, st) { + Log.error('handleNextDownloadedMedia failed: $e\n$st'); + await nextMediaOrExit(); + } // start downloading all the other possible missing media files. }); } @@ -580,210 +582,213 @@ class _MediaViewerViewState extends State @override Widget build(BuildContext context) { return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - if (_showDownloadingLoader) _loader(), - if ((currentMedia != null || videoController != null) && - (canBeSeenUntil == null || progress >= 0)) - GestureDetector( - onTap: onTap, - onDoubleTap: (videoController == null) ? null : onTap, - child: MediaViewSizingHelper( - bottomNavigation: bottomNavigation(), - requiredHeight: 55, - child: Stack( - children: [ - if (videoController != null) - Positioned.fill( - child: PhotoView.customChild( - initialScale: PhotoViewComputedScale.contained, - minScale: PhotoViewComputedScale.contained, - child: VideoPlayerHelper( - controller: videoController!, - onDoubleTap: onTap, + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + if (_showDownloadingLoader) _loader(), + if ((currentMedia != null || videoController != null) && + (canBeSeenUntil == null || progress >= 0)) + GestureDetector( + onTap: onTap, + onDoubleTap: (videoController == null) ? null : onTap, + child: MediaViewSizingHelper( + bottomNavigation: bottomNavigation(), + requiredHeight: 55, + child: Stack( + children: [ + if (videoController != null) + Positioned.fill( + child: PhotoView.customChild( + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + child: VideoPlayerHelper( + controller: videoController!, + onDoubleTap: onTap, + ), + ), + ) + else if (currentMedia != null && + (currentMedia!.mediaFile.type == MediaType.image || + currentMedia!.mediaFile.type == MediaType.gif)) + Positioned.fill( + child: PhotoView( + imageProvider: FileImage( + currentMedia!.tempPath, + ), + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, ), ), - ) - else if (currentMedia != null && - currentMedia!.mediaFile.type == MediaType.image || - currentMedia!.mediaFile.type == MediaType.gif) - Positioned.fill( - child: PhotoView( - imageProvider: FileImage( - currentMedia!.tempPath, - ), - initialScale: PhotoViewComputedScale.contained, - minScale: PhotoViewComputedScale.contained, + ], + ), + ), + ), + if (displayTwonlyPresent) + Positioned.fill( + child: GestureDetector( + onTap: () => loadCurrentMediaFile(showTwonly: true), + child: Column( + children: [ + Expanded( + child: Lottie.asset( + 'assets/animations/present.lottie.lottie', ), ), - ], - ), - ), - ), - if (displayTwonlyPresent) - Positioned.fill( - child: GestureDetector( - onTap: () => loadCurrentMediaFile(showTwonly: true), - child: Column( - children: [ - Expanded( - child: Lottie.asset( - 'assets/animations/present.lottie.lottie', + Container( + padding: const EdgeInsets.only(bottom: 200), + child: Text(context.lang.mediaViewerTwonlyTapToOpen), ), - ), - Container( - padding: const EdgeInsets.only(bottom: 200), - child: Text(context.lang.mediaViewerTwonlyTapToOpen), - ), - ], + ], + ), ), ), - ), - Positioned( - left: 10, - top: 10, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close, size: 30), - color: Colors.white, - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - if (currentMedia != null && - currentMedia?.mediaFile.downloadState != DownloadState.ready) - Positioned.fill(child: _loader()), - if (canBeSeenUntil != null || progress >= 0) Positioned( - right: 20, - top: 27, + left: 10, + top: 10, child: Row( children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - value: progress, - strokeWidth: 2, - ), + IconButton( + icon: const Icon(Icons.close, size: 30), + color: Colors.white, + onPressed: () => Navigator.pop(context), ), ], ), ), - Positioned( - top: 10, - left: showSendTextMessageInput ? 0 : null, - right: showSendTextMessageInput ? 0 : 15, - child: Text( - _currentMediaSender, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: showSendTextMessageInput ? 24 : 14, - fontWeight: FontWeight.bold, - color: showSendTextMessageInput - ? null - : const Color.fromARGB(255, 126, 126, 126), - shadows: const [ - Shadow( - color: Color.fromARGB(122, 0, 0, 0), - blurRadius: 5, - ), - ], - ), - ), - ), - if (showSendTextMessageInput) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - color: context.color.surface, - padding: const EdgeInsets.only( - bottom: 10, - left: 20, - right: 20, - top: 10, - ), + if (currentMedia != null && + currentMedia?.mediaFile.downloadState != DownloadState.ready) + Positioned.fill(child: _loader()), + if (canBeSeenUntil != null || progress >= 0) + Positioned( + right: 20, + top: 27, child: Row( children: [ - IconButton( - icon: const FaIcon(FontAwesomeIcons.xmark), - onPressed: () { - setState(() { - showShortReactions = false; - showSendTextMessageInput = false; - }); - }, + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 2, + ), ), - Expanded( - child: TextField( - autofocus: true, - controller: textMessageController, - onChanged: (value) async { - await twonlyDB.groupsDao.updateGroup( - widget.group.groupId, - GroupsCompanion( - draftMessage: Value(textMessageController.text), - ), - ); + ], + ), + ), + Positioned( + top: 10, + left: showSendTextMessageInput ? 0 : null, + right: showSendTextMessageInput ? 0 : 15, + child: Text( + _currentMediaSender, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: showSendTextMessageInput ? 24 : 14, + fontWeight: FontWeight.bold, + color: showSendTextMessageInput + ? null + : const Color.fromARGB(255, 126, 126, 126), + shadows: const [ + Shadow( + color: Color.fromARGB(122, 0, 0, 0), + blurRadius: 5, + ), + ], + ), + ), + ), + if (showSendTextMessageInput) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + color: context.color.surface, + padding: const EdgeInsets.only( + bottom: 10, + left: 20, + right: 20, + top: 10, + ), + child: Row( + children: [ + IconButton( + icon: const FaIcon(FontAwesomeIcons.xmark), + onPressed: () { + setState(() { + showShortReactions = false; + showSendTextMessageInput = false; + }); }, - onEditingComplete: () { + ), + Expanded( + child: TextField( + autofocus: true, + controller: textMessageController, + onChanged: (value) async { + await twonlyDB.groupsDao.updateGroup( + widget.group.groupId, + GroupsCompanion( + draftMessage: Value(textMessageController.text), + ), + ); + }, + onEditingComplete: () { + setState(() { + showSendTextMessageInput = false; + showShortReactions = false; + }); + }, + decoration: inputTextMessageDeco( + context, + context.lang.chatListDetailInput, + ), + ), + ), + IconButton( + icon: const FaIcon(FontAwesomeIcons.solidPaperPlane), + onPressed: () async { + if (textMessageController.text.isNotEmpty) { + await insertAndSendTextMessage( + widget.group.groupId, + textMessageController.text, + currentMessage!.messageId, + ); + textMessageController.clear(); + } setState(() { showSendTextMessageInput = false; showShortReactions = false; }); }, - decoration: inputTextMessageDeco( - context, - context.lang.chatListDetailInput, - ), ), - ), - IconButton( - icon: const FaIcon(FontAwesomeIcons.solidPaperPlane), - onPressed: () async { - if (textMessageController.text.isNotEmpty) { - await insertAndSendTextMessage( - widget.group.groupId, - textMessageController.text, - currentMessage!.messageId, - ); - textMessageController.clear(); - } - setState(() { - showSendTextMessageInput = false; - showShortReactions = false; - }); - }, - ), - ], + ], + ), ), ), + if (currentMessage != null) + AdditionalMessageContent(currentMessage!), + if (currentMedia != null) + ReactionButtons( + show: showShortReactions, + textInputFocused: showSendTextMessageInput, + mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, + groupId: widget.group.groupId, + messageId: currentMessage!.messageId, + emojiKey: emojiKey, + hide: () { + setState(() { + showShortReactions = false; + showSendTextMessageInput = false; + }); + }, + ), + Positioned.fill( + child: EmojiFloatWidget(key: emojiKey), ), - if (currentMessage != null) AdditionalMessageContent(currentMessage!), - if (currentMedia != null) - ReactionButtons( - show: showShortReactions, - textInputFocused: showSendTextMessageInput, - mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, - groupId: widget.group.groupId, - messageId: currentMessage!.messageId, - emojiKey: emojiKey, - hide: () { - setState(() { - showShortReactions = false; - showSendTextMessageInput = false; - }); - }, - ), - Positioned.fill( - child: EmojiFloatWidget(key: emojiKey), - ), - ], + ], + ), ), ); } diff --git a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart index 16055b37..73725388 100644 --- a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart +++ b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart @@ -6,6 +6,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/utils.api.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/visual/components/avatar_icon.comp.dart'; @@ -66,6 +67,10 @@ class FriendSuggestionsComp extends StatelessWidget { ); if (added > 0) await importSignalContactAndCreateRequest(userdata); + + await UserService.update( + (u) => u.userStudyCountNewFriendsViaSuggestion += 1, + ); } Future _hideAnnouncedUser(int userId) async { diff --git a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart index ddc74eb1..17c7f8c7 100644 --- a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart +++ b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart @@ -93,6 +93,7 @@ class UserDiscoverySetupComp extends StatelessWidget { Text( context.lang.onboardingUserDiscoveryShareFriends, style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -255,6 +256,7 @@ class UserDiscoverySetupComp extends StatelessWidget { Text( context.lang.onboardingUserDiscoveryLetFriendsFindYou, style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, ), const SizedBox(height: 32), diff --git a/pubspec.yaml b/pubspec.yaml index cff8922a..3613b1bd 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.2.0+109 +version: 0.2.1+110 environment: sdk: ^3.11.0