Merge pull request 'dev' (#4) from dev into main

- Feature: Verification checkmark for friends
- Fix: Added contacts in contact sharing that were not clickable.
- Fix: Open chat after the image expires in case a draft message exists
- Fix: Restore flames as a plus user
- Fix: Route not found when sharing image
- Fix: Increase recent limit in emoji keyboard
- Fix: Increase show time of the focus indication
- Fix: Quoted text message not shown properly
- Fix: Push notification in groups when someone saves an image
- Fix: Dark mode in diagnostics view
This commit is contained in:
otsmr 2026-02-21 00:44:39 +00:00
commit 6eb636f9dd
40 changed files with 463 additions and 129 deletions

View file

@ -1,5 +1,19 @@
# Changelog # Changelog
## 0.0.93
- Feature: Verification checkmark for friends
- Fix: Added contacts in contact sharing that were not clickable.
- Fix: Open chat after the image expires in case a draft message exists
- Fix: Restore flames as a plus user
- Fix: Route not found when sharing image
- Fix: Increase recent limit in emoji keyboard
- Fix: Increase show time of the focus indication
- Fix: Quoted text message not shown properly
- Fix: Push notification in groups when someone saves an image
- Fix: Dark mode in diagnostics view
## 0.0.92 ## 0.0.92
- Adds the option to share contacts - Adds the option to share contacts

View file

@ -11,6 +11,7 @@ analyzer:
avoid_positional_boolean_parameters: ignore avoid_positional_boolean_parameters: ignore
inference_failure_on_collection_literal: ignore inference_failure_on_collection_literal: ignore
matching_super_parameters: ignore matching_super_parameters: ignore
parameter_assignments: ignore
exclude: exclude:
- "lib/src/model/protobuf/**" - "lib/src/model/protobuf/**"
- "lib/src/model/protobuf/api/websocket/**" - "lib/src/model/protobuf/api/websocket/**"

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#57CC99" class="bi bi-patch-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff0000" class="bi bi-patch-check" viewBox="0 0 16 16">
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 995 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ffff00" class="bi bi-patch-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -39,6 +39,8 @@ class Routes {
static const String settingsStorageExport = '/settings/storage_data/export'; static const String settingsStorageExport = '/settings/storage_data/export';
static const String settingsHelp = '/settings/help'; static const String settingsHelp = '/settings/help';
static const String settingsHelpFaq = '/settings/help/faq'; static const String settingsHelpFaq = '/settings/help/faq';
static const String settingsHelpFaqVerifyBadge =
'/settings/help/faq/verifybadge';
static const String settingsHelpContactUs = '/settings/help/contact_us'; static const String settingsHelpContactUs = '/settings/help/contact_us';
static const String settingsHelpDiagnostics = '/settings/help/diagnostics'; static const String settingsHelpDiagnostics = '/settings/help/diagnostics';
static const String settingsHelpUserStudy = '/settings/help/user_study'; static const String settingsHelpUserStudy = '/settings/help/user_study';

View file

@ -943,7 +943,7 @@ abstract class AppLocalizations {
/// No description provided for @contactVerifyNumberTitle. /// No description provided for @contactVerifyNumberTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Verify safety number'** /// **'Verify contact'**
String get contactVerifyNumberTitle; String get contactVerifyNumberTitle;
/// No description provided for @contactVerifyNumberTapToScan. /// No description provided for @contactVerifyNumberTapToScan.
@ -970,6 +970,12 @@ abstract class AppLocalizations {
/// **'To verify the end-to-end encryption with {username}, compare the numbers with their device. The person can also scan your code with their device.'** /// **'To verify the end-to-end encryption with {username}, compare the numbers with their device. The person can also scan your code with their device.'**
String contactVerifyNumberLongDesc(Object username); String contactVerifyNumberLongDesc(Object username);
/// No description provided for @contactViewMessage.
///
/// In en, this message translates to:
/// **'Message'**
String get contactViewMessage;
/// No description provided for @contactNickname. /// No description provided for @contactNickname.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2991,6 +2997,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'To see this message, you need to update twonly.'** /// **'To see this message, you need to update twonly.'**
String get updateTwonlyMessage; String get updateTwonlyMessage;
/// No description provided for @verificationBadgeNote.
///
/// In en, this message translates to:
/// **'You can verify your friends by scanning their public QR code. Click to learn more.'**
String get verificationBadgeNote;
/// No description provided for @verificationBadgeTitle.
///
/// In en, this message translates to:
/// **'Verification'**
String get verificationBadgeTitle;
/// No description provided for @verificationBadgeGeneralDesc.
///
/// In en, this message translates to:
/// **'The green checkmark gives you the certainty that you are messaging the right person.'**
String get verificationBadgeGeneralDesc;
/// No description provided for @verificationBadgeGreenDesc.
///
/// In en, this message translates to:
/// **'Contact that you have personally verified via QR code. This also verified their public key.'**
String get verificationBadgeGreenDesc;
/// No description provided for @verificationBadgeYellowDesc.
///
/// In en, this message translates to:
/// **'(Coming soon) Contact whose QR code was scanned by one of your personally verified contacts.'**
String get verificationBadgeYellowDesc;
/// No description provided for @verificationBadgeRedDesc.
///
/// In en, this message translates to:
/// **'Unknown contact whose identity has not yet been verified.'**
String get verificationBadgeRedDesc;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -468,7 +468,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Dein Konto wird gelöscht. Es gibt keine Möglichkeit, es wiederherzustellen.'; 'Dein Konto wird gelöscht. Es gibt keine Möglichkeit, es wiederherzustellen.';
@override @override
String get contactVerifyNumberTitle => 'Sicherheitsnummer verifizieren'; String get contactVerifyNumberTitle => 'Benutzer verifizieren';
@override @override
String get contactVerifyNumberTapToScan => 'Zum Scannen tippen'; String get contactVerifyNumberTapToScan => 'Zum Scannen tippen';
@ -484,6 +484,9 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Um die Ende-zu-Ende-Verschlüsselung mit $username zu verifizieren, vergleiche die Zahlen mit ihrem Gerät. Die Person kann auch deinen Code mit ihrem Gerät scannen.'; return 'Um die Ende-zu-Ende-Verschlüsselung mit $username zu verifizieren, vergleiche die Zahlen mit ihrem Gerät. Die Person kann auch deinen Code mit ihrem Gerät scannen.';
} }
@override
String get contactViewMessage => 'Nachricht';
@override @override
String get contactNickname => 'Spitzname'; String get contactNickname => 'Spitzname';
@ -1669,4 +1672,27 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get updateTwonlyMessage => String get updateTwonlyMessage =>
'Um diese Nachricht zu sehen, musst du twonly aktualisieren.'; 'Um diese Nachricht zu sehen, musst du twonly aktualisieren.';
@override
String get verificationBadgeNote =>
'Du kannst deine Freunde verifizieren, indem du deren öffentlichen QR-Code scannst. Klicke, um mehr zu erfahren.';
@override
String get verificationBadgeTitle => 'Verifizierung';
@override
String get verificationBadgeGeneralDesc =>
'Der grüne Haken gibt dir die Sicherheit, dass du mit der richtigen Person schreibst.';
@override
String get verificationBadgeGreenDesc =>
'Kontakt, den du durch den QR-Code persönlich verifiziert hast. Dadurch wurde auch sein öffentlicher Schlüssel überprüft.';
@override
String get verificationBadgeYellowDesc =>
'(Coming soon) Kontakt, dessen QR-Code von einem deiner persönlich verifizierten Kontakte gescannt wurde.';
@override
String get verificationBadgeRedDesc =>
'Unbekannter Kontakt, dessen Identität bisher nicht verifiziert wurde.';
} }

View file

@ -463,7 +463,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Your account will be deleted. There is no change to restore it.'; 'Your account will be deleted. There is no change to restore it.';
@override @override
String get contactVerifyNumberTitle => 'Verify safety number'; String get contactVerifyNumberTitle => 'Verify contact';
@override @override
String get contactVerifyNumberTapToScan => 'Tap to scan'; String get contactVerifyNumberTapToScan => 'Tap to scan';
@ -479,6 +479,9 @@ class AppLocalizationsEn extends AppLocalizations {
return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.'; return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.';
} }
@override
String get contactViewMessage => 'Message';
@override @override
String get contactNickname => 'Nickname'; String get contactNickname => 'Nickname';
@ -1657,4 +1660,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get updateTwonlyMessage => String get updateTwonlyMessage =>
'To see this message, you need to update twonly.'; 'To see this message, you need to update twonly.';
@override
String get verificationBadgeNote =>
'You can verify your friends by scanning their public QR code. Click to learn more.';
@override
String get verificationBadgeTitle => 'Verification';
@override
String get verificationBadgeGeneralDesc =>
'The green checkmark gives you the certainty that you are messaging the right person.';
@override
String get verificationBadgeGreenDesc =>
'Contact that you have personally verified via QR code. This also verified their public key.';
@override
String get verificationBadgeYellowDesc =>
'(Coming soon) Contact whose QR code was scanned by one of your personally verified contacts.';
@override
String get verificationBadgeRedDesc =>
'Unknown contact whose identity has not yet been verified.';
} }

View file

@ -463,7 +463,7 @@ class AppLocalizationsSv extends AppLocalizations {
'Your account will be deleted. There is no change to restore it.'; 'Your account will be deleted. There is no change to restore it.';
@override @override
String get contactVerifyNumberTitle => 'Verify safety number'; String get contactVerifyNumberTitle => 'Verify contact';
@override @override
String get contactVerifyNumberTapToScan => 'Tap to scan'; String get contactVerifyNumberTapToScan => 'Tap to scan';
@ -479,6 +479,9 @@ class AppLocalizationsSv extends AppLocalizations {
return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.'; return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.';
} }
@override
String get contactViewMessage => 'Message';
@override @override
String get contactNickname => 'Nickname'; String get contactNickname => 'Nickname';
@ -1657,4 +1660,27 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get updateTwonlyMessage => String get updateTwonlyMessage =>
'To see this message, you need to update twonly.'; 'To see this message, you need to update twonly.';
@override
String get verificationBadgeNote =>
'You can verify your friends by scanning their public QR code. Click to learn more.';
@override
String get verificationBadgeTitle => 'Verification';
@override
String get verificationBadgeGeneralDesc =>
'The green checkmark gives you the certainty that you are messaging the right person.';
@override
String get verificationBadgeGreenDesc =>
'Contact that you have personally verified via QR code. This also verified their public key.';
@override
String get verificationBadgeYellowDesc =>
'(Coming soon) Contact whose QR code was scanned by one of your personally verified contacts.';
@override
String get verificationBadgeRedDesc =>
'Unknown contact whose identity has not yet been verified.';
} }

@ -1 +1 @@
Subproject commit 69d295db737253e0c1b68aedc39bf757e8d642e6 Subproject commit 6147155ce50caa97864d56e42e49a6f54702785d

View file

@ -83,7 +83,7 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) { if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) {
Log.info('Started IPA timer for verification.'); Log.info('Started IPA timer for verification.');
globalForceIpaCheck = Timer(const Duration(seconds: 5), () async { globalForceIpaCheck = Timer(const Duration(seconds: 5), () async {
Log.warn('Force Ipa check was not stopped. Requesting forced check...'); Log.info('Force Ipa check was not stopped. Requesting forced check...');
await apiService.forceIpaCheck(); await apiService.forceIpaCheck();
}); });
} }

View file

