diff --git a/assets/komet.png b/assets/komet.png new file mode 100644 index 0000000..8c251b9 Binary files /dev/null and b/assets/komet.png differ diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart new file mode 100644 index 0000000..076f4f3 --- /dev/null +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -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 createState() => _CodeConfirmationScreenState(); +} + +class _CodeConfirmationScreenState extends State { + 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 animation) { + return ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Text( + char, + key: ValueKey(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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e69de29..298ebd3 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + 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' + '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (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), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index e69de29..8288825 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + 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, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..8c52477 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/frontend/screens/auth/login_screen.dart'; void main() { runApp(const MyApp()); @@ -7,116 +9,19 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Komet', + debugShowCheckedModeBanner: false, theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + useMaterial3: true, + brightness: Brightness.dark, + textTheme: GoogleFonts.outfitTextTheme( + ThemeData.dark().textTheme, ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + home: const LoginScreen(), ); } } diff --git a/pubspec.lock b/pubspec.lock index 3e29bd2..150913d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -65,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -83,6 +115,46 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -123,6 +195,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -139,6 +219,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + url: "https://pub.dev" + source: hosted + version: "4.2906.0" meta: dependency: transitive description: @@ -155,6 +243,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -163,6 +267,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -216,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -232,6 +416,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index a704a79..b832e79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 + google_fonts: ^6.2.1 + material_symbols_icons: ^4.2906.0 dev_dependencies: flutter_test: @@ -59,6 +61,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/komet.png # To add assets to your application, add an assets section, like this: # assets: