From 77df71824e6a2e46e9fb44d1549bb2e1b106ff15 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 11 Jul 2026 16:27:37 +0700 Subject: [PATCH] feat(media): disk-streaming uploads + fix Android plugin manifest namespace --- .gitmodules | 3 + lib/core/transport/connection.dart | 189 --------- lib/core/transport/proxy_connector.dart | 360 ------------------ lib/core/transport/receiver.dart | 74 ---- lib/core/transport/sender.dart | 27 -- lib/frontend/screens/chats/chat_screen.dart | 2 +- .../screens/profile/customization_screen.dart | 187 --------- .../profile/customization_section.dart | 170 +++++++++ .../screens/profile/settings_tab.dart | 24 +- lib/frontend/widgets/message_bubble.dart | 8 +- third_party/kolibri | 1 + 11 files changed, 182 insertions(+), 863 deletions(-) delete mode 100644 lib/core/transport/connection.dart delete mode 100644 lib/core/transport/proxy_connector.dart delete mode 100644 lib/core/transport/receiver.dart delete mode 100644 lib/core/transport/sender.dart delete mode 100644 lib/frontend/screens/profile/customization_screen.dart create mode 100644 lib/frontend/screens/profile/customization_section.dart create mode 160000 third_party/kolibri diff --git a/.gitmodules b/.gitmodules index cbeb1b9..4032a51 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "third_party/rlottie"] path = third_party/rlottie url = https://github.com/Samsung/rlottie.git +[submodule "third_party/kolibri"] + path = third_party/kolibri + url = https://github.com/KometTeam/kolibri.git diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart deleted file mode 100644 index dff5aed..0000000 --- a/lib/core/transport/connection.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import '../config/proxy_config.dart'; -import '../utils/logger.dart'; -import 'proxy_connector.dart'; -import 'tls_config.dart'; -import 'traffic_monitor.dart'; -import 'vpn_bypass.dart'; - -enum SocketState { disconnected, connecting, connected } - -/// Обёртка над TCP + TLS сокетом. -/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. -class Connection { - static const Duration _defaultConnectTimeout = Duration(seconds: 15); - static const Duration _proxyLoadTimeout = Duration(seconds: 8); - static const Duration _vpnCallTimeout = Duration(seconds: 5); - - SecureSocket? _socket; - StreamSubscription? _subscription; - SocketState _state = SocketState.disconnected; - - final _dataController = StreamController.broadcast(); - final _stateController = StreamController.broadcast(); - - Stream get dataStream => _dataController.stream; - Stream get stateStream => _stateController.stream; - SocketState get state => _state; - bool get isConnected => _state == SocketState.connected; - - void _setState(SocketState newState) { - if (_state == newState) return; - _state = newState; - if (!_stateController.isClosed) _stateController.add(newState); - } - - Future connect( - String host, - int port, { - bool bypassVpn = false, - Duration? timeout, - }) async { - if (_state != SocketState.disconnected) { - logger.w('Connection.connect пропущен: state=$_state (уже $_state)'); - return; - } - _setState(SocketState.connecting); - - try { - logger.i('Connection: загрузка прокси-конфига'); - ProxySettings proxySettings; - try { - proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout); - } catch (e) { - logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси'); - proxySettings = const ProxySettings(); - } - - logger.i( - 'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}', - ); - try { - if (bypassVpn) { - await VpnBypassService.instance.bind().timeout(_vpnCallTimeout); - } else { - await VpnBypassService.instance - .restoreDefault() - .timeout(_vpnCallTimeout); - } - } catch (e) { - logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю'); - } - - logger.i( - 'Connection: открываю сокет $host:$port ' - '(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})', - ); - final socket = await _openSecureSocket( - host, - port, - proxySettings, - timeout: timeout, - ); - - _socket = socket; - _setState(SocketState.connected); - logger.i('Подключено к $host:$port'); - - final route = proxySettings.isEnabled - ? 'через прокси ${proxySettings.type.name}' - : bypassVpn - ? 'напрямую (обход VPN)' - : 'прямое соединение'; - TrafficMonitor.instance.recordEvent( - 'Подключено', - detail: '$host:$port · TLS · $route', - endpoint: '$host:$port', - ); - - _subscription = _socket!.listen( - (data) { - if (!_dataController.isClosed) _dataController.add(data); - }, - onError: (Object error) { - logger.e('Ошибка сокета: $error'); - disconnect(); - }, - onDone: () { - logger.w('Сокет закрыт сервером'); - disconnect(); - }, - ); - } catch (e) { - logger.e('Не удалось подключиться: $e'); - _setState(SocketState.disconnected); - rethrow; - } - } - - Future _openSecureSocket( - String host, - int port, - ProxySettings proxySettings, { - Duration? timeout, - }) async { - final connectTimeout = timeout ?? _defaultConnectTimeout; - Socket socket; - if (proxySettings.isEnabled) { - final connector = ProxyConnector(proxySettings); - socket = await connector.connect(host, port).timeout(connectTimeout); - logger.i('Подключено через прокси ${proxySettings.type.name}'); - } else { - logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)'); - socket = await Socket.connect(host, port, timeout: connectTimeout); - logger.i('Connection: TCP установлен, начинаю TLS'); - } - final allowInsecure = await TlsConfig.isInsecureAllowed(); - if (allowInsecure) { - logger.w( - 'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM', - ); - } - final secured = allowInsecure - ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) - : SecureSocket.secure(socket, host: host); - try { - final result = await secured.timeout(connectTimeout); - logger.i('Connection: TLS-handshake завершён'); - return result; - } on TimeoutException { - logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с'); - socket.destroy(); - rethrow; - } - } - - void write(Uint8List data) { - if (_socket == null || !isConnected) { - throw StateError('Нельзя писать: сокет не подключён'); - } - _socket!.add(data); - } - - Future disconnect() async { - _subscription?.cancel(); - _subscription = null; - final socket = _socket; - _socket = null; - - if (socket != null) { - TrafficMonitor.instance.recordEvent('Соединение закрыто'); - try { - await socket.close(); - } catch (e) { - logger.w('Ошибка при закрытии сокета: $e'); - } - } - - _setState(SocketState.disconnected); - } - - Future dispose() async { - await disconnect(); - await _dataController.close(); - await _stateController.close(); - } -} diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart deleted file mode 100644 index 6770ad0..0000000 --- a/lib/core/transport/proxy_connector.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import '../config/proxy_config.dart'; -import '../utils/logger.dart'; - -class ProxyConnector { - final ProxySettings settings; - - ProxyConnector(this.settings); - - Future connect(String targetHost, int targetPort) async { - switch (settings.type) { - case ProxyType.socks5: - return _connectSocks5(targetHost, targetPort); - case ProxyType.httpConnect: - return _connectHttpConnect(targetHost, targetPort); - case ProxyType.none: - return Socket.connect(targetHost, targetPort); - } - } - - // ── SOCKS5 (RFC 1928) ────────────────────────────────────────────────── - - Future _connectSocks5(String targetHost, int targetPort) async { - final proxySocket = await RawSocket.connect(settings.host, settings.port); - logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); - - final io = _RawSocketIO(proxySocket); - try { - // 1. Greeting - final useAuth = settings.hasCredentials; - if (useAuth) { - await io.write([0x05, 0x02, 0x00, 0x02]); - } else { - await io.write([0x05, 0x01, 0x00]); - } - - var response = await io.readExact(2); - if (response[0] != 0x05) { - throw SocketException( - 'SOCKS5: неверная версия протокола: ${response[0]}', - ); - } - - final method = response[1]; - if (method == 0xFF) { - throw SocketException( - 'SOCKS5: сервер отклонил все методы аутентификации', - ); - } - - // 2. Аутентификация (RFC 1929) - if (method == 0x02) { - if (!useAuth) { - throw SocketException('SOCKS5: прокси требует аутентификацию'); - } - final usernameBytes = utf8.encode(settings.username ?? ''); - final passwordBytes = utf8.encode(settings.password ?? ''); - final authPacket = BytesBuilder() - ..addByte(0x01) - ..addByte(usernameBytes.length) - ..add(usernameBytes) - ..addByte(passwordBytes.length) - ..add(passwordBytes); - await io.write(authPacket.toBytes()); - - final authResponse = await io.readExact(2); - if (authResponse[1] != 0x00) { - throw SocketException('SOCKS5: аутентификация не пройдена'); - } - logger.i('SOCKS5: аутентификация пройдена'); - } - - // 3. Connect request - final hostBytes = utf8.encode(targetHost); - final connectPacket = BytesBuilder() - ..addByte(0x05) // VER - ..addByte(0x01) // CMD: CONNECT - ..addByte(0x00) // RSV - ..addByte(0x03) // ATYP: domain - ..addByte(hostBytes.length) - ..add(hostBytes) - ..addByte((targetPort >> 8) & 0xFF) - ..addByte(targetPort & 0xFF); - await io.write(connectPacket.toBytes()); - - // 4. Reply - final reply = await io.readExact(4); - if (reply[0] != 0x05) { - throw SocketException('SOCKS5: неверная версия в ответе'); - } - if (reply[1] != 0x00) { - throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}'); - } - - // Пропускаем bind address - switch (reply[3]) { - case 0x01: - await io.readExact(4 + 2); - break; - case 0x03: - final lenBuf = await io.readExact(1); - await io.readExact(lenBuf[0] + 2); - break; - case 0x04: - await io.readExact(16 + 2); - break; - } - - logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); - - // Создаём локальную пару и проксируем данные - return _bridgeToFreshSocket(proxySocket, io); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - } - - // ── HTTP CONNECT ──────────────────────────────────────────────────────── - - Future _connectHttpConnect(String targetHost, int targetPort) async { - final proxySocket = await RawSocket.connect(settings.host, settings.port); - logger.i( - 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', - ); - - final io = _RawSocketIO(proxySocket); - try { - final request = StringBuffer() - ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') - ..write('Host: $targetHost:$targetPort\r\n'); - - if (settings.hasCredentials) { - final credentials = base64Encode( - utf8.encode('${settings.username}:${settings.password}'), - ); - request.write('Proxy-Authorization: Basic $credentials\r\n'); - } - request.write('\r\n'); - - await io.write(utf8.encode(request.toString())); - - // Читаем HTTP-ответ до \r\n\r\n - final headerBytes = []; - while (true) { - final byte = await io.readExact(1); - headerBytes.add(byte[0]); - if (headerBytes.length >= 4 && - headerBytes[headerBytes.length - 4] == 0x0D && - headerBytes[headerBytes.length - 3] == 0x0A && - headerBytes[headerBytes.length - 2] == 0x0D && - headerBytes[headerBytes.length - 1] == 0x0A) { - break; - } - if (headerBytes.length > 8192) { - throw SocketException( - 'HTTP CONNECT: заголовок ответа слишком большой', - ); - } - } - - final responseStr = utf8.decode(headerBytes, allowMalformed: true); - final statusLine = responseStr.split('\r\n').first; - final parts = statusLine.split(' '); - if (parts.length < 2) { - throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine'); - } - final statusCode = int.tryParse(parts[1]) ?? 0; - if (statusCode != 200) { - throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode'); - } - - logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); - return _bridgeToFreshSocket(proxySocket, io); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - } - - Future _bridgeToFreshSocket( - RawSocket proxySocket, - _RawSocketIO io, - ) async { - ServerSocket? server; - try { - server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - final clientFuture = Socket.connect( - InternetAddress.loopbackIPv4, - server.port, - ); - final serverSide = await server.first; - final clientSide = await clientFuture; - await server.close(); - - io.onData = (data) { - serverSide.add(data); - }; - io.onClosed = () { - serverSide.close(); - }; - - serverSide.listen( - (data) { - unawaited( - io.write(data).catchError((Object _) { - try { - serverSide.destroy(); - } catch (_) {} - }), - ); - }, - onError: (Object _) { - proxySocket.shutdown(SocketDirection.send); - }, - onDone: () { - proxySocket.shutdown(SocketDirection.send); - }, - ); - - // Сливаем данные, буферизованные во время handshake - io.flushBuffered(); - - logger.i('Прокси-мост через loopback создан'); - return clientSide; - } -} - -/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения. -/// -/// После handshake переключается в режим моста: -/// данные из proxy-сокета пересылаются через [onData] в loopback-пару. -class _RawSocketIO { - final RawSocket _socket; - late final StreamSubscription _sub; - - final _readBuffer = []; - Completer? _readWaiter; - Completer? _writeWaiter; - bool _closed = false; - Object? _error; - - /// Коллбэк для данных в режиме моста. - void Function(Uint8List data)? onData; - - /// Коллбэк закрытия в режиме моста. - void Function()? onClosed; - - _RawSocketIO(this._socket) { - _sub = _socket.listen( - _onEvent, - onError: (Object err) { - _error = err; - _closed = true; - _readWaiter?.completeError(err); - _readWaiter = null; - _writeWaiter?.completeError(err); - _writeWaiter = null; - }, - ); - } - - void _onEvent(RawSocketEvent event) { - switch (event) { - case RawSocketEvent.read: - final data = _socket.read(); - if (data != null) { - if (onData != null) { - // Режим моста — пересылаем напрямую - onData!(data); - } else { - // Режим handshake — буферизуем - _readBuffer.addAll(data); - _readWaiter?.complete(); - _readWaiter = null; - } - } - break; - case RawSocketEvent.write: - _writeWaiter?.complete(); - _writeWaiter = null; - break; - case RawSocketEvent.readClosed: - case RawSocketEvent.closed: - _closed = true; - onClosed?.call(); - _readWaiter?.completeError(SocketException('Прокси закрыл соединение')); - _readWaiter = null; - _writeWaiter?.completeError( - SocketException('Прокси закрыл соединение'), - ); - _writeWaiter = null; - break; - } - } - - /// Читает ровно [count] байт. - Future readExact(int count) async { - while (_readBuffer.length < count) { - if (_error != null) throw _error!; - if (_closed) { - throw SocketException( - 'Соединение закрыто ' - '(ожидали $count байт, получили ${_readBuffer.length})', - ); - } - _readWaiter = Completer(); - await _readWaiter!.future.timeout( - const Duration(seconds: 15), - onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'), - ); - } - final result = Uint8List.fromList(_readBuffer.sublist(0, count)); - _readBuffer.removeRange(0, count); - return result; - } - - /// Записывает все байты. - Future write(List data) async { - var offset = 0; - while (offset < data.length) { - if (_error != null) throw _error!; - if (_closed) throw SocketException('Соединение закрыто при записи'); - final written = _socket.write(data, offset); - if (written > 0) { - offset += written; - } else { - _writeWaiter = Completer(); - await _writeWaiter!.future.timeout( - const Duration(seconds: 15), - onTimeout: () => - throw SocketException('Тайм-аут при записи в прокси'), - ); - } - } - } - - /// Пересылает данные, оставшиеся в буфере после handshake, в мост. - void flushBuffered() { - if (_readBuffer.isNotEmpty && onData != null) { - onData!(Uint8List.fromList(_readBuffer)); - _readBuffer.clear(); - } - } - - void dispose() { - _sub.cancel(); - } -} diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart deleted file mode 100644 index 4113e4f..0000000 --- a/lib/core/transport/receiver.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:typed_data'; - -import '../protocol/packet.dart'; - -class ReceiverOverflowException implements Exception { - final int size; - const ReceiverOverflowException(this.size); - @override - String toString() => 'PacketReceiver: переполнение буфера ($size B)'; -} - -class PacketReceiver { - Uint8List _buffer = Uint8List(0); - int _start = 0; - int _end = 0; - - static const int _maxBufferSize = 16 * 1024 * 1024; - - List feed(Uint8List data) { - _append(data); - - if (_end - _start > _maxBufferSize) { - final overflow = _end - _start; - reset(); - throw ReceiverOverflowException(overflow); - } - - final packets = []; - while (_end - _start >= headerSize) { - final bd = ByteData.view( - _buffer.buffer, - _buffer.offsetInBytes + _start, - headerSize, - ); - final packedLen = bd.getUint32(6, Endian.big); - final payloadLength = packedLen & 0xFFFFFF; - final totalLength = headerSize + payloadLength; - - if (_end - _start < totalLength) break; - - packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength)); - _start += totalLength; - } - - if (_start == _end) { - _start = 0; - _end = 0; - } - return packets; - } - - void _append(Uint8List data) { - final pending = _end - _start; - if (pending == 0) { - _buffer = Uint8List.fromList(data); - _start = 0; - _end = data.length; - return; - } - final total = pending + data.length; - final newBuffer = Uint8List(total); - newBuffer.setRange(0, pending, _buffer, _start); - newBuffer.setRange(pending, total, data); - _buffer = newBuffer; - _start = 0; - _end = total; - } - - void reset() { - _buffer = Uint8List(0); - _start = 0; - _end = 0; - } -} diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart deleted file mode 100644 index 758fab0..0000000 --- a/lib/core/transport/sender.dart +++ /dev/null @@ -1,27 +0,0 @@ -import '../protocol/packet.dart'; -import '../utils/log_redact.dart'; -import '../utils/logger.dart'; -import 'connection.dart'; -import 'traffic_monitor.dart'; - -class PacketSender { - int _seq = 0; - - int get currentSeq => _seq; - - int _nextSeq() { - _seq = (_seq + 1) % 65536; - return _seq; - } - - int send(Connection connection, int opcode, Map payload) { - final seq = _nextSeq(); - final data = packPacket(opcode, payload, seq: seq); - connection.write(data); - TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, data.length); - logger.i( - '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}', - ); - return seq; - } -} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 89e5b53..f234e50 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -3897,7 +3897,7 @@ class _ChatScreenState extends State final cs = Theme.of(context).colorScheme; final accent = cs.primary; return Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), child: Row( children: [ Expanded( diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart deleted file mode 100644 index 5a794d5..0000000 --- a/lib/frontend/screens/profile/customization_screen.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; - -import '../../widgets/connection_status.dart'; - -import '../../../core/utils/haptics.dart'; -import '../../widgets/glossy_pill.dart'; -import 'app_icon_screen.dart'; -import 'appearance_screen.dart'; -import 'chat_background_screen.dart'; -import 'font_settings_screen.dart'; -import 'message_actions_screen.dart'; -import 'theme_settings_screen.dart'; - -class _CustomizationCategory { - final IconData icon; - final String title; - final String subtitle; - final WidgetBuilder builder; - - const _CustomizationCategory({ - required this.icon, - required this.title, - required this.subtitle, - required this.builder, - }); -} - -class CustomizationScreen extends StatelessWidget { - const CustomizationScreen({super.key}); - - static const List<_CustomizationCategory> _categories = [ - _CustomizationCategory( - icon: Symbols.dark_mode, - title: 'Тема', - subtitle: 'Светлая, тёмная, AMOLED, расписание', - builder: _buildThemeSettings, - ), - _CustomizationCategory( - icon: Symbols.palette, - title: 'Внешний вид', - subtitle: 'Акцентный цвет интерфейса', - builder: _buildAppearance, - ), - _CustomizationCategory( - icon: Symbols.wallpaper, - title: 'Фон чатов', - subtitle: 'Общие обои и темы для всех чатов', - builder: _buildChatBackground, - ), - _CustomizationCategory( - icon: Symbols.text_fields, - title: 'Шрифты', - subtitle: 'Шрифт приложения, свои шрифты, размер текста', - builder: _buildFontSettings, - ), - _CustomizationCategory( - icon: Symbols.touch_app, - title: 'Меню действий', - subtitle: 'Радиальное или список — для долгого нажатия на сообщение', - builder: _buildMessageActions, - ), - _CustomizationCategory( - icon: Symbols.apps, - title: 'Иконка приложения', - subtitle: 'Default или Minimal — иконка на главном экране', - builder: _buildAppIcon, - ), - ]; - - static Widget _buildAppearance(BuildContext context) => - const AppearanceScreen(); - - static Widget _buildChatBackground(BuildContext context) => - const ChatBackgroundScreen(); - - static Widget _buildFontSettings(BuildContext context) => - const FontSettingsScreen(); - - static Widget _buildThemeSettings(BuildContext context) => - const ThemeSettingsScreen(); - - static Widget _buildMessageActions(BuildContext context) => - const MessageActionsScreen(); - - static Widget _buildAppIcon(BuildContext context) => const AppIconScreen(); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return Scaffold( - backgroundColor: cs.surface, - appBar: ConnectionTitleBar( - titleText: 'Кастомизация', - backgroundColor: cs.surface, - ), - body: SafeArea( - top: false, - child: ListView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - children: [ - for (final category in _categories) ...[ - _CategoryCard( - category: category, - onTap: () { - Haptics.tap(); - Navigator.push( - context, - MaterialPageRoute(builder: category.builder), - ); - }, - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ); - } -} - -class _CategoryCard extends StatelessWidget { - final _CustomizationCategory category; - final VoidCallback onTap; - - const _CategoryCard({required this.category, required this.onTap}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), - depth: 6, - onTap: onTap, - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primaryContainer, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Icon( - category.icon, - color: cs.onPrimaryContainer, - size: 24, - weight: 500, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.title, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - category.subtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - height: 1.3, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Icon(Symbols.chevron_right, color: cs.outline, size: 22), - ], - ), - ); - } -} diff --git a/lib/frontend/screens/profile/customization_section.dart b/lib/frontend/screens/profile/customization_section.dart new file mode 100644 index 0000000..1073de2 --- /dev/null +++ b/lib/frontend/screens/profile/customization_section.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_card.dart'; +import 'app_icon_screen.dart'; +import 'appearance_screen.dart'; +import 'chat_background_screen.dart'; +import 'font_settings_screen.dart'; +import 'message_actions_screen.dart'; +import 'theme_settings_screen.dart'; + +class _CustomizationCategory { + final IconData icon; + final String title; + final WidgetBuilder builder; + + const _CustomizationCategory({ + required this.icon, + required this.title, + required this.builder, + }); +} + +class CustomizationSection extends StatefulWidget { + const CustomizationSection({super.key}); + + @override + State createState() => _CustomizationSectionState(); +} + +class _CustomizationSectionState extends State { + bool _expanded = false; + + static final List<_CustomizationCategory> _categories = [ + _CustomizationCategory( + icon: Symbols.dark_mode, + title: 'Тема', + builder: (context) => const ThemeSettingsScreen(), + ), + _CustomizationCategory( + icon: Symbols.palette, + title: 'Внешний вид', + builder: (context) => const AppearanceScreen(), + ), + _CustomizationCategory( + icon: Symbols.wallpaper, + title: 'Фон чатов', + builder: (context) => const ChatBackgroundScreen(), + ), + _CustomizationCategory( + icon: Symbols.text_fields, + title: 'Шрифты', + builder: (context) => const FontSettingsScreen(), + ), + _CustomizationCategory( + icon: Symbols.touch_app, + title: 'Меню действий', + builder: (context) => const MessageActionsScreen(), + ), + _CustomizationCategory( + icon: Symbols.apps, + title: 'Иконка приложения', + builder: (context) => const AppIconScreen(), + ), + ]; + + void _toggle() { + Haptics.tap(); + setState(() => _expanded = !_expanded); + } + + void _open(_CustomizationCategory category) { + Haptics.tap(); + Navigator.push(context, MaterialPageRoute(builder: category.builder)); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + child: Column( + children: [ + _buildHeader(cs), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: _expanded + ? Column(children: _buildCategoryTiles(cs)) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } + + Widget _buildHeader(ColorScheme cs) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _toggle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.palette, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'Кастомизация', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _expanded ? 0.5 : 0, + child: Icon( + Symbols.expand_more, + color: cs.outline, + size: 22, + weight: 400, + ), + ), + ], + ), + ), + ), + ); + } + + List _buildCategoryTiles(ColorScheme cs) { + final tiles = []; + for (var i = 0; i < _categories.length; i++) { + final category = _categories[i]; + tiles.add( + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ); + tiles.add( + SettingsNavTile( + icon: category.icon, + label: category.title, + onTap: () => _open(category), + isLast: i == _categories.length - 1, + ), + ); + } + return tiles; + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 624bba2..72833f4 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -26,7 +26,7 @@ import '../digital_id/digital_id_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; -import 'customization_screen.dart'; +import 'customization_section.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; @@ -298,26 +298,10 @@ class _SettingsTabState extends State { ), ), ), - SliverToBoxAdapter( + const SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSection( - context, - items: [ - _SettingsItem( - icon: Symbols.palette, - label: 'Кастомизация', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CustomizationScreen(), - ), - ); - }, - ), - ], - ), + padding: EdgeInsets.fromLTRB(16, 12, 16, 0), + child: CustomizationSection(), ), ), SliverToBoxAdapter( diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 2871a8c..7062163 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -530,8 +530,8 @@ class MessageBubble extends StatelessWidget { return Padding( padding: EdgeInsets.only( - left: 12, - right: 12, + left: 8, + right: 8, top: topMargin, bottom: bottomMargin, ), @@ -545,9 +545,7 @@ class MessageBubble extends StatelessWidget { children: [ if (showAvatar) _buildLeadingAvatar(cs) - else if (showAvatarSlot && chatType != "CHAT") - const SizedBox(width: 0) - else if (showAvatarSlot) + else if (showAvatarSlot && chatType == "CHAT") const CircleAvatar( radius: 15, backgroundColor: Color(0x00000000), diff --git a/third_party/kolibri b/third_party/kolibri new file mode 160000 index 0000000..ac1f58d --- /dev/null +++ b/third_party/kolibri @@ -0,0 +1 @@ +Subproject commit ac1f58d934c863e558123810e34943614b7d4760