diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..23bd6e3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +kotlin.incremental=false 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/core/config/spoof_data.dart b/lib/core/config/spoof_data.dart new file mode 100644 index 0000000..f60c3ff --- /dev/null +++ b/lib/core/config/spoof_data.dart @@ -0,0 +1,31 @@ +class SpoofData { + static const List deviceNames = [ + 'Samsung Galaxy S23', + 'Xiaomi 13 Pro', + 'Google Pixel 7', + 'OnePlus 11', + ]; + + static const List osVersions = ['12', '13', '14']; + + static const List resolutions = [ + '1080x2400', + '1440x3200', + '720x1600', + ]; + + static const List deviceIds = [ + 'a1b2c3d4e5f6', + 'f8e7d6c5b4a3', + '9876543210ab', + '1234567890cd', + ]; + + static const List architectures = ['arm64-v8a', 'armeabi-v7a']; + + static const String deviceType = 'android'; + static const String timezone = 'Europe/Moscow'; + static const String locale = 'ru_RU'; + static const String appVersion = '26.10.1'; + static const String buildNumber = '6728'; +} diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 580e134..4320e5b 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,4 +1,4 @@ -import 'package:logger/logger.dart'; +import 'package:logger/logger.dart'; //нет это не опасный вредоносный вирус логер который украдёт ваш аккаунт Browl Starz final logger = Logger( // устанавливает минимальный уровень логов. 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..0ea6a65 --- /dev/null +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.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(); + print('Resending code to ${widget.phoneNumber}'); + } + } + + void _navigateToChats() { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const ChatListScreen()), + (route) => false, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + 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: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', + style: TextStyle( + color: cs.outline, + 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: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isFocused + ? cs.primary + : (hasValue ? cs.outlineVariant : 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: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _resendCode, + child: Text( + _timerSeconds > 0 + ? 'Отправить повторно через $_timerSeconds сек.' + : 'Отправить код по SMS', + style: TextStyle( + color: cs.tertiary, + 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 + ? cs.primaryContainer + : cs.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: Icon( + Icons.arrow_forward, + color: _codeController.text.length == 5 ? cs.onPrimaryContainer : cs.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e69de29..7a705d7 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -0,0 +1,897 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/countries.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'code_confirmation_screen.dart'; +import 'select_country_screen.dart'; +import 'spoff_redacted_screen.dart'; +import '../../widgets/custom_notification.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final TextEditingController _phoneController = TextEditingController(); + late CountryName _selectedCountry; + bool _isPhoneValid = false; + bool _isTOSRead = false; + + @override + void initState() { + super.initState(); + _selectedCountry = countriesByCode['RU'] ?? allCountries.first; + _checkTOS(); + } + + Future _checkTOS() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _isTOSRead = prefs.getBool('IsReadeTOS') ?? false; + }); + } + } + + Future _markTOSRead() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('IsReadeTOS', true); + if (mounted) { + setState(() { + _isTOSRead = true; + }); + } + } + + void _showCountryPicker() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SelectCountryScreen(selectedCountry: _selectedCountry), + ), + ); + if (result != null) { + setState(() { + _selectedCountry = result; + _phoneController.clear(); + _isPhoneValid = false; + }); + } + } + + void _showTOS(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + double progress = _isTOSRead ? 1.0 : 0.0; + return StatefulBuilder( + builder: (context, setModalState) { + final cs = Theme.of(context).colorScheme; + return 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: [ + Text( + 'Условия использования', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: NotificationListener( + onNotification: + (ScrollUpdateNotification notification) { + if (_isTOSRead) return false; + final metrics = notification.metrics; + if (metrics.maxScrollExtent > 0) { + double newProgress = + metrics.pixels / metrics.maxScrollExtent; + newProgress = newProgress.clamp(0.0, 1.0); + if (newProgress >= 0.99 && !_isTOSRead) { + _markTOSRead(); + setModalState(() { + progress = 1.0; + }); + } else { + setModalState(() { + progress = newProgress; + }); + } + } + return false; + }, + child: ListView( + controller: scrollController, + children: [ + //У мя вопрос а тут точно номера статей верные??? + 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: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + const SizedBox(height: 16), + Stack( + alignment: Alignment.centerRight, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 48, + alignment: Alignment.centerLeft, + padding: EdgeInsets.only( + right: progress == 1.0 ? 56.0 : 0.0, + ), + child: Container( + height: 4, + width: double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(2), + ), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress < 1.0 ? progress : 1.0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: progress == 1.0 ? 1.0 : 0.0, + curve: Curves.easeIn, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + transform: Matrix4.translationValues( + progress == 1.0 ? 0 : 20, + 0, + 0, + ), + width: 40, + height: 40, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.check, + color: cs.onPrimaryContainer, + size: 24, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + void _showPhoneConfirmationDialog(String formattedPhone) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, anim1, anim2) => const SizedBox.shrink(), + transitionBuilder: (context, anim1, anim2, child) { + final cs = Theme.of(context).colorScheme; + final curve = Curves.easeOutQuart.transform(anim1.value); + return Opacity( + opacity: anim1.value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - curve)), + child: Transform.scale( + scale: 0.8 + (0.2 * curve), + child: AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Это правильный номер?', + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + '${_selectedCountry.phoneCode} $formattedPhone', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Изменить', + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', + ), + ), + ); + }, + child: Text( + 'Готово', + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _validateAndSubmit() { + if (!_isTOSRead) { + showCustomNotification(context, 'Соглащение прочитай щегол'); + return; + } + _showPhoneConfirmationDialog(_phoneController.text); + } + + void _showSecurityOptions(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Symbols.security, color: cs.onSurface), + title: Text( + 'Подделка спуфа', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SpoffRedactedScreen(), + ), + ); + }, + ), + ListTile( + leading: Icon(Symbols.vpn_lock, color: cs.onSurface), + title: Text( + 'Прокси', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + + void _showOtherLoginMethods(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Symbols.qr_code_2, color: cs.onSurface), + title: Text( + 'По QR code', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Symbols.key, color: cs.onSurface), + title: Text( + 'По токену', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Symbols.description, color: cs.onSurface), + title: Text( + 'По файлу сессии', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + 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: 44), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () => _showSecurityOptions(context), + icon: Icon( + Symbols.admin_panel_settings, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + IconButton( + onPressed: () {}, + icon: Icon( + Symbols.language, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + ], + ), + const SizedBox(height: 74), + Center( + child: Column( + children: [ + Image.asset( + 'assets/komet.png', + height: 80, + color: cs.onSurface, + ), + const SizedBox(height: 16), + Text( + 'Войдите в Komet', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 32, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Проверьте код страны и введите свой\nномер телефона.', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 48), + InkWell( + onTap: _showCountryPicker, + borderRadius: BorderRadius.circular(50), + child: _buildInputField( + label: 'Страна', + content: Row( + children: [ + Text( + _selectedCountry.ru, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + Icon( + Icons.keyboard_arrow_down, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + const SizedBox(height: 40), + _buildInputField( + label: 'Номер телефона', + content: Row( + children: [ + Text( + _selectedCountry.phoneCode, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 12), + Container( + width: 1, + height: 24, + color: cs.outlineVariant, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PhoneInputFormatter(_selectedCountry), + ], + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + hintText: '(000) 000-00-00', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (value) { + final digits = value.replaceAll( + RegExp(r'\D'), + '', + ); + setState(() { + _isPhoneValid = + digits.length == + _selectedCountry.phoneDigits; + }); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => _showOtherLoginMethods(context), + child: Text( + 'Другие способы входа', + style: GoogleFonts.inter( + color: cs.primary, + 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: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.4, + ), + children: [ + TextSpan( + text: + 'Продолжая, вы соглашаетесь с \n', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + TextSpan( + text: + 'пользовательскими соглашениями', + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => _showTOS(context), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FloatingActionButton( + onPressed: _isPhoneValid + ? _validateAndSubmit + : null, + backgroundColor: _isPhoneValid + ? cs.primaryContainer + : cs.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: Icon( + Icons.arrow_forward, + color: _isPhoneValid + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + Widget _buildInputField({required String label, required Widget content}) { + final cs = Theme.of(context).colorScheme; + 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: cs.primary, width: 1.5), + borderRadius: BorderRadius.circular(50), + ), + child: content, + ), + Positioned( + top: -10, + left: 20, + child: Container( + color: cs.surface, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + label, + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _PhoneInputFormatter extends TextInputFormatter { + final CountryName country; + _PhoneInputFormatter(this.country); + + @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 > country.phoneDigits) { + text = text.substring(0, country.phoneDigits); + } + + final buffer = StringBuffer(); + int digitIdx = 0; + + for (int i = 0; i < country.phoneGroupSizes.length; i++) { + if (digitIdx >= text.length) break; + + buffer.write(country.phoneGroupSeparators[i]); + + final groupSize = country.phoneGroupSizes[i]; + final remainingDigits = text.length - digitIdx; + final digitsToTake = remainingDigits < groupSize + ? remainingDigits + : groupSize; + + buffer.write(text.substring(digitIdx, digitIdx + digitsToTake)); + digitIdx += digitsToTake; + + if (digitIdx == text.length && + i < country.phoneGroupSeparators.length - 1) {} + } + + if (digitIdx == text.length && text.length == country.phoneDigits) { + if (country.phoneGroupSeparators.length > + country.phoneGroupSizes.length) { + buffer.write(country.phoneGroupSeparators.last); + } + } + + final formattedText = buffer.toString(); + return TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: formattedText.length), + ); + } +} diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart new file mode 100644 index 0000000..a72335e --- /dev/null +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/countries.dart'; + +class SelectCountryScreen extends StatefulWidget { + final CountryName selectedCountry; + + const SelectCountryScreen({super.key, required this.selectedCountry}); + + @override + State createState() => _SelectCountryScreenState(); +} + +class _SelectCountryScreenState extends State { + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + List _filteredCountries = allCountries; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _filterCountries(String query) { + setState(() { + if (query.isEmpty) { + _filteredCountries = allCountries; + } else { + final q = query.toLowerCase(); + _filteredCountries = allCountries.where((c) { + return c.ru.toLowerCase().contains(q) || + c.en.toLowerCase().contains(q) || + c.phoneCode.contains(q); + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + ), + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + hintText: 'Поиск страны...', + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + border: InputBorder.none, + ), + onChanged: _filterCountries, + ) + : Text( + 'Выберите страну', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + centerTitle: true, + actions: [ + IconButton( + onPressed: () { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchController.clear(); + _filteredCountries = allCountries; + } + }); + }, + icon: Icon( + _isSearching ? Symbols.close : Symbols.search, + color: cs.onSurface, + ), + ), + ], + ), + body: ListView.builder( + itemCount: _filteredCountries.length, + itemBuilder: (context, index) { + final country = _filteredCountries[index]; + final isSelected = country.code == widget.selectedCountry.code; + + return ListTile( + leading: Text( + country.phoneCode, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + title: Text( + country.ru, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + trailing: isSelected + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () { + Navigator.pop(context, country); + }, + ); + }, + ), + ); + } +} diff --git a/lib/frontend/screens/auth/spoff_redacted_screen.dart b/lib/frontend/screens/auth/spoff_redacted_screen.dart new file mode 100644 index 0000000..a553a59 --- /dev/null +++ b/lib/frontend/screens/auth/spoff_redacted_screen.dart @@ -0,0 +1,217 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:komet/core/config/spoof_data.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; + +class SpoffRedactedScreen extends StatefulWidget { + const SpoffRedactedScreen({super.key}); + + @override + State createState() => _SpoffRedactedScreenState(); +} + +class _SpoffRedactedScreenState extends State { + final TextEditingController _deviceNameController = TextEditingController(); + final TextEditingController _osVersionController = TextEditingController(); + final TextEditingController _resolutionController = TextEditingController(); + final TextEditingController _deviceIdController = TextEditingController(); + final TextEditingController _architectureController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final random = Random(); + + setState(() { + _deviceNameController.text = + prefs.getString('spoof_device_name') ?? + SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = + prefs.getString('spoof_os_version') ?? + SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]; + _resolutionController.text = + prefs.getString('spoof_resolution') ?? + SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _deviceIdController.text = + prefs.getString('spoof_device_id') ?? + SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _architectureController.text = + prefs.getString('spoof_architecture') ?? + SpoofData.architectures[random.nextInt( + SpoofData.architectures.length, + )]; + }); + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('spoof_device_name', _deviceNameController.text); + await prefs.setString('spoof_os_version', _osVersionController.text); + await prefs.setString('spoof_resolution', _resolutionController.text); + await prefs.setString('spoof_device_id', _deviceIdController.text); + await prefs.setString('spoof_architecture', _architectureController.text); + + if (mounted) { + showCustomNotification(context, 'Настройки сохранены'); + } + } + + @override + void dispose() { + _deviceNameController.dispose(); + _osVersionController.dispose(); + _resolutionController.dispose(); + _deviceIdController.dispose(); + _architectureController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + iconTheme: IconThemeData(color: cs.onSurface), + title: Text( + 'Подделка спуфа', + style: GoogleFonts.inter( + color: cs.onSurface, + fontWeight: FontWeight.w500, + ), + ), + backgroundColor: cs.surface, + actions: [ + IconButton( + icon: Icon(Icons.check, color: cs.primary), + onPressed: _saveSettings, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildTextField( + controller: TextEditingController(text: SpoofData.deviceType), + label: 'Тип устройства', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _deviceNameController, + label: 'Имя устройства', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _osVersionController, + label: 'Версия ОС', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _resolutionController, + label: 'Разрешение экрана', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.timezone), + label: 'Часовой пояс', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.locale), + label: 'Локаль', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _deviceIdController, + label: 'ID устройства', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.appVersion), + label: 'Версия приложения', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.buildNumber), + label: 'Build Number', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _architectureController, + label: 'Архитектура', + cs: cs, + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + bool readOnly = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + readOnly: readOnly, + style: GoogleFonts.inter( + color: readOnly ? cs.onSurfaceVariant : cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + filled: true, + fillColor: readOnly + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart new file mode 100644 index 0000000..21bf27b --- /dev/null +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class CallsTab extends StatelessWidget { + const CallsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Text( + 'Звонки (Заглушка)', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index e69de29..678ac56 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -0,0 +1,985 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'chat_screen.dart'; + +import '../calls/calls_tab.dart'; +import '../contacts/contacts_tab.dart'; +import '../profile/settings_tab.dart'; + +class ChatListScreen extends StatefulWidget { + const ChatListScreen({super.key}); + + @override + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State + with SingleTickerProviderStateMixin { + String _selectedCategory = 'Все чаты'; + int _currentNavIndex = 0; + bool _isFabOpen = false; + late AnimationController _fabController; + final Set _selectedChats = {}; + final ScrollController _scrollController = ScrollController(); + double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded + + bool get _isSelectionMode => _selectedChats.isNotEmpty; + + void _toggleSelection(String chatId) { + setState(() { + if (_selectedChats.contains(chatId)) { + _selectedChats.remove(chatId); + } else { + _selectedChats.add(chatId); + } + }); + } + + void _clearSelection() { + setState(() { + _selectedChats.clear(); + }); + } + + @override + void initState() { + super.initState(); + _fabController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_scrollController.hasClients) { + final double offset = _scrollController.offset; + if (offset < 0) { + final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (newRatio != _pullRatio) { + setState(() { + _pullRatio = newRatio; + }); + } + } else if (_pullRatio > 0) { + setState(() { + _pullRatio = 0.0; + }); + } + } + } + + @override + void dispose() { + _fabController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _toggleFab() { + setState(() { + _isFabOpen = !_isFabOpen; + if (_isFabOpen) { + _fabController.forward(); + } else { + _fabController.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Stack( + children: [ + IndexedStack( + index: _currentNavIndex, + children: [ + Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_scrollController.hasClients && + _scrollController.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + // Scrolled UP (pulling down) + setState(() { + _pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0); + }); + } else if (pointerSignal.scrollDelta.dy > 0 && + _pullRatio > 0) { + // Scrolled DOWN (folding up) + setState(() { + _pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0); + }); + } + } + } + }, + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + height: _isSelectionMode + ? 0 + : (132 + (96 * _pullRatio)), + color: Colors.transparent, + clipBehavior: Clip.hardEdge, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues( + 0, + _isSelectionMode ? -100 : 0, + 0, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 12, + 20, + 4, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 16, + ), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], + ), + ), + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + 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, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 4, + 20, + 12, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: EdgeInsets.only( + top: _isSelectionMode ? 64 : 0, + ), + sliver: SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + 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: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], + ), + ), + ), + ), + ), + SliverList( + delegate: SliverChildListDelegate([ + _buildChatItem( + 'stas', + 'Станислав', + 'Хорошо', + '10:07', + 'https://i.pravatar.cc/150?u=stas', + isOnline: true, + isRead: true, + ), + _buildChatItem( + 'ilya', + 'Илья', + 'печатает...', + '10:07', + 'https://i.pravatar.cc/150?u=ilya', + isOnline: true, + isTyping: true, + unreadCount: 1, + ), + _buildChatItem( + 'veronika', + 'Вероника', + 'Спасибо', + '09:56', + 'https://i.pravatar.cc/150?u=veronika', + isRead: true, + ), + _buildChatItem( + 'komet', + 'Komet Client', + 'Кстати. Смотрите, какую шту...', + '09:56', + 'https://i.pravatar.cc/150?u=komet', + unreadCount: 5, + isMuted: true, + ), + _buildChatItem( + 'podezd', + '4-й подъезд', + 'Людмила: Сколько?', + '09:34', + 'https://i.pravatar.cc/150?u=podezd', + unreadCount: 78, + isMuted: true, + ), + ]), + ), + ], + ), // CustomScrollView + ), // Listener + const CallsTab(), + const ContactsTab(), + const SettingsTab(), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: 8, + right: 8, + bottom: _isSelectionMode ? -100 : 24.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 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: cs.primary, + 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), + ), + ); + }), + ), + ], + ); + }, + ), + ), + ), + ), + AnimatedBuilder( + animation: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: val * 0.2), + ), + ), + ), + if (!_isSelectionMode) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 110 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 110, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ], + ); + }, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) { + final cs = Theme.of(context).colorScheme; + 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: cs.primary, width: 2) + : Border.all(color: cs.outlineVariant), + ), + child: CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(imageUrl), + ), + ), + const SizedBox(height: 6), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildFolderChip(String title) { + final cs = Theme.of(context).colorScheme; + bool isSelected = _selectedCategory == title; + return GestureDetector( + onTap: () => setState(() => _selectedCategory = title), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + title, + style: TextStyle( + color: isSelected ? cs.onPrimaryContainer : cs.primary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + Widget _buildChatItem( + String id, + String name, + String message, + String time, + String imageUrl, { + bool isOnline = false, + bool isTyping = false, + bool isRead = false, + int unreadCount = 0, + bool isMuted = false, + }) { + final cs = Theme.of(context).colorScheme; + final isSelected = _selectedChats.contains(id); + + return InkWell( + onTap: () { + if (_isSelectionMode) { + _toggleSelection(id); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), + ), + ); + } + }, + onLongPress: () => _toggleSelection(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 2, + ), + leading: Stack( + children: [ + CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + weight: 600, + ), + ), + ) + else if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + ), + ), + ], + ), + title: Row( + children: [ + Expanded( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isMuted) + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + const SizedBox(width: 8), + Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + 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 + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? cs.outline : cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavItem(int index, IconData icon, String label) { + final cs = Theme.of(context).colorScheme; + 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 ? cs.onPrimary : cs.onSurface, + 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: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFabMenu() { + final cs = Theme.of(context).colorScheme; + return Container( + width: 220, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFabMenuItem(Symbols.search, 'Найти по номеру'), + _buildFabMenuItem(Symbols.group_add, 'Добавить группу'), + _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + ], + ), + ); + } + + Widget _buildFabMenuItem(IconData icon, String title) { + final cs = Theme.of(context).colorScheme; + return InkWell( + onTap: () { + // Action logic here + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + PopupMenuItem _buildPopupMenuItem( + int value, + String title, + IconData icon, + ) { + final cs = Theme.of(context).colorScheme; + return PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 20), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildFoldedStory(String imageUrl, int index) { + return Positioned( + left: index * 12.0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black, width: 2), + ), + child: CircleAvatar( + radius: 12, + backgroundImage: NetworkImage(imageUrl), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e69de29..50f586d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'dart:ui'; + +class ChatScreen extends StatefulWidget { + final String name; + final String imageUrl; + + const ChatScreen({super.key, required this.name, required this.imageUrl}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + bool _hasText = false; + + @override + void initState() { + super.initState(); + _messageController.addListener(_onTextChanged); + } + + @override + void dispose() { + _messageController.removeListener(_onTextChanged); + _messageController.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final bool newHasText = _messageController.text.trim().isNotEmpty; + if (newHasText != _hasText) { + setState(() { + _hasText = newHasText; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8), + elevation: 0, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon( + Symbols.arrow_back, + color: Colors.white, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: 0, + title: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + const Text( + 'Connecting...', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, color: Colors.white, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon( + Symbols.more_vert, + color: Colors.white, + weight: 400, + ), + onPressed: () {}, + ), + ], + ), + body: Stack( + children: [ + Positioned.fill( + child: Image.network( + 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ), + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + ), + ), + Column(children: [const Spacer(), _buildInputArea(context)]), + ], + ), + ); + } + + Widget _buildInputArea(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + constraints: const BoxConstraints( + minHeight: 54, + maxHeight: 180, + ), + decoration: BoxDecoration( + color: const Color(0xFF1B1B1B).withOpacity(0.9), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: Colors.white.withOpacity(0.1), + width: 0.5, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Symbols.face, + color: Colors.white.withOpacity(0.7), + size: 24, + weight: 400, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _messageController, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: const InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _hasText ? 0 : 36, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _hasText ? 0 : 1, + child: _hasText + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon( + Symbols.attachment, + color: Colors.white.withOpacity(0.7), + size: 24, + weight: 400, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xFF2B2B2B), + shape: BoxShape.circle, + ), + child: Icon( + _hasText ? Symbols.send : Symbols.mic, + color: Colors.white, + size: 24, + weight: 400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart new file mode 100644 index 0000000..c47b562 --- /dev/null +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ContactsTab extends StatelessWidget { + const ContactsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Text( + 'Контакты (Заглушка)', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart new file mode 100644 index 0000000..95e1f6a --- /dev/null +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class SettingsTab extends StatelessWidget { + const SettingsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildSection( + context, + cs, + items: const [ + _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + _SettingsItem( + icon: Symbols.language, + label: 'Войти в сферум', + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: const [ + _SettingsItem( + icon: Symbols.notifications_active, + label: 'Уведомления и звук', + ), + _SettingsItem(icon: Symbols.lock, label: 'Безопасность'), + _SettingsItem(icon: Symbols.devices, label: 'Устройства'), + ], + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Symbols.qr_code_2, + color: cs.onSurfaceVariant, + size: 26, + weight: 400, + ), + onPressed: () {}, + ), + IconButton( + icon: Icon( + Symbols.edit, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 8), + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: Image.network( + 'https://i.pravatar.cc/150?u=ilya', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => CircleAvatar( + backgroundColor: cs.primaryContainer, + child: Icon( + Symbols.person, + color: cs.onPrimaryContainer, + size: 40, + ), + ), + ), + ), + ), + const SizedBox(height: 14), + Text( + 'Илья Беларуских', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 3), + Text( + '@everrnyan', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } + + Widget _buildSection( + BuildContext context, + ColorScheme cs, { + required List<_SettingsItem> items, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(items.length, (index) { + final item = items[index]; + final isLast = index == items.length - 1; + return _buildSettingsRow(context, cs, item, isLast: isLast); + }), + ), + ), + ); + } + + Widget _buildSettingsRow( + BuildContext context, + ColorScheme cs, + _SettingsItem item, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + item.icon, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + item.label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } +} + +class _SettingsItem { + final IconData icon; + final String label; + + const _SettingsItem({required this.icon, required this.label}); +} diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart new file mode 100644 index 0000000..85c6e91 --- /dev/null +++ b/lib/frontend/widgets/custom_notification.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void showCustomNotification(BuildContext context, String message) { + final overlay = Overlay.of(context); + final entry = OverlayEntry( + builder: (context) => CustomNotification(message: message), + ); + overlay.insert(entry); + Future.delayed(const Duration(milliseconds: 1900), () { + entry.remove(); + }); +} + +class CustomNotification extends StatefulWidget { + final String message; + const CustomNotification({required this.message, super.key}); + + @override + State createState() => _CustomNotificationState(); +} + +class _CustomNotificationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + ); + _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); + _controller.forward(); + Future.delayed(const Duration(milliseconds: 1600), () { + if (mounted) _controller.reverse(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Positioned( + bottom: 60, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Center( + child: FadeTransition( + opacity: _opacity, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + child: Text( + widget.message, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 429dfbf..1b586f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'backend/api.dart'; @@ -12,116 +13,49 @@ void main() async { 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', - 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), + static const _fallbackSeed = Color(0xFFC1C4FF); + + static ColorScheme _adjustScheme(ColorScheme base) { + return base.copyWith( + surface: Color.alphaBlend( + base.primary.withValues(alpha: 0.05), + const Color(0xFF0D0D14), + ), + surfaceContainerHigh: Color.alphaBlend( + base.primary.withValues(alpha: 0.08), + const Color(0xFF1A1A26), + ), + surfaceContainerHighest: Color.alphaBlend( + base.primary.withValues(alpha: 0.12), + const Color(0xFF262636), ), - 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, + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + final baseScheme = darkDynamic ?? ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.dark, + ); + + final darkScheme = _adjustScheme(baseScheme); + + return MaterialApp( + title: 'Komet', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + colorScheme: darkScheme, + textTheme: GoogleFonts.interTextTheme( + 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 6bb2187..fbb7880 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -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: @@ -192,22 +208,38 @@ 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: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + 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: @@ -224,6 +256,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: @@ -289,7 +337,15 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted version: "0.7.7" diff --git a/pubspec.yaml b/pubspec.yaml index 252d74e..3498a9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,9 +37,10 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 - device_info_plus: ^12.3.0 - flutter_timezone: ^5.0.1 - timezone: ^0.11.0 + google_fonts: ^6.2.1 + material_symbols_icons: ^4.2906.0 + dynamic_color: ^1.8.1 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: @@ -62,6 +63,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: