improved styling

This commit is contained in:
otsmr 2026-06-05 01:17:42 +02:00
parent 24efc76c02
commit e2cf5ec74a
20 changed files with 1434 additions and 932 deletions

View file

@ -208,7 +208,8 @@ class _AppMainWidgetState extends State<AppMainWidget> {
_isTwonlyLocked = false; _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 // This will only be shown in case the user have not skipped
child = SetupView( child = SetupView(
onUpdate: () => setState(() { onUpdate: () => setState(() {

View file

@ -101,7 +101,7 @@ abstract class AppLocalizations {
/// No description provided for @registerSlogan. /// No description provided for @registerSlogan.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Stay in touch with friends privately and securely.'** /// **'Stay in touch privately.'**
String get registerSlogan; String get registerSlogan;
/// No description provided for @onboardingWelcomeTitle. /// No description provided for @onboardingWelcomeTitle.
@ -173,7 +173,7 @@ abstract class AppLocalizations {
/// No description provided for @registerUsernameSlogan. /// No description provided for @registerUsernameSlogan.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your public username'** /// **'Create your account'**
String get registerUsernameSlogan; String get registerUsernameSlogan;
/// No description provided for @registerUsernameDecoration. /// No description provided for @registerUsernameDecoration.
@ -1205,8 +1205,8 @@ abstract class AppLocalizations {
/// No description provided for @userFoundBody. /// No description provided for @userFoundBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Do you want to create a follow request?'** /// **'Do you want to connect with {username}?'**
String get userFoundBody; String userFoundBody(String username);
/// No description provided for @errorInternalError. /// No description provided for @errorInternalError.
/// ///
@ -3689,6 +3689,48 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'** /// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'**
String importGalleryImportCount(num count); 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 class _AppLocalizationsDelegate

View file

@ -9,8 +9,7 @@ class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale); AppLocalizationsDe([String locale = 'de']) : super(locale);
@override @override
String get registerSlogan => String get registerSlogan => 'Privat in Kontakt bleiben.';
'Privat und sicher mit Freunden in Kontakt bleiben.';
@override @override
String get onboardingWelcomeTitle => 'Willkommen bei twonly!'; String get onboardingWelcomeTitle => 'Willkommen bei twonly!';
@ -52,7 +51,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingGetStartedTitle => 'Auf geht\'s'; String get onboardingGetStartedTitle => 'Auf geht\'s';
@override @override
String get registerUsernameSlogan => 'Dein öffentlicher Benutzername'; String get registerUsernameSlogan => 'Konto erstellen';
@override @override
String get registerUsernameDecoration => 'Benutzername'; String get registerUsernameDecoration => 'Benutzername';
@ -611,7 +610,9 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get userFoundBody => 'Möchtest du eine Folgeanfrage stellen?'; String userFoundBody(String username) {
return 'Möchtest du dich mit $username vernetzen?';
}
@override @override
String get errorInternalError => String get errorInternalError =>
@ -2127,4 +2128,26 @@ class AppLocalizationsDe extends AppLocalizations {
); );
return '$_temp0'; 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';
} }

View file

@ -9,8 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale); AppLocalizationsEn([String locale = 'en']) : super(locale);
@override @override
String get registerSlogan => String get registerSlogan => 'Stay in touch privately.';
'Stay in touch with friends privately and securely.';
@override @override
String get onboardingWelcomeTitle => 'Welcome to twonly!'; String get onboardingWelcomeTitle => 'Welcome to twonly!';
@ -51,7 +50,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingGetStartedTitle => 'Let\'s go!'; String get onboardingGetStartedTitle => 'Let\'s go!';
@override @override
String get registerUsernameSlogan => 'Your public username'; String get registerUsernameSlogan => 'Create your account';
@override @override
String get registerUsernameDecoration => 'Username'; String get registerUsernameDecoration => 'Username';
@ -606,7 +605,9 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @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 @override
String get errorInternalError => String get errorInternalError =>
@ -2109,4 +2110,26 @@ class AppLocalizationsEn extends AppLocalizations {
); );
return '$_temp0'; 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';
} }

View file

@ -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<MyButton> createState() => _MyButtonState();
}
class _MyButtonState extends State<MyButton>
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,
),
),
);
}
}

View file

