feat(media): disk-streaming uploads + fix Android plugin manifest namespace

This commit is contained in:
Jganenokk
2026-07-11 21:42:59 +03:00
committed by klockky
parent 4be18c0165
commit 77df71824e
11 changed files with 182 additions and 863 deletions
-189
View File
@@ -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<Uint8List>? _subscription;
SocketState _state = SocketState.disconnected;
final _dataController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<SocketState>.broadcast();
Stream<Uint8List> get dataStream => _dataController.stream;
Stream<SocketState> 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<void> 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<SecureSocket> _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<void> 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<void> dispose() async {
await disconnect();
await _dataController.close();
await _stateController.close();
}
}
-360
View File
@@ -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<Socket> 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<Socket> _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<Socket> _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 = <int>[];
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<Socket> _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<RawSocketEvent> _sub;
final _readBuffer = <int>[];
Completer<void>? _readWaiter;
Completer<void>? _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<Uint8List> readExact(int count) async {
while (_readBuffer.length < count) {
if (_error != null) throw _error!;
if (_closed) {
throw SocketException(
'Соединение закрыто '
'(ожидали $count байт, получили ${_readBuffer.length})',
);
}
_readWaiter = Completer<void>();
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<void> write(List<int> 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<void>();
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();
}
}
-74
View File
@@ -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<Uint8List> feed(Uint8List data) {
_append(data);
if (_end - _start > _maxBufferSize) {
final overflow = _end - _start;
reset();
throw ReceiverOverflowException(overflow);
}
final packets = <Uint8List>[];
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;
}
}
-27
View File
@@ -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<dynamic, dynamic> 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;
}
}
+1 -1
View File
@@ -3897,7 +3897,7 @@ class _ChatScreenState extends State<ChatScreen>
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(
@@ -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),
],
),
);
}
}
@@ -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<CustomizationSection> createState() => _CustomizationSectionState();
}
class _CustomizationSectionState extends State<CustomizationSection> {
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<Widget> _buildCategoryTiles(ColorScheme cs) {
final tiles = <Widget>[];
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;
}
}
+4 -20
View File
@@ -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<SettingsTab> {
),
),
),
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(
+3 -5
View File
@@ -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),