diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 8d6f6b8..288a097 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -431,6 +432,82 @@ class AccountModule { return config; } + Future updateProfileName(String firstName, String? lastName) async { + _ensureOnline(); + final payload = { + 'firstName': firstName, + }; + if (lastName != null) payload['lastName'] = lastName; + final packet = await _api.sendRequest(Opcode.profile, payload); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfileAvatar(String photoToken, String avatarType) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future getAvatarUploadUrl() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + // 2FA Creation (when not set) Future create2faTrack() async { _ensureOnline(); @@ -931,7 +1008,7 @@ class AccountModule { throw Exception('login: отсутствует profile.contact в ответе'); } final profile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(profile); + await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7283d9b..24f02d9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -98,7 +98,7 @@ class ProfileData { ); } - Map toDbRow() => { + Map toDbRow({bool isActive = false}) => { 'id': id, 'first_name': firstName, 'last_name': lastName, @@ -109,6 +109,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'is_active': isActive ? 1 : 0, 'profile_options': profileOptions?.join(','), }; } @@ -280,11 +281,11 @@ class AppDatabase { ) '''; - static Future saveProfile(ProfileData profile) async { + static Future saveProfile(ProfileData profile, {bool isActive = true}) async { final db = await _instance; await db.insert( 'profile', - profile.toDbRow(), + profile.toDbRow(isActive: isActive), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart new file mode 100644 index 0000000..11a94c5 --- /dev/null +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -0,0 +1,252 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, KometApp; +import '../../widgets/custom_notification.dart'; + +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({super.key}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + String? _avatarUrl; + int? _photoId; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + if (profile != null) { + _firstNameController.text = profile.firstName; + _lastNameController.text = profile.lastName ?? ''; + _avatarUrl = profile.baseUrl; + _photoId = profile.photoId; + setState(() => _isLoading = false); + } else { + setState(() => _isLoading = false); + } + } + + Future _saveName() async { + if (_isSaving) return; + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) { + if (mounted) showCustomNotification(context, 'Имя не может быть пустым'); + return; + } + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.updateProfileName( + firstName, + _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + ); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Имя сохранено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + Future _changeAvatar() async { + if (_isSaving) return; + try { + final uploadUrl = await accountModule.getAvatarUploadUrl(); + if (!mounted) return; + showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } + } + + Future _removeAvatar() async { + if (_isSaving || _photoId == null) return; + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.removeProfilePhoto(_photoId!); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Фото удалено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.editProfileTitle ?? 'Edit Profile', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + actions: [ + TextButton( + onPressed: _isLoading || _isSaving ? null : _saveName, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n?.editProfileSave ?? 'Save', + style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Stack( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _avatarUrl != null && _avatarUrl!.isNotEmpty + ? Image.network(_avatarUrl!, fit: BoxFit.cover) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + _firstNameController.text.isNotEmpty + ? _firstNameController.text[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + onPressed: _changeAvatar, + ), + ), + ), + ], + ), + ), + if (_photoId != null) ...[ + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: _removeAvatar, + child: Text( + l10n?.editProfileRemovePhoto ?? 'Remove photo', + style: TextStyle(color: cs.error), + ), + ), + ), + ], + const SizedBox(height: 24), + _buildTextField( + l10n?.editProfileFirstName ?? 'First name', + _firstNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 12), + _buildTextField( + l10n?.editProfileLastName ?? 'Last name', + _lastNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 120), + ], + ), + ); + } + + Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + 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: enabled, + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 715ccd1..58febca 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -115,11 +115,11 @@ class _InfoScreenState extends State { padding: const EdgeInsets.all(16), children: [ _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), const SizedBox(height: 16), _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), const SizedBox(height: 8), _buildSectionTitle(l10n.infoYMapSection, cs), @@ -240,28 +240,17 @@ class _InfoScreenState extends State { ); } - String _formatValue(dynamic value) { + String _formatValue(dynamic value, String key) { if (value == null) return '-'; if (value is Map && value.containsKey('chatMarker')) { final ts = value['chatMarker'] as int?; - if (ts != null) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - return '-'; + return ts != null ? _formatTs(ts) : '-'; } - if (value is int && value > 1000000000000) { - final dt = DateTime.fromMillisecondsSinceEpoch(value); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - if (value is int && value > 86400) { + if (value is int && value > 1000000000000) return _formatTs(value); + if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) { - return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); - } + if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -270,6 +259,13 @@ class _InfoScreenState extends State { return value.toString(); } + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + String _w(int n) { final m = n % 10; if (m == 1 && n != 11) return 'нед'; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index fd445cb..f38811b 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -6,9 +6,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -27,17 +29,25 @@ class _SettingsTabState extends State { bool _debugMenuVisible = false; int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; + StreamSubscription? _profileUpdateSub; @override void initState() { super.initState(); _loadProfile(); _loadAppVersion(); + final appState = KometApp.stateOf(context); + if (appState != null) { + _profileUpdateSub = appState.profileUpdateStream.listen((_) { + if (mounted) _loadProfile(); + }); + } } @override void dispose() { _versionSecretTapResetTimer?.cancel(); + _profileUpdateSub?.cancel(); super.dispose(); } @@ -317,7 +327,14 @@ child: _buildSection( size: 22, weight: 400, ), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EditProfileScreen(), + ), + ); + }, ), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 174459b..c7b227b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", - "chatInfoDialogStarted": "dialog started:" + "chatInfoDialogStarted": "dialog started:", + "editProfileTitle": "Edit Profile", + "editProfileSave": "Save", + "editProfileFirstName": "First name", + "editProfileLastName": "Last name", + "editProfileRemovePhoto": "Remove photo" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdfb..ce14093 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -955,6 +955,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'dialog started:'** String get chatInfoDialogStarted; + + /// No description provided for @editProfileTitle. + /// + /// In en, this message translates to: + /// **'Edit Profile'** + String get editProfileTitle; + + /// No description provided for @editProfileSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get editProfileSave; + + /// No description provided for @editProfileFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get editProfileFirstName; + + /// No description provided for @editProfileLastName. + /// + /// In en, this message translates to: + /// **'Last name'** + String get editProfileLastName; + + /// No description provided for @editProfileRemovePhoto. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get editProfileRemovePhoto; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0320c05..6f4c3c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -451,4 +451,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoDialogStarted => 'dialog started:'; + + @override + String get editProfileTitle => 'Edit Profile'; + + @override + String get editProfileSave => 'Save'; + + @override + String get editProfileFirstName => 'First name'; + + @override + String get editProfileLastName => 'Last name'; + + @override + String get editProfileRemovePhoto => 'Remove photo'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d7780cf..999ea90 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -453,4 +453,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoDialogStarted => 'ЛС начат в:'; + + @override + String get editProfileTitle => 'Редактирование профиля'; + + @override + String get editProfileSave => 'Сохранить'; + + @override + String get editProfileFirstName => 'Имя'; + + @override + String get editProfileLastName => 'Фамилия'; + + @override + String get editProfileRemovePhoto => 'Удалить фото'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c103607..3d134a9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", - "chatInfoDialogStarted": "ЛС начат в:" + "chatInfoDialogStarted": "ЛС начат в:", + "editProfileTitle": "Редактирование профиля", + "editProfileSave": "Сохранить", + "editProfileFirstName": "Имя", + "editProfileLastName": "Фамилия", + "editProfileRemovePhoto": "Удалить фото" } diff --git a/lib/main.dart b/lib/main.dart index 5c62627..1fd803a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -77,6 +77,8 @@ class KometAppState extends State { late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); + final _profileUpdateController = StreamController.broadcast(); + Stream get profileUpdateStream => _profileUpdateController.stream; @override void initState() { @@ -123,6 +125,7 @@ class KometAppState extends State { @override void dispose() { _sessionExpiredSub?.cancel(); + _profileUpdateController.close(); fpsOverlayEnabled.dispose(); super.dispose(); } @@ -147,6 +150,10 @@ class KometAppState extends State { } } + void notifyProfileUpdate() { + _profileUpdateController.add(null); + } + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend(