изменение профиля

This commit is contained in:
Jganenok
2026-05-31 17:43:58 +07:00
parent 838e892bbc
commit 4afdf4b61e
7 changed files with 185 additions and 43 deletions
+1 -1
View File
@@ -484,7 +484,7 @@ class AccountModule {
return newProfile;
}
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.profile, {
'photoToken': photoToken,
+58 -22
View File
@@ -190,13 +190,23 @@ class FileUploader {
Socket? socket;
try {
socket = await _openSocket(uri);
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
final preamble = utf8.encode(
'--$boundary\r\n'
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
'\r\n',
);
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
_writeImageHeaders(
socket,
uri,
bytes.length,
contentType: _contentTypeForFilename(filename),
preamble.length + bytes.length + epilogue.length,
boundary: boundary,
);
socket.add(preamble);
socket.add(bytes);
socket.add(epilogue);
await socket.flush();
final response = await _readFullResponse(
@@ -208,7 +218,6 @@ class FileUploader {
} catch (_) {}
if (response == null) {
logger.w('uploadImage: empty/timed-out response');
return null;
}
final (status, body) = response;
@@ -230,12 +239,12 @@ class FileUploader {
}
}
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) {
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final headers = StringBuffer()
..write('POST $path HTTP/1.1\r\n')
..write('Host: ${uri.host}\r\n')
..write('Content-Type: $contentType\r\n')
..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
..write('Content-Length: $total\r\n')
..write('Connection: keep-alive\r\n')
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
@@ -273,36 +282,63 @@ class FileUploader {
Timer? timer;
StreamSubscription<List<int>>? sub;
void finish() {
void finishWith((int, String)? value) {
timer?.cancel();
sub?.cancel();
if (completer.isCompleted) return;
if (!completer.isCompleted) completer.complete(value);
}
(int, String)? tryParse({required bool atClose}) {
final headerEnd = _findHeaderEnd(bytes);
if (headerEnd == -1) {
completer.complete(null);
return;
}
if (headerEnd == -1) return null;
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
final lines = headerStr.split('\r\n');
final parts = lines.first.split(' ');
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
final chunked = lines.skip(1).any(
final headerLines = lines.skip(1);
final chunked = headerLines.any(
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
l.toLowerCase().contains('chunked'),
);
int? contentLength;
for (final l in headerLines) {
if (l.toLowerCase().startsWith('content-length:')) {
contentLength = int.tryParse(l.split(':').last.trim());
}
}
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
final body = chunked ? _decodeChunked(rawBody) : rawBody;
completer.complete((status, body));
if (chunked) {
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
return (status, _decodeChunked(rawBody));
}
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
return null;
}
return (status, rawBody);
}
void fail() {
timer?.cancel();
sub?.cancel();
if (!completer.isCompleted) completer.complete(null);
}
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
timer = Timer(timeout, fail);
sub = socket.listen(
(chunk) {
bytes.addAll(chunk);
final parsed = tryParse(atClose: false);
if (parsed != null) finishWith(parsed);
},
onError: (e) {
logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
finishWith(tryParse(atClose: true));
},
onDone: () {
final parsed = tryParse(atClose: true);
if (parsed == null) {
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
}
finishWith(parsed);
},
);
timer = Timer(timeout, () {
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
finishWith(tryParse(atClose: true));
});
return completer.future;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
const int _avatarMaxDimension = 1024;
const int _avatarTargetBytes = 900 * 1024;
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
Uint8List? _encodeAvatar(Uint8List input) {
final decoded = img.decodeImage(input);
if (decoded == null) return null;
final oriented = img.bakeOrientation(decoded);
final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension
? img.copyResize(
oriented,
width: oriented.width >= oriented.height ? _avatarMaxDimension : null,
height: oriented.height > oriented.width ? _avatarMaxDimension : null,
interpolation: img.Interpolation.average,
)
: oriented;
var quality = 88;
var out = img.encodeJpg(image, quality: quality);
while (out.lengthInBytes > _avatarTargetBytes && quality > 35) {
quality -= 12;
out = img.encodeJpg(image, quality: quality);
}
return out;
}
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/contacts.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/utils/image_utils.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/swipe_route.dart';
@@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
if (_avatar != null) {
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
if (url != null) {
final bytes = await _avatar!.readAsBytes();
final token = await fileUploader.uploadImage(
Uri.parse(url),
bytes,
filename: _avatar!.uri.pathSegments.last,
);
if (token != null) {
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
} else if (mounted) {
showCustomNotification(context, 'Не удалось загрузить аватарку');
final bytes = await compressAvatar(await _avatar!.readAsBytes());
if (bytes == null) {
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
} else {
final token = await fileUploader.uploadImage(
Uri.parse(url),
bytes,
filename: 'avatar.jpg',
);
if (token != null) {
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
} else if (mounted) {
showCustomNotification(context, 'Не удалось загрузить аватарку');
}
}
}
}
@@ -1,10 +1,14 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/image_utils.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, KometApp;
import '../../../main.dart' show accountModule, fileUploader, KometApp;
import '../../widgets/custom_notification.dart';
const int _maxAvatarBytes = 8 * 1024 * 1024;
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@@ -77,12 +81,56 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
Future<void> _changeAvatar() async {
if (_isSaving) return;
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final bytes = picked.bytes;
if (bytes == null) {
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
return;
}
if (bytes.length > _maxAvatarBytes) {
if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
return;
}
if (!mounted) return;
setState(() => _isSaving = true);
try {
final uploadUrl = await accountModule.getAvatarUploadUrl();
final processed = await compressAvatar(bytes);
if (processed == null) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось обработать изображение');
setState(() => _isSaving = false);
return;
}
final url = await accountModule.getAvatarUploadUrl();
final token = await fileUploader.uploadImage(
Uri.parse(url),
processed,
filename: 'avatar.jpg',
);
if (token == null) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось загрузить аватарку');
setState(() => _isSaving = false);
return;
}
final newProfile = await accountModule.updateProfileAvatar(token);
if (!mounted) return;
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
setState(() {
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
_isSaving = false;
});
KometApp.stateOf(context)?.notifyProfileUpdate();
showCustomNotification(context, 'Аватарка обновлена');
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
if (!mounted) return;
showCustomNotification(context, 'Ошибка: $e');
setState(() => _isSaving = false);
}
}