diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 9c5a65a..88cde47 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -225,6 +225,20 @@ class RequestCodeResult { const RequestCodeResult({required this.token}); } +class PresetAvatar { + final int id; + final String url; + + const PresetAvatar({required this.id, required this.url}); +} + +class PresetAvatarCategory { + final String name; + final List avatars; + + const PresetAvatarCategory({required this.name, required this.avatars}); +} + class VerifyCodeResult { final Map payload; @@ -234,6 +248,37 @@ class VerifyCodeResult { String? get registerToken => _nestedToken('REGISTER'); + bool get isRegistration => registerToken != null && loginToken == null; + + List get presetAvatars { + final raw = payload['presetAvatars']; + if (raw is! List) return const []; + final categories = []; + for (final cat in raw) { + if (cat is! Map) continue; + final avatarsRaw = cat['avatars']; + if (avatarsRaw is! List) continue; + final avatars = []; + for (final a in avatarsRaw) { + if (a is! Map) continue; + final id = a['id']; + final url = a['url']; + if (id is int && url is String && url.isNotEmpty) { + avatars.add(PresetAvatar(id: id, url: url)); + } + } + if (avatars.isNotEmpty) { + categories.add( + PresetAvatarCategory( + name: cat['name']?.toString() ?? '', + avatars: avatars, + ), + ); + } + } + return categories; + } + bool get requiresPassword => payload['passwordChallenge'] != null; Map? get passwordChallenge { @@ -850,6 +895,61 @@ class AccountModule { return result; } + Future completeRegistration({ + required String token, + required String firstName, + String? lastName, + int? photoId, + }) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'tokenType': AuthRequestType.register.value, + 'firstName': firstName, + }; + if (lastName != null && lastName.isNotEmpty) { + payload['lastName'] = lastName; + } + if (photoId != null) { + payload['photoId'] = photoId; + payload['avatarType'] = 'PRESET_AVATAR'; + } + + logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})'); + + final packet = await _api.sendRequest(Opcode.authConfirm, payload); + + _checkPacketError(packet, 'completeRegistration'); + + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'completeRegistration: неожиданный тип payload: ${data.runtimeType}', + ); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('completeRegistration: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('completeRegistration: отсутствует profile.contact'); + } + final accountId = contact['id'] as int?; + if (accountId == null) { + throw Exception('completeRegistration: отсутствует id аккаунта'); + } + + final profile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(profile, isActive: true); + await TokenStorage.setActiveAccount(accountId); + + logger.i('Регистрация завершена, accountId=$accountId'); + return accountId; + } + Future login({ int? accountId, String? token, diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 5a52c01..0155fe4 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'password_2fa_screen.dart'; +import 'registration_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State return; } + if (result.isRegistration) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => RegistrationScreen( + phoneNumber: widget.phoneNumber, + registerToken: result.registerToken!, + presetAvatars: result.presetAvatars, + ), + ), + ); + return; + } + final loginResult = await accountModule.login(); if (!mounted) return; diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart new file mode 100644 index 0000000..6347c3b --- /dev/null +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -0,0 +1,327 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../backend/modules/account.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; + +class RegistrationScreen extends StatefulWidget { + final String phoneNumber; + final String registerToken; + final List presetAvatars; + + const RegistrationScreen({ + super.key, + required this.phoneNumber, + required this.registerToken, + required this.presetAvatars, + }); + + @override + State createState() => _RegistrationScreenState(); +} + +class _RegistrationScreenState extends State { + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + + int? _selectedPhotoId; + String? _selectedAvatarUrl; + bool _isSubmitting = false; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + bool get _canSubmit => + !_isSubmitting && _firstNameController.text.trim().isNotEmpty; + + Future _submit() async { + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) return; + final lastName = _lastNameController.text.trim(); + + setState(() => _isSubmitting = true); + try { + final accountId = await accountModule.completeRegistration( + token: widget.registerToken, + firstName: firstName, + lastName: lastName.isEmpty ? null : lastName, + photoId: _selectedPhotoId, + ); + + final loginResult = await accountModule.login( + accountId: accountId, + token: '', + ); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); + + if (!mounted) return; + + Navigator.pushAndRemoveUntil( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + setState(() => _isSubmitting = false); + showCustomNotification(context, e.toString()); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final firstName = _firstNameController.text.trim(); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + onPressed: _isSubmitting ? null : () => Navigator.pop(context), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _canSubmit ? _submit : null, + backgroundColor: _canSubmit + ? cs.primaryContainer + : cs.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: _isSubmitting + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: cs.onSurfaceVariant, + ), + ) + : Icon( + Icons.arrow_forward, + color: _canSubmit + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 96), + children: [ + Text( + l10n.registrationTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.registrationSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + const SizedBox(height: 28), + Center( + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _selectedAvatarUrl != null + ? CachedNetworkImage( + imageUrl: _selectedAvatarUrl!, + fit: BoxFit.cover, + ) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + firstName.isNotEmpty + ? firstName[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 28), + _buildTextField( + cs, + label: l10n.editProfileFirstName, + controller: _firstNameController, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 14), + _buildTextField( + cs, + label: l10n.editProfileLastName, + controller: _lastNameController, + textInputAction: TextInputAction.done, + ), + if (widget.presetAvatars.isNotEmpty) ...[ + const SizedBox(height: 28), + Text( + l10n.registrationChooseAvatar, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + for (final category in widget.presetAvatars) + _buildAvatarCategory(cs, category), + ], + ], + ), + ), + ); + } + + Widget _buildTextField( + ColorScheme cs, { + required String label, + required TextEditingController controller, + required TextInputAction textInputAction, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + TextField( + controller: controller, + enabled: !_isSubmitting, + textInputAction: textInputAction, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } + + Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + if (category.name.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Text( + category.name, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: category.avatars.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final avatar = category.avatars[index]; + final selected = _selectedPhotoId == avatar.id; + return GestureDetector( + onTap: _isSubmitting + ? null + : () => setState(() { + _selectedPhotoId = avatar.id; + _selectedAvatarUrl = avatar.url; + }), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? cs.primary : Colors.transparent, + width: 2.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval( + child: CachedNetworkImage( + imageUrl: avatar.url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: cs.surfaceContainerHigh, + ), + errorWidget: (_, __, ___) => Container( + color: cs.surfaceContainerHigh, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 963fb5c..8c9a99f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,5 +168,8 @@ "editProfileSave": "Save", "editProfileFirstName": "First name", "editProfileLastName": "Last name", - "editProfileRemovePhoto": "Remove photo" + "editProfileRemovePhoto": "Remove photo", + "registrationTitle": "Create your profile", + "registrationSubtitle": "Add your name and pick an avatar", + "registrationChooseAvatar": "Choose an avatar" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ec39c9d..eef4bee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1009,6 +1009,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove photo'** String get editProfileRemovePhoto; + + /// No description provided for @registrationTitle. + /// + /// In en, this message translates to: + /// **'Create your profile'** + String get registrationTitle; + + /// No description provided for @registrationSubtitle. + /// + /// In en, this message translates to: + /// **'Add your name and pick an avatar'** + String get registrationSubtitle; + + /// No description provided for @registrationChooseAvatar. + /// + /// In en, this message translates to: + /// **'Choose an avatar'** + String get registrationChooseAvatar; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 62d18b3..8303e6b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editProfileRemovePhoto => 'Remove photo'; + + @override + String get registrationTitle => 'Create your profile'; + + @override + String get registrationSubtitle => 'Add your name and pick an avatar'; + + @override + String get registrationChooseAvatar => 'Choose an avatar'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7b439e7..20ab999 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -480,4 +480,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editProfileRemovePhoto => 'Удалить фото'; + + @override + String get registrationTitle => 'Создание профиля'; + + @override + String get registrationSubtitle => 'Укажите имя и выберите аватар'; + + @override + String get registrationChooseAvatar => 'Выберите аватар'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7b3739e..ef69871 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -168,5 +168,8 @@ "editProfileSave": "Сохранить", "editProfileFirstName": "Имя", "editProfileLastName": "Фамилия", - "editProfileRemovePhoto": "Удалить фото" + "editProfileRemovePhoto": "Удалить фото", + "registrationTitle": "Создание профиля", + "registrationSubtitle": "Укажите имя и выберите аватар", + "registrationChooseAvatar": "Выберите аватар" }