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;
}),
);
} else if (!userService.currentUser.skipSetupPages && userService.currentUser.currentSetupPage != null) {
} else if (!userService.currentUser.skipSetupPages &&
userService.currentUser.currentSetupPage != null) {
// This will only be shown in case the user have not skipped
child = SetupView(
onUpdate: () => setState(() {

View file

@ -101,7 +101,7 @@ abstract class AppLocalizations {
/// No description provided for @registerSlogan.
///
/// In en, this message translates to:
/// **'Stay in touch with friends privately and securely.'**
/// **'Stay in touch privately.'**
String get registerSlogan;
/// No description provided for @onboardingWelcomeTitle.
@ -173,7 +173,7 @@ abstract class AppLocalizations {
/// No description provided for @registerUsernameSlogan.
///
/// In en, this message translates to:
/// **'Your public username'**
/// **'Create your account'**
String get registerUsernameSlogan;
/// No description provided for @registerUsernameDecoration.
@ -1205,8 +1205,8 @@ abstract class AppLocalizations {
/// No description provided for @userFoundBody.
///
/// In en, this message translates to:
/// **'Do you want to create a follow request?'**
String get userFoundBody;
/// **'Do you want to connect with {username}?'**
String userFoundBody(String username);
/// No description provided for @errorInternalError.
///
@ -3689,6 +3689,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'**
String importGalleryImportCount(num count);
/// No description provided for @emptyChatListTitle.
///
/// In en, this message translates to:
/// **'Find your first friend'**
String get emptyChatListTitle;
/// No description provided for @emptyChatListDesc.
///
/// In en, this message translates to:
/// **'Let friends scan your QR code, or share them your profile.'**
String get emptyChatListDesc;
/// No description provided for @emptyChatListShareBtn.
///
/// In en, this message translates to:
/// **'Share your profile'**
String get emptyChatListShareBtn;
/// No description provided for @emptyChatListScanBtn.
///
/// In en, this message translates to:
/// **'QR Code'**
String get emptyChatListScanBtn;
/// No description provided for @emptyChatListAddUsernameBtn.
///
/// In en, this message translates to:
/// **'By Username'**
String get emptyChatListAddUsernameBtn;
/// No description provided for @avatarCustomizeRandomize.
///
/// In en, this message translates to:
/// **'Randomize'**
String get avatarCustomizeRandomize;
/// No description provided for @avatarCustomizeReset.
///
/// In en, this message translates to:
/// **'Reset'**
String get avatarCustomizeReset;
}
class _AppLocalizationsDelegate

View file

@ -9,8 +9,7 @@ class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get registerSlogan =>
'Privat und sicher mit Freunden in Kontakt bleiben.';
String get registerSlogan => 'Privat in Kontakt bleiben.';
@override
String get onboardingWelcomeTitle => 'Willkommen bei twonly!';
@ -52,7 +51,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingGetStartedTitle => 'Auf geht\'s';
@override
String get registerUsernameSlogan => 'Dein öffentlicher Benutzername';
String get registerUsernameSlogan => 'Konto erstellen';
@override
String get registerUsernameDecoration => 'Benutzername';
@ -611,7 +610,9 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get userFoundBody => 'Möchtest du eine Folgeanfrage stellen?';
String userFoundBody(String username) {
return 'Möchtest du dich mit $username vernetzen?';
}
@override
String get errorInternalError =>
@ -2127,4 +2128,26 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get emptyChatListTitle => 'Finde deinen ersten Freund';
@override
String get emptyChatListDesc =>
'Lass Freunde deinen QR-Code scannen oder teile dein Profil mit ihnen.';
@override
String get emptyChatListShareBtn => 'Profil teilen';
@override
String get emptyChatListScanBtn => 'QR-Code';
@override
String get emptyChatListAddUsernameBtn => 'Per Benutzername';
@override
String get avatarCustomizeRandomize => 'Zufällig';
@override
String get avatarCustomizeReset => 'Zurücksetzen';
}

View file

@ -9,8 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get registerSlogan =>
'Stay in touch with friends privately and securely.';
String get registerSlogan => 'Stay in touch privately.';
@override
String get onboardingWelcomeTitle => 'Welcome to twonly!';
@ -51,7 +50,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingGetStartedTitle => 'Let\'s go!';
@override
String get registerUsernameSlogan => 'Your public username';
String get registerUsernameSlogan => 'Create your account';
@override
String get registerUsernameDecoration => 'Username';
@ -606,7 +605,9 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get userFoundBody => 'Do you want to create a follow request?';
String userFoundBody(String username) {
return 'Do you want to connect with $username?';
}
@override
String get errorInternalError =>
@ -2109,4 +2110,26 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get emptyChatListTitle => 'Find your first friend';
@override
String get emptyChatListDesc =>
'Let friends scan your QR code, or share them your profile.';
@override
String get emptyChatListShareBtn => 'Share your profile';
@override
String get emptyChatListScanBtn => 'QR Code';
@override
String get emptyChatListAddUsernameBtn => 'By Username';
@override
String get avatarCustomizeRandomize => 'Randomize';
@override
String get avatarCustomizeReset => 'Reset';
}

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

View file

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

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/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/profile_qr_code.comp.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart';
@ -40,6 +41,7 @@ class _SearchUsernameView extends State<AddNewUserView> {
final TextEditingController _usernameController = TextEditingController();
bool _isLoading = false;
bool hasRequestedUsers = false;
String? _searchError;
List<Contact> _openRequestsContacts = [];
late StreamSubscription<List<Contact>> _contactsStream;
@ -63,20 +65,24 @@ class _SearchUsernameView extends State<AddNewUserView> {
},
);
_newAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchNewAnnouncedUsersWithRelations().listen((update) {
if (mounted) {
setState(() {
_newAnnouncedUsers = update;
_newAnnouncedUsersStream = twonlyDB.userDiscoveryDao
.watchNewAnnouncedUsersWithRelations()
.listen((update) {
if (mounted) {
setState(() {
_newAnnouncedUsers = update;
});
}
});
}
});
_allAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchAllAnnouncedUsersWithRelations().listen((update) {
if (mounted) {
setState(() {
_allAnnouncedUsers = update;
_allAnnouncedUsersStream = twonlyDB.userDiscoveryDao
.watchAllAnnouncedUsersWithRelations()
.listen((update) {
if (mounted) {
setState(() {
_allAnnouncedUsers = update;
});
}
});
}
});
if (widget.username != null) {
_usernameController.text = widget.username!;
@ -90,7 +96,8 @@ class _SearchUsernameView extends State<AddNewUserView> {
Future<void> _shareProfile() async {
final pubKey = await getUserPublicKey();
final params = ShareParams(
text: 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}',
text:
'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}',
);
await SharePlus.instance.share(params);
}
@ -164,18 +171,20 @@ class _SearchUsernameView extends State<AddNewUserView> {
});
if (userdata == null) {
await showAlertDialog(
context,
context.lang.searchUsernameNotFound,
context.lang.searchUsernameNotFoundBody(username),
);
setState(() {
_searchError = context.lang.searchUsernameNotFound;
});
return;
}
setState(() {
_searchError = null;
});
final addUser = await showAlertDialog(
context,
context.lang.userFound(username),
context.lang.userFoundBody,
context.lang.userFoundBody(username),
);
if (!addUser || !mounted) return;
@ -190,7 +199,9 @@ class _SearchUsernameView extends State<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(
context,
context.lang.linkFromUsername(username),
@ -218,72 +229,93 @@ class _SearchUsernameView extends State<AddNewUserView> {
child: ListView(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: SearchBar(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
child: MyInput(
controller: _usernameController,
hintText: context.lang.searchUsernameInput,
elevation: const WidgetStatePropertyAll(0),
backgroundColor: WidgetStatePropertyAll(
context.color.surfaceContainerHighest.withValues(alpha: 0.3),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 8),
),
leading: const Icon(Icons.search, size: 20, color: Colors.grey),
trailing: [
if (_usernameController.text.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_usernameController.clear();
setState(() {});
},
),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
),
)
else
IconButton(
icon: FaIcon(
FontAwesomeIcons.magnifyingGlassPlus,
size: 20,
color: context.color.primary,
),
onPressed: () => _requestNewUserByUsername(
_usernameController.text,
),
),
] else ...[
IconButton(
icon: FaIcon(
FontAwesomeIcons.camera,
size: 20,
color: context.color.primary,
),
onPressed: () => context.push(Routes.cameraQRScanner),
tooltip: context.lang.scanOtherProfile,
),
],
],
onSubmitted: _requestNewUserByUsername,
prefixIcon: const Icon(Icons.search, size: 20),
errorText: _searchError,
onChanged: (value) {
_usernameController.text = value.toLowerCase();
_usernameController.selection = TextSelection.fromPosition(
TextPosition(offset: _usernameController.text.length),
);
setState(() {});
setState(() {
_searchError = null;
});
},
onSubmitted: _requestNewUserByUsername,
suffixIcon: _usernameController.text.isNotEmpty
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
style: IconButton.styleFrom(
backgroundColor:
context.color.surfaceContainerHighest,
foregroundColor: context.color.onSurface,
minimumSize: const Size(32, 32),
padding: EdgeInsets.zero,
shape: const CircleBorder(),
),
iconSize: 16,
icon: const Icon(Icons.close),
onPressed: () {
_usernameController.clear();
setState(() {});
},
),
const SizedBox(width: 0),
if (_isLoading)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<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(
@ -291,63 +323,40 @@ class _SearchUsernameView extends State<AddNewUserView> {
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
child: Row(
children: [
Row(
children: [
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 10,
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _shareProfile,
icon: const FaIcon(
FontAwesomeIcons.shareNodes,
size: 14,
),
label: Text(
context.lang.shareYourProfile,
style: const TextStyle(fontSize: 13),
),
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: _shareProfile,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.shareNodes, size: 14),
const SizedBox(width: 8),
Text(
context.lang.shareYourProfile,
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: context.color.secondaryContainer,
foregroundColor: context.color.onSecondaryContainer,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 10,
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _showMyQrCode,
icon: const FaIcon(
FontAwesomeIcons.qrcode,
size: 14,
),
label: Text(
],
),
),
const SizedBox(width: 8),
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _showMyQrCode,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.qrcode, size: 14),
const SizedBox(width: 8),
Text(
context.lang.openYourOwnQRcode,
style: const TextStyle(fontSize: 13),
),
),
],
),
],
),
),
],
),

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:lottie/lottie.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class OnboardingView extends StatelessWidget {
const OnboardingView({required this.callbackOnSuccess, super.key});
@ -53,19 +54,6 @@ class OnboardingView extends StatelessWidget {
),
),
),
// PageViewModel(
// title: context.lang.onboardingSendTwonliesTitle,
// body: context.lang.onboardingSendTwonliesBody,
// image: Center(
// child: Padding(
// padding: const EdgeInsets.only(top: 100),
// child: Lottie.asset(
// 'assets/animations/twonlies.lottie',
// repeat: false,
// ),
// ),
// ),
// ),
PageViewModel(
title: context.lang.onboardingNotProductTitle,
bodyWidget: Column(
@ -81,7 +69,7 @@ class OnboardingView extends StatelessWidget {
right: 50,
top: 20,
),
child: FilledButton(
child: MyButton(
onPressed: callbackOnSuccess,
child: Text(context.lang.registerSubmitButton),
),
@ -97,17 +85,6 @@ class OnboardingView extends StatelessWidget {
),
),
),
// PageViewModel(
// title: context.lang.onboardingGetStartedTitle,
// image: Center(
// child: Padding(
// padding: const EdgeInsets.only(top: 100),
// child: Lottie.asset(
// 'assets/animations/rocket.lottie',
// ),
// ),
// ),
// ),
],
done: const Text(''),
next: Text(context.lang.next),

View file

@ -3,11 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key});
@ -64,180 +63,192 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
});
}
@override
Widget build(BuildContext context) {
void _showBackupExplanation(BuildContext context) {
final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100];
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = isDark ? Colors.white : Colors.black87;
final subtitleColor = isDark ? Colors.white70 : Colors.black54;
return OnboardingWrapper(
children: [
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
),
color: Colors.white,
iconSize: 20,
),
const Spacer(),
IconButton(
onPressed: () async {
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: Colors.white,
iconSize: 20,
),
],
showModalBottomSheet<void>(
context: context,
backgroundColor: backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
const SizedBox(height: 20),
const Center(
),
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: EdgeInsets.all(20),
child: LinkLogoAnimation(),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.twonlySafeRecoverTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 48),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: usernameCtrl,
onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.alternate_email,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordCtrl,
onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
obscureText: obscureText,
decoration: InputDecoration(
hintText: context.lang.password,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.lock_outline_rounded,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureText = !obscureText;
});
},
icon: FaIcon(
obscureText
? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash,
size: 16,
color: isDark ? Colors.grey[400] : Colors.grey[600],
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.black12,
borderRadius: BorderRadius.circular(2.5),
),
),
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
const SizedBox(height: 24),
Text(
'twonly Backup',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: textColor,
),
elevation: 0,
textAlign: TextAlign.center,
),
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white),
strokeWidth: 3,
const SizedBox(height: 16),
Text(
context.lang.backupTwonlySafeLongDesc,
style: TextStyle(
fontSize: 16,
height: 1.5,
color: subtitleColor,
),
),
const SizedBox(height: 32),
MyButton(
onPressed: () => Navigator.pop(context),
child: const Text('Got it'),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final isDark = isDarkMode(context);
final titleColor = isDark ? Colors.white : Colors.black87;
final iconColor = isDark ? Colors.white70 : Colors.black54;
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
behavior: HitTestBehavior.opaque,
child: Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
),
color: iconColor,
iconSize: 20,
),
const Spacer(),
IconButton(
onPressed: () => _showBackupExplanation(context),
icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: iconColor,
iconSize: 20,
),
],
),
)
: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
const SizedBox(height: 20),
Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: LinkLogoAnimation(
color: isDark ? Colors.white : Colors.black,
),
),
),
),
),
],
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.twonlySafeRecoverTitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: titleColor,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 48),
MyInput(
controller: usernameCtrl,
onChanged: (value) => setState(() {}),
hintText: context.lang.registerUsernameDecoration,
prefixIcon: const Icon(Icons.alternate_email),
),
const SizedBox(height: 16),
MyInput(
controller: passwordCtrl,
onChanged: (value) => setState(() {}),
obscureText: obscureText,
hintText: context.lang.password,
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureText = !obscureText;
});
},
icon: FaIcon(
obscureText
? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash,
size: 16,
),
),
),
const SizedBox(height: 32),
MyButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3,
),
)
: Text(context.lang.twonlySafeRecoverBtn),
),
const Spacer(),
const SizedBox(height: 40),
],
),
),
),
);
},
),
),
const Spacer(),
const SizedBox(height: 40),
],
),
);
}
}

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/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class RegisterView extends StatefulWidget {
@ -43,8 +43,9 @@ class _RegisterViewState extends State<RegisterView> {
bool _registrationDisabled = false;
bool _isTryingToRegister = false;
bool _isValidUserName = false;
bool _showUserNameError = false;
bool _showProofOfWorkError = false;
String? _usernameErrorText;
late Future<int>? proofOfWork;
@ -58,7 +59,7 @@ class _RegisterViewState extends State<RegisterView> {
Future<void> createNewUser() async {
if (!_isValidUserName) {
setState(() {
_showUserNameError = true;
_usernameErrorText = context.lang.registerUsernameLimits;
});
return;
}
@ -67,278 +68,271 @@ class _RegisterViewState extends State<RegisterView> {
setState(() {
_isTryingToRegister = true;
_showUserNameError = false;
_usernameErrorText = null;
_showProofOfWorkError = false;
});
late int proof;
try {
late int proof;
if (proofOfWork != null) {
proof = await proofOfWork!;
} else {
final (pow, registrationDisabled) = await apiService.getProofOfWork();
if (pow == null) {
_registrationDisabled = registrationDisabled;
if (proofOfWork != null) {
proof = await proofOfWork!;
} else {
final (pow, registrationDisabled) = await apiService.getProofOfWork();
if (pow == null) {
setState(() {
_registrationDisabled = registrationDisabled;
_isTryingToRegister = false;
});
if (mounted) {
showNetworkIssue(context);
}
return;
}
proof = await calculatePoW(pow.prefix, pow.difficulty.toInt());
}
Log.info('The result of the POW is $proof');
await createIfNotExistsSignalIdentity();
var userId = 0;
final res = await apiService.register(username, inviteCode, proof);
if (res.isSuccess) {
Log.info('Got user_id ${res.value} from server');
userId = res.value.userid.toInt() as int;
} else {
proofOfWork = null;
if (res.error == ErrorCode.RegistrationDisabled) {
setState(() {
_registrationDisabled = true;
_isTryingToRegister = false;
});
return;
}
if (res.error == ErrorCode.UserIdAlreadyTaken) {
Log.error('User ID already token. Tying again.');
await deleteLocalUserData();
return createNewUser();
}
if (res.error == ErrorCode.UsernameAlreadyTaken ||
res.error == ErrorCode.UsernameNotValid) {
setState(() {
_usernameErrorText = errorCodeToText(
context,
res.error as ErrorCode,
);
_isTryingToRegister = false;
});
return;
}
if (res.error == ErrorCode.InvalidProofOfWork) {
await deleteLocalUserData();
setState(() {
_showProofOfWorkError = true;
_isTryingToRegister = false;
});
return;
}
if (mounted) {
showNetworkIssue(context);
setState(() {
_isTryingToRegister = false;
});
await showAlertDialog(
context,
'Oh no!',
errorCodeToText(context, res.error as ErrorCode),
);
}
return;
// Starting with the proof of work.
}
proof = await calculatePoW(pow.prefix, pow.difficulty.toInt());
}
Log.info('The result of the POW is $proof');
setState(() {
_isTryingToRegister = false;
});
await createIfNotExistsSignalIdentity();
final userData = UserData(
userId: userId,
username: username,
displayName: username,
subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name,
appVersion: AppState.latestAppVersionId,
);
var userId = 0;
await UserService.save(userData);
final res = await apiService.register(username, inviteCode, proof);
if (res.isSuccess) {
Log.info('Got user_id ${res.value} from server');
userId = res.value.userid.toInt() as int;
} else {
proofOfWork = null;
if (res.error == ErrorCode.RegistrationDisabled) {
_registrationDisabled = true;
return;
}
if (res.error == ErrorCode.UserIdAlreadyTaken) {
Log.error('User ID already token. Tying again.');
await deleteLocalUserData();
return createNewUser();
}
if (res.error == ErrorCode.InvalidProofOfWork) {
await deleteLocalUserData();
setState(() {
_showProofOfWorkError = true;
_isTryingToRegister = false;
});
return;
}
await apiService.authenticate();
widget.callbackOnSuccess();
} catch (e, stack) {
Log.error('Error creating new user', e, stack);
if (mounted) {
setState(() {
_isTryingToRegister = false;
});
await showAlertDialog(
context,
'Oh no!',
errorCodeToText(context, res.error as ErrorCode),
'Error',
e.toString(),
);
}
return;
}
setState(() {
_isTryingToRegister = false;
});
final userData = UserData(
userId: userId,
username: username,
displayName: username,
subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name,
appVersion: AppState.latestAppVersionId,
);
await UserService.save(userData);
await apiService.authenticate();
widget.callbackOnSuccess();
}
@override
Widget build(BuildContext context) {
final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100];
final sloganColor = isDark ? Colors.white.withValues(alpha: 0.9) : Colors.grey[800];
final secondaryButtonColor = isDark ? Colors.grey[400] : Colors.grey[600];
return OnboardingWrapper(
children: [
const SizedBox(height: 30),
Center(
child: Container(
padding: const EdgeInsets.all(10),
child: const LinkLogoAnimation(),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.registerSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 30),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_registrationDisabled) ...[
const SizedBox(height: 24),
Text(
context.lang.registrationClosed,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: Colors.red,
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
behavior: HitTestBehavior.opaque,
child: Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
),
const SizedBox(height: 48),
] else ...[
Text(
context.lang.registerUsernameSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: sloganColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
TextField(
controller: usernameController,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection = TextSelection.fromPosition(
TextPosition(
offset: usernameController.text.length,
),
);
setState(() {
_isValidUserName = usernameController.text.length >= 3;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(
RegExp('[a-z0-9A-Z._]'),
),
],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.alternate_email,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
),
if (_showUserNameError && usernameController.text.length < 3) ...[
const SizedBox(height: 8),
Text(
context.lang.registerUsernameLimits,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
if (_showProofOfWorkError) ...[
const SizedBox(height: 8),
Text(
context.lang.registerProofOfWorkFailed,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isTryingToRegister ? null : createNewUser,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: _isTryingToRegister
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white),
strokeWidth: 3,
),
)
: Text(
context.lang.registerSubmitButton,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 30),
Center(
child: Container(
padding: const EdgeInsets.all(10),
child: LinkLogoAnimation(
color: isDark ? Colors.white : Colors.black,
),
),
),
),
const SizedBox(height: 16),
],
TextButton(
onPressed: () => context.push(
Routes.settingsBackupRecovery,
),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: secondaryButtonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.registerSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color:
Theme.of(context).textTheme.bodyMedium?.color
?.withValues(alpha: 0.7) ??
(isDark
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.7)),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 40),
if (_registrationDisabled) ...[
Text(
context.lang.registrationClosed,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 40),
] else ...[
Text(
context.lang.registerUsernameSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
color: isDark ? Colors.white : Colors.black87,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 24),
MyInput(
controller: usernameController,
errorText: _usernameErrorText,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection =
TextSelection.fromPosition(
TextPosition(
offset: usernameController.text.length,
),
);
setState(() {
_isValidUserName =
usernameController.text.length >= 3;
_usernameErrorText = null;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(
RegExp('[a-z0-9A-Z._]'),
),
],
hintText: context.lang.registerUsernameDecoration,
prefixIcon: const Icon(Icons.alternate_email),
),
if (_showProofOfWorkError) ...[
const SizedBox(height: 10),
Text(
context.lang.registerProofOfWorkFailed,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
MyButton(
onPressed: _isTryingToRegister
? null
: createNewUser,
child: _isTryingToRegister
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3,
),
)
: Text(
context.lang.registerSubmitButton,
),
),
const SizedBox(height: 20),
],
MyButton(
onPressed: () => context.push(
Routes.settingsBackupRecovery,
),
variant: MyButtonVariant.secondary,
child: Text(
context.lang.twonlySafeRecoverBtn,
),
),
const Spacer(),
const SizedBox(height: 40),
],
),
),
),
child: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
);
},
),
),
const Spacer(),
const SizedBox(height: 40),
],
),
);
}
}

View file

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

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class FinishSetupComp extends StatefulWidget {
@ -123,29 +124,19 @@ class _FinishSetupCompState extends State<FinishSetupComp> {
),
),
const SizedBox(height: 14),
FilledButton.icon(
MyButton(
onPressed: onTap,
icon: const Icon(
Icons.arrow_forward_rounded,
size: 18,
),
label: Text(
context.lang.finishSetupCardAction,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
style: FilledButton.styleFrom(
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
variant: MyButtonVariant.primaryDense,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.arrow_forward_rounded,
size: 18,
),
const SizedBox(width: 8),
Text(context.lang.finishSetupCardAction),
],
),
),
],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class NextButtonComp extends StatelessWidget {
@ -24,7 +25,7 @@ class NextButtonComp extends StatelessWidget {
final currentPage = SetupPagesExtension.fromStr(
userService.currentUser.currentSetupPage,
);
return ElevatedButton(
return MyButton(
onPressed: (canSubmit && !isLoading)
? () async {
if (onPressed != null) {
@ -36,15 +37,6 @@ class NextButtonComp extends StatelessWidget {
});
}
: null,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: isLoading
? const SizedBox(
height: 24,
@ -56,7 +48,6 @@ class NextButtonComp extends StatelessWidget {
)
: Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
);
},

View file

@ -1,13 +1,14 @@
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'
show AvatarIcon;
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart';
import 'package:vector_graphics/vector_graphics.dart';
class ProfileSetupPage extends StatefulWidget {
const ProfileSetupPage({super.key});
@ -17,10 +18,7 @@ class ProfileSetupPage extends StatefulWidget {
}
class _ProfileSetupPageState extends State<ProfileSetupPage> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
late final TextEditingController _displayNameController;
@override
void initState() {
super.initState();
@ -35,95 +33,82 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.lang.onboardingProfileTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
context.lang.onboardingProfileBody,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 40),
StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: context.color.primary.withValues(alpha: 0.2),
width: 4,
),
return StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.lang.onboardingProfileTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
child: userService.currentUser.avatarSvg == null
? ClipRRect(
borderRadius: BorderRadius.circular(80),
child: Container(
width: 160,
height: 160,
color: context.color.surfaceContainer,
child: const SvgPicture(
AssetBytesLoader(
'assets/images/default_avatar.svg.vec',
),
),
),
)
: AvatarMakerAvatar(
backgroundColor: context.color.surfaceContainer,
radius: 80,
controller: _avatarMakerController,
),
);
},
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
await _avatarMakerController.performRestore();
},
icon: const Icon(Icons.palette_outlined),
label: Text(context.lang.settingsProfileCustomizeAvatar),
),
const SizedBox(height: 30),
TextField(
controller: _displayNameController,
decoration: InputDecoration(
labelText: context.lang.settingsProfileEditDisplayName,
hintText: context.lang.settingsProfileEditDisplayNameNew,
prefixIcon: const Icon(Icons.person_outline),
filled: true,
fillColor: context.color.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
const SizedBox(height: 40),
NextButtonComp(
onPressed: () async {
await UserService.update((user) {
if (_displayNameController.text.isNotEmpty) {
user.displayName = _displayNameController.text;
}
});
return false;
},
),
],
const SizedBox(height: 8),
Text(
context.lang.onboardingProfileBody,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 40),
StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: context.color.primary.withValues(alpha: 0.2),
width: 4,
),
),
child: const AvatarIcon(
fontSize: 70,
myAvatar: true,
),
);
},
),
const SizedBox(height: 16),
MyButton(
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
},
variant: MyButtonVariant.text,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.palette_outlined),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
),
),
const SizedBox(height: 30),
MyInput(
controller: _displayNameController,
hintText: context.lang.settingsProfileEditDisplayNameNew,
prefixIcon: const Icon(Icons.person_outline),
),
const SizedBox(height: 40),
NextButtonComp(
onPressed: () async {
await UserService.update((user) {
if (_displayNameController.text.isNotEmpty) {
user.displayName = _displayNameController.text;
}
});
return false;
},
),
],
);
},
);
}
}

