This commit is contained in:
otsmr 2025-05-31 16:00:58 +02:00
parent 6d5be8c52f
commit cbc3faba4e
15 changed files with 638 additions and 172 deletions

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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.';
}

View file

@ -33,6 +33,7 @@ class UserData {
DateTime? lastImageSend;
int? todaysImageCounter;
List<String>? tutorialDisplayed;
int? myBestFriendContactId;

View file

@ -36,6 +36,9 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
? null
: DateTime.parse(json['lastImageSend'] as String)
..todaysImageCounter = (json['todaysImageCounter'] as num?)?.toInt()
..tutorialDisplayed = (json['tutorialDisplayed'] as List<dynamic>?)
?.map((e) => e as String)
.toList()
..myBestFriendContactId = (json['myBestFriendContactId'] as num?)?.toInt()
..signalLastSignedPreKeyUpdated =
json['signalLastSignedPreKeyUpdated'] == null
@ -60,6 +63,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'additionalUserInvites': instance.additionalUserInvites,
'lastImageSend': instance.lastImageSend?.toIso8601String(),
'todaysImageCounter': instance.todaysImageCounter,
'tutorialDisplayed': instance.tutorialDisplayed,
'myBestFriendContactId': instance.myBestFriendContactId,
'signalLastSignedPreKeyUpdated':
instance.signalLastSignedPreKeyUpdated?.toIso8601String(),

View file

@ -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<ChatListView> {
late StreamSubscription<List<Contact>> _contactsSub;
List<Contact> _contacts = [];
List<Contact> _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<CustomChangeProvider>().isConnected;
@ -71,6 +111,7 @@ class _ChatListViewState extends State<ChatListView> {
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<ChatListView> {
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<ChatListView> {
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<ChatListView> {
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<UserListItem> createState() => _UserListItem();
@ -315,79 +345,95 @@ class _UserListItem extends State<UserListItem> {
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<Message> 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<Message> 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);
}),
);
},
),
)
],
);
}
}

View file

@ -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<ChatMessagesView> {
Map<int, List<Message>> textReactionsToMessageId = {};
Map<int, List<Message>> emojiReactionsToMessageId = {};
Message? responseToMessage;
GlobalKey verifyShieldKey = GlobalKey();
late FocusNode textFieldFocus;
@override
@ -56,6 +58,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
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<ChatMessagesView> {
children: [
Text(getContactDisplayName(user)),
SizedBox(width: 10),
VerifiedShield(user),
VerifiedShield(key: verifyShieldKey, user),
],
),
),

View file

@ -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<MemoryItem> galleryItems;
@override
State<ChatMediaEntry> createState() => _ChatMediaEntryState();
}
class _ChatMediaEntryState extends State<ChatMediaEntry> {
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,
),
),
),

View file

@ -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(),

View file

@ -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<TargetFocus> 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<bool> 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: <Widget>[
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,
),
),
)
],
),
)
],
);
}

View file

@ -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<TargetFocus> 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<TargetFocus> 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<TargetFocus> 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<TargetFocus> targets = [];
targets.add(getTargetFocus(
context,
firstUserListItemKey,
context.lang.tutorialChatMessagesReopenMessageTitle,
context.lang.tutorialChatMessagesReopenMessageDesc,
));
await showTutorial(context, targets);
}

View file

@ -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:

View file

@ -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: