Что-то похожее на фронтенд

This commit is contained in:
noxzion
2026-03-15 18:34:57 +05:00
parent 06ee2f92e5
commit a5d3c94375
7 changed files with 1419 additions and 105 deletions
@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../chats/chat_list_screen.dart';
class CodeConfirmationScreen extends StatefulWidget {
final String phoneNumber;
const CodeConfirmationScreen({super.key, required this.phoneNumber});
@override
State<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
}
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
final TextEditingController _codeController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _timerSeconds = 30;
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_timer?.cancel();
_codeController.dispose();
_focusNode.dispose();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timerSeconds = 30;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
if (_timerSeconds > 0) {
_timerSeconds--;
} else {
_timer?.cancel();
}
});
});
}
void _resendCode() {
if (_timerSeconds == 0) {
_startTimer();
// TODO: Implement actual resend logic here
print('Resending code to ${widget.phoneNumber}');
}
}
void _navigateToChats() {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const ChatListScreen()),
(route) => false,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D12),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white70),
onPressed: () => Navigator.pop(context),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
widget.phoneNumber,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 12),
const Text(
'Мы отправили SMS с кодом подтверждения на ваш номер телефона.',
style: TextStyle(
color: Colors.white54,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.4,
),
),
const SizedBox(height: 12),
Stack(
children: [
Opacity(
opacity: 0,
child: SizedBox(
height: 0,
width: 0,
child: TextField(
controller: _codeController,
focusNode: _focusNode,
keyboardType: TextInputType.number,
autofillHints: const [AutofillHints.oneTimeCode],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
],
onChanged: (value) {
setState(() {});
if (value.length == 5) {
_navigateToChats();
}
},
),
),
),
GestureDetector(
onTap: () => _focusNode.requestFocus(),
child: FittedBox(
child: Row(
children: List.generate(5, (index) {
bool isFocused = _codeController.text.length == index && _focusNode.hasFocus;
bool hasValue = _codeController.text.length > index;
String char = hasValue ? _codeController.text[index] : '';
return Container(
width: 44,
height: 54,
margin: EdgeInsets.only(right: index == 4 ? 0 : 10),
decoration: BoxDecoration(
color: const Color(0xFF1E1E2A),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isFocused
? const Color(0xFFBEC2FF)
: (hasValue ? Colors.white24 : Colors.transparent),
width: 1.5,
),
),
alignment: Alignment.center,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
);
},
child: Text(
char,
key: ValueKey<String>(char + index.toString()),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
),
);
}),
),
),
),
],
),
const SizedBox(height: 24),
GestureDetector(
onTap: _resendCode,
child: Text(
_timerSeconds > 0
? 'Отправить повторно через $_timerSeconds сек.'
: 'Отправить код по SMS',
style: const TextStyle(
color: Color(0xFF63A9F5),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () {
if (_codeController.text.length == 5) {
_navigateToChats();
}
},
backgroundColor: _codeController.text.length == 5
? const Color(0xffc1c4ff)
: const Color(0xFF1E1E2A),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
child: Icon(
Icons.arrow_forward,
color: _codeController.text.length == 5 ? Colors.black : Colors.white24,
),
),
],
),
const SizedBox(height: 24),
],
),
),
),
);
}
}
+491
View File
@@ -0,0 +1,491 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'code_confirmation_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _phoneController = TextEditingController();
String? _errorText;
void _showTOS(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: const Color(0xFF1E1E2A),
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 1.0,
snap: true,
snapSizes: const [0.7, 1.0],
expand: false,
builder: (context, scrollController) => Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Условия использования',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Symbols.close, color: Colors.white70),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView(
controller: scrollController,
children: const [
Text(
'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n'
'1. Статус и отношения\n'
'1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n'
'1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n'
'1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n'
'2. Условия использования\n'
'2.1. Используя Приложение «Komet», вы:\n'
'• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n'
'• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n'
'• Принимаете на себя все риски, связанные с использованием Приложения.\n'
'2.2. Строго запрещено:\n'
'• Использовать Приложение «Komet» для распространения запрещённого контента;\n'
'• Осуществлять массовые рассылки (спам);\n'
'• Нарушать законодательство РФ и международное право;\n'
'• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n'
'2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n'
'2.4. Особенности технического взаимодействия:\n'
'• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n'
'• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n'
'• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n'
'3. Технические аспекты\n'
'3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n'
'3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n'
'3.3. Разработчики не несут ответственности за:\n'
'• Изменения в API оригинального сервиса;\n'
'• Блокировку аккаунтов пользователей;\n'
'• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n'
'4. Конфиденциальность\n'
'4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n'
'4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n'
'4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n'
'5. Ответственность и ограничения\n'
'5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n'
'5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n'
'6. Правовые основания\n'
'6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n'
'• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n'
'• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n'
'• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n'
'• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n'
'6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n'
'• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n'
'• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n'
'• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n'
'6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n'
'7. Заключительные положения\n'
'7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n'
'• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n'
'• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n'
'• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n'
'7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n'
'8. Функции безопасности и конфиденциальности\n'
'8.1. Приложение «Komet» включает инструменты защиты приватности:\n'
'• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов OpenSourceIntelligence (OSINT);\n'
'• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n'
'• Ограничение телеметрии — для минимизации передачи диагностических данных.\n'
'8.2. Данные функции:\n'
'• Направлены исключительно на защиту конфиденциальности пользователей;\n'
'• Не используются для обхода систем безопасности оригинального сервиса;\n'
'• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n'
'8.3. Разработчики не несут ответственности за:\n'
'• Блокировки, связанные с использованием инструментов конфиденциальности;\n'
'• Изменения в работе сервиса при активации данных функций.\n'
'8.4. Функции экспорта и импорта сессии\n'
'8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n'
'• Обеспечения переносимости данных между устройствами пользователя\n'
'• Резервного копирования учетных данных\n'
'• Восстановления доступа при утере устройства\n'
'8.4.2. Особенности реализации:\n'
'• Экспорт сессии осуществляется без привязки к номеру телефона\n'
'• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n'
'• Ключ шифрования известен только пользователю и не сохраняется в приложении\n'
'8.4.3. Техническая реализация экспорта сессии:\n'
'• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n'
'• Используется подмена параметров сессии для сохранения контекста аутентификации\n'
'• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n'
'• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n'
'• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n'
'• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n'
'8.4.4. Правовые основания:\n'
'• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n'
'• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n'
'• Принцип минимизации данных — сбор только необходимой для работы информации\n'
'• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n'
'• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n'
'• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n'
'8.4.5. Ограничения ответственности:\n'
'• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n'
'• Разработчики не имеют доступа к зашифрованным данным сессии\n'
'• Восстановление утерянных паролей невозможно в целях безопасности\n'
'• Ключи шифрования не хранятся в приложении и известны только пользователю',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.5,
),
),
],
),
),
],
),
),
),
);
}
void _validateAndSubmit() {
setState(() {
final phone = _phoneController.text.replaceAll(RegExp(r'\D'), '');
if (phone.isEmpty) {
_errorText = 'Пожалуйста, введите номер телефона';
} else if (phone.length < 10) {
_errorText = 'Номер телефона слишком короткий';
} else {
_errorText = null;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CodeConfirmationScreen(
phoneNumber: '+7 ${_phoneController.text}',
),
),
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D12),
body: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null && details.primaryVelocity! < -500) {
_showTOS(context);
}
},
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: () {},
icon: const Icon(
Symbols.admin_panel_settings,
color: Colors.white70,
weight: 400,
),
),
IconButton(
onPressed: () {},
icon: const Icon(
Symbols.language,
color: Colors.white70,
weight: 400,
),
),
],
),
const SizedBox(height: 24),
Center(
child: Column(
children: [
Image.asset(
'assets/komet.png',
height: 80,
color: Colors.white,
),
const SizedBox(height: 16),
const Text(
'Войдите в Komet',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
const Text(
'Проверьте код страны и введите свой\nномер телефона.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
),
],
),
),
const SizedBox(height: 24),
_buildInputField(
label: 'Страна',
content: Row(
children: [
const Text(
'Россия',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400),
),
const Spacer(),
const Icon(Icons.keyboard_arrow_down, color: Colors.white70),
],
),
),
const SizedBox(height: 12),
_buildInputField(
label: 'Номер телефона',
isError: _errorText != null,
errorText: _errorText,
content: Row(
children: [
const Text(
'+7',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400),
),
const SizedBox(width: 12),
Container(
width: 1,
height: 24,
color: Colors.white24,
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
_PhoneInputFormatter(),
],
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400),
decoration: const InputDecoration(
hintText: '(000) 000-00-00',
hintStyle: TextStyle(color: Colors.white38, fontSize: 15, fontWeight: FontWeight.w400),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onChanged: (value) {
if (_errorText != null) {
setState(() {
_errorText = null;
});
}
},
),
),
],
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () {},
child: const Text(
'Другие способы входа',
style: TextStyle(
color: Color(0xFFAFAFFF),
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.4,
),
),
),
const Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: RichText(
text: TextSpan(
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
height: 1.4,
fontFamily: 'Outfit',
),
children: [
const TextSpan(text: 'Продолжая, вы соглашаетесь с\n'),
TextSpan(
text: 'пользовательскими соглашениями',
style: const TextStyle(
color: Color(0xFFAFAFFF),
fontWeight: FontWeight.w500,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showTOS(context),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: FloatingActionButton(
onPressed: _validateAndSubmit,
backgroundColor: const Color(0xffc1c4ff),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
child: const Icon(Icons.arrow_forward, color: Colors.black),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
),
);
},
),
),
),
);
}
Widget _buildInputField({
required String label,
required Widget content,
bool isError = false,
String? errorText,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: double.infinity,
height: 54,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border.all(
color: isError ? Colors.redAccent : const Color(0xFFBEC2FF),
width: 1.5,
),
borderRadius: BorderRadius.circular(50),
),
child: content,
),
Positioned(
top: -10,
left: 20,
child: Container(
color: const Color(0xFF0D0D12),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
label,
style: TextStyle(
color: isError ? Colors.redAccent : const Color(0xFFBEC2FF),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
),
],
),
if (isError && errorText != null) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
errorText,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
],
],
);
}
}
class _PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
var text = newValue.text.replaceAll(RegExp(r'\D'), '');
if (newValue.text.length < oldValue.text.length) {
final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), '');
if (text.length == oldDigits.length && text.isNotEmpty) {
text = text.substring(0, text.length - 1);
}
}
if (text.length > 10) text = text.substring(0, 10);
final buffer = StringBuffer();
for (int i = 0; i < text.length; i++) {
if (i == 0) buffer.write('(');
buffer.write(text[i]);
if (i == 2) buffer.write(') ');
if (i == 5) buffer.write('-');
}
final formattedText = buffer.toString();
return TextEditingValue(
text: formattedText,
selection: TextSelection.collapsed(offset: formattedText.length),
);
}
}
@@ -0,0 +1,478 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'dart:ui' as ui;
class ChatListScreen extends StatefulWidget {
const ChatListScreen({super.key});
@override
State<ChatListScreen> createState() => _ChatListScreenState();
}
class _ChatListScreenState extends State<ChatListScreen> {
String _selectedCategory = 'Все чаты';
int _currentNavIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D12),
body: SafeArea(
bottom: false,
child: Stack(
children: [
CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Подключение...',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
IconButton(
onPressed: () {},
icon: const Icon(
Symbols.more_vert,
color: Colors.white54,
weight: 400,
),
),
],
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 96,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
_buildStoryItem('Даша', 'https://i.pravatar.cc/150?u=dasha', true),
_buildStoryItem('Мастика', 'https://i.pravatar.cc/150?u=mastika', false),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
sliver: SliverToBoxAdapter(
child: Container(
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF1E1E2A),
borderRadius: BorderRadius.circular(50),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Row(
children: [
Icon(Symbols.search, color: Colors.white38, size: 20, weight: 400),
SizedBox(width: 10),
Expanded(
child: TextField(
style: TextStyle(color: Colors.white, fontSize: 15),
decoration: InputDecoration(
hintText: 'Поиск',
hintStyle: TextStyle(color: Colors.white38, fontSize: 15),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 48,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
ui.PointerDeviceKind.touch,
ui.PointerDeviceKind.mouse,
ui.PointerDeviceKind.trackpad,
},
),
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
physics: const BouncingScrollPhysics(),
children: [
_buildCategoryChip('Все чаты'),
const SizedBox(width: 8),
_buildCategoryChip('Контакты'),
const SizedBox(width: 8),
_buildCategoryChip('Пидоры'),
const SizedBox(width: 8),
_buildCategoryChip('Каналы'),
const SizedBox(width: 8),
_buildCategoryChip('Группы'),
const SizedBox(width: 8),
_buildCategoryChip('Боты'),
const SizedBox(width: 8),
_buildCategoryChip('Избранное'),
const SizedBox(width: 8),
_buildCategoryChip('Архив'),
],
),
),
),
),
SliverList(
delegate: SliverChildListDelegate([
_buildChatItem(
'Станислав',
'Хорошо',
'10:07',
'https://i.pravatar.cc/150?u=stas',
isOnline: true,
isRead: true,
),
_buildChatItem(
'Илья',
'печатает...',
'10:07',
'https://i.pravatar.cc/150?u=ilya',
isOnline: true,
isTyping: true,
unreadCount: 1,
),
_buildChatItem(
'Вероника',
'Спасибо',
'09:56',
'https://i.pravatar.cc/150?u=veronika',
isRead: true,
),
_buildChatItem(
'Komet Client',
'Кстати. Смотрите, какую шту...',
'09:56',
'https://i.pravatar.cc/150?u=komet',
unreadCount: 5,
isMuted: true,
),
_buildChatItem(
'4-й подъезд',
'Людмила: Сколько?',
'09:34',
'https://i.pravatar.cc/150?u=podezd',
unreadCount: 78,
isMuted: true,
),
const SizedBox(height: 100),
]),
),
],
),
Positioned(
left: 8,
right: 8,
bottom: 24,
child: Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: const Color(0xFF1E1E28),
borderRadius: BorderRadius.circular(34),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
double totalWidth = constraints.maxWidth;
double totalWeight = 5.2;
double unitWidth = totalWidth / totalWeight;
double activeWidth = unitWidth * 2.2;
double inactiveWidth = unitWidth * 1.0;
double leftOffset = 0;
for (int i = 0; i < _currentNavIndex; i++) {
leftOffset += inactiveWidth;
}
return Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
left: leftOffset + 4,
top: 8,
bottom: 8,
width: activeWidth - 8,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFBEC2FF),
borderRadius: BorderRadius.circular(26),
),
),
),
Row(
children: List.generate(4, (index) {
IconData icon;
String label;
switch (index) {
case 0: icon = Symbols.chat_bubble; label = 'Чаты'; break;
case 1: icon = Symbols.call; label = 'Звонки'; break;
case 2: icon = Symbols.person_pin; label = 'Контакты'; break;
default: icon = Symbols.settings; label = 'Настройки';
}
bool isSelected = _currentNavIndex == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
width: isSelected ? (activeWidth - 0.5) : (inactiveWidth - 0.5),
child: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: _buildNavItem(index, icon, label),
),
);
}),
),
],
);
},
),
),
),
Positioned(
right: 20,
bottom: 110,
child: FloatingActionButton(
onPressed: () {},
backgroundColor: const Color(0xFFC1C4FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Symbols.add, color: Colors.black, size: 28, weight: 400),
),
),
],
),
),
);
}
Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) {
return Padding(
padding: const EdgeInsets.only(right: 16),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: hasUpdate
? Border.all(color: const Color(0xFFC1C4FF), width: 2)
: Border.all(color: Colors.white10),
),
child: CircleAvatar(
radius: 26,
backgroundImage: NetworkImage(imageUrl),
),
),
const SizedBox(height: 6),
Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildCategoryChip(String title) {
bool isSelected = _selectedCategory == title;
return GestureDetector(
onTap: () => setState(() => _selectedCategory = title),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFFC1C4FF) : const Color(0xFF1E1E2A),
borderRadius: BorderRadius.circular(15),
),
child: Text(
title,
style: TextStyle(
color: isSelected ? Colors.black : Colors.white54,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
);
}
Widget _buildChatItem(
String name,
String message,
String time,
String imageUrl, {
bool isOnline = false,
bool isTyping = false,
bool isRead = false,
int unreadCount = 0,
bool isMuted = false,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
leading: Stack(
children: [
CircleAvatar(
radius: 24,
backgroundImage: NetworkImage(imageUrl),
),
if (isOnline)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFFC1C4FF),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF0D0D12), width: 2),
),
),
),
],
),
title: Row(
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
if (isMuted)
const Icon(Symbols.notifications_off, color: Colors.white24, size: 14, weight: 400),
const SizedBox(width: 8),
Text(
time,
style: const TextStyle(
color: Colors.white38,
fontSize: 12,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Expanded(
child: Text(
message,
style: TextStyle(
color: isTyping ? const Color(0xFFC1C4FF) : Colors.white54,
fontSize: 14,
fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (unreadCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isMuted ? Colors.white10 : const Color(0xFF1E1E2A),
borderRadius: BorderRadius.circular(10),
),
child: Text(
unreadCount.toString(),
style: TextStyle(
color: isMuted ? Colors.white38 : Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
)
else if (isRead)
const Icon(Symbols.done_all, color: Color(0xFFC1C4FF), size: 16, weight: 400),
],
),
),
);
}
Widget _buildNavItem(int index, IconData icon, String label) {
bool isSelected = _currentNavIndex == index;
return GestureDetector(
onTap: () => setState(() => _currentNavIndex = index),
behavior: HitTestBehavior.opaque,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: isSelected ? Colors.black : Colors.white,
size: 20,
weight: 400,
fill: isSelected ? 1.0 : 0.0,
),
AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
width: isSelected ? null : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isSelected ? 1.0 : 0.0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
color: Colors.black,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
),
),
);
}
}