diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index f8e9c23..92b1a0c 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -235,5 +235,16 @@ "planNotAllowed": "In deinem aktuellen Plan kannst du keine Mediendateien versenden. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden.", "planLimitReached": "Du hast dein Planlimit für heute erreicht. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden.", "galleryDelete": "Datei löschen", - "galleryDetails": "Details anzeigen" + "galleryDetails": "Details anzeigen", + "settingsResetTutorials": "Tutorials erneut anzeigen", + "settingsResetTutorialsDesc": "Klicke hier, um bereits angezeigte Tutorials erneut anzuzeigen.", + "settingsResetTutorialsSuccess": "Tutorials werden erneut angezeigt.", + "tutorialChatListSearchUsersTitle": "Freunde finden und Freundschaftsanfragen verwalten", + "tutorialChatListSearchUsersDesc": "Wenn du die Benutzernamen deiner Freunde kennst, kannst du sie hier suchen und eine Freundschaftsanfrage senden. Außerdem siehst du hier alle Anfragen von anderen Nutzern, die du annehmen oder blockieren kannst.", + "tutorialChatListContextMenuTitle": "Klicke lange auf den Kontakt, um das Kontextmenü zu öffnen.", + "tutorialChatListContextMenuDesc": "Mit dem Kontextmenü kannst du deine Kontakte anheften, archivieren und verschiedene Aktionen durchführen. Halte dazu einfach den Kontakt lange gedrückt und bewege dann deinen Finger auf die gewünschte Option oder tippe direkt darauf.", + "tutorialChatMessagesVerifyShieldTitle": "Verifiziere deine Kontakte!", + "tutorialChatMessagesVerifyShieldDesc": "twonly nutzt das Signal-Protokoll für eine sichere Ende-zu-Ende Verschlüsselung. Bei der ersten Kontaktaufnahme wird dafür der öffentliche Identitätsschlüssel von deinem Kontakt heruntergeladen. Um sicherzustellen, dass dieser Schlüssel nicht von Dritten ausgetauscht wurde, solltest du ihn mit deinem Freund vergleichen, wenn ihr euch persönlich trefft. Sobald du den Benutzer verifiziert hast, kannst du auch beim verschicken von Bildern und Videos den twonly-Modus aktivieren.", + "tutorialChatMessagesReopenMessageTitle": "Bilder und Videos erneut öffnen", + "tutorialChatMessagesReopenMessageDesc": "Wenn dein Freund dir ein Bild oder Video mit unendlicher Anzeigezeit gesendet hat, kannst du es bis zum Neustart der App jederzeit erneut öffnen. Um dies zu tun, musst du einfach doppelt auf die Nachricht klicken. Dein Freund erhält dann eine Benachrichtigung, dass du das Bild erneut angesehen hast." } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 59d2c20..937fd6e 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -394,5 +394,16 @@ "planLimitReached": "You have reached your plan limit for today. Upgrade your plan now to send the media file.", "planNotAllowed": "You cannot send media files with your current tariff. Upgrade your plan now to send the media file.", "galleryDelete": "Delete file", - "galleryDetails": "Show details" + "galleryDetails": "Show details", + "settingsResetTutorials": "Show tutorials again", + "settingsResetTutorialsDesc": "Click here to show already displayed tutorials again.", + "settingsResetTutorialsSuccess": "Tutorials will be displayed again.", + "tutorialChatListSearchUsersTitle": "Find Friends and Manage Friend Requests", + "tutorialChatListSearchUsersDesc": "If you know your friends' usernames, you can search for them here and send a friend request. You will also see all requests from other users that you can accept or block.", + "tutorialChatListContextMenuTitle": "Long press on the contact to open the context menu.", + "tutorialChatListContextMenuDesc": "With the context menu, you can pin, archive, and perform various actions on your contacts. Simply long press the contact and then move your finger to the desired option or tap directly on it.", + "tutorialChatMessagesVerifyShieldTitle": "Verify your contacts!", + "tutorialChatMessagesVerifyShieldDesc": "twonly uses the Signal protocol for secure end-to-end encryption. When you first contact someone, their public identity key is downloaded. To ensure that this key has not been tampered with by third parties, you should compare it with your friend when you meet in person. Once you have verified the user, you can also enable the twonly mode when sending images and videos.", + "tutorialChatMessagesReopenMessageTitle": "Reopen Images and Videos", + "tutorialChatMessagesReopenMessageDesc": "If your friend has sent you a picture or video with infinite display time, you can open it again at any time until you restart the app. To do this, simply double-click on the message. Your friend will then receive a notification that you have viewed the picture again." } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index b511b35..3a7eae5 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1429,6 +1429,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Show details'** String get galleryDetails; + + /// No description provided for @settingsResetTutorials. + /// + /// In en, this message translates to: + /// **'Show tutorials again'** + String get settingsResetTutorials; + + /// No description provided for @settingsResetTutorialsDesc. + /// + /// In en, this message translates to: + /// **'Click here to show already displayed tutorials again.'** + String get settingsResetTutorialsDesc; + + /// No description provided for @settingsResetTutorialsSuccess. + /// + /// In en, this message translates to: + /// **'Tutorials will be displayed again.'** + String get settingsResetTutorialsSuccess; + + /// No description provided for @tutorialChatListSearchUsersTitle. + /// + /// In en, this message translates to: + /// **'Find Friends and Manage Friend Requests'** + String get tutorialChatListSearchUsersTitle; + + /// No description provided for @tutorialChatListSearchUsersDesc. + /// + /// In en, this message translates to: + /// **'If you know your friends\' usernames, you can search for them here and send a friend request. You will also see all requests from other users that you can accept or block.'** + String get tutorialChatListSearchUsersDesc; + + /// No description provided for @tutorialChatListContextMenuTitle. + /// + /// In en, this message translates to: + /// **'Long press on the contact to open the context menu.'** + String get tutorialChatListContextMenuTitle; + + /// No description provided for @tutorialChatListContextMenuDesc. + /// + /// In en, this message translates to: + /// **'With the context menu, you can pin, archive, and perform various actions on your contacts. Simply long press the contact and then move your finger to the desired option or tap directly on it.'** + String get tutorialChatListContextMenuDesc; + + /// No description provided for @tutorialChatMessagesVerifyShieldTitle. + /// + /// In en, this message translates to: + /// **'Verify your contacts!'** + String get tutorialChatMessagesVerifyShieldTitle; + + /// No description provided for @tutorialChatMessagesVerifyShieldDesc. + /// + /// In en, this message translates to: + /// **'twonly uses the Signal protocol for secure end-to-end encryption. When you first contact someone, their public identity key is downloaded. To ensure that this key has not been tampered with by third parties, you should compare it with your friend when you meet in person. Once you have verified the user, you can also enable the twonly mode when sending images and videos.'** + String get tutorialChatMessagesVerifyShieldDesc; + + /// No description provided for @tutorialChatMessagesReopenMessageTitle. + /// + /// In en, this message translates to: + /// **'Reopen Images and Videos'** + String get tutorialChatMessagesReopenMessageTitle; + + /// No description provided for @tutorialChatMessagesReopenMessageDesc. + /// + /// In en, this message translates to: + /// **'If your friend has sent you a picture or video with infinite display time, you can open it again at any time until you restart the app. To do this, simply double-click on the message. Your friend will then receive a notification that you have viewed the picture again.'** + String get tutorialChatMessagesReopenMessageDesc; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index f0c8ea6..56a3e97 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -743,4 +743,47 @@ class AppLocalizationsDe extends AppLocalizations { @override String get galleryDetails => 'Details anzeigen'; + + @override + String get settingsResetTutorials => 'Tutorials erneut anzeigen'; + + @override + String get settingsResetTutorialsDesc => + 'Klicke hier, um bereits angezeigte Tutorials erneut anzuzeigen.'; + + @override + String get settingsResetTutorialsSuccess => + 'Tutorials werden erneut angezeigt.'; + + @override + String get tutorialChatListSearchUsersTitle => + 'Freunde finden und Freundschaftsanfragen verwalten'; + + @override + String get tutorialChatListSearchUsersDesc => + 'Wenn du die Benutzernamen deiner Freunde kennst, kannst du sie hier suchen und eine Freundschaftsanfrage senden. Außerdem siehst du hier alle Anfragen von anderen Nutzern, die du annehmen oder blockieren kannst.'; + + @override + String get tutorialChatListContextMenuTitle => + 'Klicke lange auf den Kontakt, um das Kontextmenü zu öffnen.'; + + @override + String get tutorialChatListContextMenuDesc => + 'Mit dem Kontextmenü kannst du deine Kontakte anheften, archivieren und verschiedene Aktionen durchführen. Halte dazu einfach den Kontakt lange gedrückt und bewege dann deinen Finger auf die gewünschte Option oder tippe direkt darauf.'; + + @override + String get tutorialChatMessagesVerifyShieldTitle => + 'Verifiziere deine Kontakte!'; + + @override + String get tutorialChatMessagesVerifyShieldDesc => + 'twonly nutzt das Signal-Protokoll für eine sichere Ende-zu-Ende Verschlüsselung. Bei der ersten Kontaktaufnahme wird dafür der öffentliche Identitätsschlüssel von deinem Kontakt heruntergeladen. Um sicherzustellen, dass dieser Schlüssel nicht von Dritten ausgetauscht wurde, solltest du ihn mit deinem Freund vergleichen, wenn ihr euch persönlich trefft. Sobald du den Benutzer verifiziert hast, kannst du auch beim verschicken von Bildern und Videos den twonly-Modus aktivieren.'; + + @override + String get tutorialChatMessagesReopenMessageTitle => + 'Bilder und Videos erneut öffnen'; + + @override + String get tutorialChatMessagesReopenMessageDesc => + 'Wenn dein Freund dir ein Bild oder Video mit unendlicher Anzeigezeit gesendet hat, kannst du es bis zum Neustart der App jederzeit erneut öffnen. Um dies zu tun, musst du einfach doppelt auf die Nachricht klicken. Dein Freund erhält dann eine Benachrichtigung, dass du das Bild erneut angesehen hast.'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index caf40b9..cfb1902 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -738,4 +738,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get galleryDetails => 'Show details'; + + @override + String get settingsResetTutorials => 'Show tutorials again'; + + @override + String get settingsResetTutorialsDesc => + 'Click here to show already displayed tutorials again.'; + + @override + String get settingsResetTutorialsSuccess => + 'Tutorials will be displayed again.'; + + @override + String get tutorialChatListSearchUsersTitle => + 'Find Friends and Manage Friend Requests'; + + @override + String get tutorialChatListSearchUsersDesc => + 'If you know your friends\' usernames, you can search for them here and send a friend request. You will also see all requests from other users that you can accept or block.'; + + @override + String get tutorialChatListContextMenuTitle => + 'Long press on the contact to open the context menu.'; + + @override + String get tutorialChatListContextMenuDesc => + 'With the context menu, you can pin, archive, and perform various actions on your contacts. Simply long press the contact and then move your finger to the desired option or tap directly on it.'; + + @override + String get tutorialChatMessagesVerifyShieldTitle => 'Verify your contacts!'; + + @override + String get tutorialChatMessagesVerifyShieldDesc => + 'twonly uses the Signal protocol for secure end-to-end encryption. When you first contact someone, their public identity key is downloaded. To ensure that this key has not been tampered with by third parties, you should compare it with your friend when you meet in person. Once you have verified the user, you can also enable the twonly mode when sending images and videos.'; + + @override + String get tutorialChatMessagesReopenMessageTitle => + 'Reopen Images and Videos'; + + @override + String get tutorialChatMessagesReopenMessageDesc => + 'If your friend has sent you a picture or video with infinite display time, you can open it again at any time until you restart the app. To do this, simply double-click on the message. Your friend will then receive a notification that you have viewed the picture again.'; } diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 24da2d7..fb6ed71 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -33,6 +33,7 @@ class UserData { DateTime? lastImageSend; int? todaysImageCounter; + List? tutorialDisplayed; int? myBestFriendContactId; diff --git a/lib/src/model/json/userdata.g.dart b/lib/src/model/json/userdata.g.dart index 2ebfae2..3694f5b 100644 --- a/lib/src/model/json/userdata.g.dart +++ b/lib/src/model/json/userdata.g.dart @@ -36,6 +36,9 @@ UserData _$UserDataFromJson(Map json) => UserData( ? null : DateTime.parse(json['lastImageSend'] as String) ..todaysImageCounter = (json['todaysImageCounter'] as num?)?.toInt() + ..tutorialDisplayed = (json['tutorialDisplayed'] as List?) + ?.map((e) => e as String) + .toList() ..myBestFriendContactId = (json['myBestFriendContactId'] as num?)?.toInt() ..signalLastSignedPreKeyUpdated = json['signalLastSignedPreKeyUpdated'] == null @@ -60,6 +63,7 @@ Map _$UserDataToJson(UserData instance) => { 'additionalUserInvites': instance.additionalUserInvites, 'lastImageSend': instance.lastImageSend?.toIso8601String(), 'todaysImageCounter': instance.todaysImageCounter, + 'tutorialDisplayed': instance.tutorialDisplayed, 'myBestFriendContactId': instance.myBestFriendContactId, 'signalLastSignedPreKeyUpdated': instance.signalLastSignedPreKeyUpdated?.toIso8601String(), diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index 9c29c3e..d0ee0b1 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -22,6 +22,7 @@ import 'package:twonly/src/views/settings/settings_main.view.dart'; import 'package:twonly/src/views/chats/add_new_user.view.dart'; import 'package:flutter/material.dart'; import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/views/tutorial/tutorials.dart'; class ChatListView extends StatefulWidget { const ChatListView({super.key}); @@ -30,6 +31,45 @@ class ChatListView extends StatefulWidget { } class _ChatListViewState extends State { + late StreamSubscription> _contactsSub; + List _contacts = []; + List _pinnedContacts = []; + + GlobalKey firstUserListItemKey = GlobalKey(); + GlobalKey searchForOtherUsers = GlobalKey(); + + @override + void initState() { + initAsync(); + super.initState(); + } + + Future initAsync() async { + final stream = twonlyDB.contactsDao.watchContactsForChatList(); + _contactsSub = stream.listen((contacts) { + setState(() { + _contacts = contacts.where((x) => !x.pinned).toList(); + _pinnedContacts = contacts.where((x) => x.pinned).toList(); + }); + ; + }); + + Future.delayed(Duration(seconds: 1), () async { + if (!mounted) return; + await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers); + if (!mounted) return; + if (_contacts.isNotEmpty) { + await showChatListTutorialContextMenu(context, firstUserListItemKey); + } + }); + } + + @override + void dispose() { + _contactsSub.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { bool isConnected = context.watch().isConnected; @@ -71,6 +111,7 @@ class _ChatListViewState extends State { return NotificationBadge( count: count.toString(), child: IconButton( + key: searchForOtherUsers, icon: FaIcon(FontAwesomeIcons.userPlus, size: 18), onPressed: () { Navigator.push( @@ -106,16 +147,8 @@ class _ChatListViewState extends State { child: isConnected ? Container() : ConnectionInfo(), ), Positioned.fill( - child: StreamBuilder( - stream: twonlyDB.contactsDao.watchContactsForChatList(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == null) { - return Container(); - } - - var contacts = snapshot.data!; - if (contacts.isEmpty) { - return Center( + child: (_contacts.isEmpty && _pinnedContacts.isEmpty) + ? Center( child: Padding( padding: const EdgeInsets.all(10), child: OutlinedButton.icon( @@ -131,60 +164,58 @@ class _ChatListViewState extends State { label: Text(context.lang.chatListViewSearchUserNameBtn)), ), - ); - } - - final pinnedUsers = contacts.where((c) => c.pinned).toList(); - - return RefreshIndicator( - onRefresh: () async { - await apiService.close(() {}); - await apiService.connect(); - await Future.delayed(Duration(seconds: 1)); - }, - child: ListView.builder( - itemCount: pinnedUsers.length + - (pinnedUsers.isNotEmpty ? 1 : 0) + - contacts.where((c) => !c.pinned).length, - itemExtentBuilder: (index, dimensions) { - int adjustedIndex = index - pinnedUsers.length; - if (pinnedUsers.isNotEmpty && adjustedIndex == 0) { - return 16; - } - return 72; + ) + : RefreshIndicator( + onRefresh: () async { + await apiService.close(() {}); + await apiService.connect(); + await Future.delayed(Duration(seconds: 1)); }, - itemBuilder: (context, index) { - // Check if the index is for the pinned users - if (index < pinnedUsers.length) { - final contact = pinnedUsers[index]; + child: ListView.builder( + itemCount: _pinnedContacts.length + + (_pinnedContacts.isNotEmpty ? 1 : 0) + + _contacts.length, + itemExtentBuilder: (index, dimensions) { + int adjustedIndex = index - _pinnedContacts.length; + if (_pinnedContacts.isNotEmpty && adjustedIndex == 0) { + return 16; + } + return 72; + }, + itemBuilder: (context, index) { + // Check if the index is for the pinned users + if (index < _pinnedContacts.length) { + final contact = _pinnedContacts[index]; + return UserListItem( + key: ValueKey(contact.userId), + user: contact, + firstUserListItemKey: + (index == 0) ? firstUserListItemKey : null, + ); + } + + // If there are pinned users, account for the Divider + int adjustedIndex = index - _pinnedContacts.length; + if (_pinnedContacts.isNotEmpty && adjustedIndex == 0) { + return Divider(); + } + + // Adjust the index for the contacts list + adjustedIndex -= (_pinnedContacts.isNotEmpty ? 1 : 0); + + // Get the contacts that are not pinned + final contact = _contacts.elementAt( + adjustedIndex, + ); return UserListItem( key: ValueKey(contact.userId), user: contact, + firstUserListItemKey: + (index == 0) ? firstUserListItemKey : null, ); - } - - // If there are pinned users, account for the Divider - int adjustedIndex = index - pinnedUsers.length; - if (pinnedUsers.isNotEmpty && adjustedIndex == 0) { - return Divider(); - } - - // Adjust the index for the contacts list - adjustedIndex -= (pinnedUsers.isNotEmpty ? 1 : 0); - - // Get the contacts that are not pinned - final contact = contacts - .where((c) => !c.pinned) - .elementAt(adjustedIndex); - return UserListItem( - key: ValueKey(contact.userId), - user: contact, - ); - }, + }, + ), ), - ); - }, - ), ), ], ), @@ -209,11 +240,10 @@ class _ChatListViewState extends State { class UserListItem extends StatefulWidget { final Contact user; + final GlobalKey? firstUserListItemKey; - const UserListItem({ - super.key, - required this.user, - }); + const UserListItem( + {super.key, required this.user, required this.firstUserListItemKey}); @override State createState() => _UserListItem(); @@ -315,79 +345,95 @@ class _UserListItem extends State { Widget build(BuildContext context) { int flameCounter = getFlameCounterFromContact(widget.user); - return UserContextMenu( - contact: widget.user, - child: ListTile( - title: Text(getContactDisplayName(widget.user)), - subtitle: (currentMessage == null) - ? Text(context.lang.chatsTapToSend) - : Row( - children: [ - MessageSendStateIcon(previewMessages), - Text("•"), - const SizedBox(width: 5), - Text( - formatDuration(lastMessageInSeconds), - style: TextStyle(fontSize: 12), - ), - if (flameCounter > 0) - FlameCounterWidget( - widget.user, - flameCounter, - prefix: true, - ), - ], - ), - leading: ContactAvatar(contact: widget.user), - trailing: IconButton( - onPressed: () { - Navigator.push(context, MaterialPageRoute( - builder: (context) { - return CameraSendToView(widget.user); - }, - )); - }, - icon: FaIcon(FontAwesomeIcons.camera, - color: context.color.outline.withAlpha(150)), + return Stack( + children: [ + Positioned( + top: 0, + bottom: 0, + left: 50, + child: SizedBox( + key: widget.firstUserListItemKey, + height: 20, + width: 20, + ), ), - onTap: () { - if (currentMessage == null) { - Navigator.push(context, MaterialPageRoute( - builder: (context) { - return CameraSendToView(widget.user); + UserContextMenu( + contact: widget.user, + child: ListTile( + title: Text( + getContactDisplayName(widget.user), + ), + subtitle: (currentMessage == null) + ? Text(context.lang.chatsTapToSend) + : Row( + children: [ + MessageSendStateIcon(previewMessages), + Text("•"), + const SizedBox(width: 5), + Text( + formatDuration(lastMessageInSeconds), + style: TextStyle(fontSize: 12), + ), + if (flameCounter > 0) + FlameCounterWidget( + widget.user, + flameCounter, + prefix: true, + ), + ], + ), + leading: ContactAvatar(contact: widget.user), + trailing: IconButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute( + builder: (context) { + return CameraSendToView(widget.user); + }, + )); }, - )); - return; - } - List msgs = previewMessages - .where((x) => x.kind == MessageKind.media) - .toList(); - if (msgs.isNotEmpty && - msgs.first.kind == MessageKind.media && - msgs.first.messageOtherId != null && - msgs.first.openedAt == null) { - switch (msgs.first.downloadState) { - case DownloadState.pending: - startDownloadMedia(msgs.first, true); - case DownloadState.downloaded: - Navigator.push( - context, - MaterialPageRoute(builder: (context) { - return MediaViewerView(widget.user); - }), - ); - default: - } - return; - } - Navigator.push( - context, - MaterialPageRoute(builder: (context) { - return ChatMessagesView(widget.user); - }), - ); - }, - ), + icon: FaIcon(FontAwesomeIcons.camera, + color: context.color.outline.withAlpha(150)), + ), + onTap: () { + if (currentMessage == null) { + Navigator.push(context, MaterialPageRoute( + builder: (context) { + return CameraSendToView(widget.user); + }, + )); + return; + } + List msgs = previewMessages + .where((x) => x.kind == MessageKind.media) + .toList(); + if (msgs.isNotEmpty && + msgs.first.kind == MessageKind.media && + msgs.first.messageOtherId != null && + msgs.first.openedAt == null) { + switch (msgs.first.downloadState) { + case DownloadState.pending: + startDownloadMedia(msgs.first, true); + case DownloadState.downloaded: + Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return MediaViewerView(widget.user); + }), + ); + default: + } + return; + } + Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return ChatMessagesView(widget.user); + }), + ); + }, + ), + ) + ], ); } } diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index 9a0af4f..ad72407 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -19,6 +19,7 @@ import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/contact/contact.view.dart'; import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/views/tutorial/tutorials.dart'; Color getMessageColor(Message message) { return (message.messageOtherId == null) @@ -48,6 +49,7 @@ class _ChatMessagesViewState extends State { Map> textReactionsToMessageId = {}; Map> emojiReactionsToMessageId = {}; Message? responseToMessage; + GlobalKey verifyShieldKey = GlobalKey(); late FocusNode textFieldFocus; @override @@ -56,6 +58,11 @@ class _ChatMessagesViewState extends State { user = widget.contact; textFieldFocus = FocusNode(); initStreams(); + + Future.delayed(Duration(seconds: 1), () async { + if (!mounted) return; + await showVerifyShieldTutorial(context, verifyShieldKey); + }); } @override @@ -251,7 +258,7 @@ class _ChatMessagesViewState extends State { children: [ Text(getContactDisplayName(user)), SizedBox(width: 10), - VerifiedShield(user), + VerifiedShield(key: verifyShieldKey, user), ], ), ), diff --git a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart index 1af6a24..f732ba5 100644 --- a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart @@ -10,8 +10,9 @@ import 'package:twonly/src/services/api/media_received.dart' as received; import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/views/chats/media_viewer.view.dart'; import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/views/tutorial/tutorials.dart'; -class ChatMediaEntry extends StatelessWidget { +class ChatMediaEntry extends StatefulWidget { const ChatMediaEntry({ super.key, required this.message, @@ -25,51 +26,82 @@ class ChatMediaEntry extends StatelessWidget { final MessageContent content; final List galleryItems; + @override + State createState() => _ChatMediaEntryState(); +} + +class _ChatMediaEntryState extends State { + GlobalKey reopenMediaFile = GlobalKey(); + + @override + void initState() { + super.initState(); + checkIfTutorialCanBeShown(); + } + + Future checkIfTutorialCanBeShown() async { + if (widget.message.openedAt == null && + widget.message.messageOtherId != null || + widget.message.mediaStored) { + return; + } + if (await received.existsMediaFile(widget.message.messageId, "png")) { + Future.delayed(Duration(seconds: 1), () { + if (!mounted) return; + showReopenMediaFilesTutorial(context, reopenMediaFile); + }); + } + } + @override Widget build(BuildContext context) { Color color = getMessageColorFromType( - content, + widget.content, context, ); return GestureDetector( + key: reopenMediaFile, onDoubleTap: () async { - if (message.openedAt == null && message.messageOtherId != null || - message.mediaStored) { + if (widget.message.openedAt == null && + widget.message.messageOtherId != null || + widget.message.mediaStored) { return; } - if (await received.existsMediaFile(message.messageId, "png")) { + if (await received.existsMediaFile(widget.message.messageId, "png")) { await encryptAndSendMessageAsync( null, - contact.userId, + widget.contact.userId, MessageJson( kind: MessageKind.reopenedMedia, - messageId: message.messageId, + messageId: widget.message.messageId, content: ReopenedMediaFileContent( - messageId: message.messageOtherId!, + messageId: widget.message.messageOtherId!, ), timestamp: DateTime.now(), ), pushKind: PushKind.reopenedMedia, ); await twonlyDB.messagesDao.updateMessageByMessageId( - message.messageId, + widget.message.messageId, MessagesCompanion(openedAt: Value(null)), ); } }, - onTap: () { - if (message.kind == MessageKind.media) { - if (message.downloadState == DownloadState.downloaded && - message.openedAt == null) { - Navigator.push( + onTap: () async { + if (widget.message.kind == MessageKind.media) { + if (widget.message.downloadState == DownloadState.downloaded && + widget.message.openedAt == null) { + await Navigator.push( context, MaterialPageRoute(builder: (context) { - return MediaViewerView(contact, initialMessage: message); + return MediaViewerView(widget.contact, + initialMessage: widget.message); }), ); - } else if (message.downloadState == DownloadState.pending) { - received.startDownloadMedia(message, true); + checkIfTutorialCanBeShown(); + } else if (widget.message.downloadState == DownloadState.pending) { + received.startDownloadMedia(widget.message, true); } } }, @@ -80,10 +112,10 @@ class ChatMediaEntry extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: InChatMediaViewer( - message: message, - contact: contact, + message: widget.message, + contact: widget.contact, color: color, - galleryItems: galleryItems, + galleryItems: widget.galleryItems, ), ), ), diff --git a/lib/src/views/settings/help/help.view.dart b/lib/src/views/settings/help/help.view.dart index 946860a..a5356e3 100644 --- a/lib/src/views/settings/help/help.view.dart +++ b/lib/src/views/settings/help/help.view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/settings/help/contact_us.view.dart'; import 'package:twonly/src/views/settings/help/credits.view.dart'; import 'package:twonly/src/views/settings/help/diagnostics.view.dart'; @@ -34,6 +35,23 @@ class HelpView extends StatelessWidget { })); }, ), + ListTile( + title: Text(context.lang.settingsResetTutorials), + subtitle: Text(context.lang.settingsResetTutorialsDesc), + onTap: () async { + final user = await getUser(); + if (user == null) return; + user.tutorialDisplayed = []; + await updateUser(user); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.lang.settingsResetTutorialsSuccess), + duration: Duration(seconds: 3), + ), + ); + }, + ), Divider(), FutureBuilder( future: PackageInfo.fromPlatform(), diff --git a/lib/src/views/tutorial/show_tutorial.dart b/lib/src/views/tutorial/show_tutorial.dart new file mode 100644 index 0000000..a484e68 --- /dev/null +++ b/lib/src/views/tutorial/show_tutorial.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mutex/mutex.dart'; +import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; + +final lockDisplayTutorial = Mutex(); + +Future showTutorial(BuildContext context, List targets) async { + await lockDisplayTutorial.protect(() async { + Completer completer = Completer(); + TutorialCoachMark( + targets: targets, + colorShadow: context.color.primary, + textSkip: context.lang.ok, + alignSkip: Alignment.bottomCenter, + textStyleSkip: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + onClickTarget: (target) { + print(target); + }, + onClickTargetWithTapPosition: (target, tapDetails) { + print("target: $target"); + print( + "clicked at position local: ${tapDetails.localPosition} - global: ${tapDetails.globalPosition}"); + }, + onClickOverlay: (target) { + print(target); + }, + onSkip: () { + completer.complete(); + return true; + }, + onFinish: () { + completer.complete(); + }, + ).show(context: context); + + await completer.future; + }); +} + +Future checkIfTutorialAlreadyShown(String tutorialId) async { + final user = await getUser(); + if (user == null) return true; + user.tutorialDisplayed ??= []; + if (user.tutorialDisplayed!.contains(tutorialId)) { + return true; + } + user.tutorialDisplayed!.add(tutorialId); + await updateUser(user); + return false; +} + +TargetFocus getTargetFocus( + BuildContext context, GlobalKey key, String title, String body) { + RenderBox renderBox = key.currentContext?.findRenderObject() as RenderBox; + Offset position = renderBox.localToGlobal(Offset.zero); + double screenHeight = MediaQuery.of(context).size.height; + double centerY = screenHeight / 2; + + double top = 0; + double bottom = 0; + + if (position.dy < centerY) { + bottom = 0; + top = position.dy; + } else { + bottom = centerY; + top = 0; + } + + return TargetFocus( + identify: title, + keyTarget: key, + contents: [ + TargetContent( + align: ContentAlign.custom, + customPosition: CustomTargetContentPosition( + left: 0, right: 0, top: top, bottom: bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + fontSize: 20.0, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Text( + body, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + ) + ], + ), + ) + ], + ); +} diff --git a/lib/src/views/tutorial/tutorials.dart b/lib/src/views/tutorial/tutorials.dart new file mode 100644 index 0000000..847e650 --- /dev/null +++ b/lib/src/views/tutorial/tutorials.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/tutorial/show_tutorial.dart'; + +Future showChatListTutorialSearchOtherUsers( + BuildContext context, + GlobalKey searchForOtherUsers, +) async { + if (await checkIfTutorialAlreadyShown("chat_list:search_users")) { + return; + } + if (!context.mounted) return; + List targets = []; + targets.add(getTargetFocus( + context, + searchForOtherUsers, + context.lang.tutorialChatListSearchUsersTitle, + context.lang.tutorialChatListSearchUsersDesc, + )); + await showTutorial(context, targets); +} + +Future showChatListTutorialContextMenu( + BuildContext context, + GlobalKey firstUserListItemKey, +) async { + if (await checkIfTutorialAlreadyShown("chat_list:context_menu")) { + return; + } + if (!context.mounted) return; + List targets = []; + targets.add(getTargetFocus( + context, + firstUserListItemKey, + context.lang.tutorialChatListContextMenuTitle, + context.lang.tutorialChatListContextMenuDesc, + )); + await showTutorial(context, targets); +} + +Future showVerifyShieldTutorial( + BuildContext context, + GlobalKey firstUserListItemKey, +) async { + if (await checkIfTutorialAlreadyShown("chat_messages:verify_shield")) { + return; + } + if (!context.mounted) return; + List targets = []; + targets.add(getTargetFocus( + context, + firstUserListItemKey, + context.lang.tutorialChatMessagesVerifyShieldTitle, + context.lang.tutorialChatMessagesVerifyShieldDesc, + )); + await showTutorial(context, targets); +} + +Future showReopenMediaFilesTutorial( + BuildContext context, + GlobalKey firstUserListItemKey, +) async { + if (await checkIfTutorialAlreadyShown("chat_messages:reopen_message")) { + return; + } + if (!context.mounted) return; + List targets = []; + targets.add(getTargetFocus( + context, + firstUserListItemKey, + context.lang.tutorialChatMessagesReopenMessageTitle, + context.lang.tutorialChatMessagesReopenMessageDesc, + )); + await showTutorial(context, targets); +} diff --git a/pubspec.lock b/pubspec.lock index 89f26f6..110f21f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1347,22 +1347,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - qr: - dependency: transitive - description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - qr_flutter: - dependency: "direct main" - description: - name: qr_flutter - sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" - url: "https://pub.dev" - source: hosted - version: "4.1.0" recase: dependency: transitive description: @@ -1656,6 +1640,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tutorial_coach_mark: + dependency: "direct main" + description: + name: tutorial_coach_mark + sha256: "9cdb721165d1cfb6e9b1910a1af1b3570fa6caa5059cf1506fcbd00bf7102abf" + url: "https://pub.dev" + source: hosted + version: "1.3.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 631e3d7..c782279 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: video_compress: ^3.1.4 share_plus: ^11.0.0 photo_view: ^0.15.0 + tutorial_coach_mark: ^1.3.0 dev_dependencies: flutter_test: