изменение профиля
This commit is contained in:
@@ -484,7 +484,7 @@ class AccountModule {
|
|||||||
return newProfile;
|
return newProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
|
Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
final packet = await _api.sendRequest(Opcode.profile, {
|
final packet = await _api.sendRequest(Opcode.profile, {
|
||||||
'photoToken': photoToken,
|
'photoToken': photoToken,
|
||||||
|
|||||||
@@ -190,13 +190,23 @@ class FileUploader {
|
|||||||
Socket? socket;
|
Socket? socket;
|
||||||
try {
|
try {
|
||||||
socket = await _openSocket(uri);
|
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(
|
_writeImageHeaders(
|
||||||
socket,
|
socket,
|
||||||
uri,
|
uri,
|
||||||
bytes.length,
|
preamble.length + bytes.length + epilogue.length,
|
||||||
contentType: _contentTypeForFilename(filename),
|
boundary: boundary,
|
||||||
);
|
);
|
||||||
|
socket.add(preamble);
|
||||||
socket.add(bytes);
|
socket.add(bytes);
|
||||||
|
socket.add(epilogue);
|
||||||
await socket.flush();
|
await socket.flush();
|
||||||
|
|
||||||
final response = await _readFullResponse(
|
final response = await _readFullResponse(
|
||||||
@@ -208,7 +218,6 @@ class FileUploader {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
logger.w('uploadImage: empty/timed-out response');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final (status, body) = response;
|
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 path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||||
final headers = StringBuffer()
|
final headers = StringBuffer()
|
||||||
..write('POST $path HTTP/1.1\r\n')
|
..write('POST $path HTTP/1.1\r\n')
|
||||||
..write('Host: ${uri.host}\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('Content-Length: $total\r\n')
|
||||||
..write('Connection: keep-alive\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')
|
..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;
|
Timer? timer;
|
||||||
StreamSubscription<List<int>>? sub;
|
StreamSubscription<List<int>>? sub;
|
||||||
|
|
||||||
void finish() {
|
void finishWith((int, String)? value) {
|
||||||
timer?.cancel();
|
timer?.cancel();
|
||||||
sub?.cancel();
|
sub?.cancel();
|
||||||
if (completer.isCompleted) return;
|
if (!completer.isCompleted) completer.complete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, String)? tryParse({required bool atClose}) {
|
||||||
final headerEnd = _findHeaderEnd(bytes);
|
final headerEnd = _findHeaderEnd(bytes);
|
||||||
if (headerEnd == -1) {
|
if (headerEnd == -1) return null;
|
||||||
completer.complete(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||||
final lines = headerStr.split('\r\n');
|
final lines = headerStr.split('\r\n');
|
||||||
final parts = lines.first.split(' ');
|
final parts = lines.first.split(' ');
|
||||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
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) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||||
l.toLowerCase().contains('chunked'),
|
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 rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||||
final body = chunked ? _decodeChunked(rawBody) : rawBody;
|
if (chunked) {
|
||||||
completer.complete((status, body));
|
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() {
|
sub = socket.listen(
|
||||||
timer?.cancel();
|
(chunk) {
|
||||||
sub?.cancel();
|
bytes.addAll(chunk);
|
||||||
if (!completer.isCompleted) completer.complete(null);
|
final parsed = tryParse(atClose: false);
|
||||||
}
|
if (parsed != null) finishWith(parsed);
|
||||||
|
},
|
||||||
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
|
onError: (e) {
|
||||||
timer = Timer(timeout, fail);
|
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;
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/chats.dart';
|
||||||
import '../../../backend/modules/contacts.dart';
|
import '../../../backend/modules/contacts.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../core/utils/image_utils.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
@@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
|||||||
if (_avatar != null) {
|
if (_avatar != null) {
|
||||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
final bytes = await _avatar!.readAsBytes();
|
final bytes = await compressAvatar(await _avatar!.readAsBytes());
|
||||||
final token = await fileUploader.uploadImage(
|
if (bytes == null) {
|
||||||
Uri.parse(url),
|
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||||
bytes,
|
} else {
|
||||||
filename: _avatar!.uri.pathSegments.last,
|
final token = await fileUploader.uploadImage(
|
||||||
);
|
Uri.parse(url),
|
||||||
if (token != null) {
|
bytes,
|
||||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
filename: 'avatar.jpg',
|
||||||
} else if (mounted) {
|
);
|
||||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
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:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../../core/utils/image_utils.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../main.dart' show accountModule, KometApp;
|
import '../../../main.dart' show accountModule, fileUploader, KometApp;
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
|
||||||
|
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
class EditProfileScreen extends StatefulWidget {
|
class EditProfileScreen extends StatefulWidget {
|
||||||
const EditProfileScreen({super.key});
|
const EditProfileScreen({super.key});
|
||||||
|
|
||||||
@@ -77,12 +81,56 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
|
|
||||||
Future<void> _changeAvatar() async {
|
Future<void> _changeAvatar() async {
|
||||||
if (_isSaving) return;
|
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 {
|
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;
|
if (!mounted) return;
|
||||||
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
|
setState(() {
|
||||||
|
_avatarUrl = newProfile.baseUrl;
|
||||||
|
_photoId = newProfile.photoId;
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
KometApp.stateOf(context)?.notifyProfileUpdate();
|
||||||
|
showCustomNotification(context, 'Аватарка обновлена');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
setState(() => _isSaving = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-6
@@ -17,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.2"
|
version: "0.1.2"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -413,6 +421,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
|
image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -505,10 +521,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -537,10 +553,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -693,6 +709,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
progress_indicator_m3e:
|
progress_indicator_m3e:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -902,10 +926,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
timezone: ^0.11.0
|
timezone: ^0.11.0
|
||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
|
image: ^4.3.0
|
||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
sqflite_common_ffi: ^2.4.0+2
|
sqflite_common_ffi: ^2.4.0+2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
|
|||||||
Reference in New Issue
Block a user