@ -32,6 +32,7 @@ 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/credits.view.dart';
import 'package:twonly/src/views/settings/help/diagnostics.view.dart'; import 'package:twonly/src/views/settings/help/diagnostics.view.dart';
import 'package:twonly/src/views/settings/help/faq.view.dart'; import 'package:twonly/src/views/settings/help/faq.view.dart';
import 'package:twonly/src/views/settings/help/faq/verifybadge.dart';
import 'package:twonly/src/views/settings/help/help.view.dart'; import 'package:twonly/src/views/settings/help/help.view.dart';
import 'package:twonly/src/views/settings/notification.view.dart'; import 'package:twonly/src/views/settings/notification.view.dart';
import 'package:twonly/src/views/settings/privacy.view.dart'; import 'package:twonly/src/views/settings/privacy.view.dart';
@ -227,6 +228,12 @@ final routerProvider = GoRouter(
GoRoute( GoRoute(
path: 'faq', path: 'faq',
builder: (context, state) => const FaqView(), builder: (context, state) => const FaqView(),
routes: [
GoRoute(
path: 'verifybadge',
builder: (context, state) => const VerificationBadeFaqView(),
),
],
), ),
GoRoute( GoRoute(
path: 'contact_us', path: 'contact_us',
@ -281,5 +288,10 @@ final routerProvider = GoRouter(
), ),
], ],
), ),
// Fallback instead of showing a Page Not Found error redirect to home
GoRoute(
path: '/:path(.*)',
redirect: (context, state) => '/',
),
], ],
); );

View file

@ -193,7 +193,11 @@ class ApiService {
} }
Future<void> _onError(dynamic e) async { Future<void> _onError(dynamic e) async {
if (e.toString().contains('Failed host lookup')) {
Log.info('WebSocket connection failed: Host not reachable.');
} else {
Log.warn('websocket error: $e'); Log.warn('websocket error: $e');
}
await onClosed(); await onClosed();
} }

View file

