Merge branch 'dev/0.5.0' into feature/server-connection-handshake

This commit is contained in:
klockky
2026-04-01 19:55:41 +03:00
committed by GitHub
17 changed files with 3157 additions and 111 deletions
+1
View File
@@ -1,2 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
kotlin.incremental=false
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+31
View File
@@ -0,0 +1,31 @@
class SpoofData {
static const List<String> deviceNames = [
'Samsung Galaxy S23',
'Xiaomi 13 Pro',
'Google Pixel 7',
'OnePlus 11',
];
static const List<String> osVersions = ['12', '13', '14'];
static const List<String> resolutions = [
'1080x2400',
'1440x3200',
'720x1600',
];
static const List<String> deviceIds = [
'a1b2c3d4e5f6',
'f8e7d6c5b4a3',
'9876543210ab',
'1234567890cd',
];
static const List<String> 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';
}
+1 -1
View File
@@ -1,4 +1,4 @@
import 'package:logger/logger.dart';
import 'package:logger/logger.dart'; //нет это не опасный вредоносный вирус логер который украдёт ваш аккаунт Browl Starz
final logger = Logger(
// устанавливает минимальный уровень логов.
@@ -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<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
}
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
final TextEditingController _codeController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _timerSeconds = 30;
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_timer?.cancel();
_codeController.dispose();
_focusNode.dispose();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timerSeconds = 30;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
if (_timerSeconds > 0) {
_timerSeconds--;
} else {
_timer?.cancel();
}
});
});
}
void _resendCode() {
if (_timerSeconds == 0) {
_startTimer();
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<double> animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
);
},
child: Text(
char,
key: ValueKey<String>(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),
],
),
),
),
);
}
}
+897
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<void> _checkTOS() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_isTOSRead = prefs.getBool('IsReadeTOS') ?? false;
});
}
}
Future<void> _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<CountryName>(
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<ScrollUpdateNotification>(
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'
'• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов OpenSourceIntelligence (OSINT);\n'
'• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n'
'• Ограничение телеметрии — для минимизации передачи диагностических данных.\n'
'8.2. Данные функции:\n'
'• Направлены исключительно на защиту конфиденциальности пользователей;\n'
'• Не используются для обхода систем безопасности оригинального сервиса;\n'
'• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n'
'8.3. Разработчики не несут ответственности за:\n'
'• Блокировки, связанные с использованием инструментов конфиденциальности;\n'
'• Изменения в работе сервиса при активации данных функций.\n'
'8.4. Функции экспорта и импорта сессии\n'
'8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n'
'• Обеспечения переносимости данных между устройствами пользователя\n'
'• Резервного копирования учетных данных\n'
'• Восстановления доступа при утере устройства\n'
'8.4.2. Особенности реализации:\n'
'• Экспорт сессии осуществляется без привязки к номеру телефона\n'
'• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n'
'• Ключ шифрования известен только пользователю и не сохраняется в приложении\n'
'8.4.3. Техническая реализация экспорта сессии:\n'
'• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n'
'• Используется подмена параметров сессии для сохранения контекста аутентификации\n'
'• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n'
'• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n'
'• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n'
'• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n'
'8.4.4. Правовые основания:\n'
'• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n'
'• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n'
'• Принцип минимизации данных — сбор только необходимой для работы информации\n'
'• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n'
'• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n'
'• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n'
'8.4.5. Ограничения ответственности:\n'
'• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n'
'• Разработчики не имеют доступа к зашифрованным данным сессии\n'
'• Восстановление утерянных паролей невозможно в целях безопасности\n'
'• Ключи шифрования не хранятся в приложении и известны только пользователю',
style: TextStyle(
color: 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),
);
}
}
@@ -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<SelectCountryScreen> createState() => _SelectCountryScreenState();
}
class _SelectCountryScreenState extends State<SelectCountryScreen> {
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
List<CountryName> _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);
},
);
},
),
);
}
}
@@ -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<SpoffRedactedScreen> createState() => _SpoffRedactedScreenState();
}
class _SpoffRedactedScreenState extends State<SpoffRedactedScreen> {
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<void> _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<void> _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,
),
),
),
],
);
}
}
+21
View File
@@ -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,
),
),
);
}
}
@@ -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<ChatListScreen> createState() => _ChatListScreenState();
}
class _ChatListScreenState extends State<ChatListScreen>
with SingleTickerProviderStateMixin {
String _selectedCategory = 'Все чаты';
int _currentNavIndex = 0;
bool _isFabOpen = false;
late AnimationController _fabController;
final Set<String> _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<int>(
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<int> _buildPopupMenuItem(
int value,
String title,
IconData icon,
) {
final cs = Theme.of(context).colorScheme;
return PopupMenuItem<int>(
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),
),
),
);
}
}
+240
View File
@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
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,
),
),
],
),
),
);
}
}
@@ -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,
),
),
);
}
}
@@ -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});
}
@@ -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<CustomNotification> createState() => _CustomNotificationState();
}
class _CustomNotificationState extends State<CustomNotification>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 300),
);
_opacity = Tween<double>(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,
),
),
),
),
),
),
);
}
}
+37 -103
View File
@@ -1,3 +1,4 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'backend/api.dart';
import 'core/storage/app_database.dart';
@@ -14,116 +15,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<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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(),
);
},
);
}
}
+36 -7
View File
@@ -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:
@@ -284,18 +284,26 @@ packages:
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:
@@ -321,6 +329,19 @@ packages:
source: hosted
version: "0.17.6"
objective_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:
name: objective_c
@@ -521,7 +542,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"
+6
View File
@@ -44,6 +44,10 @@ dependencies:
sqflite: ^2.4.2
sqflite_common_ffi: ^2.4.0+2
path: ^1.9.1
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:
@@ -66,6 +70,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: