From 24efc76c02d81c06c3e22967e798f21ce10960f6 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 1 Jun 2026 23:39:12 +0200 Subject: [PATCH 01/18] fix: add logging and hide non-downloaded images --- lib/src/database/daos/messages.dao.dart | 44 +++++++++++-------- .../services/api/mediafiles/download.api.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 8b5348be..5c280a69 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -93,24 +93,32 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { milliseconds: group!.deleteMessagesAfterMilliseconds, ), ); - return ((select(messages)..where( - (t) => - t.groupId.equals(groupId) & - // messages in groups will only be removed in case all members have received it... - // so ensuring that this message is not shown in the messages anymore - (t.openedAt.isBiggerThanValue(deletionTime) | - t.openedAt.isNull() | - t.mediaStored.equals(true)) & - (t.isDeletedFromSender.equals(true) | - (t.type.equals(MessageType.text.name).not() & - t.type.equals(MessageType.media.name).not()) | - (t.type.equals(MessageType.text.name) & - t.content.isNotNull()) | - (t.type.equals(MessageType.media.name) & - t.mediaId.isNotNull())), - )) - ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) - .watch(); + final query = select(messages).join([ + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + messages.groupId.equals(groupId) & + (messages.openedAt.isBiggerThanValue(deletionTime) | + messages.openedAt.isNull() | + messages.mediaStored.equals(true)) & + (messages.isDeletedFromSender.equals(true) | + (messages.type.equals(MessageType.text.name).not() & + messages.type.equals(MessageType.media.name).not()) | + (messages.type.equals(MessageType.text.name) & + messages.content.isNotNull()) | + (messages.type.equals(MessageType.media.name) & + messages.mediaId.isNotNull() & + (mediaFiles.downloadState.isNull() | + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not()))), + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); + + return query.map((row) => row.readTable(messages)).watch(); } Stream> watchMembersByGroupId(String groupId) { diff --git a/lib/src/services/api/mediafiles/download.api.dart b/lib/src/services/api/mediafiles/download.api.dart index 541a06da..0218275d 100644 --- a/lib/src/services/api/mediafiles/download.api.dart +++ b/lib/src/services/api/mediafiles/download.api.dart @@ -252,7 +252,7 @@ Future downloadFileFast( } else { if (response.statusCode == 404 || response.statusCode == 403) { Log.error( - 'Got ${response.statusCode} from server. Requesting upload again', + 'Got ${response.statusCode} from server for media ID ${media.mediaId}. Requesting upload again', ); // Message was deleted from the server. Requesting it again from the sender to upload it again... await requestMediaReupload(media.mediaId); diff --git a/pubspec.yaml b/pubspec.yaml index 31b7386e..e03873b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.26+135 +version: 0.2.27+136 environment: sdk: ^3.11.0 From e2cf5ec74ad0bd07812a718152416adbed9ef5b8 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 01:17:42 +0200 Subject: [PATCH 02/18] improved styling --- lib/app.dart | 3 +- .../generated/app_localizations.dart | 50 +- .../generated/app_localizations_de.dart | 31 +- .../generated/app_localizations_en.dart | 31 +- .../visual/elements/my_button.element.dart | 208 ++++++++ lib/src/visual/elements/my_input.element.dart | 237 +++++++++ .../empty_chat_list.comp.dart | 93 ++-- .../contact/add_contact_via_qr_link.view.dart | 2 +- .../views/contact/add_new_contact.view.dart | 269 +++++----- .../components/onboarding_wrapper.dart | 79 --- .../views/onboarding/onboarding.view.dart | 27 +- .../visual/views/onboarding/recover.view.dart | 339 +++++++------ .../views/onboarding/register.view.dart | 478 +++++++++--------- .../visual/views/onboarding/setup.view.dart | 22 +- .../setup/components/finish_setup.comp.dart | 35 +- .../setup/components/next_button.comp.dart | 13 +- .../views/onboarding/setup/profile.setup.dart | 171 +++---- .../backup/components/backup_setup.comp.dart | 33 +- .../settings/profile/modify_avatar.view.dart | 211 +++++--- .../views/settings/profile/profile.view.dart | 34 +- 20 files changed, 1434 insertions(+), 932 deletions(-) create mode 100644 lib/src/visual/elements/my_button.element.dart create mode 100644 lib/src/visual/elements/my_input.element.dart delete mode 100644 lib/src/visual/views/onboarding/components/onboarding_wrapper.dart diff --git a/lib/app.dart b/lib/app.dart index fd6476c3..c327d972 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -208,7 +208,8 @@ class _AppMainWidgetState extends State { _isTwonlyLocked = false; }), ); - } else if (!userService.currentUser.skipSetupPages && userService.currentUser.currentSetupPage != null) { + } else if (!userService.currentUser.skipSetupPages && + userService.currentUser.currentSetupPage != null) { // This will only be shown in case the user have not skipped child = SetupView( onUpdate: () => setState(() { diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index e9b1c99e..04b40571 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @registerSlogan. /// /// In en, this message translates to: - /// **'Stay in touch with friends privately and securely.'** + /// **'Stay in touch privately.'** String get registerSlogan; /// No description provided for @onboardingWelcomeTitle. @@ -173,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @registerUsernameSlogan. /// /// In en, this message translates to: - /// **'Your public username'** + /// **'Create your account'** String get registerUsernameSlogan; /// No description provided for @registerUsernameDecoration. @@ -1205,8 +1205,8 @@ abstract class AppLocalizations { /// No description provided for @userFoundBody. /// /// In en, this message translates to: - /// **'Do you want to create a follow request?'** - String get userFoundBody; + /// **'Do you want to connect with {username}?'** + String userFoundBody(String username); /// No description provided for @errorInternalError. /// @@ -3689,6 +3689,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'** String importGalleryImportCount(num count); + + /// No description provided for @emptyChatListTitle. + /// + /// In en, this message translates to: + /// **'Find your first friend'** + String get emptyChatListTitle; + + /// No description provided for @emptyChatListDesc. + /// + /// In en, this message translates to: + /// **'Let friends scan your QR code, or share them your profile.'** + String get emptyChatListDesc; + + /// No description provided for @emptyChatListShareBtn. + /// + /// In en, this message translates to: + /// **'Share your profile'** + String get emptyChatListShareBtn; + + /// No description provided for @emptyChatListScanBtn. + /// + /// In en, this message translates to: + /// **'QR Code'** + String get emptyChatListScanBtn; + + /// No description provided for @emptyChatListAddUsernameBtn. + /// + /// In en, this message translates to: + /// **'By Username'** + String get emptyChatListAddUsernameBtn; + + /// No description provided for @avatarCustomizeRandomize. + /// + /// In en, this message translates to: + /// **'Randomize'** + String get avatarCustomizeRandomize; + + /// No description provided for @avatarCustomizeReset. + /// + /// In en, this message translates to: + /// **'Reset'** + String get avatarCustomizeReset; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 715eb396..e6eaac68 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -9,8 +9,7 @@ class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); @override - String get registerSlogan => - 'Privat und sicher mit Freunden in Kontakt bleiben.'; + String get registerSlogan => 'Privat in Kontakt bleiben.'; @override String get onboardingWelcomeTitle => 'Willkommen bei twonly!'; @@ -52,7 +51,7 @@ class AppLocalizationsDe extends AppLocalizations { String get onboardingGetStartedTitle => 'Auf geht\'s'; @override - String get registerUsernameSlogan => 'Dein öffentlicher Benutzername'; + String get registerUsernameSlogan => 'Konto erstellen'; @override String get registerUsernameDecoration => 'Benutzername'; @@ -611,7 +610,9 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get userFoundBody => 'Möchtest du eine Folgeanfrage stellen?'; + String userFoundBody(String username) { + return 'Möchtest du dich mit $username vernetzen?'; + } @override String get errorInternalError => @@ -2127,4 +2128,26 @@ class AppLocalizationsDe extends AppLocalizations { ); return '$_temp0'; } + + @override + String get emptyChatListTitle => 'Finde deinen ersten Freund'; + + @override + String get emptyChatListDesc => + 'Lass Freunde deinen QR-Code scannen oder teile dein Profil mit ihnen.'; + + @override + String get emptyChatListShareBtn => 'Profil teilen'; + + @override + String get emptyChatListScanBtn => 'QR-Code'; + + @override + String get emptyChatListAddUsernameBtn => 'Per Benutzername'; + + @override + String get avatarCustomizeRandomize => 'Zufällig'; + + @override + String get avatarCustomizeReset => 'Zurücksetzen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index f379326c..7fa61242 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -9,8 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get registerSlogan => - 'Stay in touch with friends privately and securely.'; + String get registerSlogan => 'Stay in touch privately.'; @override String get onboardingWelcomeTitle => 'Welcome to twonly!'; @@ -51,7 +50,7 @@ class AppLocalizationsEn extends AppLocalizations { String get onboardingGetStartedTitle => 'Let\'s go!'; @override - String get registerUsernameSlogan => 'Your public username'; + String get registerUsernameSlogan => 'Create your account'; @override String get registerUsernameDecoration => 'Username'; @@ -606,7 +605,9 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get userFoundBody => 'Do you want to create a follow request?'; + String userFoundBody(String username) { + return 'Do you want to connect with $username?'; + } @override String get errorInternalError => @@ -2109,4 +2110,26 @@ class AppLocalizationsEn extends AppLocalizations { ); return '$_temp0'; } + + @override + String get emptyChatListTitle => 'Find your first friend'; + + @override + String get emptyChatListDesc => + 'Let friends scan your QR code, or share them your profile.'; + + @override + String get emptyChatListShareBtn => 'Share your profile'; + + @override + String get emptyChatListScanBtn => 'QR Code'; + + @override + String get emptyChatListAddUsernameBtn => 'By Username'; + + @override + String get avatarCustomizeRandomize => 'Randomize'; + + @override + String get avatarCustomizeReset => 'Reset'; } diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart new file mode 100644 index 00000000..22e1b63e --- /dev/null +++ b/lib/src/visual/elements/my_button.element.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/themes/light.dart'; + +enum MyButtonVariant { + primary, + secondary, + text, + primaryDense, + secondaryDense, +} + +class MyButton extends StatefulWidget { + const MyButton({ + required this.child, + required this.onPressed, + this.onLongPress, + this.variant = MyButtonVariant.primary, + super.key, + }); + + final Widget child; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final MyButtonVariant variant; + + @override + State createState() => _MyButtonState(); +} + +class _MyButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = + AnimationController( + vsync: this, + lowerBound: double.negativeInfinity, + upperBound: double.infinity, + value: 0, + )..addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + if (widget.onPressed != null || widget.onLongPress != null) { + _controller.animateTo( + 1, + duration: const Duration(milliseconds: 60), + curve: Curves.easeOut, + ); + } + } + + void _onTapUp(TapUpDetails details) { + if (widget.onPressed != null || widget.onLongPress != null) { + _bounce(); + } + } + + void _onTapCancel() { + if (widget.onPressed != null || widget.onLongPress != null) { + _bounce(); + } + } + + void _bounce() { + const spring = SpringDescription( + mass: 1, + stiffness: 400, + damping: 15, + ); + final simulation = SpringSimulation( + spring, + _controller.value, + 0, + _controller.velocity, + ); + _controller.animateWith(simulation); + } + + @override + Widget build(BuildContext context) { + // 0 (unpressed) -> scale 1.0 + // 1 (pressed) -> scale 0.98 (subtle bounce) + final scale = 1.0 - (_controller.value * 0.02); + final isEnabled = widget.onPressed != null || widget.onLongPress != null; + final isDark = isDarkMode(context); + + late final ButtonStyle buttonStyle; + switch (widget.variant) { + case MyButtonVariant.primary: + buttonStyle = FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.black87, + minimumSize: const Size.fromHeight(60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ); + case MyButtonVariant.secondary: + buttonStyle = FilledButton.styleFrom( + backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], + foregroundColor: isDark ? Colors.white : Colors.black87, + minimumSize: const Size.fromHeight(60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ); + case MyButtonVariant.text: + buttonStyle = TextButton.styleFrom( + minimumSize: const Size(0, 50), + foregroundColor: isDark + ? Colors.white.withValues(alpha: 0.7) + : Colors.black.withValues(alpha: 0.7), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ); + case MyButtonVariant.primaryDense: + buttonStyle = FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ); + case MyButtonVariant.secondaryDense: + buttonStyle = FilledButton.styleFrom( + backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], + foregroundColor: isDark ? Colors.white : Colors.black87, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ); + } + + final childButton = widget.variant == MyButtonVariant.text + ? TextButton( + style: buttonStyle, + onPressed: isEnabled ? () {} : null, + child: widget.child, + ) + : FilledButton( + style: buttonStyle, + onPressed: isEnabled ? () {} : null, + child: widget.child, + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: isEnabled ? _onTapDown : null, + onTapUp: isEnabled ? _onTapUp : null, + onTapCancel: isEnabled ? _onTapCancel : null, + onTap: widget.onPressed, + onLongPress: widget.onLongPress, + child: Transform.scale( + scale: scale, + child: AbsorbPointer( + child: childButton, + ), + ), + ); + } +} diff --git a/lib/src/visual/elements/my_input.element.dart b/lib/src/visual/elements/my_input.element.dart new file mode 100644 index 00000000..8ee00385 --- /dev/null +++ b/lib/src/visual/elements/my_input.element.dart @@ -0,0 +1,237 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class MyInput extends StatefulWidget { + const MyInput({ + required this.controller, + this.onChanged, + this.onSubmitted, + this.inputFormatters, + this.hintText, + this.prefixIcon, + this.suffixIcon, + this.keyboardType, + this.autofocus = false, + this.errorText, + this.obscureText = false, + super.key, + }); + + final TextEditingController controller; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final List? inputFormatters; + final String? hintText; + final Widget? prefixIcon; + final Widget? suffixIcon; + final TextInputType? keyboardType; + final bool autofocus; + final String? errorText; + final bool obscureText; + + @override + State createState() => _MyInputState(); +} + +class _MyInputState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = + AnimationController( + vsync: this, + lowerBound: double.negativeInfinity, + upperBound: double.infinity, + value: 0, + )..addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + _controller.animateTo( + 1, + duration: const Duration(milliseconds: 60), + curve: Curves.easeOut, + ); + } + + void _onTapUp(TapUpDetails details) { + _bounce(); + } + + void _onTapCancel() { + _bounce(); + } + + void _bounce() { + const spring = SpringDescription( + mass: 1, + stiffness: 400, + damping: 15, + ); + final simulation = SpringSimulation( + spring, + _controller.value, + 0, + _controller.velocity, + ); + _controller.animateWith(simulation); + } + + @override + Widget build(BuildContext context) { + // 0 (unpressed) -> scale 1.0 + // 1 (pressed) -> scale 0.98 (subtle bounce) + final scale = 1.0 - (_controller.value * 0.02); + final isDark = isDarkMode(context); + + final inputFillColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05); + + final inputBorderColor = isDark + ? Colors.white.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.15); + + final inputHintColor = isDark + ? Colors.white.withValues(alpha: 0.5) + : Colors.black.withValues(alpha: 0.5); + + final prefixIconColor = isDark + ? Colors.white.withValues(alpha: 0.6) + : Colors.black.withValues(alpha: 0.6); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: Transform.scale( + scale: scale, + child: TextField( + controller: widget.controller, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onTapOutside: (event) { + final pointer = event.pointer; + final startPosition = event.position; + var moved = false; + + void handlePointerEvent(PointerEvent routeEvent) { + if (routeEvent is PointerMoveEvent) { + if ((routeEvent.position - startPosition).distance > 10) { + moved = true; + } + } else if (routeEvent is PointerUpEvent) { + GestureBinding.instance.pointerRouter.removeRoute( + pointer, + handlePointerEvent, + ); + if (!moved) { + FocusManager.instance.primaryFocus?.unfocus(); + } + } else if (routeEvent is PointerCancelEvent) { + GestureBinding.instance.pointerRouter.removeRoute( + pointer, + handlePointerEvent, + ); + } + } + + GestureBinding.instance.pointerRouter.addRoute( + pointer, + handlePointerEvent, + ); + }, + inputFormatters: widget.inputFormatters, + keyboardType: widget.keyboardType, + autofocus: widget.autofocus, + obscureText: widget.obscureText, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white : Colors.black87, + ), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: TextStyle( + color: inputHintColor, + ), + filled: true, + fillColor: inputFillColor, + contentPadding: const EdgeInsets.symmetric( + vertical: 18, + horizontal: 24, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: inputBorderColor, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: inputBorderColor, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: isDark ? Colors.white : Colors.black87, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide( + color: Colors.redAccent, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide( + color: Colors.redAccent, + width: 2, + ), + ), + errorStyle: const TextStyle( + color: Colors.redAccent, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + errorText: widget.errorText, + prefixIcon: widget.prefixIcon != null + ? IconTheme( + data: IconThemeData( + color: prefixIconColor, + ), + child: widget.prefixIcon!, + ) + : null, + suffixIcon: widget.suffixIcon != null + ? IconTheme( + data: IconThemeData( + color: isDark ? Colors.white : Colors.black87, + ), + child: widget.suffixIcon!, + ) + : null, + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart index 78118ba0..ce1686af 100644 --- a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart @@ -1,14 +1,16 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart' show FaIcon, FontAwesomeIcons; +import 'package:font_awesome_flutter/font_awesome_flutter.dart' + show FaIcon, FontAwesomeIcons; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; -import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/components/profile_qr_code.comp.dart' + show ProfileQrCodeComp; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class EmptyChatListComp extends StatelessWidget { const EmptyChatListComp({super.key}); @@ -17,7 +19,8 @@ class EmptyChatListComp extends StatelessWidget { try { final pubKey = await getUserPublicKey(); final params = ShareParams( - text: 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', + text: + 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', ); await SharePlus.instance.share(params); } catch (e) { @@ -37,16 +40,16 @@ class EmptyChatListComp extends StatelessWidget { height: 24, width: double.infinity, ), - const Text( - 'Find your first friend', + Text( + context.lang.emptyChatListTitle, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 28, ), ), const SizedBox(height: 8), Text( - 'Let friends scan your QR code, or share them your profile.', + context.lang.emptyChatListDesc, style: TextStyle( fontSize: 14, color: context.color.onSurface.withValues(alpha: 0.6), @@ -56,61 +59,67 @@ class EmptyChatListComp extends StatelessWidget { const SizedBox(height: 36), const Center(child: ProfileQrCodeComp()), const SizedBox(height: 36), - // 3. Action Buttons - // Button 1: Share Profile (Full Width) - FilledButton.icon( - style: primaryColorButtonStyle, + MyButton( onPressed: () => _shareProfile(context), - icon: const FaIcon(FontAwesomeIcons.shareNodes, size: 20), - label: const Text( - 'Share your profile', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon(FontAwesomeIcons.shareNodes, size: 20), + const SizedBox(width: 8), + Text( + context.lang.emptyChatListShareBtn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], ), ), const SizedBox(height: 12), - // Button Row: Scan QR Code & Enter Username Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( - child: FilledButton.icon( - style: secondaryGreyButtonStyle(context), - onPressed: () => context.push(Routes.cameraQRScanner), - icon: const Icon(Icons.qr_code_scanner_rounded, size: 20), - label: const FittedBox( - fit: BoxFit.scaleDown, - child: Text( - 'Scan QR Code', - style: TextStyle( + MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: () => context.push(Routes.cameraQRScanner), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.qr_code_scanner_rounded, size: 20), + const SizedBox(width: 8), + Text( + context.lang.emptyChatListScanBtn, + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, ), ), - ), + ], ), ), const SizedBox(width: 12), - Expanded( - child: FilledButton.icon( - style: secondaryGreyButtonStyle(context), - onPressed: () => context.push(Routes.chatsAddNewUser), - icon: const Icon(Icons.person_add_rounded, size: 20), - label: const FittedBox( - fit: BoxFit.scaleDown, - child: Text( - 'Add by Username', - style: TextStyle( + MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: () => context.push(Routes.chatsAddNewUser), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_add_rounded, size: 20), + const SizedBox(width: 8), + Text( + context.lang.emptyChatListAddUsernameBtn, + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, ), ), - ), + ], ), ), ], ), + const SizedBox(height: 50), ], ), diff --git a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart index 00d86abb..c970a24c 100644 --- a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart +++ b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart @@ -114,7 +114,7 @@ class _AddContactViaQrLinkViewState extends State { ), const SizedBox(height: 10), Text( - context.lang.userFoundBody, + context.lang.userFoundBody(widget.profile.username), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: context.color.onSurfaceVariant, diff --git a/lib/src/visual/views/contact/add_new_contact.view.dart b/lib/src/visual/views/contact/add_new_contact.view.dart index 94ea78e8..615aaa1c 100644 --- a/lib/src/visual/views/contact/add_new_contact.view.dart +++ b/lib/src/visual/views/contact/add_new_contact.view.dart @@ -18,7 +18,8 @@ import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; -import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart'; @@ -40,6 +41,7 @@ class _SearchUsernameView extends State { final TextEditingController _usernameController = TextEditingController(); bool _isLoading = false; bool hasRequestedUsers = false; + String? _searchError; List _openRequestsContacts = []; late StreamSubscription> _contactsStream; @@ -63,20 +65,24 @@ class _SearchUsernameView extends State { }, ); - _newAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchNewAnnouncedUsersWithRelations().listen((update) { - if (mounted) { - setState(() { - _newAnnouncedUsers = update; + _newAnnouncedUsersStream = twonlyDB.userDiscoveryDao + .watchNewAnnouncedUsersWithRelations() + .listen((update) { + if (mounted) { + setState(() { + _newAnnouncedUsers = update; + }); + } }); - } - }); - _allAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchAllAnnouncedUsersWithRelations().listen((update) { - if (mounted) { - setState(() { - _allAnnouncedUsers = update; + _allAnnouncedUsersStream = twonlyDB.userDiscoveryDao + .watchAllAnnouncedUsersWithRelations() + .listen((update) { + if (mounted) { + setState(() { + _allAnnouncedUsers = update; + }); + } }); - } - }); if (widget.username != null) { _usernameController.text = widget.username!; @@ -90,7 +96,8 @@ class _SearchUsernameView extends State { Future _shareProfile() async { final pubKey = await getUserPublicKey(); final params = ShareParams( - text: 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', + text: + 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', ); await SharePlus.instance.share(params); } @@ -164,18 +171,20 @@ class _SearchUsernameView extends State { }); if (userdata == null) { - await showAlertDialog( - context, - context.lang.searchUsernameNotFound, - context.lang.searchUsernameNotFoundBody(username), - ); + setState(() { + _searchError = context.lang.searchUsernameNotFound; + }); return; } + setState(() { + _searchError = null; + }); + final addUser = await showAlertDialog( context, context.lang.userFound(username), - context.lang.userFoundBody, + context.lang.userFoundBody(username), ); if (!addUser || !mounted) return; @@ -190,7 +199,9 @@ class _SearchUsernameView extends State { ), ); - if (widget.publicKey != null && mounted && widget.publicKey!.equals(userdata.publicIdentityKey)) { + if (widget.publicKey != null && + mounted && + widget.publicKey!.equals(userdata.publicIdentityKey)) { final markAsVerified = await showAlertDialog( context, context.lang.linkFromUsername(username), @@ -218,72 +229,93 @@ class _SearchUsernameView extends State { child: ListView( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: SearchBar( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + child: MyInput( controller: _usernameController, hintText: context.lang.searchUsernameInput, - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll( - context.color.surfaceContainerHighest.withValues(alpha: 0.3), - ), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 8), - ), - leading: const Icon(Icons.search, size: 20, color: Colors.grey), - trailing: [ - if (_usernameController.text.isNotEmpty) ...[ - IconButton( - icon: const Icon(Icons.clear, size: 20), - onPressed: () { - _usernameController.clear(); - setState(() {}); - }, - ), - if (_isLoading) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ), - ) - else - IconButton( - icon: FaIcon( - FontAwesomeIcons.magnifyingGlassPlus, - size: 20, - color: context.color.primary, - ), - onPressed: () => _requestNewUserByUsername( - _usernameController.text, - ), - ), - ] else ...[ - IconButton( - icon: FaIcon( - FontAwesomeIcons.camera, - size: 20, - color: context.color.primary, - ), - onPressed: () => context.push(Routes.cameraQRScanner), - tooltip: context.lang.scanOtherProfile, - ), - ], - ], - onSubmitted: _requestNewUserByUsername, + prefixIcon: const Icon(Icons.search, size: 20), + errorText: _searchError, onChanged: (value) { _usernameController.text = value.toLowerCase(); _usernameController.selection = TextSelection.fromPosition( TextPosition(offset: _usernameController.text.length), ); - setState(() {}); + setState(() { + _searchError = null; + }); }, + onSubmitted: _requestNewUserByUsername, + suffixIcon: _usernameController.text.isNotEmpty + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: + context.color.surfaceContainerHighest, + foregroundColor: context.color.onSurface, + minimumSize: const Size(32, 32), + padding: EdgeInsets.zero, + shape: const CircleBorder(), + ), + iconSize: 16, + icon: const Icon(Icons.close), + onPressed: () { + _usernameController.clear(); + setState(() {}); + }, + ), + const SizedBox(width: 0), + if (_isLoading) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + context.color.primary, + ), + ), + ), + ) + else + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: context.color.primary, + foregroundColor: context.color.onPrimary, + minimumSize: const Size(32, 32), + padding: EdgeInsets.zero, + shape: const CircleBorder(), + ), + iconSize: 16, + icon: const Icon(Icons.person_add_rounded), + onPressed: () => _requestNewUserByUsername( + _usernameController.text, + ), + ), + const SizedBox(width: 8), + ], + ) + : Padding( + padding: const EdgeInsets.only(right: 6), + child: IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: context.color.primary, + foregroundColor: context.color.onPrimary, + minimumSize: const Size(36, 36), + padding: EdgeInsets.zero, + shape: const CircleBorder(), + ), + iconSize: 18, + icon: const FaIcon(FontAwesomeIcons.camera), + onPressed: () => context.push(Routes.cameraQRScanner), + tooltip: context.lang.scanOtherProfile, + ), + ), ), ), const SizedBox( @@ -291,63 +323,40 @@ class _SearchUsernameView extends State { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( children: [ - Row( - children: [ - Expanded( - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: primaryColor, - foregroundColor: Colors.black87, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 10, - ), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: _shareProfile, - icon: const FaIcon( - FontAwesomeIcons.shareNodes, - size: 14, - ), - label: Text( - context.lang.shareYourProfile, - style: const TextStyle(fontSize: 13), - ), + MyButton( + variant: MyButtonVariant.primaryDense, + onPressed: _shareProfile, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.shareNodes, size: 14), + const SizedBox(width: 8), + Text( + context.lang.shareYourProfile, + style: const TextStyle(fontSize: 13), ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: context.color.secondaryContainer, - foregroundColor: context.color.onSecondaryContainer, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 10, - ), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: _showMyQrCode, - icon: const FaIcon( - FontAwesomeIcons.qrcode, - size: 14, - ), - label: Text( + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: _showMyQrCode, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.qrcode, size: 14), + const SizedBox(width: 8), + Text( context.lang.openYourOwnQRcode, style: const TextStyle(fontSize: 13), ), - ), + ], ), - ], + ), ), ], ), diff --git a/lib/src/visual/views/onboarding/components/onboarding_wrapper.dart b/lib/src/visual/views/onboarding/components/onboarding_wrapper.dart deleted file mode 100644 index 2e11c426..00000000 --- a/lib/src/visual/views/onboarding/components/onboarding_wrapper.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/themes/light.dart'; - -class OnboardingWrapper extends StatelessWidget { - const OnboardingWrapper({ - required this.children, - super.key, - }); - final List children; - - @override - Widget build(BuildContext context) { - final isDark = isDarkMode(context); - final backgroundColor = isDark ? const Color(0xFF0F172A) : primaryColor; - final topBlobColor = isDark - ? primaryColor.withValues(alpha: 0.15) - : Colors.white.withValues(alpha: 0.1); - final bottomBlobColor = isDark - ? primaryColor.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.05); - - return GestureDetector( - onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - behavior: HitTestBehavior.opaque, - child: Scaffold( - backgroundColor: backgroundColor, - body: Stack( - children: [ - Positioned( - top: -100, - right: -100, - child: Container( - width: 300, - height: 300, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: topBlobColor, - ), - ), - ), - Positioned( - bottom: -50, - left: -50, - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: bottomBlobColor, - ), - ), - ), - SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/visual/views/onboarding/onboarding.view.dart b/lib/src/visual/views/onboarding/onboarding.view.dart index e6990c19..f0df4f08 100644 --- a/lib/src/visual/views/onboarding/onboarding.view.dart +++ b/lib/src/visual/views/onboarding/onboarding.view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:lottie/lottie.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class OnboardingView extends StatelessWidget { const OnboardingView({required this.callbackOnSuccess, super.key}); @@ -53,19 +54,6 @@ class OnboardingView extends StatelessWidget { ), ), ), - // PageViewModel( - // title: context.lang.onboardingSendTwonliesTitle, - // body: context.lang.onboardingSendTwonliesBody, - // image: Center( - // child: Padding( - // padding: const EdgeInsets.only(top: 100), - // child: Lottie.asset( - // 'assets/animations/twonlies.lottie', - // repeat: false, - // ), - // ), - // ), - // ), PageViewModel( title: context.lang.onboardingNotProductTitle, bodyWidget: Column( @@ -81,7 +69,7 @@ class OnboardingView extends StatelessWidget { right: 50, top: 20, ), - child: FilledButton( + child: MyButton( onPressed: callbackOnSuccess, child: Text(context.lang.registerSubmitButton), ), @@ -97,17 +85,6 @@ class OnboardingView extends StatelessWidget { ), ), ), - // PageViewModel( - // title: context.lang.onboardingGetStartedTitle, - // image: Center( - // child: Padding( - // padding: const EdgeInsets.only(top: 100), - // child: Lottie.asset( - // 'assets/animations/rocket.lottie', - // ), - // ), - // ), - // ), ], done: const Text(''), next: Text(context.lang.next), diff --git a/lib/src/visual/views/onboarding/recover.view.dart b/lib/src/visual/views/onboarding/recover.view.dart index 39f239cd..0f172a35 100644 --- a/lib/src/visual/views/onboarding/recover.view.dart +++ b/lib/src/visual/views/onboarding/recover.view.dart @@ -3,11 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:restart_app/restart_app.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; -import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; -import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart'; class BackupRecoveryView extends StatefulWidget { const BackupRecoveryView({super.key}); @@ -64,180 +63,192 @@ class _BackupRecoveryViewState extends State { }); } - @override - Widget build(BuildContext context) { + void _showBackupExplanation(BuildContext context) { final isDark = isDarkMode(context); - final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white; - final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100]; + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final textColor = isDark ? Colors.white : Colors.black87; + final subtitleColor = isDark ? Colors.white70 : Colors.black54; - return OnboardingWrapper( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon( - Icons.arrow_back_ios_new_rounded, - ), - color: Colors.white, - iconSize: 20, - ), - const Spacer(), - IconButton( - onPressed: () async { - await showAlertDialog( - context, - 'twonly Backup', - context.lang.backupTwonlySafeLongDesc, - ); - }, - icon: const FaIcon(FontAwesomeIcons.circleInfo), - color: Colors.white, - iconSize: 20, - ), - ], + showModalBottomSheet( + context: context, + backgroundColor: backgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(28), ), - const SizedBox(height: 20), - const Center( + ), + isScrollControlled: true, + builder: (context) { + return SafeArea( child: Padding( - padding: EdgeInsets.all(20), - child: LinkLogoAnimation(), - ), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - context.lang.twonlySafeRecoverTitle, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - ), - const SizedBox(height: 48), - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: cardColor, - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: usernameCtrl, - onChanged: (value) => setState(() {}), - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: isDark ? Colors.white : Colors.black, - ), - decoration: InputDecoration( - hintText: context.lang.registerUsernameDecoration, - hintStyle: TextStyle( - color: isDark ? Colors.grey[500] : Colors.grey[600], - ), - filled: true, - fillColor: inputColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - prefixIcon: Icon( - Icons.alternate_email, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - const SizedBox(height: 16), - TextField( - controller: passwordCtrl, - onChanged: (value) => setState(() {}), - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: isDark ? Colors.white : Colors.black, - ), - obscureText: obscureText, - decoration: InputDecoration( - hintText: context.lang.password, - hintStyle: TextStyle( - color: isDark ? Colors.grey[500] : Colors.grey[600], - ), - filled: true, - fillColor: inputColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - prefixIcon: Icon( - Icons.lock_outline_rounded, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - suffixIcon: IconButton( - onPressed: () { - setState(() { - obscureText = !obscureText; - }); - }, - icon: FaIcon( - obscureText - ? FontAwesomeIcons.eye - : FontAwesomeIcons.eyeSlash, - size: 16, - color: isDark ? Colors.grey[400] : Colors.grey[600], + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.black12, + borderRadius: BorderRadius.circular(2.5), ), ), ), - ), - const SizedBox(height: 32), - FilledButton( - onPressed: (!isLoading) ? _recoverTwonlySafe : null, - style: FilledButton.styleFrom( - backgroundColor: primaryColor, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(60), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), + const SizedBox(height: 24), + Text( + 'twonly Backup', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: textColor, ), - elevation: 0, + textAlign: TextAlign.center, ), - child: isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator.adaptive( - valueColor: AlwaysStoppedAnimation(Colors.white), - strokeWidth: 3, + const SizedBox(height: 16), + Text( + context.lang.backupTwonlySafeLongDesc, + style: TextStyle( + fontSize: 16, + height: 1.5, + color: subtitleColor, + ), + ), + const SizedBox(height: 32), + MyButton( + onPressed: () => Navigator.pop(context), + child: const Text('Got it'), + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final isDark = isDarkMode(context); + final titleColor = isDark ? Colors.white : Colors.black87; + final iconColor = isDark ? Colors.white70 : Colors.black54; + + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.opaque, + child: Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + ), + color: iconColor, + iconSize: 20, + ), + const Spacer(), + IconButton( + onPressed: () => _showBackupExplanation(context), + icon: const FaIcon(FontAwesomeIcons.circleInfo), + color: iconColor, + iconSize: 20, + ), + ], ), - ) - : Text( - context.lang.twonlySafeRecoverBtn, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + const SizedBox(height: 20), + Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: LinkLogoAnimation( + color: isDark ? Colors.white : Colors.black, + ), + ), ), - ), - ), - ], + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + context.lang.twonlySafeRecoverTitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: titleColor, + letterSpacing: -0.5, + ), + ), + ), + const SizedBox(height: 48), + MyInput( + controller: usernameCtrl, + onChanged: (value) => setState(() {}), + hintText: context.lang.registerUsernameDecoration, + prefixIcon: const Icon(Icons.alternate_email), + ), + const SizedBox(height: 16), + MyInput( + controller: passwordCtrl, + onChanged: (value) => setState(() {}), + obscureText: obscureText, + hintText: context.lang.password, + prefixIcon: const Icon(Icons.lock_outline_rounded), + suffixIcon: IconButton( + onPressed: () { + setState(() { + obscureText = !obscureText; + }); + }, + icon: FaIcon( + obscureText + ? FontAwesomeIcons.eye + : FontAwesomeIcons.eyeSlash, + size: 16, + ), + ), + ), + const SizedBox(height: 32), + MyButton( + onPressed: (!isLoading) ? _recoverTwonlySafe : null, + child: isLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + strokeWidth: 3, + ), + ) + : Text(context.lang.twonlySafeRecoverBtn), + ), + const Spacer(), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + }, ), ), - const Spacer(), - const SizedBox(height: 40), - ], + ), ); } } diff --git a/lib/src/visual/views/onboarding/register.view.dart b/lib/src/visual/views/onboarding/register.view.dart index 5f1ca260..fde5a191 100644 --- a/lib/src/visual/views/onboarding/register.view.dart +++ b/lib/src/visual/views/onboarding/register.view.dart @@ -17,10 +17,10 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; -import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; -import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; class RegisterView extends StatefulWidget { @@ -43,8 +43,9 @@ class _RegisterViewState extends State { bool _registrationDisabled = false; bool _isTryingToRegister = false; bool _isValidUserName = false; - bool _showUserNameError = false; + bool _showProofOfWorkError = false; + String? _usernameErrorText; late Future? proofOfWork; @@ -58,7 +59,7 @@ class _RegisterViewState extends State { Future createNewUser() async { if (!_isValidUserName) { setState(() { - _showUserNameError = true; + _usernameErrorText = context.lang.registerUsernameLimits; }); return; } @@ -67,278 +68,271 @@ class _RegisterViewState extends State { setState(() { _isTryingToRegister = true; - _showUserNameError = false; + _usernameErrorText = null; _showProofOfWorkError = false; }); - late int proof; + try { + late int proof; - if (proofOfWork != null) { - proof = await proofOfWork!; - } else { - final (pow, registrationDisabled) = await apiService.getProofOfWork(); - if (pow == null) { - _registrationDisabled = registrationDisabled; + if (proofOfWork != null) { + proof = await proofOfWork!; + } else { + final (pow, registrationDisabled) = await apiService.getProofOfWork(); + if (pow == null) { + setState(() { + _registrationDisabled = registrationDisabled; + _isTryingToRegister = false; + }); + if (mounted) { + showNetworkIssue(context); + } + return; + } + proof = await calculatePoW(pow.prefix, pow.difficulty.toInt()); + } + + Log.info('The result of the POW is $proof'); + + await createIfNotExistsSignalIdentity(); + + var userId = 0; + + final res = await apiService.register(username, inviteCode, proof); + if (res.isSuccess) { + Log.info('Got user_id ${res.value} from server'); + userId = res.value.userid.toInt() as int; + } else { + proofOfWork = null; + if (res.error == ErrorCode.RegistrationDisabled) { + setState(() { + _registrationDisabled = true; + _isTryingToRegister = false; + }); + return; + } + if (res.error == ErrorCode.UserIdAlreadyTaken) { + Log.error('User ID already token. Tying again.'); + await deleteLocalUserData(); + return createNewUser(); + } + if (res.error == ErrorCode.UsernameAlreadyTaken || + res.error == ErrorCode.UsernameNotValid) { + setState(() { + _usernameErrorText = errorCodeToText( + context, + res.error as ErrorCode, + ); + _isTryingToRegister = false; + }); + return; + } + if (res.error == ErrorCode.InvalidProofOfWork) { + await deleteLocalUserData(); + setState(() { + _showProofOfWorkError = true; + _isTryingToRegister = false; + }); + return; + } if (mounted) { - showNetworkIssue(context); + setState(() { + _isTryingToRegister = false; + }); + await showAlertDialog( + context, + 'Oh no!', + errorCodeToText(context, res.error as ErrorCode), + ); } return; - // Starting with the proof of work. } - proof = await calculatePoW(pow.prefix, pow.difficulty.toInt()); - } - Log.info('The result of the POW is $proof'); + setState(() { + _isTryingToRegister = false; + }); - await createIfNotExistsSignalIdentity(); + final userData = UserData( + userId: userId, + username: username, + displayName: username, + subscriptionPlan: 'Free', + currentSetupPage: SetupPages.profile.name, + appVersion: AppState.latestAppVersionId, + ); - var userId = 0; + await UserService.save(userData); - final res = await apiService.register(username, inviteCode, proof); - if (res.isSuccess) { - Log.info('Got user_id ${res.value} from server'); - userId = res.value.userid.toInt() as int; - } else { - proofOfWork = null; - if (res.error == ErrorCode.RegistrationDisabled) { - _registrationDisabled = true; - return; - } - if (res.error == ErrorCode.UserIdAlreadyTaken) { - Log.error('User ID already token. Tying again.'); - await deleteLocalUserData(); - return createNewUser(); - } - if (res.error == ErrorCode.InvalidProofOfWork) { - await deleteLocalUserData(); - setState(() { - _showProofOfWorkError = true; - _isTryingToRegister = false; - }); - return; - } + await apiService.authenticate(); + widget.callbackOnSuccess(); + } catch (e, stack) { + Log.error('Error creating new user', e, stack); if (mounted) { setState(() { _isTryingToRegister = false; }); await showAlertDialog( context, - 'Oh no!', - errorCodeToText(context, res.error as ErrorCode), + 'Error', + e.toString(), ); } - return; } - - setState(() { - _isTryingToRegister = false; - }); - - final userData = UserData( - userId: userId, - username: username, - displayName: username, - subscriptionPlan: 'Free', - currentSetupPage: SetupPages.profile.name, - appVersion: AppState.latestAppVersionId, - ); - - await UserService.save(userData); - - await apiService.authenticate(); - widget.callbackOnSuccess(); } @override Widget build(BuildContext context) { final isDark = isDarkMode(context); - final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white; - final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100]; - final sloganColor = isDark ? Colors.white.withValues(alpha: 0.9) : Colors.grey[800]; - final secondaryButtonColor = isDark ? Colors.grey[400] : Colors.grey[600]; - return OnboardingWrapper( - children: [ - const SizedBox(height: 30), - Center( - child: Container( - padding: const EdgeInsets.all(10), - child: const LinkLogoAnimation(), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - context.lang.registerSlogan, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Colors.white.withValues(alpha: 0.9), - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(height: 30), - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: cardColor, - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_registrationDisabled) ...[ - const SizedBox(height: 24), - Text( - context.lang.registrationClosed, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - color: Colors.red, + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.opaque, + child: Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, ), - ), - const SizedBox(height: 48), - ] else ...[ - Text( - context.lang.registerUsernameSlogan, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: sloganColor, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 20), - TextField( - controller: usernameController, - onChanged: (value) { - usernameController.text = value.toLowerCase(); - usernameController.selection = TextSelection.fromPosition( - TextPosition( - offset: usernameController.text.length, - ), - ); - setState(() { - _isValidUserName = usernameController.text.length >= 3; - }); - }, - inputFormatters: [ - LengthLimitingTextInputFormatter(12), - FilteringTextInputFormatter.allow( - RegExp('[a-z0-9A-Z._]'), - ), - ], - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: isDark ? Colors.white : Colors.black, - ), - decoration: InputDecoration( - hintText: context.lang.registerUsernameDecoration, - hintStyle: TextStyle( - color: isDark ? Colors.grey[500] : Colors.grey[600], - ), - filled: true, - fillColor: inputColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - prefixIcon: Icon( - Icons.alternate_email, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - if (_showUserNameError && usernameController.text.length < 3) ...[ - const SizedBox(height: 8), - Text( - context.lang.registerUsernameLimits, - style: const TextStyle( - color: Colors.red, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], - if (_showProofOfWorkError) ...[ - const SizedBox(height: 8), - Text( - context.lang.registerProofOfWorkFailed, - style: const TextStyle( - color: Colors.red, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], - const SizedBox(height: 24), - FilledButton( - onPressed: _isTryingToRegister ? null : createNewUser, - style: FilledButton.styleFrom( - backgroundColor: primaryColor, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(60), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - ), - elevation: 0, - ), - child: _isTryingToRegister - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive( - valueColor: AlwaysStoppedAnimation(Colors.white), - strokeWidth: 3, - ), - ) - : Text( - context.lang.registerSubmitButton, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 30), + Center( + child: Container( + padding: const EdgeInsets.all(10), + child: LinkLogoAnimation( + color: isDark ? Colors.white : Colors.black, + ), ), ), - ), - const SizedBox(height: 16), - ], - TextButton( - onPressed: () => context.push( - Routes.settingsBackupRecovery, - ), - style: TextButton.styleFrom( - minimumSize: const Size.fromHeight(50), - foregroundColor: secondaryButtonColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + context.lang.registerSlogan, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: + Theme.of(context).textTheme.bodyMedium?.color + ?.withValues(alpha: 0.7) ?? + (isDark + ? Colors.white.withValues(alpha: 0.7) + : Colors.black.withValues(alpha: 0.7)), + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 40), + if (_registrationDisabled) ...[ + Text( + context.lang.registrationClosed, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 40), + ] else ...[ + Text( + context.lang.registerUsernameSlogan, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + color: isDark ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 24), + MyInput( + controller: usernameController, + errorText: _usernameErrorText, + onChanged: (value) { + usernameController.text = value.toLowerCase(); + usernameController.selection = + TextSelection.fromPosition( + TextPosition( + offset: usernameController.text.length, + ), + ); + setState(() { + _isValidUserName = + usernameController.text.length >= 3; + _usernameErrorText = null; + }); + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(12), + FilteringTextInputFormatter.allow( + RegExp('[a-z0-9A-Z._]'), + ), + ], + hintText: context.lang.registerUsernameDecoration, + prefixIcon: const Icon(Icons.alternate_email), + ), + if (_showProofOfWorkError) ...[ + const SizedBox(height: 10), + Text( + context.lang.registerProofOfWorkFailed, + style: const TextStyle( + color: Colors.redAccent, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 32), + MyButton( + onPressed: _isTryingToRegister + ? null + : createNewUser, + child: _isTryingToRegister + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + strokeWidth: 3, + ), + ) + : Text( + context.lang.registerSubmitButton, + ), + ), + const SizedBox(height: 20), + ], + MyButton( + onPressed: () => context.push( + Routes.settingsBackupRecovery, + ), + variant: MyButtonVariant.secondary, + child: Text( + context.lang.twonlySafeRecoverBtn, + ), + ), + const Spacer(), + const SizedBox(height: 40), + ], + ), ), ), - child: Text( - context.lang.twonlySafeRecoverBtn, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ], + ); + }, ), ), - const Spacer(), - const SizedBox(height: 40), - ], + ), ); } } diff --git a/lib/src/visual/views/onboarding/setup.view.dart b/lib/src/visual/views/onboarding/setup.view.dart index 80b1760d..1021eec7 100644 --- a/lib/src/visual/views/onboarding/setup.view.dart +++ b/lib/src/visual/views/onboarding/setup.view.dart @@ -5,6 +5,7 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/onboarding/setup/backup.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/let_your_friends_find_you.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart'; @@ -152,7 +153,9 @@ class _SetupViewState extends State { right: index == currentPage.totalPages - 1 ? 0 : 8, ), decoration: BoxDecoration( - color: isFinished ? context.color.primary : context.color.surfaceContainer, + color: isFinished + ? context.color.primary + : context.color.surfaceContainer, borderRadius: BorderRadius.circular(10), ), ), @@ -175,35 +178,30 @@ class _SetupViewState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ if (currentPage.index > 0) - TextButton( + MyButton( onPressed: () async { await UserService.update((u) { u.currentSetupPage = currentPage.previous()?.name; }); }, + variant: MyButtonVariant.text, child: Text( context.lang.back, - style: TextStyle( - color: context.color.primary, - fontWeight: FontWeight.bold, - ), ), ), - if (currentPage.index > 0 && !currentPage.isLast) const SizedBox(width: 24), + if (currentPage.index > 0 && !currentPage.isLast) + const SizedBox(width: 24), if (!currentPage.isLast) - TextButton( + MyButton( onPressed: () async { await UserService.update( (u) => u.skipSetupPages = true, ); widget.onUpdate?.call(); }, + variant: MyButtonVariant.text, child: Text( context.lang.onboardingFinishLater, - style: TextStyle( - color: context.color.primary, - fontWeight: FontWeight.bold, - ), ), ), ], diff --git a/lib/src/visual/views/onboarding/setup/components/finish_setup.comp.dart b/lib/src/visual/views/onboarding/setup/components/finish_setup.comp.dart index c091586d..53851dae 100644 --- a/lib/src/visual/views/onboarding/setup/components/finish_setup.comp.dart +++ b/lib/src/visual/views/onboarding/setup/components/finish_setup.comp.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; class FinishSetupComp extends StatefulWidget { @@ -123,29 +124,19 @@ class _FinishSetupCompState extends State { ), ), const SizedBox(height: 14), - FilledButton.icon( + MyButton( onPressed: onTap, - icon: const Icon( - Icons.arrow_forward_rounded, - size: 18, - ), - label: Text( - context.lang.finishSetupCardAction, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - style: FilledButton.styleFrom( - backgroundColor: context.color.primary, - foregroundColor: context.color.onPrimary, - minimumSize: const Size(0, 40), - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, + variant: MyButtonVariant.primaryDense, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.arrow_forward_rounded, + size: 18, + ), + const SizedBox(width: 8), + Text(context.lang.finishSetupCardAction), + ], ), ), ], diff --git a/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart b/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart index 7a23e134..7973ca36 100644 --- a/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart +++ b/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; class NextButtonComp extends StatelessWidget { @@ -24,7 +25,7 @@ class NextButtonComp extends StatelessWidget { final currentPage = SetupPagesExtension.fromStr( userService.currentUser.currentSetupPage, ); - return ElevatedButton( + return MyButton( onPressed: (canSubmit && !isLoading) ? () async { if (onPressed != null) { @@ -36,15 +37,6 @@ class NextButtonComp extends StatelessWidget { }); } : null, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), - backgroundColor: context.color.primary, - foregroundColor: context.color.onPrimary, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), child: isLoading ? const SizedBox( height: 24, @@ -56,7 +48,6 @@ class NextButtonComp extends StatelessWidget { ) : Text( currentPage.isLast ? context.lang.finishSetup : context.lang.next, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ); }, diff --git a/lib/src/visual/views/onboarding/setup/profile.setup.dart b/lib/src/visual/views/onboarding/setup/profile.setup.dart index c352055e..d2bf2769 100644 --- a/lib/src/visual/views/onboarding/setup/profile.setup.dart +++ b/lib/src/visual/views/onboarding/setup/profile.setup.dart @@ -1,13 +1,14 @@ -import 'package:avatar_maker/avatar_maker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart' + show AvatarIcon; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; -import 'package:vector_graphics/vector_graphics.dart'; class ProfileSetupPage extends StatefulWidget { const ProfileSetupPage({super.key}); @@ -17,10 +18,7 @@ class ProfileSetupPage extends StatefulWidget { } class _ProfileSetupPageState extends State { - final AvatarMakerController _avatarMakerController = - PersistentAvatarMakerController(customizedPropertyCategories: []); late final TextEditingController _displayNameController; - @override void initState() { super.initState(); @@ -35,95 +33,82 @@ class _ProfileSetupPageState extends State { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.lang.onboardingProfileTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - context.lang.onboardingProfileBody, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: context.color.onSurfaceVariant, - ), - ), - const SizedBox(height: 40), - StreamBuilder( - stream: userService.onUserUpdated, - builder: (context, asyncSnapshot) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: context.color.primary.withValues(alpha: 0.2), - width: 4, - ), + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, asyncSnapshot) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.lang.onboardingProfileTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, ), - child: userService.currentUser.avatarSvg == null - ? ClipRRect( - borderRadius: BorderRadius.circular(80), - child: Container( - width: 160, - height: 160, - color: context.color.surfaceContainer, - child: const SvgPicture( - AssetBytesLoader( - 'assets/images/default_avatar.svg.vec', - ), - ), - ), - ) - : AvatarMakerAvatar( - backgroundColor: context.color.surfaceContainer, - radius: 80, - controller: _avatarMakerController, - ), - ); - }, - ), - const SizedBox(height: 16), - TextButton.icon( - onPressed: () async { - await context.push(Routes.settingsProfileModifyAvatar); - await _avatarMakerController.performRestore(); - }, - icon: const Icon(Icons.palette_outlined), - label: Text(context.lang.settingsProfileCustomizeAvatar), - ), - const SizedBox(height: 30), - TextField( - controller: _displayNameController, - decoration: InputDecoration( - labelText: context.lang.settingsProfileEditDisplayName, - hintText: context.lang.settingsProfileEditDisplayNameNew, - prefixIcon: const Icon(Icons.person_outline), - filled: true, - fillColor: context.color.surfaceContainerLow, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, ), - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - const SizedBox(height: 40), - NextButtonComp( - onPressed: () async { - await UserService.update((user) { - if (_displayNameController.text.isNotEmpty) { - user.displayName = _displayNameController.text; - } - }); - return false; - }, - ), - ], + const SizedBox(height: 8), + Text( + context.lang.onboardingProfileBody, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 40), + StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, asyncSnapshot) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.color.primary.withValues(alpha: 0.2), + width: 4, + ), + ), + child: const AvatarIcon( + fontSize: 70, + myAvatar: true, + ), + ); + }, + ), + const SizedBox(height: 16), + MyButton( + onPressed: () async { + await context.push(Routes.settingsProfileModifyAvatar); + }, + variant: MyButtonVariant.text, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.palette_outlined), + const SizedBox(width: 8), + Text(context.lang.settingsProfileCustomizeAvatar), + ], + ), + ), + const SizedBox(height: 30), + MyInput( + controller: _displayNameController, + hintText: context.lang.settingsProfileEditDisplayNameNew, + prefixIcon: const Icon(Icons.person_outline), + ), + const SizedBox(height: 40), + NextButtonComp( + onPressed: () async { + await UserService.update((user) { + if (_displayNameController.text.isNotEmpty) { + user.displayName = _displayNameController.text; + } + }); + return false; + }, + ), + ], + ); + }, ); } } diff --git a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart index d62ce050..cb553ead 100644 --- a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart +++ b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; Future isSecurePassword(String password) async { final badPasswordsStr = await rootBundle.loadString( @@ -46,29 +46,20 @@ class _BackupPasswordTextFieldState extends State { @override Widget build(BuildContext context) { - return TextField( + return MyInput( controller: widget.controller, onChanged: widget.onChanged, obscureText: _obscureText, - decoration: InputDecoration( - labelText: widget.labelText, - filled: true, - fillColor: context.color.surfaceContainerLow, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - floatingLabelBehavior: FloatingLabelBehavior.never, - suffixIcon: IconButton( - onPressed: () { - setState(() { - _obscureText = !_obscureText; - }); - }, - icon: FaIcon( - _obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash, - size: 16, - ), + hintText: widget.labelText, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + icon: FaIcon( + _obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash, + size: 16, ), ), ); diff --git a/lib/src/visual/views/settings/profile/modify_avatar.view.dart b/lib/src/visual/views/settings/profile/modify_avatar.view.dart index 8609a897..7b533a78 100644 --- a/lib/src/visual/views/settings/profile/modify_avatar.view.dart +++ b/lib/src/visual/views/settings/profile/modify_avatar.view.dart @@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class ModifyAvatarView extends StatefulWidget { const ModifyAvatarView({super.key}); @@ -15,12 +16,19 @@ class ModifyAvatarView extends StatefulWidget { } class _ModifyAvatarViewState extends State { - final AvatarMakerController _avatarMakerController = - PersistentAvatarMakerController(customizedPropertyCategories: []); + late final _CustomAvatarMakerController _avatarMakerController; @override void initState() { super.initState(); + final svg = userService.currentUser.avatarSvg; + if (svg != null && svg.isNotEmpty) { + _avatarMakerController = _CustomAvatarMakerController( + svg: svg, + ); + } else { + _avatarMakerController = _CustomAvatarMakerController.defaultAvatar(); + } } Future updateUserAvatar(String json, String svg) async { @@ -33,49 +41,38 @@ class _ModifyAvatarViewState extends State { } AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) { - if (isDarkMode(context)) { - return AvatarMakerThemeData( - boxDecoration: const BoxDecoration( - boxShadow: [BoxShadow()], - ), - unselectedTileDecoration: BoxDecoration( - color: const Color.fromARGB(255, 50, 50, 50), // Dark mode color - borderRadius: BorderRadius.circular(10), - ), - selectedTileDecoration: BoxDecoration( - color: const Color.fromARGB(255, 100, 100, 100), // Dark mode color - borderRadius: BorderRadius.circular(10), - ), - selectedIconColor: Colors.white, - unselectedIconColor: Colors.grey, - primaryBgColor: Colors.black, // Dark mode background - secondaryBgColor: Colors.grey[850], // Dark mode secondary background - labelTextStyle: const TextStyle( - color: Colors.white, - ), // Light text for dark mode - ); - } else { - return AvatarMakerThemeData( - boxDecoration: const BoxDecoration( - boxShadow: [BoxShadow()], - ), - unselectedTileDecoration: BoxDecoration( - color: const Color.fromARGB(255, 240, 240, 240), // Light mode color - borderRadius: BorderRadius.circular(10), - ), - selectedTileDecoration: BoxDecoration( - color: const Color.fromARGB(255, 200, 200, 200), // Light mode color - borderRadius: BorderRadius.circular(10), - ), - selectedIconColor: Colors.black, - unselectedIconColor: Colors.grey, - primaryBgColor: Colors.white, // Light mode background - secondaryBgColor: Colors.grey[200], // Light mode secondary background - labelTextStyle: const TextStyle( - color: Colors.black, - ), // Dark text for light mode - ); - } + final colors = context.color; + final isDark = isDarkMode(context); + return AvatarMakerThemeData( + boxDecoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + unselectedTileDecoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + selectedTileDecoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.15), + border: Border.all(color: colors.primary, width: 2), + borderRadius: BorderRadius.circular(12), + ), + selectedIconColor: colors.primary, + unselectedIconColor: colors.onSurfaceVariant.withValues(alpha: 0.6), + primaryBgColor: colors.surface, + secondaryBgColor: colors.surfaceContainerLow, + labelTextStyle: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.bold, + ), + ); } Future _showBackDialog() { @@ -148,39 +145,64 @@ class _ModifyAvatarViewState extends State { controller: _avatarMakerController, ), ), - SizedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 12, + runSpacing: 10, + alignment: WrapAlignment.center, children: [ - IconButton( - icon: const FaIcon(FontAwesomeIcons.floppyDisk), + MyButton( + variant: MyButtonVariant.primaryDense, onPressed: storeAvatarAndExit, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.floppyDisk, size: 16), + const SizedBox(width: 6), + Text(context.lang.avatarSaveChangesStore), + ], + ), ), - IconButton( - icon: const FaIcon(FontAwesomeIcons.shuffle), + MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: _avatarMakerController.randomizedSelectedOptions, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.shuffle, size: 16), + const SizedBox(width: 6), + Text(context.lang.avatarCustomizeRandomize), + ], + ), ), - IconButton( - icon: const FaIcon(FontAwesomeIcons.rotateLeft), - onLongPress: () async { - await PersistentAvatarMakerController.clearAvatarMaker(); - await _avatarMakerController.restoreState(); - }, + MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: _avatarMakerController.restoreState, + onLongPress: () { + _avatarMakerController.clearCustomizations(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.rotateLeft, size: 16), + const SizedBox(width: 6), + Text(context.lang.avatarCustomizeReset), + ], + ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 30, ), child: AvatarMakerCustomizer( scaffoldWidth: min( 600, - MediaQuery.of(context).size.width * 0.85, + MediaQuery.of(context).size.width * 0.95, ), theme: getAvatarMakerTheme(context), controller: _avatarMakerController, @@ -194,3 +216,72 @@ class _ModifyAvatarViewState extends State { ); } } + +class _CustomAvatarMakerController extends NonPersistentAvatarMakerController { + _CustomAvatarMakerController({ + required super.svg, + }) : _initialSvg = svg, + super.fromSvg() { + _initialOptions = Map.from(selectedOptions); + } + + _CustomAvatarMakerController.defaultAvatar() : _initialSvg = '', super() { + _initialOptions = Map.from(defaultSelectedOptions); + } + + final String _initialSvg; + late final Map _initialOptions; + List? _customPropertyCategories; + + void clearCustomizations() { + selectedOptions = Map.from(defaultSelectedOptions); + updatePreview(); + } + + @override + List get propertyCategories { + var list = _customPropertyCategories; + if (list == null) { + list = super.propertyCategories.map((category) { + return CustomizedPropertyCategory( + id: category.id, + name: category.name, + iconFile: category.iconFile, + properties: category.properties, + defaultValue: category.defaultValue, + ); + }).toList(); + _customPropertyCategories = list; + } + return list; + } + + @override + List get displayedPropertyCategories { + final order = [ + PropertyCategoryIds.SkinColor, + PropertyCategoryIds.EyeType, + PropertyCategoryIds.EyebrowType, + PropertyCategoryIds.Nose, + PropertyCategoryIds.MouthType, + PropertyCategoryIds.HairStyle, + PropertyCategoryIds.HairColor, + PropertyCategoryIds.FacialHairType, + PropertyCategoryIds.FacialHairColor, + PropertyCategoryIds.OutfitType, + PropertyCategoryIds.OutfitColor, + PropertyCategoryIds.Accessory, + ]; + return (propertyCategories.where((c) => order.contains(c.id)).toList() + ..sort((a, b) => order.indexOf(a.id).compareTo(order.indexOf(b.id)))); + } + + @override + Future performRestore() async { + final restoredSvg = _initialSvg.isNotEmpty ? _initialSvg : drawAvatarSVG(); + return RestoredData( + svg: restoredSvg, + options: Map.from(_initialOptions), + ); + } +} diff --git a/lib/src/visual/views/settings/profile/profile.view.dart b/lib/src/visual/views/settings/profile/profile.view.dart index 18b6781d..1a422491 100644 --- a/lib/src/visual/views/settings/profile/profile.view.dart +++ b/lib/src/visual/views/settings/profile/profile.view.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:avatar_maker/avatar_maker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -10,8 +9,10 @@ import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart'; class ProfileView extends StatefulWidget { @@ -22,9 +23,6 @@ class ProfileView extends StatefulWidget { } class _ProfileViewState extends State { - final AvatarMakerController _avatarMakerController = - PersistentAvatarMakerController(customizedPropertyCategories: []); - int twonlyScore = 0; late StreamSubscription twonlyScoreSub; @@ -104,22 +102,24 @@ class _ProfileViewState extends State { physics: const BouncingScrollPhysics(), children: [ const SizedBox(height: 25), - AvatarMakerAvatar( - backgroundColor: Colors.transparent, - radius: 80, - controller: _avatarMakerController, + const AvatarIcon( + fontSize: 70, + myAvatar: true, ), const SizedBox(height: 10), Center( - child: SizedBox( - height: 35, - child: ElevatedButton.icon( - icon: const Icon(Icons.edit), - label: Text(context.lang.settingsProfileCustomizeAvatar), - onPressed: () async { - await context.push(Routes.settingsProfileModifyAvatar); - await _avatarMakerController.performRestore(); - }, + child: MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: () async { + await context.push(Routes.settingsProfileModifyAvatar); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.edit, size: 16), + const SizedBox(width: 8), + Text(context.lang.settingsProfileCustomizeAvatar), + ], ), ), ), From 12dce4f52da26f10654911732bc455f924322142 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 02:39:56 +0200 Subject: [PATCH 03/18] replacing more buttons --- lib/src/database/daos/mediafiles.dao.dart | 4 +- lib/src/localization/translations | 2 +- .../mediafiles/mediafile.service.dart | 25 ++- .../mediafiles/thumbnail.service.dart | 197 ++++++++++++++---- .../services/memories/memories.service.dart | 82 ++++++-- .../visual/elements/my_button.element.dart | 18 ++ lib/src/visual/helpers/screenshot.helper.dart | 17 +- .../save_to_gallery.dart | 31 ++- .../share_image_contact_selection.view.dart | 102 +++++---- .../views/camera/share_image_editor.view.dart | 128 +++++++----- .../visual/views/chats/chat_list.view.dart | 66 ++++-- .../empty_chat_list.comp.dart | 30 +-- .../response_container.dart | 42 ++-- .../visual/views/chats/media_viewer.view.dart | 9 + .../open_requests_list.comp.dart | 9 +- .../components/flashback_banner.comp.dart | 12 ++ .../components/memory_thumbnail.comp.dart | 39 +++- .../visual/views/memories/memories.view.dart | 151 +++++++++++--- .../memories/synchronized_viewer.view.dart | 9 + .../visual/views/onboarding/recover.view.dart | 67 +----- .../settings/backup/backup_settings.view.dart | 7 +- .../settings/backup/backup_setup.view.dart | 37 ++-- .../backup/components/backup_setup.comp.dart | 67 ++++++ .../views/settings/help/contact_us.view.dart | 76 +++++-- .../help/contact_us/submit_message.view.dart | 4 +- test.jpg | Bin 620 -> 0 bytes 26 files changed, 841 insertions(+), 390 deletions(-) delete mode 100644 test.jpg diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 0778a72d..78f2d92f 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -141,7 +141,9 @@ class MediaFilesDao extends DatabaseAccessor Stream> watchAllStoredMediaFiles() { final query = (select(mediaFiles)..where((t) => t.stored.equals(true))).join([]) - ..groupBy([mediaFiles.storedFileHash]); + ..groupBy([ + const CustomExpression('COALESCE(stored_file_hash, media_id)') + ]); return query.map((row) => row.readTable(mediaFiles)).watch(); } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 189bf8f4..c95e98ca 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235 +Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 83528eab..ba87a485 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -213,7 +213,12 @@ class MediaFileService { } Future createThumbnail() async { - if (!storedPath.existsSync()) { + if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { + if (storedPath.existsSync() && storedPath.lengthSync() == 0) { + try { + storedPath.deleteSync(); + } catch (_) {} + } if (mediaFile.stored && mediaFile.createdAt.isBefore( clock.now().subtract(const Duration(days: 30)), @@ -288,8 +293,10 @@ class MediaFileService { bool get imagePreviewAvailable => mediaFile.hasThumbnail || - thumbnailPath.existsSync() || - storedPath.existsSync(); + (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) || + mediaFile.type == MediaType.audio || + ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && + storedPath.existsSync() && storedPath.lengthSync() > 0); Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); @@ -439,7 +446,7 @@ class MediaFileService { return; } - if (!storedPath.existsSync()) { + if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), @@ -448,7 +455,7 @@ class MediaFileService { } try { - final bytes = storedPath.readAsBytesSync(); + final bytes = await storedPath.readAsBytes(); final result = await compute(_processImageCrop, bytes); if (result.isCropped && result.pngBytes != null) { @@ -460,18 +467,18 @@ class MediaFileService { ); if (webpBytes.isNotEmpty) { - storedPath.writeAsBytesSync(webpBytes); + await storedPath.writeAsBytes(webpBytes); } else { Log.warn('WebP compression returned empty, falling back to PNG'); - storedPath.writeAsBytesSync(result.pngBytes!); + await storedPath.writeAsBytes(result.pngBytes!); } } catch (e) { Log.error('Error compressing to WebP, falling back to PNG: $e'); - storedPath.writeAsBytesSync(result.pngBytes!); + await storedPath.writeAsBytes(result.pngBytes!); } if (thumbnailPath.existsSync()) { - thumbnailPath.deleteSync(); + await thumbnailPath.delete(); } await createThumbnail(); final checksum = await sha256File(storedPath); diff --git a/lib/src/services/mediafiles/thumbnail.service.dart b/lib/src/services/mediafiles/thumbnail.service.dart index 2e2209de..35fd86a6 100644 --- a/lib/src/services/mediafiles/thumbnail.service.dart +++ b/lib/src/services/mediafiles/thumbnail.service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:image/image.dart' as img; import 'package:pro_video_editor/pro_video_editor.dart'; @@ -11,34 +12,61 @@ Future createThumbnailsForVideo( ) async { final stopwatch = Stopwatch()..start(); - if (destinationFile.existsSync()) { - return true; - } - - final images = await ProVideoEditor.instance.getThumbnails( - ThumbnailConfigs( - video: EditorVideo.file(sourceFile), - outputFormat: ThumbnailFormat.webp, - timestamps: const [ - Duration.zero, - ], - outputSize: const Size(272, 153), - ), - ); - - if (images.isNotEmpty) { - stopwatch.stop(); - destinationFile.writeAsBytesSync(images.first); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', - ); - return true; - } else { - Log.warn( - 'Thumbnail creation failed for the video.', - ); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source video file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } + + if (destinationFile.existsSync()) { + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } + } + + try { + final images = await ProVideoEditor.instance.getThumbnails( + ThumbnailConfigs( + video: EditorVideo.file(sourceFile), + outputFormat: ThumbnailFormat.webp, + timestamps: const [ + Duration.zero, + ], + outputSize: const Size(272, 153), + ), + ); + + if (images.isNotEmpty && images.first.isNotEmpty) { + stopwatch.stop(); + await destinationFile.writeAsBytes(images.first); + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', + ); + return true; + } + } + } catch (e) { + Log.error('Error creating video thumbnail: $e'); + } + + Log.warn( + 'Thumbnail creation failed for the video.', + ); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; } Future createThumbnailsForImage( @@ -47,6 +75,26 @@ Future createThumbnailsForImage( ) async { final stopwatch = Stopwatch()..start(); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source image file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } + + if (destinationFile.existsSync()) { + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } + } + try { await FlutterImageCompress.compressAndGetFile( sourceFile.absolute.path, @@ -57,12 +105,28 @@ Future createThumbnailsForImage( format: CompressFormat.webp, ); stopwatch.stop(); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', - ); - return true; + + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', + ); + return true; + } else { + Log.error('Compressed image thumbnail is empty or missing.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } } catch (e) { Log.error('Error creating image thumbnail: $e'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } } @@ -73,40 +137,81 @@ Future createThumbnailsForGif( ) async { final stopwatch = Stopwatch()..start(); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source GIF file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } + if (destinationFile.existsSync()) { - return true; + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } } try { // For GIFs, we decode the first frame and save it as WebP - final bytes = sourceFile.readAsBytesSync(); - final image = img.decodeGif(bytes); - if (image == null) { + final bytes = await sourceFile.readAsBytes(); + final pngBytes = await compute(_processGifThumbnail, bytes); + if (pngBytes == null || pngBytes.isEmpty) { Log.error('Could not decode GIF for thumbnail.'); return false; } - final thumbnail = img.copyResize( - image, - width: image.width > image.height ? 400 : null, - height: image.height >= image.width ? 400 : null, - ); - - final pngBytes = img.encodePng(thumbnail); final webp = await FlutterImageCompress.compressWithList( pngBytes, format: CompressFormat.webp, quality: 85, ); - destinationFile.writeAsBytesSync(webp); + if (webp.isEmpty) { + Log.error('GIF thumbnail compression returned empty.'); + return false; + } + + await destinationFile.writeAsBytes(webp); stopwatch.stop(); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', - ); - return true; + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', + ); + return true; + } else { + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } } catch (e) { Log.error('Error creating GIF thumbnail: $e'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } } + +Uint8List? _processGifThumbnail(Uint8List bytes) { + final image = img.decodeGif(bytes); + if (image == null) return null; + + final thumbnail = img.copyResize( + image, + width: image.width > image.height ? 400 : null, + height: image.height >= image.width ? 400 : null, + ); + + return img.encodePng(thumbnail); +} diff --git a/lib/src/services/memories/memories.service.dart b/lib/src/services/memories/memories.service.dart index b9d65939..4a532f70 100644 --- a/lib/src/services/memories/memories.service.dart +++ b/lib/src/services/memories/memories.service.dart @@ -200,6 +200,12 @@ class MemoriesService { Future _initAsync() async { try { + // Start DB subscription first so files with existing thumbnails are shown immediately. + await _dbSubscription?.cancel(); + _dbSubscription = twonlyDB.mediaFilesDao + .watchAllStoredMediaFiles() + .listen(_processMediaFilesStream); + final pendingFiles = await twonlyDB.mediaFilesDao .getAllMediaFilesPendingMigration(); @@ -210,23 +216,25 @@ class MemoriesService { ); _notifyState(); - for (final mediaFile in pendingFiles) { + // Run the multi-step background migration process asynchronously. + unawaited(_processMigrationQueue(pendingFiles)); + } + } catch (e) { + Log.error('Error initializing MemoriesService: $e'); + } + } + + Future _processMigrationQueue(List pendingFiles) async { + try { + // Phase 1: Create thumbnails first so files can be shown in the + // gallery immediately, without waiting for heavier operations. + for (final mediaFile in pendingFiles) { + try { final mediaService = MediaFileService(mediaFile); - if (mediaService.mediaFile.storedFileHash == null) { - await mediaService.hashMediaFile(); - } - - if (!mediaService.mediaFile.hasCropAnalyzed) { - await mediaService.cropTransparentBorders(); - } - - if (mediaService.mediaFile.sizeInBytes == null) { - await mediaService.calculateAndSaveSize(); - } - if (!mediaService.mediaFile.hasThumbnail) { - if (mediaService.thumbnailPath.existsSync()) { + if (mediaService.thumbnailPath.existsSync() && + mediaService.thumbnailPath.lengthSync() > 0) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasThumbnail: Value(true)), @@ -235,18 +243,48 @@ class MemoriesService { await mediaService.createThumbnail(); } } - _updateMigrationCount(_currentState.filesToMigrate - 1); + } catch (e) { + Log.error( + 'Error creating thumbnail for ${mediaFile.mediaId}: $e', + ); } - - _updateMigrationCount(0); + _updateMigrationCount(_currentState.filesToMigrate - 1); } - await _dbSubscription?.cancel(); - _dbSubscription = twonlyDB.mediaFilesDao - .watchAllStoredMediaFiles() - .listen(_processMediaFilesStream); + _updateMigrationCount(0); + + // Phase 2: Background — hash, crop analysis, size calculation. + // Each DB write here fires the stream subscription above, keeping + // the gallery state fresh without a separate notification step. + await _backgroundProcessPendingFiles(pendingFiles); } catch (e) { - Log.error('Error initializing MemoriesService: $e'); + Log.error('Error in background migration queue: $e'); + } + } + + Future _backgroundProcessPendingFiles( + List pendingFiles, + ) async { + for (final mediaFile in pendingFiles) { + try { + final mediaService = MediaFileService(mediaFile); + + if (mediaService.mediaFile.storedFileHash == null) { + await mediaService.hashMediaFile(); + } + + if (!mediaService.mediaFile.hasCropAnalyzed) { + await mediaService.cropTransparentBorders(); + } + + if (mediaService.mediaFile.sizeInBytes == null) { + await mediaService.calculateAndSaveSize(); + } + } catch (e) { + Log.error( + 'Error in background processing of ${mediaFile.mediaId}: $e', + ); + } } } diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index 22e1b63e..3bbec5cc 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -7,6 +7,7 @@ enum MyButtonVariant { primary, secondary, text, + primaryMiddle, primaryDense, secondaryDense, } @@ -142,6 +143,23 @@ class _MyButtonState extends State borderRadius: BorderRadius.circular(18), ), ); + case MyButtonVariant.primaryMiddle: + buttonStyle = FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ); case MyButtonVariant.primaryDense: buttonStyle = FilledButton.styleFrom( backgroundColor: primaryColor, diff --git a/lib/src/visual/helpers/screenshot.helper.dart b/lib/src/visual/helpers/screenshot.helper.dart index 8605a38e..654de721 100644 --- a/lib/src/visual/helpers/screenshot.helper.dart +++ b/lib/src/visual/helpers/screenshot.helper.dart @@ -25,10 +25,20 @@ class ScreenshotImageHelper { return imageBytes; } if (imageBytesFuture != null) { - return imageBytesFuture; + try { + return imageBytes = await imageBytesFuture; + } catch (e) { + Log.error('Could not resolve imageBytesFuture: $e'); + return null; + } } if (file != null) { - return file!.readAsBytes(); + try { + return imageBytes = await file!.readAsBytes(); + } catch (e) { + Log.error('Could not read bytes from file: $e'); + return null; + } } if (image == null) return null; final img = await image!.toByteData(format: io.ImageByteFormat.png); @@ -61,7 +71,8 @@ class ScreenshotController { var tmpPixelRatio = pixelRatio; if (tmpPixelRatio == null) { if (context != null && context.mounted) { - tmpPixelRatio = tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio; + tmpPixelRatio = + tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio; } } final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1); diff --git a/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart b/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart index 9be4b6aa..98d8778c 100644 --- a/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart +++ b/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart @@ -8,6 +8,7 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; class SaveToGalleryButton extends StatefulWidget { @@ -33,18 +34,11 @@ class SaveToGalleryButtonState extends State { @override Widget build(BuildContext context) { - return OutlinedButton( - style: OutlinedButton.styleFrom( - iconColor: _imageSaved - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - foregroundColor: _imageSaved - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - ), - onPressed: (widget.isLoading) - ? null - : () async { + final isEnabled = !widget.isLoading && !_imageSaving; + return MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: isEnabled + ? () async { setState(() { _imageSaving = true; }); @@ -83,19 +77,24 @@ class SaveToGalleryButtonState extends State { _imageSaving = false; }); } - }, + } + : null, child: Row( + mainAxisSize: MainAxisSize.min, children: [ if (_imageSaving || widget.isLoading) const SizedBox( width: 12, height: 12, - child: CircularProgressIndicator.adaptive(strokeWidth: 1), + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), ) else _imageSaved - ? const Icon(Icons.check) - : const FaIcon(FontAwesomeIcons.floppyDisk), + ? const Icon(Icons.check, size: 14) + : const FaIcon(FontAwesomeIcons.floppyDisk, size: 14), if (widget.displayButtonLabel) const SizedBox(width: 10), if (widget.displayButtonLabel) Text( diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index 752157a1..dd890637 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -17,6 +17,7 @@ import 'package:twonly/src/visual/components/contact_request_badge.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/headline.element.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart'; @@ -111,7 +112,9 @@ class _ShareImageView extends State { for (final group in groups) { if (group.pinned) continue; - if (!group.archived && getFlameCounterFromGroup(group).counter > 0 && bestFriends.length < 6) { + if (!group.archived && + getFlameCounterFromGroup(group).counter > 0 && + bestFriends.length < 6) { bestFriends.add(group); } else { otherUsers.add(group); @@ -131,7 +134,10 @@ class _ShareImageView extends State { await updateGroups( _allGroups .where( - (x) => !x.archived || !hideArchivedUsers || widget.selectedGroupIds.contains(x.groupId), + (x) => + !x.archived || + !hideArchivedUsers || + widget.selectedGroupIds.contains(x.groupId), ) .toList(), ); @@ -193,7 +199,8 @@ class _ShareImageView extends State { selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImagePinnedContacts, - showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, + showSelectAll: + !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), BestFriendsSelector( @@ -201,7 +208,8 @@ class _ShareImageView extends State { selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImageBestFriends, - showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, + showSelectAll: + !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), if (_otherUsers.isNotEmpty) @@ -264,7 +272,8 @@ class _ShareImageView extends State { child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (widget.mediaFileService.mediaFile.type == MediaType.image && + if (widget.mediaFileService.mediaFile.type == + MediaType.image && _screenshotImage?.image != null && userService.currentUser.showShowImagePreviewWhenSending) SizedBox( @@ -288,50 +297,53 @@ class _ShareImageView extends State { ), ), ), - FilledButton.icon( - icon: !mediaStoreFutureReady || sendingImage - ? SizedBox( - height: 12, - width: 12, + MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: + !mediaStoreFutureReady || + widget.selectedGroupIds.isEmpty || + sendingImage + ? null + : () async { + setState(() { + sendingImage = true; + }); + + // in case mediaStoreFutureReady is ready, the image is stored in the originalPath + await insertMediaFileInMessagesTable( + widget.mediaFileService, + widget.selectedGroupIds.toList(), + additionalData: widget.additionalData, + ); + + if (context.mounted) { + Navigator.pop(context, true); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!mediaStoreFutureReady || sendingImage) + const SizedBox( + height: 14, + width: 14, child: CircularProgressIndicator.adaptive( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), + valueColor: AlwaysStoppedAnimation( + Colors.black87, + ), ), ) - : const FaIcon(FontAwesomeIcons.solidPaperPlane), - onPressed: () async { - if (!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty) { - return; - } - - setState(() { - sendingImage = true; - }); - - // in case mediaStoreFutureReady is ready, the image is stored in the originalPath - await insertMediaFileInMessagesTable( - widget.mediaFileService, - widget.selectedGroupIds.toList(), - additionalData: widget.additionalData, - ); - - if (context.mounted) { - Navigator.pop(context, true); - } - }, - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(vertical: 10, horizontal: 30), - ), - backgroundColor: WidgetStateProperty.all( - !mediaStoreFutureReady || widget.selectedGroupIds.isEmpty - ? context.color.onSurface - : context.color.primary, - ), - ), - label: Text( - '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', - style: const TextStyle(fontSize: 17), + else + const FaIcon( + FontAwesomeIcons.solidPaperPlane, + size: 14, + ), + const SizedBox(width: 8), + Text( + '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', + ), + ], ), ), ], diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 923f157a..e3a8b436 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:typed_data'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; @@ -18,6 +19,7 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/emoji_picker.bottom.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; @@ -214,7 +216,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheRight { if (layers.isNotEmpty && - (layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -290,9 +293,13 @@ class _ShareImageEditorView extends State { if (media.type == MediaType.video) ...[ const SizedBox(height: 8), ActionButton( - (mediaService.removeAudio) ? Icons.volume_off_rounded : Icons.volume_up_rounded, + (mediaService.removeAudio) + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, tooltipText: 'Enable Audio in Video', - color: (mediaService.removeAudio) ? Colors.white.withAlpha(160) : Colors.white, + color: (mediaService.removeAudio) + ? Colors.white.withAlpha(160) + : Colors.white, onPressed: () async { await mediaService.toggleRemoveAudio(); if (mediaService.removeAudio) { @@ -330,7 +337,9 @@ class _ShareImageEditorView extends State { ActionButton( FontAwesomeIcons.shieldHeart, tooltipText: context.lang.protectAsARealTwonly, - color: media.requiresAuthentication ? Theme.of(context).colorScheme.primary : Colors.white, + color: media.requiresAuthentication + ? Theme.of(context).colorScheme.primary + : Colors.white, onPressed: () async { await mediaService.setRequiresAuth(!media.requiresAuthentication); selectedGroupIds = HashSet(); @@ -376,7 +385,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheTop { if (layers.isNotEmpty && - (layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -468,7 +478,8 @@ class _ShareImageEditorView extends State { } if (layers.length == 2) { final filterLayer = layers[1]; - if (layers.first is BackgroundLayerData && filterLayer is FilterLayerData) { + if (layers.first is BackgroundLayerData && + filterLayer is FilterLayerData) { if (filterLayer.page == 1) { return (layers.first as BackgroundLayerData).image.image; } @@ -501,6 +512,17 @@ class _ShareImageEditorView extends State { } Future storeImageAsOriginal() async { + Uint8List? gifBytes; + ScreenshotImageHelper? image; + if (media.type == MediaType.gif) { + gifBytes = await widget.screenshotImage?.getBytes(); + } else { + image = await getEditedImageBytes(); + if (image != null) { + await image.getBytes(); + } + } + if (mediaService.overlayImagePath.existsSync()) { mediaService.overlayImagePath.deleteSync(); } @@ -512,14 +534,12 @@ class _ShareImageEditorView extends State { mediaService.originalPath.deleteSync(); } } - ScreenshotImageHelper? image; + if (media.type == MediaType.gif) { - final bytes = await widget.screenshotImage?.getBytes(); - if (bytes != null) { - mediaService.originalPath.writeAsBytesSync(bytes.toList()); + if (gifBytes != null) { + mediaService.originalPath.writeAsBytesSync(gifBytes.toList()); } } else { - image = await getEditedImageBytes(); if (image == null) return null; final bytes = await image.getBytes(); if (bytes == null) { @@ -657,7 +677,9 @@ class _ShareImageEditorView extends State { await askToCloseThenClose(); }, child: Scaffold( - backgroundColor: widget.sharedFromGallery ? null : Colors.white.withAlpha(0), + backgroundColor: widget.sharedFromGallery + ? null + : Colors.white.withAlpha(0), resizeToAvoidBottomInset: false, body: Stack( fit: StackFit.expand, @@ -701,49 +723,57 @@ class _ShareImageEditorView extends State { ), if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) - OutlinedButton( - style: OutlinedButton.styleFrom( - iconColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of( - context, - ).colorScheme.primary, - ), + MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: pushShareImageView, - child: const FaIcon(FontAwesomeIcons.userPlus), + child: const FaIcon( + FontAwesomeIcons.userPlus, + size: 14, + ), ), SizedBox(width: widget.sendToGroup == null ? 20 : 10), - FilledButton.icon( - icon: sendingOrLoadingImage - ? SizedBox( - height: 12, - width: 12, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), + IntrinsicWidth( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: sendingOrLoadingImage + ? null + : () async { + if (widget.sendToGroup == null) { + return pushShareImageView(); + } + await sendImageToSinglePerson(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (sendingOrLoadingImage) + const SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.black87, + ), + ), + ) + else + const FaIcon( + FontAwesomeIcons.solidPaperPlane, + size: 14, ), - ) - : const FaIcon(FontAwesomeIcons.solidPaperPlane), - onPressed: () async { - if (sendingOrLoadingImage) return; - if (widget.sendToGroup == null) { - return pushShareImageView(); - } - await sendImageToSinglePerson(); - }, - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric( - vertical: 10, - horizontal: 30, - ), + const SizedBox(width: 8), + Text( + (widget.sendToGroup == null) + ? context.lang.shareImagedEditorShareWith + : substringBy( + widget.sendToGroup!.groupName, + 15, + ), + ), + ], ), ), - label: Text( - (widget.sendToGroup == null) - ? context.lang.shareImagedEditorShareWith - : substringBy(widget.sendToGroup!.groupName, 15), - style: const TextStyle(fontSize: 17), - ), ), ], ), diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 4feb177f..29ac3bf2 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -40,7 +40,10 @@ class _ChatListViewState extends State { bool _hasContacts = false; bool _loading = true; - bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; + bool get _hasOpenGroup => + _groupsNotPinned.isNotEmpty || + _groupsArchived.isNotEmpty || + _groupsPinned.isNotEmpty; GlobalKey searchForOtherUsers = GlobalKey(); bool showFeedbackShortcut = false; @@ -64,35 +67,43 @@ class _ChatListViewState extends State { _contactsSub = stream.listen((groups) { if (!mounted) return; setState(() { - _groupsNotPinned = groups.where((x) => !x.pinned && !x.archived).toList(); + _groupsNotPinned = groups + .where((x) => !x.pinned && !x.archived) + .toList(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList(); _loading = false; }); }); - _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) { + _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen(( + contacts, + ) { if (!mounted) return; setState(() { _hasContacts = contacts.isNotEmpty; }); }); - _countContactRequestStream = twonlyDB.contactsDao.watchContactsRequestedCount().listen((update) { - if (update != null) { - if (!mounted) return; - setState(() { - _countContactRequest = update; + _countContactRequestStream = twonlyDB.contactsDao + .watchContactsRequestedCount() + .listen((update) { + if (update != null) { + if (!mounted) return; + setState(() { + _countContactRequest = update; + }); + } }); - } - }); - _countAnnouncedStream = twonlyDB.userDiscoveryDao.watchNewAnnouncementsWithDataCount().listen((update) { - if (!mounted) return; - setState(() { - _countAnnouncedUsers = update; - }); - }); + _countAnnouncedStream = twonlyDB.userDiscoveryDao + .watchNewAnnouncementsWithDataCount() + .listen((update) { + if (!mounted) return; + setState(() { + _countAnnouncedUsers = update; + }); + }); WidgetsBinding.instance.addPostFrameCallback((_) async { final changeLog = await rootBundle.loadString('CHANGELOG.md'); @@ -101,7 +112,8 @@ class _ChatListViewState extends State { changeLog.codeUnits, )).bytes; if (!userService.currentUser.hideChangeLog && - userService.currentUser.lastChangeLogHash.toString() != changeLogHash.toString()) { + userService.currentUser.lastChangeLogHash.toString() != + changeLogHash.toString()) { await UserService.update((u) { u.lastChangeLogHash = changeLogHash; }); @@ -190,11 +202,16 @@ class _ChatListViewState extends State { ), Center( child: NotificationBadgeComp( - backgroundColor: isDarkMode(context) ? Colors.white : Colors.black, + backgroundColor: isDarkMode(context) + ? Colors.white + : Colors.black, textColor: isDarkMode(context) ? Colors.black : Colors.white, - count: (_countAnnouncedUsers + _countContactRequest).toString(), + count: (_countAnnouncedUsers + _countContactRequest) + .toString(), child: IconButton( - color: (_countAnnouncedUsers + _countContactRequest > 0) ? Colors.black : null, + color: (_countAnnouncedUsers + _countContactRequest > 0) + ? Colors.black + : null, key: searchForOtherUsers, icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), onPressed: () => context.push(Routes.chatsAddNewUser), @@ -240,7 +257,10 @@ class _ChatListViewState extends State { _groupsNotPinned.length + (_groupsArchived.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { - if (index >= _groupsNotPinned.length + _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0)) { + if (index >= + _groupsNotPinned.length + + _groupsPinned.length + + (_groupsPinned.isNotEmpty ? 1 : 0)) { if (_groupsArchived.isEmpty) return Container(); return ListTile( title: Text( @@ -304,7 +324,9 @@ class _ChatListViewState extends State { child: Center( child: FaIcon( FontAwesomeIcons.qrcode, - color: isDarkMode(context) ? Colors.black : Colors.white, + color: isDarkMode(context) + ? Colors.black + : Colors.white, ), ), ), diff --git a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart index ce1686af..9330061e 100644 --- a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart @@ -59,21 +59,23 @@ class EmptyChatListComp extends StatelessWidget { const SizedBox(height: 36), const Center(child: ProfileQrCodeComp()), const SizedBox(height: 36), - MyButton( - onPressed: () => _shareProfile(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const FaIcon(FontAwesomeIcons.shareNodes, size: 20), - const SizedBox(width: 8), - Text( - context.lang.emptyChatListShareBtn, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + IntrinsicWidth( + child: MyButton( + onPressed: () => _shareProfile(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon(FontAwesomeIcons.shareNodes, size: 20), + const SizedBox(width: 8), + Text( + context.lang.emptyChatListShareBtn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - ], + ], + ), ), ), const SizedBox(height: 12), diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index d0d8d58d..9508dc7d 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -40,8 +40,10 @@ class _ResponseContainerState extends State { void didChangeDependencies() { super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) { - final messageBox = _message.currentContext?.findRenderObject() as RenderBox?; - final previewBox = _preview.currentContext?.findRenderObject() as RenderBox?; + final messageBox = + _message.currentContext?.findRenderObject() as RenderBox?; + final previewBox = + _preview.currentContext?.findRenderObject() as RenderBox?; if (messageBox == null || previewBox == null) { return; } @@ -64,7 +66,9 @@ class _ResponseContainerState extends State { return widget.child!; } return GestureDetector( - onTap: widget.scrollToMessage == null ? null : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), + onTap: widget.scrollToMessage == null + ? null + : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, @@ -140,12 +144,16 @@ class _ResponsePreviewState extends State { } Future initAsync() async { - _message ??= await twonlyDB.messagesDao.getMessageById(widget.messageId!).getSingleOrNull(); + _message ??= await twonlyDB.messagesDao + .getMessageById(widget.messageId!) + .getSingleOrNull(); if (_message?.mediaId != null) { _mediaService = await MediaFileService.fromMediaId(_message!.mediaId!); } if (_message?.senderId != null) { - final contact = await twonlyDB.contactsDao.getContactByUserId(_message!.senderId!).getSingleOrNull(); + final contact = await twonlyDB.contactsDao + .getContactByUserId(_message!.senderId!) + .getSingleOrNull(); if (contact != null) { _username = getContactDisplayName(contact); } @@ -263,15 +271,21 @@ class _ResponsePreviewState extends State { ], ), ), - if (_mediaService != null && _mediaService!.mediaFile.type != MediaType.audio) - SizedBox( - height: widget.showBorder ? 100 : 210, - child: Image.file( - _mediaService!.mediaFile.type == MediaType.video - ? _mediaService!.thumbnailPath - : _mediaService!.storedPath, - ), - ), + if (_mediaService != null && + _mediaService!.mediaFile.type != MediaType.audio) + () { + final isVideo = _mediaService!.mediaFile.type == MediaType.video; + final pathToCheck = isVideo + ? _mediaService!.thumbnailPath + : _mediaService!.storedPath; + if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) { + return SizedBox( + height: widget.showBorder ? 100 : 210, + child: Image.file(pathToCheck), + ); + } + return const SizedBox.shrink(); + }(), ], ), ); diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index eaea72cd..76aa2d6e 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -698,6 +698,15 @@ class _MediaViewerViewState extends State { ), initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white38, + size: 64, + ), + ); + }, ), ), ], diff --git a/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart b/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart index 6f665b63..cc6fdfed 100644 --- a/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart +++ b/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart @@ -72,7 +72,10 @@ class OpenRequestsListComp extends StatelessWidget { if (block) { const update = ContactsCompanion(blocked: Value(true)); if (context.mounted) { - await twonlyDB.contactsDao.updateContact(contact.userId, update); + await twonlyDB.contactsDao.updateContact( + contact.userId, + update, + ); } } }, @@ -189,7 +192,9 @@ class OpenRequestsListComp extends StatelessWidget { ), trailing: Row( mainAxisSize: MainAxisSize.min, - children: contact.requested ? requestedActions(context, contact) : sendRequestActions(context, contact), + children: contact.requested + ? requestedActions(context, contact) + : sendRequestActions(context, contact), ), ); }), diff --git a/lib/src/visual/views/memories/components/flashback_banner.comp.dart b/lib/src/visual/views/memories/components/flashback_banner.comp.dart index df3fb0c4..2d5b8a54 100644 --- a/lib/src/visual/views/memories/components/flashback_banner.comp.dart +++ b/lib/src/visual/views/memories/components/flashback_banner.comp.dart @@ -66,6 +66,18 @@ class MemoriesFlashbackBannerComp extends StatelessWidget { Image.file( items.first.mediaService.storedPath, fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return ColoredBox( + color: Colors.grey.shade800, + child: const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white30, + size: 32, + ), + ), + ); + }, ), Positioned.fill( child: DecoratedBox( diff --git a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart index 20f5a0de..10e96fcb 100644 --- a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart +++ b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart @@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State _scaleController.value = 1.0; } - _listener = ImageStreamListener((info, _) { - if (mounted) { - setState(() { - _imageInfo = info; - }); - } - }); + _listener = ImageStreamListener( + (info, _) { + if (mounted) { + setState(() { + _imageInfo = info; + }); + } + }, + onError: (exception, stackTrace) { + if (mounted) { + setState(() { + _imageProvider = null; + _imageInfo = null; + }); + } + }, + ); _resolveImage(); } void _resolveImage() { final media = widget.galleryItem.mediaService; - final hasThumbnail = media.thumbnailPath.existsSync(); - final hasStored = media.storedPath.existsSync(); + final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0; + final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0; final isImageOrGif = media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.gif; @@ -181,6 +191,17 @@ class _MemoriesThumbnailCompState extends State image: _imageProvider!, fit: BoxFit.cover, gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return ColoredBox( + color: Colors.grey.shade200, + child: const Center( + child: FaIcon( + FontAwesomeIcons.image, + color: Colors.black26, + ), + ), + ); + }, ) else ColoredBox( diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart index 12e47292..0013d330 100644 --- a/lib/src/visual/views/memories/memories.view.dart +++ b/lib/src/visual/views/memories/memories.view.dart @@ -193,6 +193,63 @@ class MemoriesViewState extends State { }); } + Future _showProgressDialog( + String message, + Future Function(void Function(double progress) setProgress) task, + ) async { + final progressNotifier = ValueNotifier(0); + + // Show non-dismissible progress dialog + // ignore: unawaited_futures + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Text( + message, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (context, progress, _) { + return Column( + children: [ + LinearProgressIndicator(value: progress), + const SizedBox(height: 10), + Text( + '${(progress * 100).toInt()}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ], + ), + ), + ); + }, + ); + + try { + await Future.delayed(Duration.zero); + await task((p) => progressNotifier.value = p); + } finally { + if (mounted) { + Navigator.of(context).pop(); + } + progressNotifier.dispose(); + } + } + Future _batchDelete() async { final count = _selectedMediaIds.length; final confirmed = await showAlertDialog( @@ -204,15 +261,24 @@ class MemoriesViewState extends State { if (!confirmed) return; final items = _service.currentState.galleryItems; - for (final mediaId in _selectedMediaIds) { - final item = items - .where((e) => e.mediaService.mediaFile.mediaId == mediaId) - .firstOrNull; - if (item != null) { - item.mediaService.fullMediaRemoval(); - } - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); - } + final selectedList = _selectedMediaIds.toList(); + + await _showProgressDialog( + 'Deleting memories...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + final item = items + .where((e) => e.mediaService.mediaFile.mediaId == mediaId) + .firstOrNull; + if (item != null) { + item.mediaService.fullMediaRemoval(); + } + await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); + setProgress((i + 1) / selectedList.length); + } + }, + ); setState(_selectedMediaIds.clear); @@ -226,23 +292,34 @@ class MemoriesViewState extends State { Future _batchExport() async { final items = _service.currentState.galleryItems; + final selectedList = _selectedMediaIds.toList(); + if (selectedList.isEmpty) return; try { - for (final mediaId in _selectedMediaIds) { - final item = items - .where((e) => e.mediaService.mediaFile.mediaId == mediaId) - .firstOrNull; - if (item != null) { - final media = item.mediaService; - if (media.mediaFile.type == MediaType.video) { - await saveVideoToGallery(media.storedPath.path); - } else if (media.mediaFile.type == MediaType.image || - media.mediaFile.type == MediaType.gif) { - final imageBytes = await media.storedPath.readAsBytes(); - await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt); + await _showProgressDialog( + 'Exporting memories...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + final item = items + .where((e) => e.mediaService.mediaFile.mediaId == mediaId) + .firstOrNull; + if (item != null) { + final media = item.mediaService; + if (media.mediaFile.type == MediaType.video) { + await saveVideoToGallery(media.storedPath.path); + } else if (media.mediaFile.type == MediaType.image || + media.mediaFile.type == MediaType.gif) { + final imageBytes = await media.storedPath.readAsBytes(); + await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt); + } + } + setProgress((i + 1) / selectedList.length); } - } - } + }, + ); + + setState(_selectedMediaIds.clear); if (!mounted) return; showSnackbar( @@ -258,26 +335,36 @@ class MemoriesViewState extends State { Future _batchFavorite() async { final items = _service.currentState.galleryItems; + final selectedList = _selectedMediaIds.toList(); + if (selectedList.isEmpty) return; + var favCount = 0; for (final item in items) { - if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) { + if (selectedList.contains(item.mediaService.mediaFile.mediaId)) { if (item.mediaService.mediaFile.isFavorite) { favCount++; } } } final areAllFav = - _selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length; + selectedList.isNotEmpty && favCount == selectedList.length; final targetFav = !areAllFav; - for (final mediaId in _selectedMediaIds) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaId, - MediaFilesCompanion(isFavorite: Value(targetFav)), - ); - } + await _showProgressDialog( + targetFav ? 'Adding to favorites...' : 'Removing from favorites...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + await twonlyDB.mediaFilesDao.updateMedia( + mediaId, + MediaFilesCompanion(isFavorite: Value(targetFav)), + ); + setProgress((i + 1) / selectedList.length); + } + }, + ); - setState(() {}); + setState(_selectedMediaIds.clear); } @override diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 19ec6f65..61ce30bb 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -347,6 +347,15 @@ class _SynchronizedImageViewerScreenState backgroundDecoration: const BoxDecoration( color: Colors.transparent, ), + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white38, + size: 64, + ), + ); + }, scaleStateChangedCallback: (state) { final zoomed = state != PhotoViewScaleState.initial; diff --git a/lib/src/visual/views/onboarding/recover.view.dart b/lib/src/visual/views/onboarding/recover.view.dart index 0f172a35..f659c972 100644 --- a/lib/src/visual/views/onboarding/recover.view.dart +++ b/lib/src/visual/views/onboarding/recover.view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; +import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; class BackupRecoveryView extends StatefulWidget { const BackupRecoveryView({super.key}); @@ -63,70 +64,6 @@ class _BackupRecoveryViewState extends State { }); } - void _showBackupExplanation(BuildContext context) { - final isDark = isDarkMode(context); - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; - final textColor = isDark ? Colors.white : Colors.black87; - final subtitleColor = isDark ? Colors.white70 : Colors.black54; - - showModalBottomSheet( - context: context, - backgroundColor: backgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(28), - ), - ), - isScrollControlled: true, - builder: (context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: isDark ? Colors.white24 : Colors.black12, - borderRadius: BorderRadius.circular(2.5), - ), - ), - ), - const SizedBox(height: 24), - Text( - 'twonly Backup', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: textColor, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - context.lang.backupTwonlySafeLongDesc, - style: TextStyle( - fontSize: 16, - height: 1.5, - color: subtitleColor, - ), - ), - const SizedBox(height: 32), - MyButton( - onPressed: () => Navigator.pop(context), - child: const Text('Got it'), - ), - ], - ), - ), - ); - }, - ); - } @override Widget build(BuildContext context) { @@ -164,7 +101,7 @@ class _BackupRecoveryViewState extends State { ), const Spacer(), IconButton( - onPressed: () => _showBackupExplanation(context), + onPressed: () => showBackupExplanation(context), icon: const FaIcon(FontAwesomeIcons.circleInfo), color: iconColor, iconSize: 20, diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index 2622345e..62eb9a96 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/model/json/backup.model.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class BackupView extends StatefulWidget { const BackupView({super.key}); @@ -176,7 +177,8 @@ class _BackupViewState extends State { ]), ), const SizedBox(height: 10), - OutlinedButton( + MyButton( + variant: MyButtonVariant.primaryMiddle, onPressed: _isLoading ? null : () async { @@ -194,7 +196,8 @@ class _BackupViewState extends State { ), const SizedBox(height: 32), Center( - child: FilledButton( + child: MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: () => context.push(Routes.settingsBackupSetup, extra: true), child: Text( diff --git a/lib/src/visual/views/settings/backup/backup_setup.view.dart b/lib/src/visual/views/settings/backup/backup_setup.view.dart index 2eb9638c..5dab3c22 100644 --- a/lib/src/visual/views/settings/backup/backup_setup.view.dart +++ b/lib/src/visual/views/settings/backup/backup_setup.view.dart @@ -6,6 +6,7 @@ import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; class SetupBackupView extends StatefulWidget { @@ -76,13 +77,7 @@ class _SetupBackupViewState extends State { title: const Text('twonly Backup'), actions: [ IconButton( - onPressed: () async { - await showAlertDialog( - context, - 'twonly Backup', - context.lang.backupTwonlySafeLongDesc, - ); - }, + onPressed: () => showBackupExplanation(context), icon: const FaIcon(FontAwesomeIcons.circleInfo), iconSize: 18, ), @@ -131,7 +126,8 @@ class _SetupBackupViewState extends State { ), const SizedBox(height: 10), Center( - child: FilledButton.icon( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, onPressed: (!_isLoading && (_passwordController.text == @@ -140,17 +136,26 @@ class _SetupBackupViewState extends State { !kReleaseMode)) ? _updateBackupPassword : null, - icon: _isLoading - ? const SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isLoading) + const SizedBox( height: 12, width: 12, - child: CircularProgressIndicator.adaptive(strokeWidth: 1), + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + ), ) - : const Icon(Icons.lock_clock_rounded), - label: Text( - userService.currentUser.isBackupEnabled - ? context.lang.backupEnableBackup - : context.lang.backupChangePassword, + else + const Icon(Icons.lock_clock_rounded), + const SizedBox(width: 8), + Text( + userService.currentUser.isBackupEnabled + ? context.lang.backupEnableBackup + : context.lang.backupChangePassword, + ), + ], ), ), ), diff --git a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart index cb553ead..c5fcc189 100644 --- a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart +++ b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; Future isSecurePassword(String password) async { @@ -90,3 +92,68 @@ class PasswordRequirementText extends StatelessWidget { ); } } + +void showBackupExplanation(BuildContext context) { + final isDark = isDarkMode(context); + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final textColor = isDark ? Colors.white : Colors.black87; + final subtitleColor = isDark ? Colors.white70 : Colors.black54; + + showModalBottomSheet( + context: context, + backgroundColor: backgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + isScrollControlled: true, + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.black12, + borderRadius: BorderRadius.circular(2.5), + ), + ), + ), + const SizedBox(height: 24), + Text( + 'twonly Backup', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + context.lang.backupTwonlySafeLongDesc, + style: TextStyle( + fontSize: 16, + height: 1.5, + color: subtitleColor, + ), + ), + const SizedBox(height: 32), + MyButton( + onPressed: () => Navigator.pop(context), + child: const Text('Got it'), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/src/visual/views/settings/help/contact_us.view.dart b/lib/src/visual/views/settings/help/contact_us.view.dart index bbaeb18d..1727c4da 100644 --- a/lib/src/visual/views/settings/help/contact_us.view.dart +++ b/lib/src/visual/views/settings/help/contact_us.view.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; @@ -12,6 +15,7 @@ import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/help/contact_us/submit_message.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; @@ -29,13 +33,29 @@ class _ContactUsState extends State { int? _selectedFeedback; String? _selectedReason; String? debugLogDownloadToken; + String? debugLogEncryptionKey; - Future uploadDebugLog() async { - if (debugLogDownloadToken != null) return debugLogDownloadToken; + Future<(String, String)?> uploadDebugLog() async { + if (debugLogDownloadToken != null && debugLogEncryptionKey != null) { + return (debugLogDownloadToken!, debugLogEncryptionKey!); + } final downloadToken = getRandomUint8List(32); + final encryptionKey = getRandomUint8List(32); final debugLog = await loadLogFile(); + // 1. Compress the debug log + final logBytes = utf8.encode(debugLog); + final compressedBytes = gzip.encode(logBytes); + + // 2. Encrypt using AES-GCM (with 256 bits) + final algorithm = AesGcm.with256bits(); + final secretBox = await algorithm.encrypt( + compressedBytes, + secretKey: SecretKey(encryptionKey), + ); + final encryptedData = secretBox.concatenation(); + final messageOnSuccess = TextMessage() ..body = [] ..userId = Int64(); @@ -43,7 +63,7 @@ class _ContactUsState extends State { final uploadRequest = UploadRequest( messagesOnSuccess: [messageOnSuccess], downloadTokens: [downloadToken], - encryptedData: debugLog.codeUnits, + encryptedData: encryptedData, ); final uploadRequestBytes = uploadRequest.writeToBuffer(); @@ -71,10 +91,13 @@ class _ContactUsState extends State { final response = await requestMultipart.send(); if (response.statusCode == 200) { + final tokenHex = uint8ListToHex(downloadToken); + final keyHex = uint8ListToHex(encryptionKey); setState(() { - debugLogDownloadToken = uint8ListToHex(downloadToken); + debugLogDownloadToken = tokenHex; + debugLogEncryptionKey = keyHex; }); - return debugLogDownloadToken; + return (tokenHex, keyHex); } return null; } @@ -108,13 +131,13 @@ class _ContactUsState extends State { } if (includeDebugLog) { - String? token; + (String, String)? result; try { - token = await uploadDebugLog(); + result = await uploadDebugLog(); } catch (e) { Log.error(e); } - if (token == null) { + if (result == null) { if (!mounted) return null; showSnackbar(context, 'Could not upload the debug log!'); setState(() { @@ -122,7 +145,10 @@ class _ContactUsState extends State { }); return null; } - debugLogToken = 'Debug Log: https://api.twonly.eu/api/download/$token'; + final downloadToken = result.$1; + final encryptionKey = result.$2; + debugLogToken = + 'Debug Log: https://logs.twonly.eu#$downloadToken/$encryptionKey'; } setState(() { @@ -238,17 +264,8 @@ $debugLogToken ), ), ), - ElevatedButton.icon( - icon: isLoading - ? SizedBox( - height: 12, - width: 12, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), - ), - ) - : const FaIcon(FontAwesomeIcons.angleRight), + MyButton( + variant: MyButtonVariant.primaryDense, onPressed: isLoading ? null : () async { @@ -263,7 +280,24 @@ $debugLogToken Navigator.pop(context); } }, - label: Text(context.lang.next), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + const SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.black87), + ), + ) + else + const FaIcon(FontAwesomeIcons.angleRight, size: 14), + const SizedBox(width: 8), + Text(context.lang.next), + ], + ), ), ], ), diff --git a/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart b/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart index 104cc236..3846c486 100644 --- a/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart +++ b/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class SubmitMessage extends StatefulWidget { const SubmitMessage({required this.fullMessage, super.key}); @@ -100,7 +101,8 @@ class _ContactUsState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( + MyButton( + variant: MyButtonVariant.primaryDense, onPressed: isLoading ? null : _submitFeedback, child: Text(context.lang.submit), ), diff --git a/test.jpg b/test.jpg deleted file mode 100644 index c91ed5daa7ebf028e1158c9cac567e51dfecc1ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 620 zcmex=PfpQEif~-P{hK_8)fr;!& zg(60c6BlwQJ8e8D8g%i4ig8j=6DOCLxP+vXs+zinrk07RnYo3fm9vYho4bdnS8zyZ zSa?KaRB}pcT6#uiR&hybS$RceRdY*gTYE=m*QCi)rcRqaW9F9X@jO*zpr5PhGlvL# Date: Fri, 5 Jun 2026 10:36:36 +0200 Subject: [PATCH 04/18] Auto-detect if FCM token does not work and trigger a reset --- CHANGELOG.md | 6 + .../NotificationService.swift | 40 +++++++ lib/src/constants/routes.keys.dart | 2 + lib/src/constants/secure_storage.keys.dart | 3 + lib/src/providers/routing.provider.dart | 5 + lib/src/services/api/server_messages.api.dart | 18 ++- .../notifications/fcm.notifications.dart | 101 +++++++++++++++++ .../settings/developer/developer.view.dart | 47 ++++---- .../settings/developer/informations.view.dart | 107 ++++++++++++++++++ 9 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 lib/src/visual/views/settings/developer/informations.view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2ef533..fd9e1fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.28 + +- Improved: Design of some UI components +- Improved: Memories viewer shows state for batch operations and has improved performance +- Fix: Auto-detect if FCM token does not work and trigger a reset + ## 0.2.26 - New: Import images from the gallery diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 5ec11854..124932a9 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -15,6 +15,11 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + // Store the current timestamp in Keychain for iOS FCM messaging tracking + let nowMs = String(format: "%.0f", Date().timeIntervalSince1970 * 1000) + writeToKeychain(key: "last_fcm_message_timestamp", value: nowMs) + NSLog("Received APNs push notification, updated last_fcm_message_timestamp to \(nowMs)") + if let bestAttemptContent = bestAttemptContent { guard bestAttemptContent.userInfo as? [String: Any] != nil, @@ -205,6 +210,41 @@ func readFromKeychain(key: String) -> String? { return nil } +// Helper function to write to Keychain +func writeToKeychain(key: String, value: String) { + guard let data = value.data(using: .utf8) else { + NSLog("Failed to convert value to data for keychain key: \(key)") + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", + ] + + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data + ] + + let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + NSLog("Failed to add keychain item: \(addStatus)") + } else { + NSLog("Successfully added keychain item for key: \(key)") + } + } else if status != errSecSuccess { + NSLog("Failed to update keychain item: \(status)") + } else { + NSLog("Successfully updated keychain item for key: \(key)") + } +} + func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) { let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 86804303..017745f0 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -62,5 +62,7 @@ class Routes { '/settings/developer/automated_testing'; static const String settingsDeveloperReduceFlames = '/settings/developer/reduce_flames'; + static const String settingsDeveloperInformations = + '/settings/developer/informations'; static const String settingsInvite = '/settings/invite'; } diff --git a/lib/src/constants/secure_storage.keys.dart b/lib/src/constants/secure_storage.keys.dart index 37aebba0..d8020454 100644 --- a/lib/src/constants/secure_storage.keys.dart +++ b/lib/src/constants/secure_storage.keys.dart @@ -12,4 +12,7 @@ class SecureStorageKeys { // Not required for backup... static const String receivingPushKeys = 'push_keys_receiving'; static const String sendingPushKeys = 'push_keys_sending'; + static const String lastFcmMessageTimestamp = 'last_fcm_message_timestamp'; + static const String lastServerMessageTimestamp = + 'last_server_message_timestamp'; } diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index a807edd2..55b3ef1e 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -26,6 +26,7 @@ import 'package:twonly/src/visual/views/settings/data_and_storage/import_from_ga import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart'; import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart'; import 'package:twonly/src/visual/views/settings/developer/developer.view.dart'; +import 'package:twonly/src/visual/views/settings/developer/informations.view.dart'; import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart'; import 'package:twonly/src/visual/views/settings/developer/retransmission_data.view.dart'; import 'package:twonly/src/visual/views/settings/help/changelog.view.dart'; @@ -288,6 +289,10 @@ final routerProvider = GoRouter( path: 'automated_testing', builder: (context, state) => const AutomatedTestingView(), ), + GoRoute( + path: 'informations', + builder: (context, state) => const DeveloperInformationsView(), + ), GoRoute( path: 'reduce_flames', builder: (context, state) => const ReduceFlamesView(), diff --git a/lib/src/services/api/server_messages.api.dart b/lib/src/services/api/server_messages.api.dart index a16b2028..b1833390 100644 --- a/lib/src/services/api/server_messages.api.dart +++ b/lib/src/services/api/server_messages.api.dart @@ -31,6 +31,7 @@ import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; +import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; @@ -153,7 +154,10 @@ Future _handleClient2ClientMessage( Log.info( '[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.', ); - await tryToSendCompleteMessage(receiptId: newReceiptId, blocking: false); + await tryToSendCompleteMessage( + receiptId: newReceiptId, + blocking: false, + ); } case Message_Type.CIPHERTEXT: @@ -276,9 +280,15 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw( Log.info('[$receiptId] Finished handleEncryptedMessage'); - if (Platform.isAndroid && a == null && b == null) { - // Message was handled without any error -> Show push notification to the user. - await showPushNotificationFromServerMessages(fromUserId, encryptedContent); + if (a == null && b == null) { + unawaited(updateLastServerMessageTimestamp()); + if (Platform.isAndroid) { + // Message was handled without any error. Show push notification to the user for Android. + await showPushNotificationFromServerMessages( + fromUserId, + encryptedContent, + ); + } } return (a, b); diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index f7059ea1..fce869b5 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -6,9 +6,11 @@ import 'dart:io' show Platform; import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/services/background/callback_dispatcher.background.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; @@ -107,6 +109,7 @@ Future initFCMService() async { ); unawaited(checkForTokenUpdates()); + unawaited(checkFcmHealthAndResetIfNeeded()); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); @@ -133,6 +136,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } Future handleRemoteMessage(RemoteMessage message) async { + await updateLastFcmMessageTimestamp(); if (!Platform.isAndroid) { Log.error('Got message in Dart while on iOS'); } @@ -157,3 +161,100 @@ Future handleRemoteMessage(RemoteMessage message) async { // await handlePushData(message.data['push_data'] as String); // } } + +Future updateLastFcmMessageTimestamp() async { + const storage = FlutterSecureStorage(); + final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); + try { + await storage.write( + key: SecureStorageKeys.lastFcmMessageTimestamp, + value: nowMs, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + Log.info('Updated last FCM message timestamp to $nowMs'); + } catch (e) { + Log.error('Could not write last FCM message timestamp: $e'); + } +} + +Future updateLastServerMessageTimestamp() async { + const storage = FlutterSecureStorage(); + final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); + try { + await storage.write( + key: SecureStorageKeys.lastServerMessageTimestamp, + value: nowMs, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + Log.info('Updated last server message timestamp to $nowMs'); + } catch (e) { + Log.error('Could not write last server message timestamp: $e'); + } +} + +Future checkFcmHealthAndResetIfNeeded() async { + if (!userService.isUserCreated) return; + const storage = FlutterSecureStorage(); + try { + final lastFcmStr = await storage.read( + key: SecureStorageKeys.lastFcmMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + final lastServerStr = await storage.read( + key: SecureStorageKeys.lastServerMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + final now = DateTime.now(); + final threeDaysAgo = now.subtract(const Duration(days: 3)); + + DateTime? lastFcmTime; + if (lastFcmStr != null) { + final ms = int.tryParse(lastFcmStr); + if (ms != null) { + lastFcmTime = DateTime.fromMillisecondsSinceEpoch(ms); + } + } + + if (lastFcmTime != null) { + Log.info('Last message received via FCM messaging system: $lastFcmTime'); + } else { + Log.info('No record of a message received via FCM messaging system.'); + } + + DateTime? lastServerTime; + if (lastServerStr != null) { + final ms = int.tryParse(lastServerStr); + if (ms != null) { + lastServerTime = DateTime.fromMillisecondsSinceEpoch(ms); + } + } + + // Check conditions: + // 1. No messages received via FCM in the last 3 days (either null or older than 3 days) + final fcmInactive = lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo); + // 2. Server message received within the last 3 days + final serverActive = lastServerTime != null && lastServerTime.isAfter(threeDaysAgo); + + if (fcmInactive && serverActive) { + Log.warn('FCM has been inactive for >3 days, but server messages have been active. Resetting FCM tokens...'); + await resetFCMTokens(); + } else { + Log.info('FCM check passed. No reset needed.'); + } + } catch (e) { + Log.error('Error during FCM health check: $e'); + } +} diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index 7926d295..bc6d203b 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -297,8 +297,8 @@ class _DeveloperSettingsViewState extends State { ), ), ListTile( - title: const Text('User ID'), - subtitle: Text(userService.currentUser.userId.toString()), + title: const Text('Informations'), + onTap: () => context.push(Routes.settingsDeveloperInformations), ), ListTile( title: const Text('Show Retransmission Database'), @@ -343,24 +343,6 @@ class _DeveloperSettingsViewState extends State { onChanged: (a) => toggleVideoStabilization(), ), ), - ListTile( - title: const Text('Delete all (!) app data'), - onTap: () async { - final ok = await showAlertDialog( - context, - 'Sure?', - 'If you do not have a backup, you have to register with a new account.', - ); - if (ok) { - await deleteLocalUserData(); - await Restart.restartApp( - notificationTitle: 'Account successfully deleted', - notificationBody: 'Click here to open the app again', - forceKill: true, - ); - } - }, - ), ListTile( title: const Text('Reduce flames'), onTap: () => context.push(Routes.settingsDeveloperReduceFlames), @@ -400,7 +382,9 @@ class _DeveloperSettingsViewState extends State { ? const SizedBox( width: 24, height: 24, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ) : null, onTap: _isGeneratingMockImages @@ -415,6 +399,27 @@ class _DeveloperSettingsViewState extends State { }); }, ), + ListTile( + title: const Text( + 'Delete all app data', + style: TextStyle(color: Colors.red), + ), + onTap: () async { + final ok = await showAlertDialog( + context, + 'Sure?', + 'If you do not have a backup, you have to register with a new account.', + ); + if (ok) { + await deleteLocalUserData(); + await Restart.restartApp( + notificationTitle: 'Account successfully deleted', + notificationBody: 'Click here to open the app again', + forceKill: true, + ); + } + }, + ), ], ); }, diff --git a/lib/src/visual/views/settings/developer/informations.view.dart b/lib/src/visual/views/settings/developer/informations.view.dart new file mode 100644 index 00000000..6faaa7ec --- /dev/null +++ b/lib/src/visual/views/settings/developer/informations.view.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/secure_storage.keys.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; + +class DeveloperInformationsView extends StatefulWidget { + const DeveloperInformationsView({super.key}); + + @override + State createState() => + _DeveloperInformationsViewState(); +} + +class _DeveloperInformationsViewState extends State { + String? _lastFcmTimestamp; + String? _lastServerTimestamp; + + @override + void initState() { + super.initState(); + _loadInformations(); + } + + Future _loadInformations({bool showFeedback = false}) async { + const storage = FlutterSecureStorage(); + try { + final lastFcm = await storage.read( + key: SecureStorageKeys.lastFcmMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + final lastServer = await storage.read( + key: SecureStorageKeys.lastServerMessageTimestamp, + iOptions: const IOSOptions( + groupId: 'CN332ZUGRP.eu.twonly.shared', + accessibility: KeychainAccessibility.first_unlock, + ), + ); + if (mounted) { + setState(() { + _lastFcmTimestamp = lastFcm; + _lastServerTimestamp = lastServer; + }); + if (showFeedback) { + showSnackbar( + context, + 'Developer information loaded', + level: SnackbarLevel.success, + ); + } + } + } catch (_) {} + } + + String _formatTimestamp(String? timestampStr) { + if (timestampStr == null) return 'Never'; + final ms = int.tryParse(timestampStr); + if (ms == null) return 'Invalid: $timestampStr'; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + return dt.toLocal().toString(); + } + + @override + Widget build(BuildContext context) { + final userId = userService.currentUser.userId.toString(); + + return Scaffold( + appBar: AppBar( + title: const Text('Informations'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadInformations(showFeedback: true), + ), + ], + ), + body: ListView( + children: [ + ListTile( + title: const Text('User ID'), + subtitle: Text(userId), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: userId)); + showSnackbar(context, 'User ID copied to clipboard'); + }, + ), + ), + const Divider(), + ListTile( + title: const Text('Last FCM Message'), + subtitle: Text(_formatTimestamp(_lastFcmTimestamp)), + ), + ListTile( + title: const Text('Last Server Message'), + subtitle: Text(_formatTimestamp(_lastServerTimestamp)), + ), + ], + ), + ); + } +} From 1d3b8dbd8af48fa1d489c8ee9cee946eb8c2ed82 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 10:49:57 +0200 Subject: [PATCH 05/18] ensure correct ordering of media files --- lib/src/database/daos/messages.dao.dart | 6 ++- .../camera_preview_controller_view.dart | 37 +++++++++++++------ .../visual/views/chats/media_viewer.view.dart | 13 +++++++ .../views/onboarding/setup/profile.setup.dart | 3 +- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 5c280a69..5a9c7923 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -50,7 +50,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { mediaFiles, mediaFiles.mediaId.equalsExp(messages.mediaId), ), - ])..where( + ]) + ..where( mediaFiles.downloadState .equals(DownloadState.reuploadRequested.name) .not() & @@ -60,7 +61,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages.mediaId.isNotNull() & messages.senderId.isNotNull() & messages.type.equals(MessageType.media.name), - ); + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); return query.map((row) => row.readTable(messages)).watch(); } diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index 253e62c8..08c37db9 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -254,17 +254,32 @@ class _CameraPreviewViewState extends State { } } - Future requestMicrophonePermission() async { - final statuses = await [ - Permission.microphone, - ].request(); - if (statuses[Permission.microphone]!.isPermanentlyDenied) { - await openAppSettings(); - } else { - _hasAudioPermission = await Permission.microphone.isGranted; - setState(() { - // _hasAudioPermission - }); + Future requestMicrophonePermission({int retryCount = 0}) async { + try { + final statuses = await [ + Permission.microphone, + ].request(); + if (statuses[Permission.microphone]!.isPermanentlyDenied) { + await openAppSettings(); + } else { + _hasAudioPermission = await Permission.microphone.isGranted; + setState(() { + // _hasAudioPermission + }); + } + } on PlatformException catch (e) { + if (e.message?.contains('already running') ?? false) { + if (retryCount < 5) { + Log.warn( + 'Microphone permission request conflict, retrying in 300ms... (attempt ${retryCount + 1})', + ); + await Future.delayed(const Duration(milliseconds: 300)); + return requestMicrophonePermission(retryCount: retryCount + 1); + } + } + Log.error('PlatformException in requestMicrophonePermission: $e'); + } catch (e) { + Log.error('Error in requestMicrophonePermission: $e'); } } diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 76aa2d6e..e1c16156 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -158,6 +158,19 @@ class _MediaViewerViewState extends State { allMediaFiles.add(msg); } } + if (allMediaFiles.length > 1) { + if (widget.initialMessage == null && + currentMedia == null && + !_showDownloadingLoader) { + allMediaFiles.sort( + (a, b) => a.createdAt.compareTo(b.createdAt), + ); + } else { + final upcoming = allMediaFiles.sublist(1) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + allMediaFiles = [allMediaFiles.first, ...upcoming]; + } + } if (mounted) setState(() {}); if (firstRun) { firstRun = false; diff --git a/lib/src/visual/views/onboarding/setup/profile.setup.dart b/lib/src/visual/views/onboarding/setup/profile.setup.dart index d2bf2769..0f695e11 100644 --- a/lib/src/visual/views/onboarding/setup/profile.setup.dart +++ b/lib/src/visual/views/onboarding/setup/profile.setup.dart @@ -59,6 +59,7 @@ class _ProfileSetupPageState extends State { builder: (context, asyncSnapshot) { return Container( padding: const EdgeInsets.all(4), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( @@ -67,7 +68,7 @@ class _ProfileSetupPageState extends State { ), ), child: const AvatarIcon( - fontSize: 70, + fontSize: 68, myAvatar: true, ), ); From 34ecb66e0b201790faa3308c54f8f86cd3527766 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 10:56:17 +0200 Subject: [PATCH 06/18] fix user discovery not working some times --- rust/src/bridge/callbacks.rs | 11 +++++++---- rust/src/bridge/callbacks/macros.rs | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/rust/src/bridge/callbacks.rs b/rust/src/bridge/callbacks.rs index 7db2b450..6aaaafcc 100644 --- a/rust/src/bridge/callbacks.rs +++ b/rust/src/bridge/callbacks.rs @@ -7,9 +7,10 @@ use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion}; use crate::error::{Result, TwonlyError}; use crate::{callback_generator, frb_generated::StreamSink}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; -static FLUTTER_CALLBACKS: OnceLock = OnceLock::new(); +static FLUTTER_CALLBACKS: std::sync::RwLock> = + std::sync::RwLock::new(None); // This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks callback_generator! { @@ -39,8 +40,10 @@ callback_generator! { } } -pub(crate) fn get_callbacks() -> Result<&'static FlutterCallbacks> { +pub(crate) fn get_callbacks() -> Result { FLUTTER_CALLBACKS - .get() + .read() + .unwrap() + .clone() .ok_or(TwonlyError::MissingCallbackInitialization) } diff --git a/rust/src/bridge/callbacks/macros.rs b/rust/src/bridge/callbacks/macros.rs index 7fd28918..61700f88 100644 --- a/rust/src/bridge/callbacks/macros.rs +++ b/rust/src/bridge/callbacks/macros.rs @@ -13,6 +13,7 @@ macro_rules! callback_generator { ) => { // 1. Generate the Nested Sub-Structs $( + #[derive(Clone)] pub(crate) struct $sub_struct_name { $( pub(crate) $fn_name: Arc DartFnFuture<$output> + Send + Sync + 'static>, @@ -21,6 +22,7 @@ macro_rules! callback_generator { )* // 2. Generate the Main Container Struct + #[derive(Clone)] pub(crate) struct $struct_name { $( pub(crate) $sub_struct_field: $sub_struct_name, @@ -48,9 +50,8 @@ macro_rules! callback_generator { }; // Use the static global strictly named FLUTTER_CALLBACKS - FLUTTER_CALLBACKS.set(callbacks).unwrap_or_else(|_| { - println!("Callbacks were already initialized!"); - }); + let mut lock = FLUTTER_CALLBACKS.write().unwrap(); + *lock = Some(callbacks); } } }; From 92b615959b819e65d553abf938ab31ddd7cc8033 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 11:01:20 +0200 Subject: [PATCH 07/18] improve buttons --- .../visual/views/chats/chat_list.view.dart | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 29ac3bf2..421d2a2e 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -311,34 +311,33 @@ class _ChatListViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - Material( - elevation: 3, - shape: const CircleBorder(), - color: context.color.primary, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => context.push(Routes.settingsPublicProfile), - child: SizedBox( - width: 45, - height: 45, - child: Center( - child: FaIcon( - FontAwesomeIcons.qrcode, - color: isDarkMode(context) - ? Colors.black - : Colors.white, - ), - ), - ), + FloatingActionButton( + heroTag: 'qrcode_fab', + elevation: 2, + backgroundColor: isDarkMode(context) + ? Colors.grey[800] + : Colors.grey[200], + foregroundColor: isDarkMode(context) + ? Colors.white + : Colors.black87, + onPressed: () => context.push(Routes.settingsPublicProfile), + child: FaIcon( + FontAwesomeIcons.qrcode, + color: isDarkMode(context) + ? Colors.white + : Colors.black87, ), ), const SizedBox(height: 12), FloatingActionButton( - backgroundColor: context.color.primary, + heroTag: 'new_chat_fab', + elevation: 2, + backgroundColor: primaryColor, + foregroundColor: Colors.black87, onPressed: () => context.push(Routes.chatsStartNewChat), - child: FaIcon( + child: const FaIcon( FontAwesomeIcons.penToSquare, - color: isDarkMode(context) ? Colors.black : Colors.white, + color: Colors.black87, ), ), ], From 9224c77eca547930e119fdce6091ab216e1c7ca7 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 11:10:16 +0200 Subject: [PATCH 08/18] feedback on click --- .../context_menu/context_menu.helper.dart | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/src/visual/context_menu/context_menu.helper.dart b/lib/src/visual/context_menu/context_menu.helper.dart index d05077c1..f065ac27 100644 --- a/lib/src/visual/context_menu/context_menu.helper.dart +++ b/lib/src/visual/context_menu/context_menu.helper.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -18,8 +19,62 @@ class ContextMenu extends StatefulWidget { State createState() => _ContextMenuState(); } -class _ContextMenuState extends State { +class _ContextMenuState extends State + with SingleTickerProviderStateMixin { Offset? _tapPosition; + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = + AnimationController( + vsync: this, + lowerBound: double.negativeInfinity, + upperBound: double.infinity, + value: 0, + )..addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + _tapPosition = details.globalPosition; + _controller.animateTo( + 1, + duration: const Duration(milliseconds: 60), + curve: Curves.easeOut, + ); + } + + void _onTapUp(TapUpDetails details) { + _bounce(); + } + + void _onTapCancel() { + _bounce(); + } + + void _bounce() { + const spring = SpringDescription( + mass: 1, + stiffness: 400, + damping: 15, + ); + final simulation = SpringSimulation( + spring, + _controller.value, + 0, + _controller.velocity, + ); + _controller.animateWith(simulation); + } Widget _getIcon(dynamic icon) { return Padding( @@ -45,6 +100,7 @@ class _ContextMenuState extends State { return; } unawaited(HapticFeedback.heavyImpact()); + _bounce(); await showMenu( context: context, @@ -82,12 +138,16 @@ class _ContextMenuState extends State { @override Widget build(BuildContext context) { + final scale = 1.0 - (_controller.value * 0.02); return GestureDetector( onLongPress: _showCustomMenu, - onTapDown: (details) { - _tapPosition = details.globalPosition; - }, - child: widget.child, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: Transform.scale( + scale: scale, + child: widget.child, + ), ); } } From 3306a1f9ec204f5f068639b5d6018b6d42b941ca Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 11:41:03 +0200 Subject: [PATCH 09/18] upgrading flutter --- android/app/build.gradle | 1 - android/gradle.properties | 4 ++++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 4 ++-- pubspec.lock | 24 +++++++++---------- pubspec.yaml | 2 +- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0ea3d5e0..8ff9cb7b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -3,7 +3,6 @@ plugins { // START: FlutterFire Configuration id 'com.google.gms.google-services' // END: FlutterFire Configuration - id "kotlin-android" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" } diff --git a/android/gradle.properties b/android/gradle.properties index 25971708..4147ba38 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7d27c463..bf14aa8c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index c79517de..affa3a38 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,11 +18,11 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.9.1' apply false + id "com.android.application" version '8.11.1' apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.3.15" apply false // END: FlutterFire Configuration - id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app" diff --git a/pubspec.lock b/pubspec.lock index 6c86ebd9..6fb8bcbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -794,26 +794,26 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.3.1" flutter_secure_storage_darwin: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.2" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -826,10 +826,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_web - sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" flutter_secure_storage_windows: dependency: transitive description: @@ -1303,10 +1303,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1946,10 +1946,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e03873b6..8153f1c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,7 +94,7 @@ dependencies: archive: ^4.0.7 # 6.5 mio file_picker: ^10.3.6 # 2 mio get: ^4.7.2 # 740 k - flutter_secure_storage: ^10.0.0 # 1.85 mio + flutter_secure_storage: ^10.3.1 # 1.85 mio permission_handler: ^12.0.0+1 # 2 mio # Not yet checked From e59c4728a045a5b5d661686174719ea4dbc67b02 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 12:25:59 +0200 Subject: [PATCH 10/18] update flutter for ios --- analysis_options.yaml | 1 + .../NotificationService.swift | 30 +- ios/Podfile | 11 +- ios/Podfile.lock | 355 +----------------- ios/Runner.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 194 ++++++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 194 ++++++++++ .../message_send_state_icon.dart | 2 +- .../views/onboarding/setup/profile.setup.dart | 8 +- pubspec.yaml | 3 + 11 files changed, 457 insertions(+), 381 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/analysis_options.yaml b/analysis_options.yaml index 229bcedc..4f5e5b81 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,6 +19,7 @@ analyzer: - "lib/core/**" - "lib/src/localization/**" - "rust_builder/" + - "build/" - "dependencies/**" - "pubspec.yaml" - "**.arb" diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 124932a9..ebf7b038 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -193,6 +193,7 @@ func readFromKeychain(key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, + kSecAttrService as String: "flutter_secure_storage_service", kSecReturnData as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitOne, kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", // Use your access group @@ -220,28 +221,23 @@ func writeToKeychain(key: String, value: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, - kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", + kSecAttrService as String: "flutter_secure_storage_service", + kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared" ] - let attributesToUpdate: [String: Any] = [ - kSecValueData as String: data - ] + // Delete existing item first to ensure a clean overwrite + SecItemDelete(query as CFDictionary) - let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + // Add the new item with background-compatible accessibility + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - if status == errSecItemNotFound { - var addQuery = query - addQuery[kSecValueData as String] = data - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - if addStatus != errSecSuccess { - NSLog("Failed to add keychain item: \(addStatus)") - } else { - NSLog("Successfully added keychain item for key: \(key)") - } - } else if status != errSecSuccess { - NSLog("Failed to update keychain item: \(status)") + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + NSLog("Failed to write keychain item for key \(key): \(status)") } else { - NSLog("Successfully updated keychain item for key: \(key)") + NSLog("Successfully wrote keychain item for key: \(key)") } } diff --git a/ios/Podfile b/ios/Podfile index f30e30fc..1ce516d2 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -28,17 +28,9 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup -pod 'Firebase', :modular_headers => true -pod 'FirebaseMessaging', :modular_headers => true -pod 'FirebaseCoreInternal', :modular_headers => true -pod 'GoogleUtilities', :modular_headers => true -pod 'FirebaseCore', :modular_headers => true -pod 'SwiftProtobuf' -# pod 'sqlite3', :modular_headers => true - - target 'Runner' do + pod 'SwiftProtobuf' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths @@ -83,5 +75,6 @@ post_install do |installer| end target 'NotificationService' do + pod 'SwiftProtobuf' # pod 'Firebase/Messaging' end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d0110fad..7fa3eac4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,136 +1,18 @@ PODS: - - app_links (7.0.0): - - Flutter - audio_waveforms (0.0.1): - Flutter - - background_downloader (0.0.1): - - Flutter - - camera_avfoundation (0.0.1): - - Flutter - - connectivity_plus (0.0.1): - - Flutter - cryptography_flutter_plus (0.2.0): - Flutter - - device_info_plus (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - emoji_picker_flutter (0.0.1): - - Flutter - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - - Firebase (12.9.0): - - Firebase/Core (= 12.9.0) - - Firebase/Core (12.9.0): - - Firebase/CoreOnly - - FirebaseAnalytics (~> 12.9.0) - - Firebase/CoreOnly (12.9.0): - - FirebaseCore (~> 12.9.0) - - Firebase/Installations (12.9.0): - - Firebase/CoreOnly - - FirebaseInstallations (~> 12.9.0) - - Firebase/Messaging (12.9.0): - - Firebase/CoreOnly - - FirebaseMessaging (~> 12.9.0) - - firebase_app_installations (0.4.1): - - Firebase/Installations (= 12.9.0) - - firebase_core - - Flutter - - firebase_core (4.6.0): - - Firebase/CoreOnly (= 12.9.0) - - Flutter - - firebase_messaging (16.1.3): - - Firebase/Messaging (= 12.9.0) - - firebase_core - - Flutter - - FirebaseAnalytics (12.9.0): - - FirebaseAnalytics/Default (= 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleAppMeasurement/Default (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - FirebaseCore (12.9.0): - - FirebaseCoreInternal (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.9.0): - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.9.0): - - FirebaseCore (~> 12.9.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Reachability (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - Flutter (1.0.0) - flutter_image_compress_common (1.0.0): - Flutter - Mantle - SDWebImage - SDWebImageWebPCoder - - flutter_keyboard_visibility_temp_fork (0.0.1): - - Flutter - - flutter_local_notifications (0.0.1): - - Flutter - - flutter_secure_storage_darwin (10.0.0): - - Flutter - - FlutterMacOS - flutter_sharing_intent (1.0.1): - Flutter - flutter_volume_controller (0.0.1): - Flutter - - gal (1.0.0): - - Flutter - - FlutterMacOS - google_mlkit_barcode_scanning (0.14.2): - Flutter - google_mlkit_commons @@ -142,33 +24,6 @@ PODS: - Flutter - google_mlkit_commons - GoogleMLKit/FaceDetection (~> 9.0.0) - - GoogleAdsOnDeviceConversion (3.2.0): - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.9.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.9.0): - - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleAppMeasurement/IdentitySupport (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.9.0): - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/MethodSwizzler (~> 8.1) - - GoogleUtilities/Network (~> 8.1) - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - nanopb (~> 3.30910.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) @@ -185,54 +40,16 @@ PODS: - GoogleToolboxForMac/Defines (= 4.2.1) - "GoogleToolboxForMac/NSData+zlib (4.2.1)": - GoogleToolboxForMac/Defines (= 4.2.1) - - GoogleUtilities (8.1.0): - - GoogleUtilities/AppDelegateSwizzler (= 8.1.0) - - GoogleUtilities/Environment (= 8.1.0) - - GoogleUtilities/Logger (= 8.1.0) - - GoogleUtilities/MethodSwizzler (= 8.1.0) - - GoogleUtilities/Network (= 8.1.0) - - "GoogleUtilities/NSData+zlib (= 8.1.0)" - - GoogleUtilities/Privacy (= 8.1.0) - - GoogleUtilities/Reachability (= 8.1.0) - - GoogleUtilities/SwizzlerTestHelpers (= 8.1.0) - - GoogleUtilities/UserDefaults (= 8.1.0) - - GoogleUtilities/AppDelegateSwizzler (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Privacy - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.1.0): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Privacy - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.1.0)": - - GoogleUtilities/Privacy - GoogleUtilities/Privacy (8.1.0) - - GoogleUtilities/Reachability (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/SwizzlerTestHelpers (8.1.0): - - GoogleUtilities/MethodSwizzler - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (3.5.0) - - image_picker_ios (0.0.1): - - Flutter - - in_app_purchase_storekit (0.0.1): - - Flutter - - FlutterMacOS - - integration_test (0.0.1): - - Flutter - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -245,9 +62,6 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv - - local_auth_darwin (0.0.1): - - Flutter - - FlutterMacOS - Mantle (2.2.0): - Mantle/extobjc (= 2.2.0) - Mantle/extobjc (2.2.0) @@ -276,18 +90,11 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - package_info_plus (0.4.5): - - Flutter - permission_handler_apple (9.3.0): - Flutter - - photo_manager (3.9.0): - - Flutter - - FlutterMacOS - pro_video_editor (0.0.1): - Flutter - PromisesObjC (2.4.0) - - restart_app (1.7.3): - - Flutter - rust_lib_twonly (0.0.1): - Flutter - screen_protector (1.5.1): @@ -300,90 +107,29 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - Sentry/HybridSDK (8.58.0) - - sentry_flutter (9.16.0): - - Flutter - - FlutterMacOS - - Sentry/HybridSDK (= 8.58.0) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - SwiftProtobuf (1.36.1) - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS + - SwiftProtobuf (1.38.0) - workmanager_apple (0.0.1): - Flutter DEPENDENCIES: - - app_links (from `.symlinks/plugins/app_links/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - cryptography_flutter_plus (from `.symlinks/plugins/cryptography_flutter_plus/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - - Firebase - - firebase_app_installations (from `.symlinks/plugins/firebase_app_installations/ios`) - - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - - FirebaseCore - - FirebaseCoreInternal - - FirebaseMessaging - Flutter (from `Flutter`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - - flutter_keyboard_visibility_temp_fork (from `.symlinks/plugins/flutter_keyboard_visibility_temp_fork/ios`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) - - gal (from `.symlinks/plugins/gal/darwin`) - google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`) - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) - google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`) - - GoogleUtilities - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) - pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`) - - restart_app (from `.symlinks/plugins/restart_app/ios`) - rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`) - screen_protector (from `.symlinks/plugins/screen_protector/ios`) - - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - SwiftProtobuf - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) SPEC REPOS: trunk: - - DKImagePickerController - - DKPhotoGallery - - Firebase - - FirebaseAnalytics - - FirebaseCore - - FirebaseCoreInternal - - FirebaseInstallations - - FirebaseMessaging - - GoogleAdsOnDeviceConversion - - GoogleAppMeasurement - GoogleDataTransport - GoogleMLKit - GoogleToolboxForMac @@ -401,138 +147,54 @@ SPEC REPOS: - ScreenProtectorKit - SDWebImage - SDWebImageWebPCoder - - Sentry - SwiftProtobuf - - SwiftyGif EXTERNAL SOURCES: - app_links: - :path: ".symlinks/plugins/app_links/ios" audio_waveforms: :path: ".symlinks/plugins/audio_waveforms/ios" - background_downloader: - :path: ".symlinks/plugins/background_downloader/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" cryptography_flutter_plus: :path: ".symlinks/plugins/cryptography_flutter_plus/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" - emoji_picker_flutter: - :path: ".symlinks/plugins/emoji_picker_flutter/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" - firebase_app_installations: - :path: ".symlinks/plugins/firebase_app_installations/ios" - firebase_core: - :path: ".symlinks/plugins/firebase_core/ios" - firebase_messaging: - :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter flutter_image_compress_common: :path: ".symlinks/plugins/flutter_image_compress_common/ios" - flutter_keyboard_visibility_temp_fork: - :path: ".symlinks/plugins/flutter_keyboard_visibility_temp_fork/ios" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" - flutter_secure_storage_darwin: - :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" flutter_sharing_intent: :path: ".symlinks/plugins/flutter_sharing_intent/ios" flutter_volume_controller: :path: ".symlinks/plugins/flutter_volume_controller/ios" - gal: - :path: ".symlinks/plugins/gal/darwin" google_mlkit_barcode_scanning: :path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios" google_mlkit_commons: :path: ".symlinks/plugins/google_mlkit_commons/ios" google_mlkit_face_detection: :path: ".symlinks/plugins/google_mlkit_face_detection/ios" - image_picker_ios: - :path: ".symlinks/plugins/image_picker_ios/ios" - in_app_purchase_storekit: - :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" - integration_test: - :path: ".symlinks/plugins/integration_test/ios" - local_auth_darwin: - :path: ".symlinks/plugins/local_auth_darwin/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - photo_manager: - :path: ".symlinks/plugins/photo_manager/darwin" pro_video_editor: :path: ".symlinks/plugins/pro_video_editor/ios" - restart_app: - :path: ".symlinks/plugins/restart_app/ios" rust_lib_twonly: :path: ".symlinks/plugins/rust_lib_twonly/ios" screen_protector: :path: ".symlinks/plugins/screen_protector/ios" - sentry_flutter: - :path: ".symlinks/plugins/sentry_flutter/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" workmanager_apple: :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: - app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8 audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf - background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad - camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_app_installations: 1abd8d071ea2022d7888f7a9713710c37136ff91 - firebase_core: 8e6f58412ca227827c366b92e7cee047a2148c60 - firebase_messaging: c3aa897e0d40109cfb7927c40dc0dea799863f3b - FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 - FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 - FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 - FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa - FirebaseMessaging: 7d6cdbff969127c4151c824fe432f0e301210f15 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 - flutter_keyboard_visibility_temp_fork: 95b2d534bacf6ac62e7fcbe5c2a9e2c2a17ce06f - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb - gal: baecd024ebfd13c441269ca7404792a7152fde89 google_mlkit_barcode_scanning: 12d8422d8f7b00726dedf9cac00188a2b98750c2 google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166 google_mlkit_face_detection: ee4b72cfae062b4c972204be955d83055a4bfd36 - GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 - in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343 @@ -540,28 +202,17 @@ SPEC CHECKSUMS: MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2 rust_lib_twonly: 73165b05d0cda50db45852db63f49caa7f319520 screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150 ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be - sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38 - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a + SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: ae041999f13ba7b2285ff9ad9bc688ed647bbcb7 +PODFILE CHECKSUM: 5bc5189c9ac5776fa63783a6a4fade6f2bc4c3f4 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8f7e3a91..02744fea 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; }; F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -114,6 +115,7 @@ E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F02F7A1D63544AA9F23A1085 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -165,6 +167,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, CA4FDF5DD8F229C30DE512AF /* Pods_Runner.framework in Frameworks */, D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */, ); @@ -200,6 +203,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -307,6 +311,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -378,6 +385,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -1309,6 +1319,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..d2e98753 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,194 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81", + "version" : "4.6.0-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa", + "state" : { + "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", + "version" : "8.58.0" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42..c3fedb29 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + { SizedBox( width: 10, height: 10, - child: CircularProgressIndicator.adaptive(strokeWidth: 1, valueColor: AlwaysStoppedAnimation(color)), + child: CircularProgressIndicator(strokeWidth: 1, color: color), ), const SizedBox(width: 2), ], diff --git a/lib/src/visual/views/onboarding/setup/profile.setup.dart b/lib/src/visual/views/onboarding/setup/profile.setup.dart index 0f695e11..d5802760 100644 --- a/lib/src/visual/views/onboarding/setup/profile.setup.dart +++ b/lib/src/visual/views/onboarding/setup/profile.setup.dart @@ -8,6 +8,7 @@ import 'package:twonly/src/visual/components/avatar_icon.comp.dart' show AvatarIcon; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; +import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; class ProfileSetupPage extends StatefulWidget { @@ -60,10 +61,13 @@ class _ProfileSetupPageState extends State { return Container( padding: const EdgeInsets.all(4), clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + foregroundDecoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: context.color.primary.withValues(alpha: 0.2), + color: primaryColor, width: 4, ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 8153f1c6..3c2b2fca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -206,6 +206,9 @@ flutter_launcher_icons: flutter: uses-material-design: true + # config: + # enable-swift-package-manager: false + # Enable generation of localized Strings from arb files. generate: true From a9bbbacd3ad6310e2ed541a9dcd03ee328e6a228 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 12:27:07 +0200 Subject: [PATCH 11/18] fix anayzer --- .../chats/chat_messages_components/animated_new_message.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart b/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart index 01569e01..35e92966 100644 --- a/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart +++ b/lib/src/visual/views/chats/chat_messages_components/animated_new_message.dart @@ -76,7 +76,7 @@ class _AnimatedNewMessageState extends State parent: _controller, curve: Curves.easeOut, ), - axisAlignment: 1, + alignment: Alignment.bottomLeft, child: ScaleTransition( scale: _scaleAnimation, alignment: Alignment.bottomRight, From 97cdee2a372586f9dbb858266e0a8f01d18262b6 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 12:27:59 +0200 Subject: [PATCH 12/18] bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3c2b2fca..56a60558 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.27+136 +version: 0.2.28+137 environment: sdk: ^3.11.0 From 67b3ad2275cac8302612c74d3194d19ebb259106 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 5 Jun 2026 12:56:49 +0200 Subject: [PATCH 13/18] fix apple permission issue --- ios/Podfile | 22 ++++++++++++++++++++++ ios/Podfile.lock | 2 +- ios/Runner/Info.plist | 2 ++ pubspec.yaml | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ios/Podfile b/ios/Podfile index 1ce516d2..0ed93e4f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -68,6 +68,28 @@ post_install do |installer| ## dart: PermissionGroup.mediaLibrary 'PERMISSION_PHOTOS=1', + + ## dart: PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse + 'PERMISSION_LOCATION=0', + + ## dart: PermissionGroup.contacts + 'PERMISSION_CONTACTS=0', + + ## dart: PermissionGroup.calendar, PermissionGroup.reminders + 'PERMISSION_EVENTS=0', + 'PERMISSION_REMINDERS=0', + + ## dart: PermissionGroup.speech + 'PERMISSION_SPEECH_RECOGNITION=0', + + ## dart: PermissionGroup.bluetooth + 'PERMISSION_BLUETOOTH=0', + + ## dart: PermissionGroup.appTrackingTransparency + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + + ## dart: PermissionGroup.sensors + 'PERMISSION_SENSORS=0', ] end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7fa3eac4..f2578927 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -213,6 +213,6 @@ SPEC CHECKSUMS: SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: 5bc5189c9ac5776fa63783a6a4fade6f2bc4c3f4 +PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf COCOAPODS: 1.16.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a39646a0..96bed792 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -55,6 +55,8 @@ Use your microphone to enable audio when making videos. NSPhotoLibraryUsageDescription twonly will save photos or videos to your library. + NSLocationWhenInUseUsageDescription + This app does not use or store your location information. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/pubspec.yaml b/pubspec.yaml index 56a60558..12dbfffc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.2.28+137 +version: 0.2.29+138 environment: sdk: ^3.11.0 From da97fe5f3da8ef3c36e1ba841828d4d9d8ca547a Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 6 Jun 2026 00:46:19 +0200 Subject: [PATCH 14/18] add new test --- lib/src/database/daos/messages.dao.dart | 86 ++++++++--------- test/services/messages_purge_test.dart | 118 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 test/services/messages_purge_test.dart diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 5a9c7923..123d76ed 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -46,23 +46,23 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { Stream> watchMediaNotOpened(String groupId) { final query = select(messages).join([ - leftOuterJoin( - mediaFiles, - mediaFiles.mediaId.equalsExp(messages.mediaId), - ), - ]) - ..where( - mediaFiles.downloadState - .equals(DownloadState.reuploadRequested.name) - .not() & - mediaFiles.type.equals(MediaType.audio.name).not() & - messages.openedAt.isNull() & - messages.groupId.equals(groupId) & - messages.mediaId.isNotNull() & - messages.senderId.isNotNull() & - messages.type.equals(MessageType.media.name), - ) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not() & + mediaFiles.type.equals(MediaType.audio.name).not() & + messages.openedAt.isNull() & + messages.groupId.equals(groupId) & + messages.mediaId.isNotNull() & + messages.senderId.isNotNull() & + messages.type.equals(MessageType.media.name), + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); return query.map((row) => row.readTable(messages)).watch(); } @@ -95,30 +95,31 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { milliseconds: group!.deleteMessagesAfterMilliseconds, ), ); - final query = select(messages).join([ - leftOuterJoin( - mediaFiles, - mediaFiles.mediaId.equalsExp(messages.mediaId), - ), - ]) - ..where( - messages.groupId.equals(groupId) & - (messages.openedAt.isBiggerThanValue(deletionTime) | - messages.openedAt.isNull() | - messages.mediaStored.equals(true)) & - (messages.isDeletedFromSender.equals(true) | - (messages.type.equals(MessageType.text.name).not() & - messages.type.equals(MessageType.media.name).not()) | - (messages.type.equals(MessageType.text.name) & - messages.content.isNotNull()) | - (messages.type.equals(MessageType.media.name) & - messages.mediaId.isNotNull() & - (mediaFiles.downloadState.isNull() | - mediaFiles.downloadState - .equals(DownloadState.reuploadRequested.name) - .not()))), - ) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + final query = + select(messages).join([ + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + messages.groupId.equals(groupId) & + (messages.openedAt.isBiggerThanValue(deletionTime) | + messages.openedAt.isNull() | + messages.mediaStored.equals(true)) & + (messages.isDeletedFromSender.equals(true) | + (messages.type.equals(MessageType.text.name).not() & + messages.type.equals(MessageType.media.name).not()) | + (messages.type.equals(MessageType.text.name) & + messages.content.isNotNull()) | + (messages.type.equals(MessageType.media.name) & + messages.mediaId.isNotNull() & + (mediaFiles.downloadState.isNull() | + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not()))), + ) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); return query.map((row) => row.readTable(messages)).watch(); } @@ -171,7 +172,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { m.isDeletedFromSender.equals(true)) | m.mediaStored.equals(false)) & // Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later.. - (m.openedByAll.isSmallerThanValue(deletionTime) | + ((m.openedByAll.isNotNull() & + m.openedByAll.isSmallerThanValue(deletionTime)) | (m.isDeletedFromSender.equals(true) & m.createdAt.isSmallerThanValue(deletionTime))), )) diff --git a/test/services/messages_purge_test.dart b/test/services/messages_purge_test.dart new file mode 100644 index 00000000..7a347f64 --- /dev/null +++ b/test/services/messages_purge_test.dart @@ -0,0 +1,118 @@ +import 'package:clock/clock.dart'; +import 'package:drift/drift.dart' hide isNotNull, isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/json/userdata.model.dart'; +import 'package:twonly/src/services/user.service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await locator.reset(); + locator + ..registerSingleton( + TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ), + ) + ..registerSingleton(UserService()); + + userService.currentUser = UserData( + userId: 1, + username: 'test_user', + displayName: 'Test User', + subscriptionPlan: 'Free', + currentSetupPage: null, + appVersion: 100, + ); + userService.isUserCreated = true; + }); + + tearDown(() async { + await twonlyDB.close(); + }); + + test('purgeMessageTable preserves unopened messages and deletes expired ones', () async { + final now = clock.now(); + const retentionMs = 7200000; // 2 hours + final deletionLimit = now.subtract(const Duration(milliseconds: retentionMs)); + + // 1. Insert a group with 2 hour retention policy + await twonlyDB.groupsDao.createNewGroup( + GroupsCompanion.insert( + groupId: 'test_group', + groupName: 'Test Group', + deleteMessagesAfterMilliseconds: const Value(retentionMs), + ), + ); + + // 2. Insert test messages: + // Msg A: Unopened (openedByAll is null) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_a_unopened', + groupId: 'test_group', + type: 'text', + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))), // older than deletion threshold + ), + ); + + // Msg B: Opened long ago (openedByAll is older than deletion threshold) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_b_opened_expired', + groupId: 'test_group', + type: 'text', + openedByAll: Value(deletionLimit.subtract(const Duration(minutes: 5))), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 30))), + ), + ); + + // Msg C: Opened recently (openedByAll is newer than deletion threshold) + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_c_opened_recent', + groupId: 'test_group', + type: 'text', + openedByAll: Value(deletionLimit.add(const Duration(minutes: 5))), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 10))), + ), + ); + + // Msg D: Deleted from sender, older than threshold + await twonlyDB.messagesDao.insertMessage( + MessagesCompanion.insert( + messageId: 'msg_d_sender_deleted_expired', + groupId: 'test_group', + type: 'text', + isDeletedFromSender: const Value(true), + createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))), + ), + ); + + // Run purge + await twonlyDB.messagesDao.purgeMessageTable(); + + // Verify database state + final allMessages = await twonlyDB.select(twonlyDB.messages).get(); + final remainingIds = allMessages.map((m) => m.messageId).toList(); + + // msg_a_unopened should be preserved because it was never opened (openedByAll was null) + expect(remainingIds.contains('msg_a_unopened'), isTrue); + + // msg_b_opened_expired should be deleted because openedByAll < deletionLimit + expect(remainingIds.contains('msg_b_opened_expired'), isFalse); + + // msg_c_opened_recent should be preserved because openedByAll >= deletionLimit + expect(remainingIds.contains('msg_c_opened_recent'), isTrue); + + // msg_d_sender_deleted_expired should be deleted because isDeletedFromSender is true and createdAt < deletionLimit + expect(remainingIds.contains('msg_d_sender_deleted_expired'), isFalse); + }); +} From d1eb5d5be6d02a050b96c8dad008c9ef16082a27 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 7 Jun 2026 12:10:10 +0200 Subject: [PATCH 15/18] fix callback issue and increase minimum threshold --- CHANGELOG.md | 4 + lib/core/bridge/callbacks.dart | 3 + lib/core/bridge/wrapper/user_discovery.dart | 17 ++- lib/core/frb_generated.dart | 69 +++++++++--- lib/globals.dart | 3 + lib/src/callbacks/callbacks.dart | 2 + .../database/daos/key_verification.dao.dart | 4 + lib/src/model/json/userdata.model.dart | 4 +- lib/src/model/json/userdata.model.g.dart | 4 +- lib/src/services/migrations.service.dart | 14 +++ lib/src/services/user_discovery.service.dart | 6 +- .../components/user_discovery_setup.comp.dart | 33 ++++-- .../user_discovery_settings.view.dart | 2 +- rust/src/bridge/callbacks.rs | 33 ++++-- rust/src/bridge/callbacks/macros.rs | 7 +- rust/src/bridge/wrapper/user_discovery.rs | 100 +++++++++++------- rust/src/frb_generated.rs | 34 +++--- 17 files changed, 243 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9e1fc9..40292fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.30 + +- Fix: Changed minimum threshold for the user discovery to 3 + ## 0.2.28 - Improved: Design of some UI components diff --git a/lib/core/bridge/callbacks.dart b/lib/core/bridge/callbacks.dart index c7bdf17e..ad534436 100644 --- a/lib/core/bridge/callbacks.dart +++ b/lib/core/bridge/callbacks.dart @@ -9,8 +9,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `get_callbacks` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`, `UserDiscoveryCallbacks` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone` Future initFlutterCallbacks({ + required int callbackId, required FutureOr> Function() loggingGetStreamSink, required FutureOr Function(Uint8List) userDiscoverySignData, required FutureOr Function(Uint8List, Uint8List, Uint8List) @@ -39,6 +41,7 @@ Future initFlutterCallbacks({ required FutureOr Function(PlatformInt64) userDiscoveryGetContactPromotion, }) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks( + callbackId: callbackId, loggingGetStreamSink: loggingGetStreamSink, userDiscoverySignData: userDiscoverySignData, userDiscoveryVerifySignature: userDiscoveryVerifySignature, diff --git a/lib/core/bridge/wrapper/user_discovery.dart b/lib/core/bridge/wrapper/user_discovery.dart index dab9c080..bff42c1d 100644 --- a/lib/core/bridge/wrapper/user_discovery.dart +++ b/lib/core/bridge/wrapper/user_discovery.dart @@ -9,36 +9,45 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; class FlutterUserDiscovery { const FlutterUserDiscovery(); - static Future getCurrentVersion() => RustLib.instance.api - .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion(); + static Future getCurrentVersion({required int callbackId}) => + RustLib.instance.api + .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion( + callbackId: callbackId, + ); static Future> getNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List receivedVersion, }) => RustLib.instance.api .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages( + callbackId: callbackId, contactId: contactId, receivedVersion: receivedVersion, ); static Future handleNewMessages({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, required List messages, }) => RustLib.instance.api .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages( + callbackId: callbackId, contactId: contactId, publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp, messages: messages, ); static Future initializeOrUpdate({ + required int callbackId, required int threshold, required PlatformInt64 userId, required List publicKey, required bool sharePromotion, }) => RustLib.instance.api .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate( + callbackId: callbackId, threshold: threshold, userId: userId, publicKey: publicKey, @@ -46,19 +55,23 @@ class FlutterUserDiscovery { ); static Future shouldRequestNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List version, }) => RustLib.instance.api .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages( + callbackId: callbackId, contactId: contactId, version: version, ); static Future updateVerificationStateForUser({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, }) => RustLib.instance.api .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser( + callbackId: callbackId, contactId: contactId, publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp, ); diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index ae786047..8570e08f 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -87,16 +87,20 @@ class RustLib extends BaseEntrypoint { abstract class RustLibApi extends BaseApi { Future - crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion(); + crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion({ + required int callbackId, + }); Future> crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List receivedVersion, }); Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, required List messages, @@ -104,6 +108,7 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({ + required int callbackId, required int threshold, required PlatformInt64 userId, required List publicKey, @@ -112,17 +117,20 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List version, }); Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, }); Future crateBridgeCallbacksInitFlutterCallbacks({ + required int callbackId, required FutureOr> Function() loggingGetStreamSink, required FutureOr Function(Uint8List) userDiscoverySignData, required FutureOr Function(Uint8List, Uint8List, Uint8List) @@ -242,11 +250,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @override Future - crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion() { + crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion({ + required int callbackId, + }) { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, @@ -260,7 +271,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta, - argValues: [], + argValues: [callbackId], apiImpl: this, ), ); @@ -270,12 +281,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_get_current_version", - argNames: [], + argNames: ["callbackId"], ); @override Future> crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List receivedVersion, }) { @@ -283,6 +295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_i_64(contactId, serializer); sse_encode_list_prim_u_8_loose(receivedVersion, serializer); pdeCallFfi( @@ -298,7 +311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta, - argValues: [contactId, receivedVersion], + argValues: [callbackId, contactId, receivedVersion], apiImpl: this, ), ); @@ -308,12 +321,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_get_new_messages", - argNames: ["contactId", "receivedVersion"], + argNames: ["callbackId", "contactId", "receivedVersion"], ); @override Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, required List messages, @@ -322,6 +336,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_i_64(contactId, serializer); sse_encode_opt_box_autoadd_i_64( publicKeyVerifiedTimestamp, @@ -341,7 +356,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta, - argValues: [contactId, publicKeyVerifiedTimestamp, messages], + argValues: [ + callbackId, + contactId, + publicKeyVerifiedTimestamp, + messages, + ], apiImpl: this, ), ); @@ -351,12 +371,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_handle_new_messages", - argNames: ["contactId", "publicKeyVerifiedTimestamp", "messages"], + argNames: [ + "callbackId", + "contactId", + "publicKeyVerifiedTimestamp", + "messages", + ], ); @override Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({ + required int callbackId, required int threshold, required PlatformInt64 userId, required List publicKey, @@ -366,6 +392,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_u_8(threshold, serializer); sse_encode_i_64(userId, serializer); sse_encode_list_prim_u_8_loose(publicKey, serializer); @@ -383,7 +410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta, - argValues: [threshold, userId, publicKey, sharePromotion], + argValues: [callbackId, threshold, userId, publicKey, sharePromotion], apiImpl: this, ), ); @@ -393,12 +420,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_initialize_or_update", - argNames: ["threshold", "userId", "publicKey", "sharePromotion"], + argNames: [ + "callbackId", + "threshold", + "userId", + "publicKey", + "sharePromotion", + ], ); @override Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({ + required int callbackId, required PlatformInt64 contactId, required List version, }) { @@ -406,6 +440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_i_64(contactId, serializer); sse_encode_list_prim_u_8_loose(version, serializer); pdeCallFfi( @@ -421,7 +456,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta, - argValues: [contactId, version], + argValues: [callbackId, contactId, version], apiImpl: this, ), ); @@ -431,12 +466,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_should_request_new_messages", - argNames: ["contactId", "version"], + argNames: ["callbackId", "contactId", "version"], ); @override Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({ + required int callbackId, required PlatformInt64 contactId, PlatformInt64? publicKeyVerifiedTimestamp, }) { @@ -444,6 +480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_i_64(contactId, serializer); sse_encode_opt_box_autoadd_i_64( publicKeyVerifiedTimestamp, @@ -462,7 +499,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta, - argValues: [contactId, publicKeyVerifiedTimestamp], + argValues: [callbackId, contactId, publicKeyVerifiedTimestamp], apiImpl: this, ), ); @@ -472,11 +509,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta => const TaskConstMeta( debugName: "flutter_user_discovery_update_verification_state_for_user", - argNames: ["contactId", "publicKeyVerifiedTimestamp"], + argNames: ["callbackId", "contactId", "publicKeyVerifiedTimestamp"], ); @override Future crateBridgeCallbacksInitFlutterCallbacks({ + required int callbackId, required FutureOr> Function() loggingGetStreamSink, required FutureOr Function(Uint8List) userDiscoverySignData, required FutureOr Function(Uint8List, Uint8List, Uint8List) @@ -513,6 +551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(callbackId, serializer); sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( loggingGetStreamSink, serializer, @@ -586,6 +625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ), constMeta: kCrateBridgeCallbacksInitFlutterCallbacksConstMeta, argValues: [ + callbackId, loggingGetStreamSink, userDiscoverySignData, userDiscoveryVerifySignature, @@ -611,6 +651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta( debugName: "init_flutter_callbacks", argNames: [ + "callbackId", "loggingGetStreamSink", "userDiscoverySignData", "userDiscoveryVerifySignature", diff --git a/lib/globals.dart b/lib/globals.dart index 35de1911..8ec8015a 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:math'; import 'package:camera/camera.dart'; import 'package:path_provider/path_provider.dart'; import 'package:twonly/src/utils/log.dart'; +final int isolateCallbackId = Random().nextInt(0x7FFFFFFF); + class AppEnvironment { static late String cacheDir; static late String supportDir; diff --git a/lib/src/callbacks/callbacks.dart b/lib/src/callbacks/callbacks.dart index 07862ace..37da7a30 100644 --- a/lib/src/callbacks/callbacks.dart +++ b/lib/src/callbacks/callbacks.dart @@ -1,9 +1,11 @@ import 'package:twonly/core/bridge/callbacks.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/src/callbacks/logging.callbacks.dart'; import 'package:twonly/src/callbacks/user_discovery.callbacks.dart'; Future initFlutterCallbacksForRust() async { await initFlutterCallbacks( + callbackId: isolateCallbackId, loggingGetStreamSink: LoggingCallbacks.getStreamSink, userDiscoverySetShares: UserDiscoveryCallbacks.setShares, userDiscoveryGetShareForContact: diff --git a/lib/src/database/daos/key_verification.dao.dart b/lib/src/database/daos/key_verification.dao.dart index caa8272c..93e3e007 100644 --- a/lib/src/database/daos/key_verification.dao.dart +++ b/lib/src/database/daos/key_verification.dao.dart @@ -1,6 +1,7 @@ import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:twonly/core/bridge/wrapper/user_discovery.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; @@ -216,6 +217,7 @@ class KeyVerificationDao extends DatabaseAccessor ); if (userService.currentUser.isUserDiscoveryEnabled) { await FlutterUserDiscovery.updateVerificationStateForUser( + callbackId: isolateCallbackId, contactId: contactId, publicKeyVerifiedTimestamp: clock.now().millisecondsSinceEpoch, ); @@ -232,6 +234,7 @@ class KeyVerificationDao extends DatabaseAccessor )..where((kv) => kv.contactId.equals(contactId))).go(); if (userService.currentUser.isUserDiscoveryEnabled) { await FlutterUserDiscovery.updateVerificationStateForUser( + callbackId: isolateCallbackId, contactId: contactId, ); } @@ -251,6 +254,7 @@ class KeyVerificationDao extends DatabaseAccessor final remaining = await getContactVerification(contactId); if (remaining.isEmpty && userService.currentUser.isUserDiscoveryEnabled) { await FlutterUserDiscovery.updateVerificationStateForUser( + callbackId: isolateCallbackId, contactId: contactId, ); } diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index c5b4ce2b..a1005444 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -114,8 +114,8 @@ class UserData { @JsonKey(defaultValue: 4) int requiredSendImages = 4; - @JsonKey(defaultValue: 2) - int userDiscoveryThreshold = 2; + @JsonKey(defaultValue: 3) + int userDiscoveryThreshold = 3; @JsonKey(defaultValue: false) bool userDiscoveryRequiresManualApproval = false; diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 9cec2fea..cb8ee42b 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -62,7 +62,7 @@ UserData _$UserDataFromJson(Map json) => ), ) ..storeMediaFilesInGallery = - json['storeMediaFilesInGallery'] as bool? ?? false + json['storeMediaFilesInGallery'] as bool? ?? true ..autoStoreAllSendUnlimitedMediaFiles = json['autoStoreAllSendUnlimitedMediaFiles'] as bool? ?? false ..typingIndicators = json['typingIndicators'] as bool? ?? true @@ -78,7 +78,7 @@ UserData _$UserDataFromJson(Map json) => json['isUserDiscoveryEnabled'] as bool? ?? false ..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4 ..userDiscoveryThreshold = - (json['userDiscoveryThreshold'] as num?)?.toInt() ?? 2 + (json['userDiscoveryThreshold'] as num?)?.toInt() ?? 3 ..userDiscoveryRequiresManualApproval = json['userDiscoveryRequiresManualApproval'] as bool? ?? false ..userDiscoverySharePromotion = diff --git a/lib/src/services/migrations.service.dart b/lib/src/services/migrations.service.dart index 979cb260..6e2bf460 100644 --- a/lib/src/services/migrations.service.dart +++ b/lib/src/services/migrations.service.dart @@ -14,6 +14,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/json/signal_identity.model.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; @@ -144,6 +145,19 @@ Future runMigrations() async { } } + if (userService.currentUser.appVersion < 116) { + if (userService.currentUser.userDiscoveryThreshold == 2) { + if (userService.currentUser.isUserDiscoveryEnabled) { + await UserDiscoveryService.initializeOrUpdate( + threshold: 3, + sharePromotion: userService.currentUser.userDiscoverySharePromotion, + ); + } else { + await UserService.update((u) => u..userDiscoveryThreshold = 3); + } + } + await UserService.update((u) => u.appVersion = 116); + } if (kDebugMode) { assert( AppState.latestAppVersionId == 116, diff --git a/lib/src/services/user_discovery.service.dart b/lib/src/services/user_discovery.service.dart index 11d41f1f..7dfff07f 100644 --- a/lib/src/services/user_discovery.service.dart +++ b/lib/src/services/user_discovery.service.dart @@ -84,6 +84,7 @@ class UserDiscoveryService { final publicKey = await getUserPublicKey(); Log.info('UserDiscoveryService: initializing Rust bridge'); await FlutterUserDiscovery.initializeOrUpdate( + callbackId: isolateCallbackId, threshold: threshold, userId: userId, publicKey: publicKey, @@ -104,7 +105,7 @@ class UserDiscoveryService { static Future getCurrentVersion() async { try { - return await FlutterUserDiscovery.getCurrentVersion() + return await FlutterUserDiscovery.getCurrentVersion(callbackId: isolateCallbackId) .timeout(const Duration(seconds: 5)); } catch (e) { Log.error(e); @@ -139,6 +140,7 @@ class UserDiscoveryService { ) async { try { return await FlutterUserDiscovery.shouldRequestNewMessages( + callbackId: isolateCallbackId, contactId: fromUserId, version: receivedVersion, ).timeout(const Duration(seconds: 5)); @@ -154,6 +156,7 @@ class UserDiscoveryService { ) async { try { return await FlutterUserDiscovery.getNewMessages( + callbackId: isolateCallbackId, contactId: fromUserId, receivedVersion: receivedVersion, ).timeout(const Duration(seconds: 5)); @@ -172,6 +175,7 @@ class UserDiscoveryService { .getContactVerification(fromUserId); return await FlutterUserDiscovery.handleNewMessages( + callbackId: isolateCallbackId, contactId: fromUserId, messages: messages, publicKeyVerifiedTimestamp: diff --git a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart index 56a1a897..57e66462 100644 --- a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart +++ b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart @@ -108,7 +108,8 @@ class UserDiscoverySetupComp extends StatelessWidget { @override Widget build(BuildContext context) { final showShareYourFriends = - showOnlySpecificPage == UserDiscoveryPages.all || showOnlySpecificPage == UserDiscoveryPages.shareYourFriends; + showOnlySpecificPage == UserDiscoveryPages.all || + showOnlySpecificPage == UserDiscoveryPages.shareYourFriends; final showLetYourFriendsFindYou = showOnlySpecificPage == UserDiscoveryPages.all || showOnlySpecificPage == UserDiscoveryPages.letYourFriendsFindYou; @@ -336,7 +337,9 @@ class UserDiscoverySetupComp extends StatelessWidget { ), ), subtitle: Text( - context.lang.userDiscoverySettingsManualApprovalDesc, + context + .lang + .userDiscoverySettingsManualApprovalDesc, style: TextStyle( fontSize: 11, color: context.color.onSurfaceVariant, @@ -350,13 +353,16 @@ class UserDiscoverySetupComp extends StatelessWidget { ), ], ), - crossFadeState: state.isUserDiscoveryEnabled ? CrossFadeState.showSecond : CrossFadeState.showFirst, + crossFadeState: state.isUserDiscoveryEnabled + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, duration: const Duration(milliseconds: 300), ), ], ), ), - if (showOnlySpecificPage == UserDiscoveryPages.all) const SizedBox(height: 48), + if (showOnlySpecificPage == UserDiscoveryPages.all) + const SizedBox(height: 48), ], if (showLetYourFriendsFindYou) ...[ Text( @@ -587,7 +593,9 @@ class UserDiscoverySetupComp extends StatelessWidget { children: [ Expanded( child: Text( - context.lang.userDiscoverySettingsMutualFriends, + context + .lang + .userDiscoverySettingsMutualFriends, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -603,9 +611,10 @@ class UserDiscoverySetupComp extends StatelessWidget { color: context.color.surface, borderRadius: BorderRadius.circular(8), border: Border.all( - color: context.color.outlineVariant.withValues( - alpha: 0.5, - ), + color: context.color.outlineVariant + .withValues( + alpha: 0.5, + ), ), ), child: DropdownButtonHideUnderline( @@ -616,9 +625,9 @@ class UserDiscoverySetupComp extends StatelessWidget { fontWeight: FontWeight.bold, ), items: List.generate( - 9, + 8, (index) { - final value = index + 2; + final value = index + 3; return DropdownMenuItem( value: value, child: Text('$value'), @@ -640,7 +649,9 @@ class UserDiscoverySetupComp extends StatelessWidget { ), ], ), - crossFadeState: state.sharePromotion ? CrossFadeState.showSecond : CrossFadeState.showFirst, + crossFadeState: state.sharePromotion + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, duration: const Duration(milliseconds: 300), ), ], diff --git a/lib/src/visual/views/settings/privacy/user_discovery/user_discovery_settings.view.dart b/lib/src/visual/views/settings/privacy/user_discovery/user_discovery_settings.view.dart index 06b926ad..f4b2c246 100644 --- a/lib/src/visual/views/settings/privacy/user_discovery/user_discovery_settings.view.dart +++ b/lib/src/visual/views/settings/privacy/user_discovery/user_discovery_settings.view.dart @@ -26,7 +26,7 @@ class _UserDiscoverySettingsViewState extends State { isUserDiscoveryEnabled: u.isUserDiscoveryEnabled, sharePromotion: u.userDiscoverySharePromotion, isManualApprovalEnabled: u.userDiscoveryRequiresManualApproval, - threshold: u.userDiscoveryThreshold, + threshold: u.userDiscoveryThreshold < 3 ? 3 : u.userDiscoveryThreshold, ); } diff --git a/rust/src/bridge/callbacks.rs b/rust/src/bridge/callbacks.rs index 6aaaafcc..1b94d68f 100644 --- a/rust/src/bridge/callbacks.rs +++ b/rust/src/bridge/callbacks.rs @@ -9,7 +9,13 @@ use crate::error::{Result, TwonlyError}; use crate::{callback_generator, frb_generated::StreamSink}; use std::sync::Arc; -static FLUTTER_CALLBACKS: std::sync::RwLock> = +use std::collections::HashMap; + +tokio::task_local! { + pub(crate) static CURRENT_CALLBACK_ID: u32; +} + +pub(crate) static FLUTTER_CALLBACKS: std::sync::RwLock>> = std::sync::RwLock::new(None); // This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks @@ -41,9 +47,24 @@ callback_generator! { } pub(crate) fn get_callbacks() -> Result { - FLUTTER_CALLBACKS - .read() - .unwrap() - .clone() - .ok_or(TwonlyError::MissingCallbackInitialization) + let caller_opt = CURRENT_CALLBACK_ID.try_with(|&c| c).ok(); + + let lock = FLUTTER_CALLBACKS.read().unwrap(); + let map = lock.as_ref().ok_or(TwonlyError::MissingCallbackInitialization)?; + + if let Some(id) = caller_opt { + if let Some(cb) = map.get(&id) { + return Ok(cb.clone()); + } + } + + // Fallback: if not in a scoped tokio task or if the specific callback_id isn't found, + // we pick the first available callbacks from the map. This gracefully handles + // tracing initialization which happens outside of any scoped task. + if let Some((_, cb)) = map.iter().next() { + tracing::error!("FlutterCallbacks fallback used: No CURRENT_CALLBACK_ID scope was found, or the ID was missing from the map. Using an arbitrary callback. This may lead to race conditions if multiple isolates are active."); + return Ok(cb.clone()); + } + + Err(TwonlyError::MissingCallbackInitialization) } diff --git a/rust/src/bridge/callbacks/macros.rs b/rust/src/bridge/callbacks/macros.rs index 61700f88..02b1d8d8 100644 --- a/rust/src/bridge/callbacks/macros.rs +++ b/rust/src/bridge/callbacks/macros.rs @@ -32,6 +32,7 @@ macro_rules! callback_generator { // 3. Generate the Automated Init Function paste::paste! { pub fn init_flutter_callbacks( + callback_id: u32, $( $( // Parameters: sub-struct_field + _ + fn_name @@ -49,9 +50,11 @@ macro_rules! callback_generator { )* }; - // Use the static global strictly named FLUTTER_CALLBACKS let mut lock = FLUTTER_CALLBACKS.write().unwrap(); - *lock = Some(callbacks); + if lock.is_none() { + *lock = Some(std::collections::HashMap::new()); + } + lock.as_mut().unwrap().insert(callback_id, callbacks); } } }; diff --git a/rust/src/bridge/wrapper/user_discovery.rs b/rust/src/bridge/wrapper/user_discovery.rs index 4ecb59c2..c19ec41f 100644 --- a/rust/src/bridge/wrapper/user_discovery.rs +++ b/rust/src/bridge/wrapper/user_discovery.rs @@ -1,3 +1,4 @@ +use crate::bridge::callbacks::CURRENT_CALLBACK_ID; use crate::bridge::get_twonly_flutter; use crate::error::Result; @@ -5,78 +6,95 @@ pub struct FlutterUserDiscovery {} impl FlutterUserDiscovery { pub async fn initialize_or_update( + callback_id: u32, threshold: u8, user_id: i64, public_key: Vec, share_promotion: bool, ) -> Result<()> { - tracing::info!("Rust bridge: initialize_or_update started"); - let twonly = get_twonly_flutter()?; - tracing::info!("Rust bridge: getting user_discovery lock"); - let user_discovery = twonly.user_discovery.get().await; - tracing::info!("Rust bridge: calling initialize_or_update on protocols"); - let res = user_discovery - .initialize_or_update(threshold, user_id, public_key, share_promotion) - .await; - tracing::info!("Rust bridge: initialize_or_update on protocols finished"); - Ok(res?) + CURRENT_CALLBACK_ID.scope(callback_id, async move { + tracing::info!("Rust bridge: initialize_or_update started"); + let twonly = get_twonly_flutter()?; + tracing::info!("Rust bridge: getting user_discovery lock"); + let user_discovery = twonly.user_discovery.get().await; + tracing::info!("Rust bridge: calling initialize_or_update on protocols"); + let res = user_discovery + .initialize_or_update(threshold, user_id, public_key, share_promotion) + .await; + tracing::info!("Rust bridge: initialize_or_update on protocols finished"); + Ok(res?) + }).await } - pub async fn get_current_version() -> Result> { - Ok(get_twonly_flutter()? - .user_discovery - .get() - .await - .get_current_version() - .await?) + pub async fn get_current_version(callback_id: u32) -> Result> { + CURRENT_CALLBACK_ID.scope(callback_id, async move { + Ok(get_twonly_flutter()? + .user_discovery + .get() + .await + .get_current_version() + .await?) + }).await } pub async fn get_new_messages( + callback_id: u32, contact_id: i64, received_version: &[u8], ) -> Result>> { - Ok(get_twonly_flutter()? - .user_discovery - .get() - .await - .get_new_messages(contact_id, received_version) - .await?) + CURRENT_CALLBACK_ID.scope(callback_id, async move { + Ok(get_twonly_flutter()? + .user_discovery + .get() + .await + .get_new_messages(contact_id, received_version) + .await?) + }).await } pub async fn should_request_new_messages( + callback_id: u32, contact_id: i64, version: &[u8], ) -> Result>> { - Ok(get_twonly_flutter()? - .user_discovery - .get() - .await - .should_request_new_messages(contact_id, version) - .await?) + CURRENT_CALLBACK_ID.scope(callback_id, async move { + Ok(get_twonly_flutter()? + .user_discovery + .get() + .await + .should_request_new_messages(contact_id, version) + .await?) + }).await } pub async fn handle_new_messages( + callback_id: u32, contact_id: i64, public_key_verified_timestamp: Option, messages: Vec>, ) -> Result<()> { - Ok(get_twonly_flutter()? - .user_discovery - .get() - .await - .handle_new_messages(contact_id, public_key_verified_timestamp, messages) - .await?) + CURRENT_CALLBACK_ID.scope(callback_id, async move { + Ok(get_twonly_flutter()? + .user_discovery + .get() + .await + .handle_new_messages(contact_id, public_key_verified_timestamp, messages) + .await?) + }).await } pub async fn update_verification_state_for_user( + callback_id: u32, contact_id: i64, public_key_verified_timestamp: Option, ) -> Result<()> { - Ok(get_twonly_flutter()? - .user_discovery - .get() - .await - .update_verification_state_for_user(contact_id, public_key_verified_timestamp) - .await?) + CURRENT_CALLBACK_ID.scope(callback_id, async move { + Ok(get_twonly_flutter()? + .user_discovery + .get() + .await + .update_verification_state_for_user(contact_id, public_key_verified_timestamp) + .await?) + }).await } } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 0be3e628..c63d3a4b 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -55,9 +55,9 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_curr FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_current_version", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - deserializer.end(); move |context| async move { + let api_callback_id = ::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_current_version().await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_current_version(api_callback_id).await?; Ok(output_ok) })().await) } }) } @@ -70,10 +70,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_new_ FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_contact_id = ::sse_decode(&mut deserializer); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_contact_id = ::sse_decode(&mut deserializer); let api_received_version = >::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_new_messages(api_contact_id, &api_received_version).await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_new_messages(api_callback_id, api_contact_id, &api_received_version).await?; Ok(output_ok) })().await) } }) } @@ -86,11 +87,12 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_handle_n FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_handle_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_contact_id = ::sse_decode(&mut deserializer); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_contact_id = ::sse_decode(&mut deserializer); let api_public_key_verified_timestamp = >::sse_decode(&mut deserializer); let api_messages = >>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::handle_new_messages(api_contact_id, api_public_key_verified_timestamp, api_messages).await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::handle_new_messages(api_callback_id, api_contact_id, api_public_key_verified_timestamp, api_messages).await?; Ok(output_ok) })().await) } }) } @@ -103,12 +105,13 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_initiali FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_initialize_or_update", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_threshold = ::sse_decode(&mut deserializer); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_threshold = ::sse_decode(&mut deserializer); let api_user_id = ::sse_decode(&mut deserializer); let api_public_key = >::sse_decode(&mut deserializer); let api_share_promotion = ::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::initialize_or_update(api_threshold, api_user_id, api_public_key, api_share_promotion).await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::initialize_or_update(api_callback_id, api_threshold, api_user_id, api_public_key, api_share_promotion).await?; Ok(output_ok) })().await) } }) } @@ -121,10 +124,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_should_r FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_should_request_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_contact_id = ::sse_decode(&mut deserializer); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_contact_id = ::sse_decode(&mut deserializer); let api_version = >::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::should_request_new_messages(api_contact_id, &api_version).await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::should_request_new_messages(api_callback_id, api_contact_id, &api_version).await?; Ok(output_ok) })().await) } }) } @@ -137,10 +141,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_v FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_update_verification_state_for_user", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_contact_id = ::sse_decode(&mut deserializer); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_contact_id = ::sse_decode(&mut deserializer); let api_public_key_verified_timestamp = >::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::update_verification_state_for_user(api_contact_id, api_public_key_verified_timestamp).await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::update_verification_state_for_user(api_callback_id, api_contact_id, api_public_key_verified_timestamp).await?; Ok(output_ok) })().await) } }) } @@ -153,7 +158,8 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl( FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "init_flutter_callbacks", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_logging_get_stream_sink = decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(::sse_decode(&mut deserializer)); + let api_callback_id = ::sse_decode(&mut deserializer); +let api_logging_get_stream_sink = decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(::sse_decode(&mut deserializer)); let api_user_discovery_sign_data = decode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(::sse_decode(&mut deserializer)); let api_user_discovery_verify_signature = decode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(::sse_decode(&mut deserializer)); let api_user_discovery_verify_stored_pubkey = decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(::sse_decode(&mut deserializer)); @@ -169,7 +175,7 @@ let api_user_discovery_set_contact_version = decode_DartFn_Inputs_i_64_list_prim let api_user_discovery_push_new_user_relation = decode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(::sse_decode(&mut deserializer)); let api_user_discovery_get_contact_promotion = decode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(::sse_decode(&mut deserializer));deserializer.end(); move |context| { transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_,()>::Ok({ crate::bridge::callbacks::init_flutter_callbacks(api_logging_get_stream_sink, api_user_discovery_sign_data, api_user_discovery_verify_signature, api_user_discovery_verify_stored_pubkey, api_user_discovery_set_shares, api_user_discovery_get_share_for_contact, api_user_discovery_push_own_promotion_and_clear_old_version, api_user_discovery_get_own_promotions_after_version, api_user_discovery_store_other_promotion, api_user_discovery_get_other_promotions_by_public_id, api_user_discovery_get_announced_user_by_public_id, api_user_discovery_get_contact_version, api_user_discovery_set_contact_version, api_user_discovery_push_new_user_relation, api_user_discovery_get_contact_promotion); })?; Ok(output_ok) + let output_ok = Result::<_,()>::Ok({ crate::bridge::callbacks::init_flutter_callbacks(api_callback_id, api_logging_get_stream_sink, api_user_discovery_sign_data, api_user_discovery_verify_signature, api_user_discovery_verify_stored_pubkey, api_user_discovery_set_shares, api_user_discovery_get_share_for_contact, api_user_discovery_push_own_promotion_and_clear_old_version, api_user_discovery_get_own_promotions_after_version, api_user_discovery_store_other_promotion, api_user_discovery_get_other_promotions_by_public_id, api_user_discovery_get_announced_user_by_public_id, api_user_discovery_get_contact_version, api_user_discovery_set_contact_version, api_user_discovery_push_new_user_relation, api_user_discovery_get_contact_promotion); })?; Ok(output_ok) })()) } }) } From b49b9cf452c13bf282db4f8808731f381a89d592 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 7 Jun 2026 12:23:54 +0200 Subject: [PATCH 16/18] fix groups where not ordered correctly in the chat list --- lib/src/database/daos/groups.dao.dart | 9 +++++++-- lib/src/database/twonly.db.dart | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 1763432b..929dc2e0 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart' show clock; import 'package:drift/drift.dart'; import 'package:hashlib/random.dart'; import 'package:twonly/locator.dart'; @@ -327,12 +328,16 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { String groupId, DateTime newLastMessage, ) async { + final now = clock.now(); + final clampedLastMessage = newLastMessage.isAfter(now) + ? now + : newLastMessage; await (update(groups)..where( (t) => t.groupId.equals(groupId) & - (t.lastMessageExchange.isSmallerThanValue(newLastMessage)), + (t.lastMessageExchange.isSmallerThanValue(clampedLastMessage)), )) - .write(GroupsCompanion(lastMessageExchange: Value(newLastMessage))); + .write(GroupsCompanion(lastMessageExchange: Value(clampedLastMessage))); } Stream> watchNonDirectGroupsForMember(int contactId) { diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 812b03e9..91fe020e 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -239,8 +239,11 @@ class TwonlyDB extends _$TwonlyDB { } void markUpdated() { - notifyUpdates({TableUpdate.onTable(messages, kind: UpdateKind.update)}); - notifyUpdates({TableUpdate.onTable(contacts, kind: UpdateKind.update)}); + notifyUpdates({ + TableUpdate.onTable(messages, kind: UpdateKind.update), + TableUpdate.onTable(contacts, kind: UpdateKind.update), + TableUpdate.onTable(groups, kind: UpdateKind.update), + }); } Future printTableSizes() async { From f66e1f17bf36aeb62750eaea1599d0a1d38059c7 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 7 Jun 2026 12:44:57 +0200 Subject: [PATCH 17/18] fix the response view --- .../entries/chat_audio_entry.dart | 112 +++++----- .../entries/chat_text_entry.dart | 87 +++----- .../response_container.dart | 210 ++++++++---------- 3 files changed, 180 insertions(+), 229 deletions(-) diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart index 349ace0f..a29455a6 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart @@ -31,34 +31,27 @@ class ChatAudioEntry extends StatelessWidget { return Container(); // media file was purged } - return LayoutBuilder( - builder: (context, constraints) { - final textWidth = measureTextWidth(info.text); - const timeWidth = 60.0; - final isExpanded = - info.expanded || - (textWidth + timeWidth + 20 > constraints.maxWidth); - final effectiveSpacerWidth = - constraints.minWidth - textWidth - timeWidth; - final spacerWidth = effectiveSpacerWidth > 0 - ? effectiveSpacerWidth - : 0.0; + final showTime = info.displayTime || message.modifiedAt != null; - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - minWidth: 250, - ), - padding: info.padding, - decoration: BoxDecoration( - color: info.color, - borderRadius: borderRadius, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (info.displayUserName != '') - Text( + return IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + minWidth: 280, + ), + padding: info.padding, + decoration: BoxDecoration( + color: info.color, + borderRadius: borderRadius, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (info.displayUserName != '') + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( info.displayUserName, textAlign: TextAlign.left, style: const TextStyle( @@ -66,42 +59,37 @@ class ChatAudioEntry extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (isExpanded && info.text != '') - Expanded( - child: BetterText( - text: info.text, - textColor: info.textColor, - ), - ) - else if (info.text != '') ...[ - BetterText(text: info.text, textColor: info.textColor), - SizedBox(width: spacerWidth), - ] else ...[ - if (mediaService.mediaFile.downloadState == - DownloadState.ready || - mediaService.mediaFile.downloadState == null) - mediaService.tempPath.existsSync() - ? InChatAudioPlayer( - path: mediaService.tempPath.path, - message: message, - ) - : Container() - else - MessageSendStateIcon([message], [mediaService.mediaFile]), - SizedBox(width: spacerWidth), - ], - if (info.displayTime || message.modifiedAt != null) - FriendlyMessageTime(message: message), - ], ), - ], - ), - ); - }, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (info.text != '') + Expanded( + child: BetterText( + text: info.text, + textColor: info.textColor, + ), + ) + else + Expanded( + child: mediaService.mediaFile.downloadState == + DownloadState.ready || + mediaService.mediaFile.downloadState == null + ? (mediaService.tempPath.existsSync() + ? InChatAudioPlayer( + path: mediaService.tempPath.path, + message: message, + ) + : Container()) + : MessageSendStateIcon([message], [mediaService.mediaFile]), + ), + if (showTime) FriendlyMessageTime(message: message), + ], + ), + ], + ), + ), ); } } diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart index 67d0a1a5..694f3581 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart @@ -34,34 +34,27 @@ class ChatTextEntry extends StatelessWidget { ); } - return LayoutBuilder( - builder: (context, constraints) { - final textWidth = measureTextWidth(info.text); - const timeWidth = 60.0; - final isExpanded = - info.expanded || - (textWidth + timeWidth + 20 > constraints.maxWidth); - final effectiveSpacerWidth = - constraints.minWidth - textWidth - timeWidth; - final spacerWidth = effectiveSpacerWidth > 0 - ? effectiveSpacerWidth - : 0.0; + final showTime = info.displayTime || message.modifiedAt != null; - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - minWidth: info.minWidth, - ), - padding: info.padding, - decoration: BoxDecoration( - color: info.color, - borderRadius: borderRadius, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (info.displayUserName != '') - Text( + return IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + minWidth: info.minWidth, + ), + padding: info.padding, + decoration: BoxDecoration( + color: info.color, + borderRadius: borderRadius, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (info.displayUserName != '') + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( info.displayUserName, textAlign: TextAlign.left, style: const TextStyle( @@ -69,31 +62,23 @@ class ChatTextEntry extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (isExpanded) - Expanded( - child: BetterText( - text: info.text, - textColor: info.textColor, - ), - ) - else ...[ - BetterText(text: info.text, textColor: info.textColor), - SizedBox( - width: spacerWidth, - ), - ], - if (info.displayTime || message.modifiedAt != null) - FriendlyMessageTime(message: message), - ], ), - ], - ), - ); - }, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: BetterText( + text: info.text, + textColor: info.textColor, + ), + ), + if (showTime) FriendlyMessageTime(message: message), + ], + ), + ], + ), + ), ); } } diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index 9508dc7d..771cb931 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -9,7 +9,7 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/views/chats/chat_messages.view.dart'; -class ResponseContainer extends StatefulWidget { +class ResponseContainer extends StatelessWidget { const ResponseContainer({ required this.msg, required this.group, @@ -27,86 +27,55 @@ class ResponseContainer extends StatefulWidget { final BorderRadius borderRadius; final void Function(String)? scrollToMessage; - @override - State createState() => _ResponseContainerState(); -} - -class _ResponseContainerState extends State { - double? minWidth; - final GlobalKey _message = GlobalKey(); - final GlobalKey _preview = GlobalKey(); - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final messageBox = - _message.currentContext?.findRenderObject() as RenderBox?; - final previewBox = - _preview.currentContext?.findRenderObject() as RenderBox?; - if (messageBox == null || previewBox == null) { - return; - } - setState(() { - if (messageBox.size.width > previewBox.size.width) { - minWidth = messageBox.size.width; - } else { - minWidth = previewBox.size.width; - } - }); - }); - } - @override Widget build(BuildContext context) { - if (widget.msg.quotesMessageId == null) { - if (widget.child == null) { + if (msg.quotesMessageId == null) { + if (child == null) { return Container(); } - return widget.child!; + return child!; } return GestureDetector( - onTap: widget.scrollToMessage == null + onTap: scrollToMessage == null ? null - : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), + : () => scrollToMessage!(msg.quotesMessageId!), child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, ), decoration: BoxDecoration( - color: getMessageColor(widget.msg.senderId != null), - borderRadius: widget.borderRadius, + color: getMessageColor(msg.senderId != null), + borderRadius: borderRadius, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - key: _preview, - padding: const EdgeInsets.only(top: 4, right: 4, left: 4), - child: Container( - width: minWidth, - decoration: BoxDecoration( - color: context.color.surface.withAlpha(150), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(8), - topLeft: Radius.circular(8), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(4), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4, right: 4, left: 4), + child: Container( + decoration: BoxDecoration( + color: context.color.surface.withAlpha(150), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + child: ResponsePreview( + group: group, + messageId: msg.quotesMessageId, + showBorder: false, + showLeftBorder: false, + colorUsername: false, ), ), - child: ResponsePreview( - group: widget.group, - messageId: widget.msg.quotesMessageId, - showBorder: false, - ), ), - ), - SizedBox( - key: _message, - width: minWidth, - child: widget.child, - ), - ], + if (child != null) child!, + ], + ), ), ), ); @@ -119,6 +88,8 @@ class ResponsePreview extends StatefulWidget { required this.showBorder, this.message, this.messageId, + this.showLeftBorder = true, + this.colorUsername = false, super.key, }); @@ -126,6 +97,8 @@ class ResponsePreview extends StatefulWidget { final String? messageId; final Group group; final bool showBorder; + final bool showLeftBorder; + final bool colorUsername; @override State createState() => _ResponsePreviewState(); @@ -212,80 +185,85 @@ class _ResponsePreviewState extends State { if (_message!.senderId == null) { _username = context.lang.you; - // _username = _message!.senderId.toString(); } color = getMessageColor(_message!.senderId != null); + } - if (!_message!.mediaStored) { - return Container( - padding: widget.showBorder - ? const EdgeInsets.only(left: 10, right: 10) - : const EdgeInsets.symmetric(horizontal: 5), - decoration: (widget.showBorder) - ? BoxDecoration( - border: Border( - left: BorderSide( - color: color, - width: 2, - ), - ), - ) - : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _username, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - if (subtitle != null) Text(subtitle), - ], + final hasImage = + _message != null && + _message!.mediaStored && + _mediaService != null && + _mediaService!.mediaFile.type != MediaType.audio; + + Widget? imageWidget; + if (hasImage) { + final isVideo = _mediaService!.mediaFile.type == MediaType.video; + final pathToCheck = isVideo + ? _mediaService!.thumbnailPath + : _mediaService!.storedPath; + if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) { + imageWidget = Container( + height: 40, + width: 40, + margin: const EdgeInsets.only(left: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + pathToCheck, + fit: BoxFit.cover, + ), ), ); } } return Container( - padding: const EdgeInsets.only(left: 10), - width: 200, - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: color, - width: 2, - ), - ), + padding: EdgeInsets.only( + left: widget.showLeftBorder ? 8 : 4, + right: 6, + top: 4, + bottom: 4, ), + constraints: BoxConstraints( + minWidth: 60, + maxWidth: MediaQuery.of(context).size.width * 0.7, + ), + decoration: widget.showLeftBorder + ? BoxDecoration( + border: Border( + left: BorderSide( + color: color, + width: 2.5, + ), + ), + ) + : null, child: Row( + mainAxisSize: MainAxisSize.min, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( _username, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: widget.colorUsername ? color : null, + ), ), - if (subtitle != null) Text(subtitle), + if (subtitle != null) + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ), - if (_mediaService != null && - _mediaService!.mediaFile.type != MediaType.audio) - () { - final isVideo = _mediaService!.mediaFile.type == MediaType.video; - final pathToCheck = isVideo - ? _mediaService!.thumbnailPath - : _mediaService!.storedPath; - if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) { - return SizedBox( - height: widget.showBorder ? 100 : 210, - child: Image.file(pathToCheck), - ); - } - return const SizedBox.shrink(); - }(), + if (imageWidget != null) imageWidget, ], ), ); From 52f962ae2efd41c30c1dcc1bc6c4f168e27119d0 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 7 Jun 2026 12:53:44 +0200 Subject: [PATCH 18/18] fix transparent color --- lib/src/visual/elements/my_button.element.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index 3bbec5cc..4f5e1c15 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -98,6 +98,12 @@ class _MyButtonState extends State final scale = 1.0 - (_controller.value * 0.02); final isEnabled = widget.onPressed != null || widget.onLongPress != null; final isDark = isDarkMode(context); + final disabledBgColor = isDark + ? const Color(0xFF353535) + : const Color(0xFFE0E0E0); + final disabledFgColor = isDark + ? const Color(0xFF757575) + : const Color(0xFF9E9E9E); late final ButtonStyle buttonStyle; switch (widget.variant) { @@ -105,6 +111,8 @@ class _MyButtonState extends State buttonStyle = FilledButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, minimumSize: const Size.fromHeight(60), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), @@ -119,6 +127,8 @@ class _MyButtonState extends State buttonStyle = FilledButton.styleFrom( backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], foregroundColor: isDark ? Colors.white : Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, minimumSize: const Size.fromHeight(60), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), @@ -135,6 +145,7 @@ class _MyButtonState extends State foregroundColor: isDark ? Colors.white.withValues(alpha: 0.7) : Colors.black.withValues(alpha: 0.7), + disabledForegroundColor: disabledFgColor, textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -147,6 +158,8 @@ class _MyButtonState extends State buttonStyle = FilledButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, minimumSize: const Size(0, 48), padding: const EdgeInsets.symmetric( horizontal: 24, @@ -164,6 +177,8 @@ class _MyButtonState extends State buttonStyle = FilledButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, minimumSize: const Size(0, 40), padding: const EdgeInsets.symmetric( horizontal: 16, @@ -181,6 +196,8 @@ class _MyButtonState extends State buttonStyle = FilledButton.styleFrom( backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], foregroundColor: isDark ? Colors.white : Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, minimumSize: const Size(0, 40), padding: const EdgeInsets.symmetric( horizontal: 16,