@ -37,6 +37,14 @@ Future<bool> canMediaFileBeDownloaded(MediaFile mediaFile) async {
// If not delete the message as it can not be downloaded from the server anymore. // If not delete the message as it can not be downloaded from the server anymore.
if (messages.length != 1) { if (messages.length != 1) {
if (messages.isEmpty) {
MediaFileService(mediaFile).fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
Log.warn(
'Media file which is in downloading status has not text message. Deleting media file. ${mediaFile.mediaId}.',
);
return false;
}
Log.warn( Log.warn(
'A media for download must have one original message, but it has ${messages.length}.', 'A media for download must have one original message, but it has ${messages.length}.',
); );

View file

@ -69,12 +69,14 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
try { try {
if (receiptId == null && receipt == null) return null; if (receiptId == null && receipt == null) return null;
if (receipt == null) { if (receipt == null) {
// ignore: parameter_assignments
receipt = await twonlyDB.receiptsDao.getReceiptById(receiptId!); receipt = await twonlyDB.receiptsDao.getReceiptById(receiptId!);
if (receipt == null) { if (receipt == null) {
Log.warn('Receipt not found.'); Log.warn('Receipt not found.');
return null; return null;
} }
} }
// ignore: parameter_assignments
receiptId = receipt.receiptId; receiptId = receipt.receiptId;
final contact = final contact =

View file

@ -281,6 +281,13 @@ Future<PushNotification?> getPushNotificationFromEncryptedContent(
} }
if (content.hasMediaUpdate()) { if (content.hasMediaUpdate()) {
final msg = await twonlyDB.messagesDao
.getMessageById(content.reaction.targetMessageId)
.getSingleOrNull();
// These notifications should only be send to the original sender.
if (msg == null || msg.senderId == null || msg.senderId != toUserId) {
return null;
}
switch (content.mediaUpdate.type) { switch (content.mediaUpdate.type) {
case EncryptedContent_MediaUpdate_Type.REOPENED: case EncryptedContent_MediaUpdate_Type.REOPENED:
kind = PushKind.reopenedMedia; kind = PushKind.reopenedMedia;

View file

@ -10,8 +10,20 @@ enum SubscriptionPlan {
Plus, Plus,
} }
bool isAdditionalAccount(SubscriptionPlan plan) { enum PremiumFeatures { RestoreFlames }
return plan == SubscriptionPlan.Free || plan == SubscriptionPlan.Plus;
const Map<PremiumFeatures, List<SubscriptionPlan>> planPermissions = {
PremiumFeatures.RestoreFlames: [
SubscriptionPlan.Family,
SubscriptionPlan.Plus,
SubscriptionPlan.Tester,
SubscriptionPlan.Pro,
],
};
bool isUserAllowed(SubscriptionPlan plan, PremiumFeatures feature) {
final allowedPlans = planPermissions[feature] ?? [];
return allowedPlans.contains(plan);
} }
bool isPayingUser(SubscriptionPlan plan) { bool isPayingUser(SubscriptionPlan plan) {

View file

@ -241,7 +241,7 @@ InputDecoration inputTextMessageDeco(BuildContext context) {
String truncateString(String input, {int maxLength = 20}) { String truncateString(String input, {int maxLength = 20}) {
if (input.length > maxLength) { if (input.length > maxLength) {
return '${input.substring(0, maxLength)}...'; return '${input.characters.take(maxLength)}...';
} }
return input; return input;
} }

View file

@ -207,6 +207,9 @@ class MainCameraController {
Log.error(e); Log.error(e);
} }
// display the focus point at least 500ms
await Future.delayed(const Duration(milliseconds: 500));
focusPointOffset = null; focusPointOffset = null;
setState(); setState();
} }

View file

@ -3,7 +3,9 @@ import 'dart:convert';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/services/api/utils.dart';
@ -99,7 +101,15 @@ class _ContactRow extends StatefulWidget {
class _ContactRowState extends State<_ContactRow> { class _ContactRowState extends State<_ContactRow> {
bool _isLoading = false; bool _isLoading = false;
Future<void> _onContactClick() async { Future<void> _onContactClick(bool isAdded) async {
if (widget.contact.userId.toInt() == gUser.userId) {
await context.push(Routes.settingsProfile);
return;
}
if (isAdded) {
await context.push(Routes.profileContact(widget.contact.userId.toInt()));
return;
}
setState(() { setState(() {
_isLoading = true; _isLoading = true;
}); });
@ -152,9 +162,9 @@ class _ContactRowState extends State<_ContactRow> {
widget.contact.userId.toInt() == gUser.userId; widget.contact.userId.toInt() == gUser.userId;
return GestureDetector( return GestureDetector(
onTap: (widget.message.senderId == null || isAdded || _isLoading) onTap: _isLoading ? null : () => _onContactClick(isAdded),
? null child: ColoredBox(
: _onContactClick, color: Colors.transparent,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row( child: Row(
@ -181,7 +191,8 @@ class _ContactRowState extends State<_ContactRow> {
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
), ),
) )
else else
@ -194,6 +205,7 @@ class _ContactRowState extends State<_ContactRow> {
], ],
), ),
), ),
),
); );
}, },
); );

View file

@ -509,6 +509,7 @@ class _MessageInputState extends State<MessageInput> {
TextStyle(fontSize: 24 * (Platform.isIOS ? 1.2 : 1)), TextStyle(fontSize: 24 * (Platform.isIOS ? 1.2 : 1)),
emojiViewConfig: EmojiViewConfig( emojiViewConfig: EmojiViewConfig(
backgroundColor: context.color.surfaceContainer, backgroundColor: context.color.surfaceContainer,
recentsLimit: 40,
), ),
searchViewConfig: SearchViewConfig( searchViewConfig: SearchViewConfig(
backgroundColor: context.color.surfaceContainer, backgroundColor: context.color.surfaceContainer,

View file

@ -4,10 +4,12 @@ import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:no_screenshot/no_screenshot.dart'; import 'package:no_screenshot/no_screenshot.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart' import 'package:twonly/src/database/tables/mediafiles.table.dart'
show DownloadState, MediaType; show DownloadState, MediaType;
@ -131,7 +133,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
setState(() {}); setState(() {});
if (firstRun) { if (firstRun) {
// ignore: parameter_assignments
firstRun = false; firstRun = false;
await loadCurrentMediaFile(); await loadCurrentMediaFile();
} }
@ -154,7 +155,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
progressTimer?.cancel(); progressTimer?.cancel();
if (allMediaFiles.isEmpty) { if (allMediaFiles.isEmpty) {
final group = await twonlyDB.groupsDao.getGroup(widget.group.groupId);
if (mounted) {
if (group != null &&
group.draftMessage != null &&
group.draftMessage != '') {
context.replace(Routes.chatsMessages, extra: group);
} else {
Navigator.pop(context); Navigator.pop(context);
}
}
} else { } else {
await loadCurrentMediaFile(); await loadCurrentMediaFile();
} }

View file

@ -1,3 +1,5 @@
// ignore_for_file: parameter_assignments
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart' hide Column; import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -27,13 +27,9 @@ class BetterListTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
leading: Padding( leading: SizedBox(
padding: (padding == null) width: 50,
? const EdgeInsets.only( child: Center(
right: 10,
left: 19,
)
: padding!,
child: (leading != null) child: (leading != null)
? leading ? leading
: FaIcon( : FaIcon(
@ -42,6 +38,7 @@ class BetterListTile extends StatelessWidget {
color: color, color: color,
), ),
), ),
),
trailing: trailing, trailing: trailing,
title: Text( title: Text(
text, text,

View file

@ -57,6 +57,7 @@ class EmojiPickerBottom extends StatelessWidget {
TextStyle(fontSize: 24 * (Platform.isIOS ? 1.2 : 1)), TextStyle(fontSize: 24 * (Platform.isIOS ? 1.2 : 1)),
emojiViewConfig: EmojiViewConfig( emojiViewConfig: EmojiViewConfig(
backgroundColor: context.color.surfaceContainer, backgroundColor: context.color.surfaceContainer,
recentsLimit: 40,
), ),
searchViewConfig: SearchViewConfig( searchViewConfig: SearchViewConfig(
backgroundColor: context.color.surfaceContainer, backgroundColor: context.color.surfaceContainer,

View file

@ -46,7 +46,7 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
} }
Future<void> _restoreFlames() async { Future<void> _restoreFlames() async {
if (!isPayingUser(getCurrentPlan())) { if (!isUserAllowed(getCurrentPlan(), PremiumFeatures.RestoreFlames)) {
await context.push(Routes.settingsSubscription); await context.push(Routes.settingsSubscription);
return; return;
} }

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class SvgIcons {
static const String verifiedGreen = 'assets/icons/verified_badge_green.svg';
static const String verifiedYellow = 'assets/icons/verified_badge_yellow.svg';
static const String verifiedRed = 'assets/icons/verified_badge_red.svg';
}
class SvgIcon extends StatelessWidget {
const SvgIcon({
required this.assetPath,
super.key,
this.size = 24.0,
this.color,
});
final String assetPath;
final double? size;
final Color? color;
@override
Widget build(BuildContext context) {
return SvgPicture.asset(
assetPath,
width: size,
height: size,
colorFilter:
color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null,
);
}
}

View file

@ -1,17 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/components/svg_icon.dart';
class VerifiedShield extends StatefulWidget { class VerifiedShield extends StatefulWidget {
const VerifiedShield({ const VerifiedShield({
this.contact, this.contact,
this.group, this.group,
super.key, super.key,
this.size = 18, this.size = 15,
}); });
final Group? group; final Group? group;
final Contact? contact; final Contact? contact;
@ -64,13 +64,15 @@ class _VerifiedShieldState extends State<VerifiedShield> {
message: isVerified message: isVerified
? 'You verified this contact' ? 'You verified this contact'
: 'You have not verifies this contact.', : 'You have not verifies this contact.',
child: FaIcon( child: Padding(
isVerified ? FontAwesomeIcons.shieldHeart : Icons.gpp_maybe_rounded, padding: const EdgeInsetsGeometry.only(top: 2),
color: child: SvgIcon(
isVerified ? Theme.of(context).colorScheme.primary : Colors.red, assetPath:
isVerified ? SvgIcons.verifiedGreen : SvgIcons.verifiedRed,
size: widget.size, size: widget.size,
), ),
), ),
),
); );
} }
} }

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/max_flame_list_title.dart'; import 'package:twonly/src/views/components/max_flame_list_title.dart';
import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart'; import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/views/components/svg_icon.dart';
import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/views/components/verified_shield.dart';
import 'package:twonly/src/views/groups/group.view.dart'; import 'package:twonly/src/views/groups/group.view.dart';
@ -163,6 +164,21 @@ class _ContactViewState extends State<ContactView> {
if (getContactDisplayName(contact) != contact.username) if (getContactDisplayName(contact) != contact.username)
Center(child: Text('(${contact.username})')), Center(child: Text('(${contact.username})')),
const SizedBox(height: 50), const SizedBox(height: 50),
BetterListTile(
icon: FontAwesomeIcons.solidComments,
text: context.lang.contactViewMessage,
onTap: () async {
final group =
await twonlyDB.groupsDao.getDirectChat(contact.userId);
if (group != null && context.mounted) {
await context.push(
Routes.chatsMessages,
extra: group,
);
}
},
),
const Divider(),
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.pencil, icon: FontAwesomeIcons.pencil,
text: context.lang.contactNickname, text: context.lang.contactNickname,
@ -176,7 +192,6 @@ class _ContactViewState extends State<ContactView> {
} }
}, },
), ),
const Divider(),
SelectChatDeletionTimeListTitle( SelectChatDeletionTimeListTitle(
groupId: getUUIDforDirectChat(widget.userId, gUser.userId), groupId: getUUIDforDirectChat(widget.userId, gUser.userId),
), ),
@ -185,7 +200,11 @@ class _ContactViewState extends State<ContactView> {
contactId: widget.userId, contactId: widget.userId,
), ),
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.shieldHeart, leading: SvgIcon(
assetPath: SvgIcons.verifiedGreen,
size: 20,
color: IconTheme.of(context).color,
),
text: context.lang.contactVerifyNumberTitle, text: context.lang.contactVerifyNumberTitle,
onTap: () async { onTap: () async {
await context.push(Routes.settingsPublicProfile); await context.push(Routes.settingsPublicProfile);

View file

@ -1,3 +1,5 @@
// ignore_for_file: parameter_assignments
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,3 +1,5 @@
// ignore_for_file: parameter_assignments
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -45,9 +45,29 @@ class _PublicProfileViewState extends State<PublicProfileView> {
body: Column( body: Column(
children: [ children: [
Container(width: double.infinity), Container(width: double.infinity),
const SizedBox( const SizedBox(height: 10),
height: 30, GestureDetector(
onTap: () => context.push(Routes.settingsHelpFaqVerifyBadge),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 30),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: context.color.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
), ),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(context.lang.verificationBadgeNote),
),
const SizedBox(width: 10),
const FaIcon(FontAwesomeIcons.angleRight),
],
),
),
),
const SizedBox(height: 20),
if (_qrCode != null && _userAvatar != null) if (_qrCode != null && _userAvatar != null)
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(

View file

@ -1,4 +1,4 @@
// ignore_for_file: avoid_dynamic_calls // ignore_for_file: parameter_assignments, avoid_dynamic_calls
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader.dart';
@ -13,8 +13,6 @@ class DiagnosticsView extends StatefulWidget {
} }
class _DiagnosticsViewState extends State<DiagnosticsView> { class _DiagnosticsViewState extends State<DiagnosticsView> {
final ScrollController _scrollController = ScrollController();
String? _debugLogText; String? _debugLogText;
@override @override
@ -28,41 +26,6 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
setState(() {}); setState(() {});
} }
Future<void> _scrollToBottom() async {
// Assuming the button is at the bottom of the scroll view
await _scrollController.animateTo(
_scrollController.position.maxScrollExtent, // Scroll to the bottom
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
Future<void> _shareDebugLog() async {
if (_debugLogText == null) return;
final directory = await getApplicationSupportDirectory();
final logFile = XFile('${directory.path}/app.log');
final params = ShareParams(
text: 'Debug log',
files: [logFile],
);
final result = await SharePlus.instance.share(params);
if (result.status != ShareResultStatus.success) {
await Clipboard.setData(
ClipboardData(text: _debugLogText!),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Log copied to clipboard!'),
),
);
}
}
}
Future<void> _deleteDebugLog() async { Future<void> _deleteDebugLog() async {
if (await deleteLogFile()) { if (await deleteLogFile()) {
if (!mounted) return; if (!mounted) return;
@ -100,13 +63,10 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
TextButton( TextButton(
onPressed: _shareDebugLog, onPressed: () =>
context.push(Routes.settingsHelpContactUs),
child: const Text('Share debug log'), child: const Text('Share debug log'),
), ),
TextButton(
onPressed: _scrollToBottom,
child: const Text('Scroll to Bottom'),
),
TextButton( TextButton(
onPressed: _deleteDebugLog, onPressed: _deleteDebugLog,
child: const Text('Delete Log File'), child: const Text('Delete Log File'),
@ -186,7 +146,6 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
selected: selected, selected: selected,
onSelected: (_) => _setFilter(label), onSelected: (_) => _setFilter(label),
selectedColor: _colorForLevel(label).withAlpha(120), selectedColor: _colorForLevel(label).withAlpha(120),
backgroundColor: Colors.grey.shade200,
); );
} }
@ -198,7 +157,7 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'monospace', fontFamily: 'monospace',
); );
const msgStyle = TextStyle(color: Colors.black87, fontFamily: 'monospace'); const msgStyle = TextStyle(fontFamily: 'monospace');
return TextSpan( return TextSpan(
children: [ children: [
@ -249,12 +208,7 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
), ),
Expanded( Expanded(
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.only(left: 8),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Scrollbar( child: Scrollbar(
controller: _controller, controller: _controller,
child: ListView.builder( child: ListView.builder(

View file

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/svg_icon.dart';
class VerificationBadeFaqView extends StatefulWidget {
const VerificationBadeFaqView({super.key});
@override
State<VerificationBadeFaqView> createState() =>
_VerificationBadeFaqViewState();
}
class _VerificationBadeFaqViewState extends State<VerificationBadeFaqView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.verificationBadgeTitle),
),
body: ListView(
padding: const EdgeInsets.all(40),
children: [
Text(
context.lang.verificationBadgeGeneralDesc,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
_buildItem(
icon: const SvgIcon(assetPath: SvgIcons.verifiedGreen, size: 40),
description: context.lang.verificationBadgeGreenDesc,
),
_buildItem(
icon: const SvgIcon(assetPath: SvgIcons.verifiedYellow, size: 40),
description: context.lang.verificationBadgeYellowDesc,
),
_buildItem(
icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40),
description: context.lang.verificationBadgeRedDesc,
),
],
),
);
}
Widget _buildItem({required Widget icon, required String description}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Row(
children: [
icon,
const SizedBox(width: 20),
Expanded(
child: Text(
description,
style: const TextStyle(fontSize: 16, height: 1.4),
),
),
],
),
);
}
}

View file

@ -1,3 +1,5 @@
// ignore_for_file: parameter_assignments
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,3 +1,5 @@
// ignore_for_file: parameter_assignments
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.0.91+91 version: 0.0.93+93
environment: environment:
sdk: ^3.6.0 sdk: ^3.6.0
@ -203,6 +203,7 @@ flutter:
assets: assets:
# Add assets from the images directory to the application. # Add assets from the images directory to the application.
- assets/images/ - assets/images/
- assets/icons/
- assets/animated_icons/ - assets/animated_icons/
- assets/animations/ - assets/animations/
- assets/passwords/ - assets/passwords/

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/services/subscription.service.dart';
void main() {
group('testing subscription permissions', () {
test('test if restore flames is allowed', () {
expect(
true,
isUserAllowed(SubscriptionPlan.Plus, PremiumFeatures.RestoreFlames),
);
expect(
false,
isUserAllowed(SubscriptionPlan.Free, PremiumFeatures.RestoreFlames),
);
});
});
}