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), + ], ), ), ),