View file

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

View file

@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class ModifyAvatarView extends StatefulWidget {
const ModifyAvatarView({super.key});
@ -15,12 +16,19 @@ class ModifyAvatarView extends StatefulWidget {
}
class _ModifyAvatarViewState extends State<ModifyAvatarView> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
late final _CustomAvatarMakerController _avatarMakerController;
@override
void initState() {
super.initState();
final svg = userService.currentUser.avatarSvg;
if (svg != null && svg.isNotEmpty) {
_avatarMakerController = _CustomAvatarMakerController(
svg: svg,
);
} else {
_avatarMakerController = _CustomAvatarMakerController.defaultAvatar();
}
}
Future<void> updateUserAvatar(String json, String svg) async {
@ -33,49 +41,38 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
}
AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) {
if (isDarkMode(context)) {
return AvatarMakerThemeData(
boxDecoration: const BoxDecoration(
boxShadow: [BoxShadow()],
),
unselectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 50, 50, 50), // Dark mode color
borderRadius: BorderRadius.circular(10),
),
selectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 100, 100, 100), // Dark mode color
borderRadius: BorderRadius.circular(10),
),
selectedIconColor: Colors.white,
unselectedIconColor: Colors.grey,
primaryBgColor: Colors.black, // Dark mode background
secondaryBgColor: Colors.grey[850], // Dark mode secondary background
labelTextStyle: const TextStyle(
color: Colors.white,
), // Light text for dark mode
);
} else {
return AvatarMakerThemeData(
boxDecoration: const BoxDecoration(
boxShadow: [BoxShadow()],
),
unselectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 240, 240, 240), // Light mode color
borderRadius: BorderRadius.circular(10),
),
selectedTileDecoration: BoxDecoration(
color: const Color.fromARGB(255, 200, 200, 200), // Light mode color
borderRadius: BorderRadius.circular(10),
),
selectedIconColor: Colors.black,
unselectedIconColor: Colors.grey,
primaryBgColor: Colors.white, // Light mode background
secondaryBgColor: Colors.grey[200], // Light mode secondary background
labelTextStyle: const TextStyle(
color: Colors.black,
), // Dark text for light mode
);
}
final colors = context.color;
final isDark = isDarkMode(context);
return AvatarMakerThemeData(
boxDecoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
unselectedTileDecoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
selectedTileDecoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.15),
border: Border.all(color: colors.primary, width: 2),
borderRadius: BorderRadius.circular(12),
),
selectedIconColor: colors.primary,
unselectedIconColor: colors.onSurfaceVariant.withValues(alpha: 0.6),
primaryBgColor: colors.surface,
secondaryBgColor: colors.surfaceContainerLow,
labelTextStyle: TextStyle(
color: colors.onSurface,
fontWeight: FontWeight.bold,
),
);
}
Future<bool?> _showBackDialog() {
@ -148,39 +145,64 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
controller: _avatarMakerController,
),
),
SizedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 12,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.floppyDisk),
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: storeAvatarAndExit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.floppyDisk, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarSaveChangesStore),
],
),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.shuffle),
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed:
_avatarMakerController.randomizedSelectedOptions,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.shuffle, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeRandomize),
],
),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.rotateLeft),
onLongPress: () async {
await PersistentAvatarMakerController.clearAvatarMaker();
await _avatarMakerController.restoreState();
},
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _avatarMakerController.restoreState,
onLongPress: () {
_avatarMakerController.clearCustomizations();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.rotateLeft, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeReset),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 30,
),
child: AvatarMakerCustomizer(
scaffoldWidth: min(
600,
MediaQuery.of(context).size.width * 0.85,
MediaQuery.of(context).size.width * 0.95,
),
theme: getAvatarMakerTheme(context),
controller: _avatarMakerController,
@ -194,3 +216,72 @@ class _ModifyAvatarViewState extends State<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 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@ -10,8 +9,10 @@ import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart';
class ProfileView extends StatefulWidget {
@ -22,9 +23,6 @@ class ProfileView extends StatefulWidget {
}
class _ProfileViewState extends State<ProfileView> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
int twonlyScore = 0;
late StreamSubscription<int> twonlyScoreSub;
@ -104,22 +102,24 @@ class _ProfileViewState extends State<ProfileView> {
physics: const BouncingScrollPhysics(),
children: <Widget>[
const SizedBox(height: 25),
AvatarMakerAvatar(
backgroundColor: Colors.transparent,
radius: 80,
controller: _avatarMakerController,
const AvatarIcon(
fontSize: 70,
myAvatar: true,
),
const SizedBox(height: 10),
Center(
child: SizedBox(
height: 35,
child: ElevatedButton.icon(
icon: const Icon(Icons.edit),
label: Text(context.lang.settingsProfileCustomizeAvatar),
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
await _avatarMakerController.performRestore();
},
child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
),
),
),