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),
const SizedBox(width: 8),
Text(
context.lang.emptyChatListShareBtn,
style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, 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),
icon: const Icon(Icons.qr_code_scanner_rounded, size: 20), child: Row(
label: const FittedBox( mainAxisSize: MainAxisSize.min,
fit: BoxFit.scaleDown, children: [
child: Text( const Icon(Icons.qr_code_scanner_rounded, size: 20),
'Scan QR Code', const SizedBox(width: 8),
style: TextStyle( Text(
context.lang.emptyChatListScanBtn,
style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, 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(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
),
),
], ],
), ),
),
const SizedBox(width: 12),
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), 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,14 +65,18 @@ class _SearchUsernameView extends State<AddNewUserView> {
}, },
); );
_newAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchNewAnnouncedUsersWithRelations().listen((update) { _newAnnouncedUsersStream = twonlyDB.userDiscoveryDao
.watchNewAnnouncedUsersWithRelations()
.listen((update) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_newAnnouncedUsers = update; _newAnnouncedUsers = update;
}); });
} }
}); });
_allAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchAllAnnouncedUsersWithRelations().listen((update) { _allAnnouncedUsersStream = twonlyDB.userDiscoveryDao
.watchAllAnnouncedUsersWithRelations()
.listen((update) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_allAnnouncedUsers = update; _allAnnouncedUsers = update;
@ -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,137 +229,135 @@ 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(() {
_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(() {}); 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(
height: 10, height: 10,
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column( child: Row(
children: [
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: _shareProfile,
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( const FaIcon(FontAwesomeIcons.shareNodes, size: 14),
children: [ const SizedBox(width: 8),
Expanded( Text(
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, context.lang.shareYourProfile,
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: FilledButton.icon( child: MyButton(
style: FilledButton.styleFrom( variant: MyButtonVariant.secondaryDense,
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, onPressed: _showMyQrCode,
icon: const FaIcon( child: Row(
FontAwesomeIcons.qrcode, mainAxisSize: MainAxisSize.min,
size: 14, children: [
), const FaIcon(FontAwesomeIcons.qrcode, size: 14),
label: Text( const SizedBox(width: 8),
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,13 +63,94 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
}); });
} }
void _showBackupExplanation(BuildContext context) {
final isDark = isDarkMode(context);
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = isDark ? Colors.white : Colors.black87;
final subtitleColor = isDark ? Colors.white70 : Colors.black54;
showModalBottomSheet<void>(
context: context,
backgroundColor: backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
),
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.black12,
borderRadius: BorderRadius.circular(2.5),
),
),
),
const SizedBox(height: 24),
Text(
'twonly Backup',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
context.lang.backupTwonlySafeLongDesc,
style: TextStyle(
fontSize: 16,
height: 1.5,
color: subtitleColor,
),
),
const SizedBox(height: 32),
MyButton(
onPressed: () => Navigator.pop(context),
child: const Text('Got it'),
),
],
),
),
);
},
);
}
@override @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 titleColor = isDark ? Colors.white : Colors.black87;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100]; final iconColor = isDark ? Colors.white70 : Colors.black54;
return OnboardingWrapper( 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: [ children: [
Row( Row(
children: [ children: [
@ -79,29 +159,25 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
icon: const Icon( icon: const Icon(
Icons.arrow_back_ios_new_rounded, Icons.arrow_back_ios_new_rounded,
), ),
color: Colors.white, color: iconColor,
iconSize: 20, iconSize: 20,
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
onPressed: () async { onPressed: () => _showBackupExplanation(context),
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo), icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: Colors.white, color: iconColor,
iconSize: 20, iconSize: 20,
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Center( Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: LinkLogoAnimation(), child: LinkLogoAnimation(
color: isDark ? Colors.white : Colors.black,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -110,83 +186,28 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
child: Text( child: Text(
context.lang.twonlySafeRecoverTitle, context.lang.twonlySafeRecoverTitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: titleColor,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
Container( MyInput(
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, controller: usernameCtrl,
onChanged: (value) => setState(() {}), onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration, hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle( prefixIcon: const Icon(Icons.alternate_email),
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), const SizedBox(height: 16),
TextField( MyInput(
controller: passwordCtrl, controller: passwordCtrl,
onChanged: (value) => setState(() {}), onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
obscureText: obscureText, obscureText: obscureText,
decoration: InputDecoration(
hintText: context.lang.password, hintText: context.lang.password,
hintStyle: TextStyle( prefixIcon: const Icon(Icons.lock_outline_rounded),
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( suffixIcon: IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -198,46 +219,36 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
? FontAwesomeIcons.eye ? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash, : FontAwesomeIcons.eyeSlash,
size: 16, size: 16,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
), ),
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
FilledButton( MyButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null, onPressed: (!isLoading) ? _recoverTwonlySafe : null,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: isLoading child: isLoading
? const SizedBox( ? const SizedBox(
height: 24, height: 24,
width: 24, width: 24,
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white), valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3, strokeWidth: 3,
), ),
) )
: Text( : Text(context.lang.twonlySafeRecoverBtn),
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
), ),
const Spacer(), const Spacer(),
const SizedBox(height: 40), 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,10 +68,11 @@ class _RegisterViewState extends State<RegisterView> {
setState(() { setState(() {
_isTryingToRegister = true; _isTryingToRegister = true;
_showUserNameError = false; _usernameErrorText = null;
_showProofOfWorkError = false; _showProofOfWorkError = false;
}); });
try {
late int proof; late int proof;
if (proofOfWork != null) { if (proofOfWork != null) {
@ -78,12 +80,14 @@ class _RegisterViewState extends State<RegisterView> {
} else { } else {
final (pow, registrationDisabled) = await apiService.getProofOfWork(); final (pow, registrationDisabled) = await apiService.getProofOfWork();
if (pow == null) { if (pow == null) {
setState(() {
_registrationDisabled = registrationDisabled; _registrationDisabled = registrationDisabled;
_isTryingToRegister = false;
});
if (mounted) { if (mounted) {
showNetworkIssue(context); showNetworkIssue(context);
} }
return; return;
// Starting with the proof of work.
} }
proof = await calculatePoW(pow.prefix, pow.difficulty.toInt()); proof = await calculatePoW(pow.prefix, pow.difficulty.toInt());
} }
@ -101,7 +105,10 @@ class _RegisterViewState extends State<RegisterView> {
} else { } else {
proofOfWork = null; proofOfWork = null;
if (res.error == ErrorCode.RegistrationDisabled) { if (res.error == ErrorCode.RegistrationDisabled) {
setState(() {
_registrationDisabled = true; _registrationDisabled = true;
_isTryingToRegister = false;
});
return; return;
} }
if (res.error == ErrorCode.UserIdAlreadyTaken) { if (res.error == ErrorCode.UserIdAlreadyTaken) {
@ -109,6 +116,17 @@ class _RegisterViewState extends State<RegisterView> {
await deleteLocalUserData(); await deleteLocalUserData();
return createNewUser(); 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) { if (res.error == ErrorCode.InvalidProofOfWork) {
await deleteLocalUserData(); await deleteLocalUserData();
setState(() { setState(() {
@ -147,23 +165,50 @@ class _RegisterViewState extends State<RegisterView> {
await apiService.authenticate(); await apiService.authenticate();
widget.callbackOnSuccess(); widget.callbackOnSuccess();
} catch (e, stack) {
Log.error('Error creating new user', e, stack);
if (mounted) {
setState(() {
_isTryingToRegister = false;
});
await showAlertDialog(
context,
'Error',
e.toString(),
);
}
}
} }
@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(
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: [ children: [
const SizedBox(height: 30), const SizedBox(height: 30),
Center( Center(
child: Container( child: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: const LinkLogoAnimation(), child: LinkLogoAnimation(
color: isDark ? Colors.white : Colors.black,
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -174,61 +219,55 @@ class _RegisterViewState extends State<RegisterView> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.white.withValues(alpha: 0.9), 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, fontWeight: FontWeight.w500,
), ),
), ),
), ),
const SizedBox(height: 30), const SizedBox(height: 40),
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) ...[ if (_registrationDisabled) ...[
const SizedBox(height: 24),
Text( Text(
context.lang.registrationClosed, context.lang.registrationClosed,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.red, color: Colors.redAccent,
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 48), const SizedBox(height: 40),
] else ...[ ] else ...[
Text( Text(
context.lang.registerUsernameSlogan, context.lang.registerUsernameSlogan,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 22,
color: sloganColor, color: isDark ? Colors.white : Colors.black87,
fontWeight: FontWeight.w600, fontWeight: FontWeight.bold,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 24),
TextField( MyInput(
controller: usernameController, controller: usernameController,
errorText: _usernameErrorText,
onChanged: (value) { onChanged: (value) {
usernameController.text = value.toLowerCase(); usernameController.text = value.toLowerCase();
usernameController.selection = TextSelection.fromPosition( usernameController.selection =
TextSelection.fromPosition(
TextPosition( TextPosition(
offset: usernameController.text.length, offset: usernameController.text.length,
), ),
); );
setState(() { setState(() {
_isValidUserName = usernameController.text.length >= 3; _isValidUserName =
usernameController.text.length >= 3;
_usernameErrorText = null;
}); });
}, },
inputFormatters: [ inputFormatters: [
@ -237,108 +276,63 @@ class _RegisterViewState extends State<RegisterView> {
RegExp('[a-z0-9A-Z._]'), RegExp('[a-z0-9A-Z._]'),
), ),
], ],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration, hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle( prefixIcon: const Icon(Icons.alternate_email),
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) ...[ if (_showProofOfWorkError) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
Text( Text(
context.lang.registerProofOfWorkFailed, context.lang.registerProofOfWorkFailed,
style: const TextStyle( style: const TextStyle(
color: Colors.red, color: Colors.redAccent,
fontSize: 13, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 32),
FilledButton( MyButton(
onPressed: _isTryingToRegister ? null : createNewUser, onPressed: _isTryingToRegister
style: FilledButton.styleFrom( ? null
backgroundColor: primaryColor, : createNewUser,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: _isTryingToRegister child: _isTryingToRegister
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white), valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3, strokeWidth: 3,
), ),
) )
: Text( : Text(
context.lang.registerSubmitButton, context.lang.registerSubmitButton,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
), ),
), ),
), const SizedBox(height: 20),
const SizedBox(height: 16),
], ],
TextButton( MyButton(
onPressed: () => context.push( onPressed: () => context.push(
Routes.settingsBackupRecovery, Routes.settingsBackupRecovery,
), ),
style: TextButton.styleFrom( variant: MyButtonVariant.secondary,
minimumSize: const Size.fromHeight(50),
foregroundColor: secondaryButtonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text( child: Text(
context.lang.twonlySafeRecoverBtn, context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
), ),
), ),
const Spacer(), const Spacer(),
const SizedBox(height: 40), 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)
if (currentPage.index > 0 && !currentPage.isLast) const SizedBox(width: 24), 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,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.arrow_forward_rounded, Icons.arrow_forward_rounded,
size: 18, size: 18,
), ),
label: Text( const SizedBox(width: 8),
context.lang.finishSetupCardAction, 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,
), ),
), ),
], ],

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,6 +33,9 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -65,52 +66,34 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
width: 4, width: 4,
), ),
), ),
child: userService.currentUser.avatarSvg == null child: const AvatarIcon(
? ClipRRect( fontSize: 70,
borderRadius: BorderRadius.circular(80), myAvatar: true,
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), const SizedBox(height: 16),
TextButton.icon( MyButton(
onPressed: () async { onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar); await context.push(Routes.settingsProfileModifyAvatar);
await _avatarMakerController.performRestore();
}, },
icon: const Icon(Icons.palette_outlined), variant: MyButtonVariant.text,
label: Text(context.lang.settingsProfileCustomizeAvatar), 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), const SizedBox(height: 30),
TextField( MyInput(
controller: _displayNameController, controller: _displayNameController,
decoration: InputDecoration(
labelText: context.lang.settingsProfileEditDisplayName,
hintText: context.lang.settingsProfileEditDisplayNameNew, hintText: context.lang.settingsProfileEditDisplayNameNew,
prefixIcon: const Icon(Icons.person_outline), 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), const SizedBox(height: 40),
NextButtonComp( NextButtonComp(
@ -125,5 +108,7 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
), ),
], ],
); );
},
);
} }
} }

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,19 +46,11 @@ 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,
filled: true,
fillColor: context.color.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
suffixIcon: IconButton( suffixIcon: IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -70,7 +62,6 @@ class _BackupPasswordTextFieldState extends State<BackupPasswordTextField> {
size: 16, 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;
final isDark = isDarkMode(context);
return AvatarMakerThemeData( return AvatarMakerThemeData(
boxDecoration: const BoxDecoration( boxDecoration: BoxDecoration(
boxShadow: [BoxShadow()], 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( unselectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 50, 50, 50), // Dark mode color color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
selectedTileDecoration: BoxDecoration( selectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 100, 100, 100), // Dark mode color color: colors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), 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,
), ),
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
);
}
} }
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( ),
icon: const FaIcon(FontAwesomeIcons.shuffle), MyButton(
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( ),
icon: const FaIcon(FontAwesomeIcons.rotateLeft), MyButton(
onLongPress: () async { variant: MyButtonVariant.secondaryDense,
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(
icon: const Icon(Icons.edit),
label: Text(context.lang.settingsProfileCustomizeAvatar),
onPressed: () async { onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar); await context.push(Routes.settingsProfileModifyAvatar);
await _avatarMakerController.performRestore();
}, },
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
), ),
), ),
), ),