From c0e45cfe1f1777649cfb4e28a636a4a9c82de89f Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 19 May 2026 22:33:24 +0200 Subject: [PATCH] improve the add friends view --- lib/src/database/daos/messages.dao.dart | 8 +- .../generated/app_localizations.dart | 12 + .../generated/app_localizations_de.dart | 7 + .../generated/app_localizations_en.dart | 7 + lib/src/localization/translations | 2 +- .../components/profile_qr_code.comp.dart | 97 ++++++ .../views/contact/add_new_contact.view.dart | 290 +++++++++++++----- lib/src/visual/views/public_profile.view.dart | 46 +-- 8 files changed, 339 insertions(+), 130 deletions(-) create mode 100644 lib/src/visual/components/profile_qr_code.comp.dart diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 1a1b5d15..d794b8cf 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -278,14 +278,12 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messageId, MessageActionType.openedAt, ); - final now = clock.now(); - await (update( messages, )..where((tbl) => tbl.messageId.equals(messageId))).write( MessagesCompanion( - openedAt: Value(now), - openedByAll: Value(isOpenedByAll ? now : null), + openedAt: Value(timestamp), + openedByAll: Value(isOpenedByAll ? timestamp : null), ), ); } catch (e) { @@ -309,7 +307,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ); await twonlyDB.messagesDao.updateMessageId( messageId, - MessagesCompanion(ackByServer: Value(clock.now())), + MessagesCompanion(ackByServer: Value(timestamp)), ); } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 0e1508c2..5dfb6404 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2342,6 +2342,12 @@ abstract class AppLocalizations { /// **'Open your own QR code'** String get openYourOwnQRcode; + /// No description provided for @addContactQrSheetSubtext. + /// + /// In en, this message translates to: + /// **'Let a friend scan this QR code to add you'** + String get addContactQrSheetSubtext; + /// No description provided for @finishSetupCardTitle. /// /// In en, this message translates to: @@ -3343,6 +3349,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Drag to Zoom'** String get dragToZoom; + + /// No description provided for @showUsername. + /// + /// In en, this message translates to: + /// **'Show username'** + String get showUsername; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 0424c01d..812e65ec 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1277,6 +1277,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get openYourOwnQRcode => 'Eigenen QR-Code öffnen'; + @override + String get addContactQrSheetSubtext => + 'Lass einen Freund diesen QR-Code scannen, um dich hinzuzufügen'; + @override String get finishSetupCardTitle => 'Profil vervollständigen'; @@ -1901,4 +1905,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dragToZoom => 'Zum Zoomen ziehen'; + + @override + String get showUsername => 'Benutzernamen anzeigen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 0d5f389c..c9f2aaa7 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1268,6 +1268,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get openYourOwnQRcode => 'Open your own QR code'; + @override + String get addContactQrSheetSubtext => + 'Let a friend scan this QR code to add you'; + @override String get finishSetupCardTitle => 'Complete your profile'; @@ -1885,4 +1889,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dragToZoom => 'Drag to Zoom'; + + @override + String get showUsername => 'Show username'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 49a063c3..f356d455 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 49a063c35d7173082c224cf4da1e8d5eb3978ebc +Subproject commit f356d455e46d223ca2ea370892d23e54a6fe48c4 diff --git a/lib/src/visual/components/profile_qr_code.comp.dart b/lib/src/visual/components/profile_qr_code.comp.dart new file mode 100644 index 00000000..bc917f46 --- /dev/null +++ b/lib/src/visual/components/profile_qr_code.comp.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:twonly/src/utils/avatars.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/qr.utils.dart'; + +class ProfileQrCodeComp extends StatefulWidget { + const ProfileQrCodeComp({ + this.size = 250, + this.showAvatar = true, + super.key, + }); + + final double size; + final bool showAvatar; + + @override + State createState() => _ProfileQrCodeCompState(); +} + +class _ProfileQrCodeCompState extends State { + String? _qrCode; + Uint8List? _userAvatar; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + final qr = await QrCodeUtils.publicProfileLink(); + final avatar = widget.showAvatar ? await getUserAvatar() : null; + if (mounted) { + setState(() { + _qrCode = qr; + _userAvatar = avatar; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading || _qrCode == null) { + return SizedBox( + width: widget.size, + height: widget.size, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + return Container( + // padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: context.color.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 3, + offset: Offset(0, 2), + ), + ], + ), + child: QrImageView.withQr( + qr: QrCode.fromData( + data: _qrCode!, + errorCorrectLevel: QrErrorCorrectLevel.M, + ), + eyeStyle: QrEyeStyle( + color: isDarkMode(context) ? Colors.black : Colors.white, + borderRadius: 2, + ), + dataModuleStyle: QrDataModuleStyle( + color: isDarkMode(context) ? Colors.black : Colors.white, + borderRadius: 2, + ), + gapless: false, + embeddedImage: (widget.showAvatar && _userAvatar != null) + ? MemoryImage(_userAvatar!) + : null, + embeddedImageStyle: QrEmbeddedImageStyle( + size: const Size(60, 66), + embeddedImageShape: EmbeddedImageShape.square, + shapeColor: context.color.primary, + safeArea: true, + ), + size: widget.size, + ), + ); + } +} 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 6808ce0c..130710a4 100644 --- a/lib/src/visual/views/contact/add_new_contact.view.dart +++ b/lib/src/visual/views/contact/add_new_contact.view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart' hide Column; @@ -6,14 +7,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 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/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/utils.api.dart'; +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/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'; @@ -85,6 +90,61 @@ class _SearchUsernameView extends State { twonlyDB.userDiscoveryDao.markAllValidAnnouncedUsersAsShown(); } + Future _shareProfile() async { + final pubKey = await getUserPublicKey(); + final params = ShareParams( + text: + 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', + ); + await SharePlus.instance.share(params); + } + + void _showMyQrCode() { + // ignore: inference_failure_on_function_invocation + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + decoration: BoxDecoration( + color: context.color.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: double.infinity), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: context.color.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + const ProfileQrCodeComp(), + const SizedBox(height: 24), + Text( + context.lang.addContactQrSheetSubtext, + style: TextStyle( + fontSize: 14, + color: context.color.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + @override void dispose() { _contactsStream.cancel(); @@ -154,21 +214,6 @@ class _SearchUsernameView extends State { if (added > 0) await importSignalContactAndCreateRequest(userdata); } - InputDecoration _getInputDecoration(String hintText) { - return InputDecoration( - hintText: hintText, - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(9), - borderSide: BorderSide(color: context.color.primary), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: context.color.outline), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), - ); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -176,77 +221,162 @@ class _SearchUsernameView extends State { title: Text(context.lang.addFriendTitle), ), body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - Expanded( - child: TextField( - onSubmitted: _requestNewUserByUsername, - onChanged: (value) { - _usernameController.text = value.toLowerCase(); - _usernameController.selection = - TextSelection.fromPosition( - TextPosition( - offset: _usernameController.text.length, - ), - ); - setState(() {}); - }, - inputFormatters: [ - LengthLimitingTextInputFormatter(12), - FilteringTextInputFormatter.allow( - RegExp('[a-z0-9A-Z._]'), - ), - ], - controller: _usernameController, - decoration: _getInputDecoration( - context.lang.searchUsernameInput, + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: SearchBar( + 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(strokeWidth: 2), + ), + ) + else + IconButton( + icon: FaIcon( + FontAwesomeIcons.magnifyingGlassPlus, + size: 20, + color: context.color.primary, + ), + onPressed: () => _requestNewUserByUsername( + _usernameController.text, ), ), - ), - ], - ), - ), - const SizedBox( - height: 20, - ), - Expanded( - child: ListView( - children: [ - Center( - child: OutlinedButton.icon( - onPressed: () => - context.push(Routes.settingsPublicProfile), - icon: const FaIcon(FontAwesomeIcons.qrcode), - label: Text(context.lang.scanQrOrShow), + ] else ...[ + IconButton( + icon: FaIcon( + FontAwesomeIcons.camera, + size: 20, + color: context.color.primary, ), + onPressed: () => context.push(Routes.cameraQRScanner), + tooltip: context.lang.scanOtherProfile, ), - OpenRequestsListComp( - contacts: _openRequestsContacts, - relations: _allAnnouncedUsers, - ), - FriendSuggestionsComp(_newAnnouncedUsers), ], - ), + ], + onSubmitted: _requestNewUserByUsername, + onChanged: (value) { + _usernameController.text = value.toLowerCase(); + _usernameController.selection = TextSelection.fromPosition( + TextPosition(offset: _usernameController.text.length), + ); + setState(() {}); + }, ), - ], - ), - ), - ), - floatingActionButton: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: FloatingActionButton( - onPressed: _isLoading || _usernameController.text.isEmpty - ? null - : () => _requestNewUserByUsername(_usernameController.text), - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : const FaIcon(FontAwesomeIcons.magnifyingGlassPlus), + ), + const SizedBox( + height: 10, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + 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: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + context.lang.shareYourProfile, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + 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: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + context.lang.openYourOwnQRcode, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 15), + OpenRequestsListComp( + contacts: _openRequestsContacts, + relations: _allAnnouncedUsers, + ), + FriendSuggestionsComp(_newAnnouncedUsers), + ], ), ), ); diff --git a/lib/src/visual/views/public_profile.view.dart b/lib/src/visual/views/public_profile.view.dart index 693be045..5783e81f 100644 --- a/lib/src/visual/views/public_profile.view.dart +++ b/lib/src/visual/views/public_profile.view.dart @@ -5,15 +5,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:qr_flutter/qr_flutter.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/avatars.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/utils/qr.utils.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart'; +import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart'; import 'package:twonly/src/visual/themes/light.dart'; @@ -25,8 +23,6 @@ class PublicProfileView extends StatefulWidget { } class _PublicProfileViewState extends State { - String? _qrCode; - Uint8List? _userAvatar; Uint8List? _publicKey; int _countContactRequest = 0; late StreamSubscription _countContactRequestStream; @@ -38,8 +34,6 @@ class _PublicProfileViewState extends State { } Future initAsync() async { - _qrCode = await QrCodeUtils.publicProfileLink(); - _userAvatar = await getUserAvatar(); _publicKey = await getUserPublicKey(); if (mounted) setState(() {}); @@ -134,43 +128,7 @@ class _PublicProfileViewState extends State { ), ), const SizedBox(height: 20), - if (_qrCode != null && _userAvatar != null) - Container( - decoration: BoxDecoration( - color: context.color.primary, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - child: QrImageView.withQr( - qr: QrCode.fromData( - data: _qrCode!, - errorCorrectLevel: QrErrorCorrectLevel.M, - ), - eyeStyle: QrEyeStyle( - color: isDarkMode(context) ? Colors.black : Colors.white, - borderRadius: 2, - ), - dataModuleStyle: QrDataModuleStyle( - color: isDarkMode(context) ? Colors.black : Colors.white, - borderRadius: 2, - ), - gapless: false, - embeddedImage: MemoryImage(_userAvatar!), - embeddedImageStyle: QrEmbeddedImageStyle( - size: const Size(60, 66), - embeddedImageShape: EmbeddedImageShape.square, - shapeColor: context.color.primary, - safeArea: true, - ), - size: 250, - ), - ), + const ProfileQrCodeComp(), const SizedBox(height: 20), Text( userService.currentUser.username,