@ -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<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final Widget? prefixIcon;
final Widget? suffixIcon;
final TextInputType? keyboardType;
final bool autofocus;
final String? errorText;
final bool obscureText;
@override
State<MyInput> createState() => _MyInputState();
}
class _MyInputState extends State<MyInput> 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,
),
),
),
);
}
}

View file

@ -1,14 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; 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:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'
import 'package:twonly/src/visual/themes/light.dart'; show ProfileQrCodeComp;
import 'package:twonly/src/visual/elements/my_button.element.dart';
class EmptyChatListComp extends StatelessWidget { class EmptyChatListComp extends StatelessWidget {
const EmptyChatListComp({super.key}); const EmptyChatListComp({super.key});
@ -17,7 +19,8 @@ class EmptyChatListComp extends StatelessWidget {
try { try {
final pubKey = await getUserPublicKey(); final pubKey = await getUserPublicKey();
final params = ShareParams( 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); await SharePlus.instance.share(params);
} catch (e) { } catch (e) {
@ -37,16 +40,16 @@ class EmptyChatListComp extends StatelessWidget {
height: 24, height: 24,
width: double.infinity, width: double.infinity,
), ),
const Text( Text(
'Find your first friend', context.lang.emptyChatListTitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Let friends scan your QR code, or share them your profile.', context.lang.emptyChatListDesc,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: context.color.onSurface.withValues(alpha: 0.6), color: context.color.onSurface.withValues(alpha: 0.6),
@ -56,61 +59,67 @@ class EmptyChatListComp extends StatelessWidget {
const SizedBox(height: 36), const SizedBox(height: 36),
const Center(child: ProfileQrCodeComp()), const Center(child: ProfileQrCodeComp()),
const SizedBox(height: 36), const SizedBox(height: 36),
// 3. Action Buttons MyButton(
// Button 1: Share Profile (Full Width)
FilledButton.icon(
style: primaryColorButtonStyle,
onPressed: () => _shareProfile(context), onPressed: () => _shareProfile(context),
icon: const FaIcon(FontAwesomeIcons.shareNodes, size: 20), child: Row(
label: const Text( mainAxisAlignment: MainAxisAlignment.center,
'Share your profile', children: [
style: TextStyle( const FaIcon(FontAwesomeIcons.shareNodes, size: 20),
fontSize: 16, const SizedBox(width: 8),
fontWeight: FontWeight.bold, Text(
), context.lang.emptyChatListShareBtn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Button Row: Scan QR Code & Enter Username
Row( Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( MyButton(
child: FilledButton.icon( variant: MyButtonVariant.secondaryDense,
style: secondaryGreyButtonStyle(context), onPressed: () => context.push(Routes.cameraQRScanner),
onPressed: () => context.push(Routes.cameraQRScanner), child: Row(
icon: const Icon(Icons.qr_code_scanner_rounded, size: 20), mainAxisSize: MainAxisSize.min,
label: const FittedBox( children: [
fit: BoxFit.scaleDown, const Icon(Icons.qr_code_scanner_rounded, size: 20),
child: Text( const SizedBox(width: 8),
'Scan QR Code', Text(
style: TextStyle( context.lang.emptyChatListScanBtn,
style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( MyButton(
child: FilledButton.icon( variant: MyButtonVariant.secondaryDense,
style: secondaryGreyButtonStyle(context), onPressed: () => context.push(Routes.chatsAddNewUser),
onPressed: () => context.push(Routes.chatsAddNewUser), child: Row(
icon: const Icon(Icons.person_add_rounded, size: 20), mainAxisSize: MainAxisSize.min,
label: const FittedBox( children: [
fit: BoxFit.scaleDown, const Icon(Icons.person_add_rounded, size: 20),
child: Text( const SizedBox(width: 8),
'Add by Username', Text(
style: TextStyle( context.lang.emptyChatListAddUsernameBtn,
style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ],
), ),
), ),
], ],
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
], ],
), ),

View file

@ -114,7 +114,7 @@ class _AddContactViaQrLinkViewState extends State<AddContactViaQrLinkView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
context.lang.userFoundBody, context.lang.userFoundBody(widget.profile.username),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,

View file

@ -18,7 +18,8 @@ import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.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/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/friend_suggestions.comp.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/open_requests_list.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<AddNewUserView> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
bool hasRequestedUsers = false; bool hasRequestedUsers = false;
String? _searchError;
List<Contact> _openRequestsContacts = []; List<Contact> _openRequestsContacts = [];
late StreamSubscription<List<Contact>> _contactsStream; late StreamSubscription<List<Contact>> _contactsStream;
@ -63,20 +65,24 @@ class _SearchUsernameView extends State<AddNewUserView> {
}, },
); );
_newAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchNewAnnouncedUsersWithRelations().listen((update) { _newAnnouncedUsersStream = twonlyDB.userDiscoveryDao
if (mounted) { .watchNewAnnouncedUsersWithRelations()
setState(() { .listen((update) {
_newAnnouncedUsers = update; if (mounted) {
setState(() {
_newAnnouncedUsers = update;
});
}
}); });
} _allAnnouncedUsersStream = twonlyDB.userDiscoveryDao
}); .watchAllAnnouncedUsersWithRelations()
_allAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchAllAnnouncedUsersWithRelations().listen((update) { .listen((update) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_allAnnouncedUsers = update; _allAnnouncedUsers = update;
});
}
}); });
}
});
if (widget.username != null) { if (widget.username != null) {
_usernameController.text = widget.username!; _usernameController.text = widget.username!;
@ -90,7 +96,8 @@ class _SearchUsernameView extends State<AddNewUserView> {
Future<void> _shareProfile() async { Future<void> _shareProfile() async {
final pubKey = await getUserPublicKey(); final pubKey = await getUserPublicKey();
final params = ShareParams( 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); await SharePlus.instance.share(params);
} }
@ -164,18 +171,20 @@ class _SearchUsernameView extends State<AddNewUserView> {
}); });
if (userdata == null) { if (userdata == null) {
await showAlertDialog( setState(() {
context, _searchError = context.lang.searchUsernameNotFound;
context.lang.searchUsernameNotFound, });
context.lang.searchUsernameNotFoundBody(username),
);
return; return;
} }
setState(() {
_searchError = null;
});
final addUser = await showAlertDialog( final addUser = await showAlertDialog(
context, context,
context.lang.userFound(username), context.lang.userFound(username),
context.lang.userFoundBody, context.lang.userFoundBody(username),
); );
if (!addUser || !mounted) return; if (!addUser || !mounted) return;
@ -190,7 +199,9 @@ class _SearchUsernameView extends State<AddNewUserView> {
), ),
); );
if (widget.publicKey != null && mounted && widget.publicKey!.equals(userdata.publicIdentityKey)) { if (widget.publicKey != null &&
mounted &&
widget.publicKey!.equals(userdata.publicIdentityKey)) {
final markAsVerified = await showAlertDialog( final markAsVerified = await showAlertDialog(
context, context,
context.lang.linkFromUsername(username), context.lang.linkFromUsername(username),
@ -218,72 +229,93 @@ class _SearchUsernameView extends State<AddNewUserView> {
child: ListView( child: ListView(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
child: SearchBar( child: MyInput(
controller: _usernameController, controller: _usernameController,
hintText: context.lang.searchUsernameInput, hintText: context.lang.searchUsernameInput,
elevation: const WidgetStatePropertyAll(0), prefixIcon: const Icon(Icons.search, size: 20),
backgroundColor: WidgetStatePropertyAll( errorText: _searchError,
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,
onChanged: (value) { onChanged: (value) {
_usernameController.text = value.toLowerCase(); _usernameController.text = value.toLowerCase();
_usernameController.selection = TextSelection.fromPosition( _usernameController.selection = TextSelection.fromPosition(
TextPosition(offset: _usernameController.text.length), 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<Color>(
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( const SizedBox(
@ -291,63 +323,40 @@ class _SearchUsernameView extends State<AddNewUserView> {
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( MyButton(
children: [ variant: MyButtonVariant.primaryDense,
Expanded( onPressed: _shareProfile,
child: FilledButton.icon( child: Row(
style: FilledButton.styleFrom( mainAxisSize: MainAxisSize.min,
backgroundColor: primaryColor, children: [
foregroundColor: Colors.black87, const FaIcon(FontAwesomeIcons.shareNodes, size: 14),
padding: const EdgeInsets.symmetric( const SizedBox(width: 8),
vertical: 8, Text(
horizontal: 10, context.lang.shareYourProfile,
), style: const TextStyle(fontSize: 13),
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),
),
), ),
), ],
const SizedBox(width: 8), ),
Expanded( ),
child: FilledButton.icon( const SizedBox(width: 8),
style: FilledButton.styleFrom( Expanded(
backgroundColor: context.color.secondaryContainer, child: MyButton(
foregroundColor: context.color.onSecondaryContainer, variant: MyButtonVariant.secondaryDense,
padding: const EdgeInsets.symmetric( onPressed: _showMyQrCode,
vertical: 8, child: Row(
horizontal: 10, mainAxisSize: MainAxisSize.min,
), children: [
elevation: 0, const FaIcon(FontAwesomeIcons.qrcode, size: 14),
shape: RoundedRectangleBorder( const SizedBox(width: 8),
borderRadius: BorderRadius.circular(12), Text(
),
),
onPressed: _showMyQrCode,
icon: const FaIcon(
FontAwesomeIcons.qrcode,
size: 14,
),
label: Text(
context.lang.openYourOwnQRcode, context.lang.openYourOwnQRcode,
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ],
), ),
], ),
), ),
], ],
), ),

View file

@ -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<Widget> 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,
),
),
),
);
},
),
),
],
),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:introduction_screen/introduction_screen.dart'; import 'package:introduction_screen/introduction_screen.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class OnboardingView extends StatelessWidget { class OnboardingView extends StatelessWidget {
const OnboardingView({required this.callbackOnSuccess, super.key}); 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( PageViewModel(
title: context.lang.onboardingNotProductTitle, title: context.lang.onboardingNotProductTitle,
bodyWidget: Column( bodyWidget: Column(
@ -81,7 +69,7 @@ class OnboardingView extends StatelessWidget {
right: 50, right: 50,
top: 20, top: 20,
), ),
child: FilledButton( child: MyButton(
onPressed: callbackOnSuccess, onPressed: callbackOnSuccess,
child: Text(context.lang.registerSubmitButton), 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(''), done: const Text(''),
next: Text(context.lang.next), next: Text(context.lang.next),

View file

@ -3,11 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:restart_app/restart_app.dart'; import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.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/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/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
class BackupRecoveryView extends StatefulWidget { class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key}); const BackupRecoveryView({super.key});
@ -64,180 +63,192 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
}); });
} }
@override void _showBackupExplanation(BuildContext context) {
Widget build(BuildContext context) {
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white; final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100]; final textColor = isDark ? Colors.white : Colors.black87;
final subtitleColor = isDark ? Colors.white70 : Colors.black54;
return OnboardingWrapper( showModalBottomSheet<void>(
children: [ context: context,
Row( backgroundColor: backgroundColor,
children: [ shape: const RoundedRectangleBorder(
IconButton( borderRadius: BorderRadius.vertical(
onPressed: () => Navigator.of(context).pop(), top: Radius.circular(28),
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,
),
],
), ),
const SizedBox(height: 20), ),
const Center( isScrollControlled: true,
builder: (context) {
return SafeArea(
child: Padding( child: Padding(
padding: EdgeInsets.all(20), padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: LinkLogoAnimation(), child: Column(
), mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 16), children: [
Padding( Center(
padding: const EdgeInsets.symmetric(horizontal: 20), child: Container(
child: Text( width: 40,
context.lang.twonlySafeRecoverTitle, height: 5,
textAlign: TextAlign.center, decoration: BoxDecoration(
style: const TextStyle( color: isDark ? Colors.white24 : Colors.black12,
fontSize: 24, borderRadius: BorderRadius.circular(2.5),
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],
), ),
), ),
), ),
), const SizedBox(height: 24),
const SizedBox(height: 32), Text(
FilledButton( 'twonly Backup',
onPressed: (!isLoading) ? _recoverTwonlySafe : null, style: TextStyle(
style: FilledButton.styleFrom( fontSize: 22,
backgroundColor: primaryColor, fontWeight: FontWeight.bold,
foregroundColor: Colors.white, color: textColor,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
), ),
elevation: 0, textAlign: TextAlign.center,
), ),
child: isLoading const SizedBox(height: 16),
? const SizedBox( Text(
height: 24, context.lang.backupTwonlySafeLongDesc,
width: 24, style: TextStyle(
child: CircularProgressIndicator.adaptive( fontSize: 16,
valueColor: AlwaysStoppedAnimation(Colors.white), height: 1.5,
strokeWidth: 3, 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,
),
],
), ),
) const SizedBox(height: 20),
: Text( Center(
context.lang.twonlySafeRecoverBtn, child: Padding(
style: const TextStyle( padding: const EdgeInsets.all(20),
fontSize: 18, child: LinkLogoAnimation(
fontWeight: FontWeight.bold, 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),
],
); );
} }
} }

View file

@ -17,10 +17,10 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/utils/pow.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.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/groups/group.view.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.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'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class RegisterView extends StatefulWidget { class RegisterView extends StatefulWidget {
@ -43,8 +43,9 @@ class _RegisterViewState extends State<RegisterView> {
bool _registrationDisabled = false; bool _registrationDisabled = false;
bool _isTryingToRegister = false; bool _isTryingToRegister = false;
bool _isValidUserName = false; bool _isValidUserName = false;
bool _showUserNameError = false;
bool _showProofOfWorkError = false; bool _showProofOfWorkError = false;
String? _usernameErrorText;
late Future<int>? proofOfWork; late Future<int>? proofOfWork;
@ -58,7 +59,7 @@ class _RegisterViewState extends State<RegisterView> {
Future<void> createNewUser() async { Future<void> createNewUser() async {
if (!_isValidUserName) { if (!_isValidUserName) {
setState(() { setState(() {
_showUserNameError = true; _usernameErrorText = context.lang.registerUsernameLimits;
}); });
return; return;
} }
@ -67,278 +68,271 @@ class _RegisterViewState extends State<RegisterView> {
setState(() { setState(() {
_isTryingToRegister = true; _isTryingToRegister = true;
_showUserNameError = false; _usernameErrorText = null;
_showProofOfWorkError = false; _showProofOfWorkError = false;
}); });
late int proof; try {
late int proof;
if (proofOfWork != null) { if (proofOfWork != null) {
proof = await proofOfWork!; proof = await proofOfWork!;
} else { } else {
final (pow, registrationDisabled) = await apiService.getProofOfWork(); final (pow, registrationDisabled) = await apiService.getProofOfWork();
if (pow == null) { if (pow == null) {
_registrationDisabled = registrationDisabled; 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) { if (mounted) {
showNetworkIssue(context); setState(() {
_isTryingToRegister = false;
});
await showAlertDialog(
context,
'Oh no!',
errorCodeToText(context, res.error as ErrorCode),
);
} }
return; 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); await apiService.authenticate();
if (res.isSuccess) { widget.callbackOnSuccess();
Log.info('Got user_id ${res.value} from server'); } catch (e, stack) {
userId = res.value.userid.toInt() as int; Log.error('Error creating new user', e, stack);
} 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;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_isTryingToRegister = false; _isTryingToRegister = false;
}); });
await showAlertDialog( await showAlertDialog(
context, context,
'Oh no!', 'Error',
errorCodeToText(context, res.error as ErrorCode), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = isDarkMode(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( return GestureDetector(
children: [ onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
const SizedBox(height: 30), behavior: HitTestBehavior.opaque,
Center( child: Scaffold(
child: Container( backgroundColor: Theme.of(context).scaffoldBackgroundColor,
padding: const EdgeInsets.all(10), body: SafeArea(
child: const LinkLogoAnimation(), child: LayoutBuilder(
), builder: (context, constraints) {
), return SingleChildScrollView(
const SizedBox(height: 12), padding: const EdgeInsets.symmetric(horizontal: 24),
Padding( child: ConstrainedBox(
padding: const EdgeInsets.symmetric(horizontal: 20), constraints: BoxConstraints(
child: Text( minHeight: constraints.maxHeight,
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,
), ),
), child: IntrinsicHeight(
const SizedBox(height: 48), child: Column(
] else ...[ crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
context.lang.registerUsernameSlogan, const SizedBox(height: 30),
textAlign: TextAlign.center, Center(
style: TextStyle( child: Container(
fontSize: 16, padding: const EdgeInsets.all(10),
color: sloganColor, child: LinkLogoAnimation(
fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black,
), ),
),
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,
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 16), Padding(
], padding: const EdgeInsets.symmetric(horizontal: 20),
TextButton( child: Text(
onPressed: () => context.push( context.lang.registerSlogan,
Routes.settingsBackupRecovery, textAlign: TextAlign.center,
), style: TextStyle(
style: TextButton.styleFrom( fontSize: 16,
minimumSize: const Size.fromHeight(50), color:
foregroundColor: secondaryButtonColor, Theme.of(context).textTheme.bodyMedium?.color
shape: RoundedRectangleBorder( ?.withValues(alpha: 0.7) ??
borderRadius: BorderRadius.circular(18), (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),
],
); );
} }
} }

View file

@ -5,6 +5,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/profile.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.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/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/let_your_friends_find_you.setup.dart';
import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart';
@ -152,7 +153,9 @@ class _SetupViewState extends State<SetupView> {
right: index == currentPage.totalPages - 1 ? 0 : 8, right: index == currentPage.totalPages - 1 ? 0 : 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isFinished ? context.color.primary : context.color.surfaceContainer, color: isFinished
? context.color.primary
: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
@ -175,35 +178,30 @@ class _SetupViewState extends State<SetupView> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (currentPage.index > 0) if (currentPage.index > 0)
TextButton( MyButton(
onPressed: () async { onPressed: () async {
await UserService.update((u) { await UserService.update((u) {
u.currentSetupPage = currentPage.previous()?.name; u.currentSetupPage = currentPage.previous()?.name;
}); });
}, },
variant: MyButtonVariant.text,
child: Text( child: Text(
context.lang.back, 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) if (!currentPage.isLast)
TextButton( MyButton(
onPressed: () async { onPressed: () async {
await UserService.update( await UserService.update(
(u) => u.skipSetupPages = true, (u) => u.skipSetupPages = true,
); );
widget.onUpdate?.call(); widget.onUpdate?.call();
}, },
variant: MyButtonVariant.text,
child: Text( child: Text(
context.lang.onboardingFinishLater, context.lang.onboardingFinishLater,
style: TextStyle(
color: context.color.primary,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.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'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class FinishSetupComp extends StatefulWidget { class FinishSetupComp extends StatefulWidget {
@ -123,29 +124,19 @@ class _FinishSetupCompState extends State<FinishSetupComp> {
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton.icon( MyButton(
onPressed: onTap, onPressed: onTap,
icon: const Icon( variant: MyButtonVariant.primaryDense,
Icons.arrow_forward_rounded, child: Row(
size: 18, mainAxisSize: MainAxisSize.min,
), children: [
label: Text( const Icon(
context.lang.finishSetupCardAction, Icons.arrow_forward_rounded,
style: const TextStyle( size: 18,
fontWeight: FontWeight.bold, ),
), const SizedBox(width: 8),
), Text(context.lang.finishSetupCardAction),
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,
), ),
), ),
], ],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.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'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class NextButtonComp extends StatelessWidget { class NextButtonComp extends StatelessWidget {
@ -24,7 +25,7 @@ class NextButtonComp extends StatelessWidget {
final currentPage = SetupPagesExtension.fromStr( final currentPage = SetupPagesExtension.fromStr(
userService.currentUser.currentSetupPage, userService.currentUser.currentSetupPage,
); );
return ElevatedButton( return MyButton(
onPressed: (canSubmit && !isLoading) onPressed: (canSubmit && !isLoading)
? () async { ? () async {
if (onPressed != null) { if (onPressed != null) {
@ -36,15 +37,6 @@ class NextButtonComp extends StatelessWidget {
}); });
} }
: null, : 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 child: isLoading
? const SizedBox( ? const SizedBox(
height: 24, height: 24,
@ -56,7 +48,6 @@ class NextButtonComp extends StatelessWidget {
) )
: Text( : Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next, currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
); );
}, },

View file

@ -1,13 +1,14 @@
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.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:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart';
import 'package:vector_graphics/vector_graphics.dart';
class ProfileSetupPage extends StatefulWidget { class ProfileSetupPage extends StatefulWidget {
const ProfileSetupPage({super.key}); const ProfileSetupPage({super.key});
@ -17,10 +18,7 @@ class ProfileSetupPage extends StatefulWidget {
} }
class _ProfileSetupPageState extends State<ProfileSetupPage> { class _ProfileSetupPageState extends State<ProfileSetupPage> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
late final TextEditingController _displayNameController; late final TextEditingController _displayNameController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -35,95 +33,82 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return StreamBuilder(
mainAxisSize: MainAxisSize.min, stream: userService.onUserUpdated,
children: [ builder: (context, asyncSnapshot) {
Text( return Column(
context.lang.onboardingProfileTitle, mainAxisSize: MainAxisSize.min,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( children: [
fontWeight: FontWeight.bold, Text(
), context.lang.onboardingProfileTitle,
), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
const SizedBox(height: 8), fontWeight: FontWeight.bold,
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: 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: 8),
), Text(
), context.lang.onboardingProfileBody,
const SizedBox(height: 40), textAlign: TextAlign.center,
NextButtonComp( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
onPressed: () async { color: context.color.onSurfaceVariant,
await UserService.update((user) { ),
if (_displayNameController.text.isNotEmpty) { ),
user.displayName = _displayNameController.text; const SizedBox(height: 40),
} StreamBuilder(
}); stream: userService.onUserUpdated,
return false; 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;
},
),
],
);
},
); );
} }
} }

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 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<bool> isSecurePassword(String password) async { Future<bool> isSecurePassword(String password) async {
final badPasswordsStr = await rootBundle.loadString( final badPasswordsStr = await rootBundle.loadString(
@ -46,29 +46,20 @@ class _BackupPasswordTextFieldState extends State<BackupPasswordTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return MyInput(
controller: widget.controller, controller: widget.controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
obscureText: _obscureText, obscureText: _obscureText,
decoration: InputDecoration( hintText: widget.labelText,
labelText: widget.labelText, suffixIcon: IconButton(
filled: true, onPressed: () {
fillColor: context.color.surfaceContainerLow, setState(() {
border: OutlineInputBorder( _obscureText = !_obscureText;
borderRadius: BorderRadius.circular(16), });
borderSide: BorderSide.none, },
), icon: FaIcon(
floatingLabelBehavior: FloatingLabelBehavior.never, _obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
suffixIcon: IconButton( size: 16,
onPressed: () {
setState(() {
_obscureText = !_obscureText;
});
},
icon: FaIcon(
_obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
size: 16,
),
), ),
), ),
); );

View file

@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class ModifyAvatarView extends StatefulWidget { class ModifyAvatarView extends StatefulWidget {
const ModifyAvatarView({super.key}); const ModifyAvatarView({super.key});
@ -15,12 +16,19 @@ class ModifyAvatarView extends StatefulWidget {
} }
class _ModifyAvatarViewState extends State<ModifyAvatarView> { class _ModifyAvatarViewState extends State<ModifyAvatarView> {
final AvatarMakerController _avatarMakerController = late final _CustomAvatarMakerController _avatarMakerController;
PersistentAvatarMakerController(customizedPropertyCategories: []);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final svg = userService.currentUser.avatarSvg;
if (svg != null && svg.isNotEmpty) {
_avatarMakerController = _CustomAvatarMakerController(
svg: svg,
);
} else {
_avatarMakerController = _CustomAvatarMakerController.defaultAvatar();
}
} }
Future<void> updateUserAvatar(String json, String svg) async { Future<void> updateUserAvatar(String json, String svg) async {
@ -33,49 +41,38 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
} }
AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) { AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) {
if (isDarkMode(context)) { final colors = context.color;
return AvatarMakerThemeData( final isDark = isDarkMode(context);
boxDecoration: const BoxDecoration( return AvatarMakerThemeData(
boxShadow: [BoxShadow()], boxDecoration: BoxDecoration(
), color: colors.surface,
unselectedTileDecoration: BoxDecoration( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: const Color.fromARGB(255, 50, 50, 50), // Dark mode color boxShadow: [
borderRadius: BorderRadius.circular(10), BoxShadow(
), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
selectedTileDecoration: BoxDecoration( blurRadius: 10,
color: const Color.fromARGB(255, 100, 100, 100), // Dark mode color offset: const Offset(0, -5),
borderRadius: BorderRadius.circular(10), ),
), ],
selectedIconColor: Colors.white, ),
unselectedIconColor: Colors.grey, unselectedTileDecoration: BoxDecoration(
primaryBgColor: Colors.black, // Dark mode background color: colors.surfaceContainerHigh,
secondaryBgColor: Colors.grey[850], // Dark mode secondary background borderRadius: BorderRadius.circular(12),
labelTextStyle: const TextStyle( ),
color: Colors.white, selectedTileDecoration: BoxDecoration(
), // Light text for dark mode color: colors.primary.withValues(alpha: 0.15),
); border: Border.all(color: colors.primary, width: 2),
} else { borderRadius: BorderRadius.circular(12),
return AvatarMakerThemeData( ),
boxDecoration: const BoxDecoration( selectedIconColor: colors.primary,
boxShadow: [BoxShadow()], unselectedIconColor: colors.onSurfaceVariant.withValues(alpha: 0.6),
), primaryBgColor: colors.surface,
unselectedTileDecoration: BoxDecoration( secondaryBgColor: colors.surfaceContainerLow,
color: const Color.fromARGB(255, 240, 240, 240), // Light mode color labelTextStyle: TextStyle(
borderRadius: BorderRadius.circular(10), color: colors.onSurface,
), fontWeight: FontWeight.bold,
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
);
}
} }
Future<bool?> _showBackDialog() { Future<bool?> _showBackDialog() {
@ -148,39 +145,64 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
controller: _avatarMakerController, controller: _avatarMakerController,
), ),
), ),
SizedBox( Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 12),
mainAxisAlignment: MainAxisAlignment.center, child: Wrap(
spacing: 12,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [ children: [
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.floppyDisk), variant: MyButtonVariant.primaryDense,
onPressed: storeAvatarAndExit, onPressed: storeAvatarAndExit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.floppyDisk, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarSaveChangesStore),
],
),
), ),
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.shuffle), variant: MyButtonVariant.secondaryDense,
onPressed: onPressed:
_avatarMakerController.randomizedSelectedOptions, _avatarMakerController.randomizedSelectedOptions,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.shuffle, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeRandomize),
],
),
), ),
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.rotateLeft), variant: MyButtonVariant.secondaryDense,
onLongPress: () async {
await PersistentAvatarMakerController.clearAvatarMaker();
await _avatarMakerController.restoreState();
},
onPressed: _avatarMakerController.restoreState, 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(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 30, vertical: 30,
), ),
child: AvatarMakerCustomizer( child: AvatarMakerCustomizer(
scaffoldWidth: min( scaffoldWidth: min(
600, 600,
MediaQuery.of(context).size.width * 0.85, MediaQuery.of(context).size.width * 0.95,
), ),
theme: getAvatarMakerTheme(context), theme: getAvatarMakerTheme(context),
controller: _avatarMakerController, controller: _avatarMakerController,
@ -194,3 +216,72 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
); );
} }
} }
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<PropertyCategoryIds, PropertyItem> _initialOptions;
List<CustomizedPropertyCategory>? _customPropertyCategories;
void clearCustomizations() {
selectedOptions = Map.from(defaultSelectedOptions);
updatePreview();
}
@override
List<CustomizedPropertyCategory> 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<CustomizedPropertyCategory> 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<RestoredData> performRestore() async {
final restoredSvg = _initialSvg.isNotEmpty ? _initialSvg : drawAvatarSVG();
return RestoredData(
svg: restoredSvg,
options: Map.from(_initialOptions),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.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/components/snackbar.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.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'; import 'package:twonly/src/visual/views/groups/group.view.dart';
class ProfileView extends StatefulWidget { class ProfileView extends StatefulWidget {
@ -22,9 +23,6 @@ class ProfileView extends StatefulWidget {
} }
class _ProfileViewState extends State<ProfileView> { class _ProfileViewState extends State<ProfileView> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
int twonlyScore = 0; int twonlyScore = 0;
late StreamSubscription<int> twonlyScoreSub; late StreamSubscription<int> twonlyScoreSub;
@ -104,22 +102,24 @@ class _ProfileViewState extends State<ProfileView> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
children: <Widget>[ children: <Widget>[
const SizedBox(height: 25), const SizedBox(height: 25),
AvatarMakerAvatar( const AvatarIcon(
backgroundColor: Colors.transparent, fontSize: 70,
radius: 80, myAvatar: true,
controller: _avatarMakerController,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Center( Center(
child: SizedBox( child: MyButton(
height: 35, variant: MyButtonVariant.secondaryDense,
child: ElevatedButton.icon( onPressed: () async {
icon: const Icon(Icons.edit), await context.push(Routes.settingsProfileModifyAvatar);
label: Text(context.lang.settingsProfileCustomizeAvatar), },
onPressed: () async { child: Row(
await context.push(Routes.settingsProfileModifyAvatar); mainAxisSize: MainAxisSize.min,
await _avatarMakerController.performRestore(); children: [
}, const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
), ),
), ),
), ),