new project stable version

This commit is contained in:
2026-05-10 19:11:31 +03:00
commit 3616f84556
391 changed files with 23857 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
import '../components/card_input_field.dart'; // ← новый импорт
import '../components/utils/card_formatter.dart';
import '../viewmodel/add_card_bloc.dart';
import '../event/add_card_event.dart';
import '../state/add_card_state.dart';
class AddCardScreen extends StatelessWidget {
const AddCardScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocListener<AddCardBloc, AddCardState>(
listenWhen: (previous, current) =>
previous.status != current.status &&
current.status == AddCardStatus.success,
listener: (context, state) {
context.pop();
},
child: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
// 🔹 ВЕРХНЯЯ ЧАСТЬ (шапка + форма)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const CustomAppBar(title: 'Добавление карты'),
const SizedBox(height: 24),
// 🔹 ОСНОВНОЙ КОНТЕЙНЕР С ПОЛЯМИ
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
// Номер карты
BlocBuilder<AddCardBloc, AddCardState>(
builder: (context, state) {
return CardInputField(
hintText: '0000 0000 0000 0000',
icon: Icons.credit_card,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(16),
CardNumberFormatter(),
],
letterSpacing: 2,
onChanged: (value) {
// Очищаем от пробелов перед отправкой в BLoC
final cleanValue = value.replaceAll(' ', '');
context.read<AddCardBloc>().add(CardNumberChanged(cleanValue));
},
);
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: BlocBuilder<AddCardBloc, AddCardState>(
builder: (context, state) {
return CardInputField(
hintText: 'MM/YY',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
CardMonthInputFormatter(),
],
onChanged: (value) {
final cleanValue = value.replaceAll('/', '');
context.read<AddCardBloc>().add(ExpiryDateChanged(cleanValue));
},
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: BlocBuilder<
AddCardBloc,
AddCardState>(
builder: (context, state) {
return CardInputField(
hintText: 'CVV',
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter
.digitsOnly,
LengthLimitingTextInputFormatter(
3),
],
onChanged: (value) {
context.read<AddCardBloc>().add(
CvvChanged(value));
},
);
},
),
),
],
),
const SizedBox(height: 16),
BlocBuilder<AddCardBloc, AddCardState>(
builder: (context, state) {
return CardInputField(
hintText: 'Имя и фамилия на карте',
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization
.words,
onChanged: (value) {
context.read<AddCardBloc>().add(
CardHolderChanged(value));
},
);
},
),
const SizedBox(height: 20),
// 🔹 КНОПКА "ДОБАВИТЬ КАРТУ"
BlocBuilder<AddCardBloc, AddCardState>(
builder: (context, state) {
return Container(
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.white.withOpacity(0.15),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: state.isFormValid
? () => {
context.read<AddCardBloc>().add(
AddCardSubmitted()),
context.go("/home/payment-methods")
}
: null,
borderRadius: BorderRadius.circular(
24),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment
.center,
children: [
Icon(
Icons.add,
color: state.isFormValid
? const Color(0xFF66E3C4)
: Colors.white
.withOpacity(0.3),
size: 20,
),
const SizedBox(width: 8),
Text(
'Добавить карту',
style: TextStyle(
color: state.isFormValid
? Colors.white
: Colors.white
.withOpacity(0.3),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
},
),
],
),
),
],
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 24),
child: Column(
children: [
// Текст о безопасности
const Text(
'Мы не сохраняем данные карты у себя. Оплата происходит '
'через сертифицированный провайдер Беларуси bePaid. '
'Платежная страница системы bePaid отвечает требованиям '
'безопасности передачи данных PCI DSS Level I. '
'Все конфиденциальные данные хранятся в зашифрованном виде.',
style: TextStyle(
color: Colors.white38,
fontSize: 11,
height: 1.5,
),
textAlign: TextAlign.justify,
),
const SizedBox(height: 24),
// Логотипы платёжных систем
const SizedBox(height: 24),
],
),
),
],
),
),
),
)
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../components/gradient_button.dart';
import '../../core/app_colors.dart';
class BlockedScreen extends StatelessWidget {
const BlockedScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
"Аккаунт заблокирован",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 22),
),
const SizedBox(height: 40),
Image.asset(
'assets/ban.png',
width: double.infinity,
fit: BoxFit.cover,
),
const SizedBox(height: 40),
GradientButton(
text: "Обратиться в техподдержку",
onTap: () {},
showArrows: true,
height: 50,
width: 290,
fontSize: 14,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
import '../components/link_row.dart';
class DocumentsScreen extends StatelessWidget {
const DocumentsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
// ✅ Используем общий AppBar
const SizedBox(height: 16),
CustomAppBar(title: 'Документы'),
const SizedBox(height: 32),
// Список ссылок
LinkRow(
icon: 'assets/icons/doc.png',
title: 'Договор аренды',
onTap: () => openLink('https://...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/doc.png',
title: 'Политика конфиденциальности',
onTap: () => openLink('https://...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/doc.png',
title: 'Правила вождения',
onTap: () => openLink('https://...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/doc.png',
title: 'Правила оплаты картой',
onTap: () => openLink('https://...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
const Spacer(), // Отодвигаем картинку вниз
const SizedBox(height: 20),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../core/app_colors.dart';
import '../../domain/entities/user_profile.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../event/edit_profile_event.dart';
import '../state/edit_profile_state.dart';
import '../event/profile_event.dart';
import '../viewmodel/edit_profile_bloc.dart';
import '../viewmodel/profile_bloc.dart';
class EditProfileScreen extends StatefulWidget {
final UserProfile profile;
const EditProfileScreen({super.key, required this.profile});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
late final TextEditingController nameController;
late final TextEditingController birthDateController;
late final TextEditingController phoneController;
late final TextEditingController emailController;
DateTime? _selectedBirthDate;
@override
void initState() {
super.initState();
context.read<EditProfileBloc>().add(EditProfileStarted());
nameController = TextEditingController();
phoneController = TextEditingController();
emailController = TextEditingController();
String initialDateText = '';
if (widget.profile.birthDate.isNotEmpty) {
try {
_selectedBirthDate = DateFormat('dd.MM.yyyy').parse(widget.profile.birthDate);
initialDateText = DateFormat('dd.MM.yyyy').format(_selectedBirthDate!);
} catch (e) {
initialDateText = widget.profile.birthDate;
print("EXCEPTION: $e");
}
}
birthDateController = TextEditingController(text: initialDateText);
}
void _submit(BuildContext context) {
final profileFromState = context.read<EditProfileBloc>().state.profile;
if (profileFromState == null) return;
final String birthDateForApi = _selectedBirthDate?.toIso8601String() ?? '';
final updatedProfile = profileFromState.copyWith(
name: nameController.text,
birthDate: birthDateForApi.isNotEmpty ? "${birthDateForApi}Z" : '',
email: emailController.text,
);
context.read<EditProfileBloc>().add(EditProfileSubmitted(updatedProfile));
context.read<ProfileBloc>().add(ProfileUpdated());
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedBirthDate ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 100),
lastDate: DateTime.now(),
);
if (picked != null && picked != _selectedBirthDate) {
setState(() {
_selectedBirthDate = picked;
birthDateController.text = DateFormat('dd.MM.yyyy').format(picked);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: BlocConsumer<EditProfileBloc, EditProfileState>(
listener: (context, state) {
final profile = state.profile;
if (profile != null && nameController.text.isEmpty) {
nameController.text = profile.name;
phoneController.text = profile.phone;
emailController.text = profile.email;
birthDateController.text = profile.birthDate;
}
if (state.isSuccess) {
context.read<ProfileBloc>().add(ProfileUpdated());
context.pop();
}
if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
}
},
builder: (context, state) {
if (state.profile == null) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return Column(
children: [
const SizedBox(height: 16),
CustomAppBar(title: 'Личные данные'),
const SizedBox(height: 32),
_Field(
'Имя',
nameController,
iconPath: 'assets/icons/edit.png',
),
_Field(
'Дата рождения',
birthDateController,
iconPath: 'assets/icons/edit.png',
readOnly: true,
onTap: () => _selectDate(
context,
),
),
_Field(
'Телефон',
phoneController,
iconPath: 'assets/icons/lock.png',
enabled: false,
),
_Field(
'E-mail',
emailController,
iconPath: 'assets/icons/edit.png',
),
const Spacer(),
state.isSaving
? const CircularProgressIndicator(color: Colors.white)
: GradientButton(
text: 'Сохранить изменения',
onTap: () => _submit(context),
width: double.infinity,
height: 56,
fontSize: 16,
showArrows: true,
),
const SizedBox(height: 24),
],
);
},
),
),
),
),
);
}
}
class _Field extends StatelessWidget {
final String label;
final TextEditingController controller;
final String iconPath;
final bool enabled;
final bool readOnly;
final VoidCallback? onTap;
const _Field(
this.label,
this.controller, {
required this.iconPath,
this.enabled = true,
this.readOnly = false,
this.onTap
});
@override
Widget build(BuildContext context) {
final borderColor = enabled
? Colors.white.withOpacity(0.3)
: Colors.white.withOpacity(0.15);
final iconOpacity = enabled ? 0.7 : 0.3;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
controller: controller,
enabled: enabled,
readOnly: readOnly,
onTap: onTap,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: AppColors.white70),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: borderColor),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: AppColors.smsDigit, width: 1.5),
),
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: Opacity(
opacity: iconOpacity,
child: Image.asset(iconPath, width: 20, height: 20),
),
),
suffixIconConstraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
),
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
class LicenseAgreementScreen extends StatelessWidget {
const LicenseAgreementScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
// 🔹 APPBAR С КНОПКОЙ НАЗАД
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: ' '),
),
// 🔹 ПРОКРУЧИВАЕМЫЙ ТЕКСТ
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Лицензионное соглашение на использование программы «Be Happy» для мобильных устройств',
style: const TextStyle(
color: Colors.white,
fontSize: 18
),
),
const SizedBox(height: 16),
_buildParagraph(
'Перед использованием программы, пожалуйста, ознакомьтесь с условиями нижеследующего лицензионного соглашения.\n\n'
'Любое использование Вами программы означает полное и безоговорочное принятие Вами условий настоящего лицензионного соглашения.\n\n'
'Если Вы не принимаете условия лицензионного соглашения в полном объёме, Вы не имеете права использовать программу в каких-либо целях.',
),
const SizedBox(height: 24),
_buildSectionHeader('1. Общие положения'),
_buildParagraph(
'1.1. Настоящее Лицензионное соглашение («Лицензия») устанавливает условия использования программы «Be Happy» для мобильных устройств («Программа») и заключено между любым лицом, использующим Программу («Пользователь»), и ООО “БИХЕППИБЕЛ”, Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А, являющимся правообладателем исключительного права на Программу («Лицензиар»).\n\n'
'1.2. Копируя Программу, устанавливая её на свое мобильное устройство или используя Программу любым образом, Пользователь выражает свое полное и безоговорочное согласие со всеми условиями Лицензии.\n\n'
'1.3. Использование Программы разрешается только на условиях настоящей Лицензии. Если Пользователь не принимает условия Лицензии в полном объёме, Пользователь не имеет права использовать Программу в каких-либо целях. Использование Программы с нарушением (невыполнением) какого-либо из условий Лицензии запрещено.\n\n'
'1.4. Использование Программы Пользователем на условиях настоящей Лицензии в личных некоммерческих целях осуществляется безвозмездно. Использование Программы на условиях и способами, не предусмотренными настоящей Лицензией, возможно только на основании отдельного соглашения с Лицензиаром.\n\n'
'• Оферта на подключение к программе «Be Happy», размещенная в сети Интернет по адресу: https://behappybel.by,\n'
'• Условия подключения к сервису «Be Happy», размещенные в сети Интернет по адресу: https://behappybel.by,\n'
'• «Политика конфиденциальности», размещенная в сети Интернет по адресу: https://behappybel.by.\n\n'
'Указанные документы (в том числе любые из их частей) могут быть изменены Правообладателем в одностороннем порядке без какого-либо специального уведомления, новая редакция документов вступает в силу с момента их опубликования, если иное не предусмотрено новыми редакциями документов.\n\n'
'1.6. Все или некоторые функции Программы могут быть недоступны или ограничены в зависимости от наличия или отсутствия акцепта Пользователем документов, указанных в пункте 1.5 настоящей Лицензии.\n\n'
'1.7. К настоящей Лицензии и всем отношениям, связанным с использованием Программы, подлежит применению право Республики Беларусь и любые претензии или иски, вытекающие из настоящей Лицензии или использования Программы, должны быть поданы и рассмотрены в суде по месту нахождения Правообладателя.\n\n'
'1.8. Лицензиар может предоставить Пользователю перевод настоящей Лицензии с русского на другие языки, однако в случае противоречия между условиями Лицензии на русском языке и ее переводом, юридическую силу имеет исключительно русскоязычная версия Лицензии. Русскоязычная версия Лицензии размещена по адресу: https://behappybel.by.',
),
const SizedBox(height: 24),
_buildSectionHeader('2. Права на Программу'),
_buildParagraph(
'2.1. Исключительное право на Программу принадлежит Лицензиар.',
),
const SizedBox(height: 24),
_buildSectionHeader('3. Лицензия'),
_buildParagraph(
'3.1. Лицензиар безвозмездно, на условиях простой (неисключительной) лицензии, предоставляет Пользователю непередаваемое право использования Программы на территории всех стран мира следующими способами:\n\n'
'3.1.1. Воспроизводить Программу путем её копирования и установки на мобильное (-ые) устройство (-ва) Пользователя. При установке на мобильное устройство каждой копии Программы присваивается индивидуальный номер, который автоматически сообщается Правообладателю;\n\n'
'3.1.2. Применять Программу по прямому функциональному назначению только после принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов и ввода в интерфейсе Программы специального кода (пароля), предоставленного Пользователю в подтверждение принятия указанных документов.',
),
const SizedBox(height: 24),
_buildSectionHeader('4. Ограничения'),
_buildParagraph(
'4.1. За исключением использования в объемах и способами, прямо предусмотренными настоящей Лицензией или законодательством Республики Беларусь, Пользователь не имеет права изменять, декомпилировать, дизассемблировать, дешифровать и производить иные действия с объектным кодом Программы, имеющие целью получение информации о реализации алгоритмов, используемых в Программе, создавать производные произведения с использованием Программы, а также осуществлять (разрешать осуществлять) иное использование Программы, без письменного согласия Лицензиара.\n\n'
'4.2. Пользователь не имеет право воспроизводить и распространять Программу в коммерческих целях (в том числе за плату), в том числе в составе сборников программных продуктов, без письменного согласия Лицензиара.\n\n'
'4.3. Пользователь не имеет права распространять Программу в виде, отличном от того, в котором он ее получил, без письменного согласия Правообладателя.\n\n'
'4.4. Программа должна использоваться (в том числе распространяться) под наименованием: «Be Happy». Пользователь не вправе изменять наименование Программы, изменять и/или удалять знак охраны авторского права (copyright notice) или иные указания на Лицензиара.',
),
const SizedBox(height: 24),
_buildSectionHeader('5. Условия использования отдельных функций Программы'),
_buildParagraph(
'5.1. Выполнение некоторых функций Программы возможно только при наличии доступа к сети Интернет. Пользователь самостоятельно получает и оплачивает такой доступ на условиях и по тарифам своего оператора связи или провайдера доступа к сети Интернет.\n\n'
'5.2. Пользователь считается правомерно владеющим экземпляром Программы, при условии ввода в интерфейсе Программы специального кода (пароля), который выдается Лицензиар Пользователю в случае акцепта Пользователем Оферты на подключение к программе «Be Happy» размещенной в сети Интернет по адресу: https://behappybel.by и при условии соответствия Пользователя требованиям, указанным в Условиях подключения к сервису «Be Happy», размещенных в сети Интернет по адресу: https://behappybel.by В этом случае Пользователь вправе использовать функциональные возможности Программы.\n\n'
'5.3. Специальных код (пароль), необходимый для подтверждения правомерности владения (использования) Пользователем экземпляром Программы, не считается действительным, если он был получен без принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов.',
),
const SizedBox(height: 24),
_buildSectionHeader('6. Ответственность по Лицензии'),
_buildParagraph(
'6.1. Программа (включая её части и содержание) предоставляется на условиях «как есть» (as is). Лицензиар не предоставляет никаких гарантий в отношении безошибочной и бесперебойной работы Программы или отдельных её компонентов и/или функций, соответствия Программы конкретным целям и ожиданиям Пользователя, не гарантирует достоверность, точность, полноту и своевременность Данных, а также не предоставляет никаких иных гарантий, прямо не указанных в настоящей Лицензии.\n\n'
'6.2. Лицензиар не несет ответственности за какие-либо прямые или косвенные последствия какого-либо использования или невозможности использования Программы (включая ей части и содержание) и/или ущерб, причиненный Пользователю и/или третьим сторонам в результате какого-либо использования, неиспользования или невозможности использования Программы (включая ей части и содержание) или отдельных её компонентов и/или функций, в том числе из-за возможных ошибок или сбоев в работе Программы, за исключением случаев, прямо предусмотренных законодательством.\n\n'
'6.3. Пользователь настоящим уведомлен и соглашается, что при использовании Программы Лицензиар в автоматическом передается следующая информация: тип операционной системы мобильного устройства Пользователя, версия и идентификатор Программы, статистика использования функций Программы, а также иная техническая информация.\n\n'
'6.4. Программа может содержать ссылки на сайты и приложения третьих лиц. Правообладатель не контролирует и не несет ответственности за содержание и порядок использования таких сайтов и приложений. Условия использования таких сайтов и приложений определены в их соглашениях и политиках конфиденциальности.\n\n'
'6.5. Все вопросы и претензии, связанные с использованием/невозможностью использования Программы, а также возможным нарушением Программой законодательства и/или прав третьих лиц, должны направляться через форму обратной связи по адресу:',
),
const SizedBox(height: 24),
_buildSectionHeader('7. Обновления/новые версии Программы'),
_buildParagraph(
'7.1. Действие настоящей Лицензии распространяется на все последующие обновления/новые версии Программы. Соглашаясь с установкой обновления/новой версии Программы, Пользователь принимает условия настоящей Лицензии для соответствующих обновлений/новых версий Программы, если обновление/установка новой версии Программы не сопровождается иным Лицензионным соглашением.',
),
const SizedBox(height: 24),
_buildSectionHeader('8. Изменения условий настоящей Лицензии'),
_buildParagraph(
'8.1. Настоящее лицензионное соглашение может изменяться Лицензиром в одностороннем порядке. Уведомление Пользователя о внесенных изменениях в условия настоящей Лицензии публикуется на странице: https://behappybel.by. Указанные изменения в условиях лицензионного соглашения вступают в силу с даты их публикации, если иное не оговорено в соответствующей публикации.',
),
const SizedBox(height: 24),
_buildSectionHeader('Лицензиар'),
_buildParagraph(
'ООО “БИХЕППИБЕЛ”\n'
'УНП 392050943\n'
'ОКПО 511318892000\n'
'Свидетельство о государственной регистрации № 392026683 от 22.01.2026 выдано Инспекцией Министерства по налогам и сборам Республики Беларусь по Октябрьскому району г.Витебска\n\n'
'Юридический адрес: Республика Беларусь, 210017, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А',
),
const SizedBox(height: 32),
],
),
),
),
],
),
),
),
);
}
Widget _buildParagraph(String text) {
return Text(
text,
style: const TextStyle(
color: Color(0xFFD1D1D6),
fontSize: 14,
height: 1.5,
),
);
}
Widget _buildSectionHeader(String text) {
return Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.4,
),
);
}
}

View File

@@ -0,0 +1,834 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
import 'package:be_happy/domain/entities/client_notification.dart';
import 'package:be_happy/domain/usecase/book_scooter_usecase.dart';
import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart';
import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart';
import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart';
import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart';
import 'package:be_happy/domain/usecase/get_scooter_usecase.dart';
import 'package:be_happy/domain/usecase/save_map_settings_usecase.dart';
import 'package:be_happy/presentation/components/fine_notification_card.dart';
import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart';
import 'package:be_happy/presentation/components/payment_notification_card.dart';
import 'package:be_happy/presentation/components/sheet/current_rides_sheet.dart';
import 'package:be_happy/presentation/components/sheet/map_settings_sheet.dart';
import 'package:be_happy/presentation/components/sheet/tariff_sheet.dart';
import 'package:be_happy/presentation/event/current_rides_event.dart';
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart';
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/scooter.dart';
import '../../domain/entities/zone.dart';
import '../../domain/usecase/get_address_by_point_usecase.dart';
import '../../domain/usecase/get_available_tariffs_usecase.dart';
import '../../domain/usecase/get_notifications_stream_usecase.dart';
import '../components/notification_toast.dart';
import '../components/sheet/scooter_bottom_sheet.dart';
import '../components/side_menu.dart';
import '../components/unpaid_order_notification_card.dart';
import '../event/map_event.dart';
import '../event/scooter_detail_modal_event.dart';
import '../state/map_state.dart';
import '../viewmodel/map_bloc.dart';
import '../viewmodel/scooter_detail_modal_bloc.dart';
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
YandexMapController? mapController;
Position? _currentPosition;
StreamSubscription<Position>? _positionStreamSubscription;
StreamSubscription<ClientNotification>? _notificationStreamSubscription;
bool _isFirstLocationUpdate = true;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_checkLocationPermission();
_initScooterIcon();
_startNotificationStream();
context.read<MapBloc>().add(FetchProfileData());
context.read<MapBloc>().add(CheckUser());
}
@override
void dispose() {
_debounceTimer?.cancel();
_positionStreamSubscription?.cancel();
_notificationStreamSubscription?.cancel();
context.read<MapBloc>().stopNotificationStream();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
drawer: const SideMenu(),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Stack(
children: [
BlocConsumer<MapBloc, ScooterState>(
listenWhen: (previous, current) {
return current.lastNotification !=
previous.lastNotification ||
current.flags != previous.flags;
},
listener: (context, state) {
if (state.lastNotification != null) {
_showNotificationToast(state.lastNotification!);
}
if (state.flags != null) {
if (!state.flags.hasCard) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
if (state.flags.hasFine) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: FineNotificationCard(
/*onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},*/
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
if (state.flags.hasUnpaidOrder) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: UnpaidOrderNotificationCard(
/*onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},*/
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
}
},
buildWhen: (previous, current) =>
previous.scooters != current.scooters ||
previous.zones != current.zones,
builder: (context, state) {
final scooters = _buildScooterPlacemarks(
state.scooters,
state.address ?? "Unknown address",
);
final zonePolygons = _buildZonePolygons(state.zones);
return RepaintBoundary(
child: YandexMap(
onMapCreated: (controller) {
controller.toggleUserLayer(visible: true);
mapController = controller;
if (_currentPosition != null) {
_fetchScooters();
}
},
onCameraPositionChanged:
(cameraPosition, reason, finished) {
if (finished) {
_fetchScooters();
}
},
mapObjects: [
...zonePolygons,
ClusterizedPlacemarkCollection(
mapId: const MapObjectId('scooters_cluster'),
placemarks: scooters,
radius: 30,
minZoom: 15,
consumeTapEvents: true,
onClusterTap: (collection, cluster) {
final clusteredPlacemarks = cluster.placemarks;
final filtered = state.scooters.where((scooter) {
return clusteredPlacemarks.any(
(pm) => pm.mapId.value == scooter.id.toString(),
);
}).toList();
_onMarkerTap(filtered);
},
onClusterAdded: (self, cluster) async {
return cluster.copyWith(
appearance: cluster.appearance.copyWith(
opacity: 1.0,
icon: PlacemarkIcon.single(
PlacemarkIconStyle(
image: BitmapDescriptor.fromBytes(
await ClusterIconPainter(
cluster.size,
).getClusterIconBytes(),
),
scale: 0.8,
),
),
),
);
},
),
],
),
);
},
),
// Индикатор загрузки (отдельный строитель для статуса)
BlocBuilder<MapBloc, ScooterState>(
buildWhen: (previous, current) =>
previous.status != current.status,
builder: (context, state) {
if (state.status == ScooterStatus.loading) {
return const Positioned(
top: 80,
left: 0,
right: 0,
child: Center(child: CircularProgressIndicator()),
);
}
return const SizedBox.shrink();
},
),
// Кнопки управления (Меню, Уведомления)
_buildTopButtons(),
// Кнопки навигации
if (_currentPosition != null) _buildSideControls(),
_buildCentralQrButton(),
],
),
),
),
);
}
void _startNotificationStream() {
final notificationsStreamUseCase = getIt<GetNotificationsStreamUseCase>();
_notificationStreamSubscription = notificationsStreamUseCase().listen(
(notification) {
if (mounted) {
context.read<MapBloc>().add(NotificationReceived(notification));
}
},
onError: (error) {
print("SSE NOTIFICATION ERROR: $error");
},
);
}
void _checkLocationPermission() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
await Geolocator.requestPermission();
}
_getCurrentLocation();
// _startTrackingLocation();
}
void _startTrackingLocation() {
const LocationSettings locationSettings = LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 10,
);
_positionStreamSubscription =
Geolocator.getPositionStream(
locationSettings: locationSettings,
).listen((Position position) {
if (!mounted) return;
print(
"----------------------------------------------------- tracking... --------------------------------------------------------",
);
setState(() => _currentPosition = position);
context.read<MapBloc>().add(
UpdateUserLocation(position.latitude, position.longitude),
);
if (_isFirstLocationUpdate) {
_moveCameraToPoint(position.latitude, position.longitude, zoom: 15);
_isFirstLocationUpdate = false;
}
_fetchScooters();
});
}
void _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() => _currentPosition = position);
if (mapController != null && mounted) {
await _moveCameraToPoint(position.latitude, position.longitude);
_fetchScooters();
}
} catch (e) {
debugPrint('Ошибка геолокации: $e');
}
}
void _fetchScooters() async {
final controller = mapController;
if (controller == null) return;
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () async {
final visibleRegion = await controller.getVisibleRegion();
final areaScooters = [
visibleRegion.bottomRight.longitude,
visibleRegion.bottomRight.latitude,
visibleRegion.topLeft.latitude,
visibleRegion.topLeft.longitude,
];
final areaZones = [
visibleRegion.bottomRight.longitude,
visibleRegion.bottomRight.latitude,
visibleRegion.topLeft.longitude,
visibleRegion.topLeft.latitude,
];
if (mounted) {
context.read<MapBloc>().add(FetchScooters(areaZones, areaScooters));
}
});
}
Future<void> _moveCameraToPoint(
double lat,
double lon, {
double zoom = 15,
}) async {
await mapController?.moveCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: Point(latitude: lat, longitude: lon),
zoom: zoom,
),
),
);
}
void _onMarkerTap(List<Scooter> scooters) async {
context.push(
"/home/scooter-sheet",
extra: {'scooters': scooters, 'currentLocation': _currentPosition},
);
/*final scoot = await showModalBottomSheet<Scooter>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) {
return BlocProvider(
create: (context) =>
ScooterDetailModalBloc(
getIt<GetAddressByPointUsecase>(),
getIt<GetScooterUsecase>(),
getIt<GetPedestrianRoutesUsecase>(),
)..add(
ScooterDetailModalStarted(
scooters,
_currentPosition!.latitude,
_currentPosition!.longitude,
),
),
child: ScooterBottomSheet(),
);
},
);*/
/*bool? isBooking = false;
if (scoot != null) {
final result = await context.push('/home/scooter/${scoot.id}');
if (result == true) {
// Даем небольшую задержку, чтобы навигация завершилась корректно
await Future.delayed(Duration(milliseconds: 300), () async {
isBooking = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) => BlocProvider(
create: (context) => TariffSheetBloc(
getIt<GetAvailableTariffsUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<BookScooterUsecase>(),
),
child: TariffSheet(scooter: scoot),
),
);
});
}
}
if (isBooking ?? false) {
showModalBottomSheet(
context: context,
builder: (context) => BlocProvider(
create: (context) =>
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
..add(LoadClientOrders(1)),
child: CurrentRidesSheet(clientId: 1),
),
);
}*/
}
void _onMapSettingsTap() {
context.push("/home/map-settings-sheet");
/*showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) {
return BlocProvider(
create: (context) => MapSettingsModalBloc(
getIt<GetMapSettingsUsecase>(),
getIt<SaveMapSettingsUsecase>(),
)..add(MapSettingsModalStarted()),
child: MapSettingsSheet(),
);
},
);*/
}
void _onNotificationTap() {
context.push("/home/current-rides-sheet");
/*showModalBottomSheet(
context: context,
builder: (context) => BlocProvider(
create: (context) =>
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
..add(LoadClientOrders()),
child: CurrentRidesSheet(),
),
);*/
// BotToast.showCustomNotification(
// duration: const Duration(seconds: 4),
//
// toastBuilder: (_) {
// return NotificationToast(
// title: "",
// onClose: () {
// BotToast.cleanAll();
// },
// );
// },
// );
}
void _showNotificationToast(ClientNotification notification) {
String title = _getNotificationTitle(notification.type);
Color backgroundColor = _getNotificationColor(notification.type);
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 50),
child: Material(
elevation: 8,
shadowColor: Colors.black26,
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 60,
child: Image.asset(
'assets/icons/clichnik.png',
fit: BoxFit.contain,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
notification.content,
style: TextStyle(fontSize: 14),
),
],
),
),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () {
BotToast.cleanAll();
},
),
),
],
),
),
),
);
},
);
}
String _getNotificationTitle(NotificationType type) {
switch (type) {
case NotificationType.info:
return 'Информация';
case NotificationType.attention:
return 'Внимание';
case NotificationType.warning:
return 'Предупреждение';
}
}
Color _getNotificationColor(NotificationType type) {
switch (type) {
case NotificationType.info:
return const Color(0xFF2196F3);
case NotificationType.attention:
return const Color(0xFFFF9800);
case NotificationType.warning:
return Colors.red;
}
}
void _initScooterIcon() async {
await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png');
}
List<PlacemarkMapObject> _buildScooterPlacemarks(
List<Scooter> scooters,
String address,
) {
return scooters.map((scooter) {
return PlacemarkMapObject(
mapId: MapObjectId('${scooter.id}'),
point: Point(latitude: scooter.longitude, longitude: scooter.latitude),
icon: PlacemarkIcon.single(
PlacemarkIconStyle(
image: BitmapDescriptor.fromAssetImage(
'assets/icons/scooter_placemark_fill.png',
),
scale: 0.2,
),
),
opacity: 1.0,
onTap: (object, point) async => {
_onMarkerTap([scooter]),
},
);
}).toList();
}
List<MapObject> _buildZonePolygons(List<Zone>? zones) {
if (zones == null || zones.isEmpty) return [];
List<MapObject> objects = [];
List<LinearRing> allZoneHoles = [];
for (var zone in zones) {
var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList();
var cleanPoints = <Point>[];
for (var p in points) {
if (cleanPoints.isEmpty || cleanPoints.last != p) {
cleanPoints.add(p);
}
}
if (cleanPoints.length > 2) {
if (cleanPoints.first != cleanPoints.last) {
cleanPoints.add(cleanPoints.first);
}
allZoneHoles.add(LinearRing(points: cleanPoints));
}
}
objects.add(
PolygonMapObject(
mapId: const MapObjectId('global_inverse_mask'),
polygon: Polygon(
outerRing: const LinearRing(points: [
Point(latitude: 85, longitude: -179.9),
Point(latitude: 85, longitude: 179.9),
Point(latitude: -85, longitude: 179.9),
Point(latitude: -85, longitude: -179.9),
]),
innerRings: allZoneHoles,
),
strokeWidth: 0,
fillColor: Colors.red.withOpacity(0.15),
zIndex: 0,
),
);
for (var zone in zones) {
Color borderColor;
if (zone.type == "Drive") {
borderColor = const Color(0xFF5ECD4C);
} else if (zone.type == "NotDrive") {
borderColor = const Color(0xFFEF4444);
} else {
borderColor = const Color(0xFFA78BFA);
}
objects.add(
PolylineMapObject(
mapId: MapObjectId('zone_contour_${zone.id}'),
polyline: Polyline(
points: zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(),
),
strokeColor: borderColor,
strokeWidth: 2.0,
zIndex: 1,
),
);
}
return objects;
}
Widget _buildTopButtons() {
return Stack(
children: [
Positioned(
top: 16,
left: 16,
child: Builder(
builder: (innerContext) => _RoundIconButton(
icon: Icons.menu,
onPressed: () => {Scaffold.of(innerContext).openDrawer()},
),
),
),
Positioned(
top: 16,
right: 16,
child: Column(
children: [
_RoundIconButton(
icon: Icons.notifications_sharp,
onPressed: _onNotificationTap,
),
const SizedBox(height: 12),
_RoundIconButton(
icon: Icons.directions_run,
onPressed: () => context.push("/home/current-rides-sheet"),
),
],
),
),
],
);
}
Widget _buildCentralQrButton() {
return Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: () => context.push("/home/qr-info"),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.darkBlue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.qr_code_scanner,
color: Colors.white,
size: 32,
),
),
),
),
);
}
Widget _buildSideControls() {
return Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_CircleIconButton(icon: Icons.map, onPressed: _onMapSettingsTap),
const SizedBox(height: 16),
_CircleIconButton(
icon: Icons.my_location,
onPressed: () {
context.read<MapBloc>().add(
UpdateUserLocation(
_currentPosition!.latitude,
_currentPosition!.longitude,
),
);
_moveCameraToPoint(
_currentPosition!.latitude,
_currentPosition!.longitude,
zoom: 17,
);
},
),
],
),
),
);
}
}
Future<Uint8List> painterToBytes(CustomPainter painter, Size size) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
painter.paint(canvas, size);
final picture = recorder.endRecording();
final image = await picture.toImage(size.width.toInt(), size.height.toInt());
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
class _RoundIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const _RoundIconButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.darkBlue,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 24),
onPressed: onPressed,
),
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const _CircleIconButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: AppColors.darkBlue,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 24),
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,387 @@
import 'package:flutter/material.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as dom;
import 'dart:convert';
import 'package:be_happy/di/service_locator.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
import '../../domain/usecase/get_news_by_id_usecase.dart';
class NewsDetailScreen extends StatefulWidget {
final int newsId;
final String title;
const NewsDetailScreen({
super.key,
required this.newsId,
required this.title,
});
@override
State<NewsDetailScreen> createState() => _NewsDetailScreenState();
}
class _NewsDetailScreenState extends State<NewsDetailScreen> {
bool _isLoading = true;
String? _errorMessage;
dynamic _news;
@override
void initState() {
super.initState();
_fetchNews();
}
Future<void> _fetchNews() async {
try {
final usecase = getIt<GetNewsByIdUsecase>();
final news = await usecase(widget.newsId);
if (mounted) {
setState(() {
_news = news;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
// 🔹 Заголовок в AppBar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: widget.title),
),
// 🔹 Контент
Expanded(
child: _isLoading
? const Center(
child: CircularProgressIndicator(color: Colors.white),
)
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Ошибка загрузки новости',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
_fetchNews();
});
},
child: const Text('Повторить'),
),
],
),
)
: _news != null
? _buildNewsContent(_news)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
Widget _buildNewsContent(dynamic news) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Текст новости: сначала проверяем textJson, потом text
if (news.textJson != null)
..._parseJsonText(news.textJson)
else if (news.text != null)
..._parseHtmlText(news.text),
],
),
);
}
List<Widget> _parseHtmlText(String htmlText) {
final parsedHtml = html_parser.parse(htmlText);
final List<dom.Node> elements = parsedHtml.body?.nodes ?? [];
return _parseHtmlElements(elements);
}
List<Widget> _parseHtmlElements(List<dom.Node> nodes) {
List<Widget> widgets = [];
for (final node in nodes) {
if (node is dom.Element) {
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
node.text,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
);
break;
case 'p':
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
node.text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
),
);
break;
case 'ul':
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.where((element) => element is dom.Element && element.localName == 'li')
.map((dom.Element li) => Padding(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
)),
Expanded(
child: Text(
li.text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
),
],
),
))
.toList(),
),
),
);
break;
default:
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
node.text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
);
}
} else if (node is dom.Text) {
widgets.add(
Text(
node.text,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
);
}
}
return widgets;
}
// 🔹 Парсинг JSON-текста (новый метод)
List<Widget> _parseJsonText(String jsonString) {
try {
final dynamic data = jsonDecode(jsonString);
return _parseJsonNode(data);
} catch (e) {
return [
const Text(
'Ошибка отображения текста новости',
style: TextStyle(color: Colors.red, fontSize: 14),
),
];
}
}
List<Widget> _parseJsonNode(dynamic node) {
List<Widget> widgets = [];
if (node is List) {
// Если корень — массив, парсим каждый элемент
for (final item in node) {
widgets.addAll(_parseJsonNode(item));
}
} else if (node is Map<String, dynamic>) {
final type = node['type'] as String?;
final content = node['content'];
switch (type) {
case 'div':
if (content is List) {
widgets.addAll(_parseJsonNode(content));
}
break;
case 'h2':
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
(content is List)
? content.join(' ')
: content?.toString() ?? '',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
);
break;
case 'p':
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
(content is List)
? content.join(' ')
: content?.toString() ?? '',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
),
);
break;
case 'ul':
if (content is List) {
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: content
.where((item) => item is Map<String, dynamic>)
.map((item) => item as Map<String, dynamic>)
.where((item) => item['type'] == 'li')
.map((li) => Padding(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
)),
Expanded(
child: Text(
(li['content'] is List)
? li['content'].join(' ')
: li['content']?.toString() ?? '',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.5,
),
),
),
],
),
))
.toList(),
),
),
);
}
break;
default:
// Если тип неизвестен, просто выводим текст
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
(content is List)
? content.join(' ')
: content?.toString() ?? type ?? 'unknown',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
);
}
}
return widgets;
}
String _formatDate(DateTime? date) {
if (date == null) return '';
final localDate = date.toLocal();
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
}
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:developer' as dev;
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../components/custom_app_bar.dart';
import '../event/news_event.dart';
import '../state/news_state.dart';
import '../viewmodel/news_bloc.dart';
import 'news_detail_screen.dart';
class NewsScreen extends StatelessWidget {
const NewsScreen({super.key});
@override
Widget build(BuildContext context) {
dev.log('🔍 NewsScreen: Создание экрана новостей');
return BlocProvider(
create: (context) {
dev.log('🔍 NewsScreen: Создание NewsBloc');
return getIt<NewsBloc>()..add(const NewsFetchRequested());
},
child: const NewsView(),
);
}
}
class NewsView extends StatelessWidget {
const NewsView({super.key});
@override
Widget build(BuildContext context) {
dev.log('🔍 NewsView: Построение UI');
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Новости'),
),
const SizedBox(height: 32),
Expanded(
child: BlocBuilder<NewsBloc, NewsState>(
builder: (context, state) {
dev.log('🔍 NewsView: Состояние ${state.status}, новостей: ${state.news.length}');
if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (state.status == NewsStatus.failure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Ошибка загрузки новостей',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
state.errorMessage ?? '',
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
dev.log('🔍 NewsView: Повторная загрузка');
context.read<NewsBloc>().add(const NewsFetchRequested());
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.smsDigit,
),
child: const Text('Повторить'),
),
],
),
);
}
if (state.news.isEmpty) {
return const _EmptyState();
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: state.news.length,
itemBuilder: (context, index) {
return _NewsCard(news: state.news[index]);
},
);
},
),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/news_empty.png',
width: 280,
height: 280,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.newspaper_outlined,
size: 120,
color: Colors.white38,
);
},
),
const SizedBox(height: 32),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 40),
child: Text(
'Следите за нашими последними новостями и акциями! Сейчас их нет, но скоро они появятся.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 16,
height: 1.5,
),
),
),
],
);
}
}
class _NewsCard extends StatelessWidget {
final dynamic news;
const _NewsCard({required this.news});
@override
Widget build(BuildContext context) {
final date = _formatDate(news.publishedAt);
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF141530),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: const TextStyle(
color: AppColors.white70,
fontSize: 12,
),
),
const SizedBox(height: 8),
Text(
news.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
news.previewText,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
height: 1.4,
),
),
const SizedBox(height: 16),
SizedBox(
height: 40,
child: OutlinedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetailScreen(
newsId: news.id,
title: news.title,
),
),
);
},
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)),
padding: const EdgeInsets.symmetric(horizontal: 20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Подробнее',
style: TextStyle(color: AppColors.smsDigit),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit,
),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit.withOpacity(0.6),
),
Icon(
Icons.arrow_forward_ios_sharp,
size: 12,
color: AppColors.smsDigit.withOpacity(0.3),
),
],
),
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else if (difference.inDays == 1) {
return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else {
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../components/gradient_button.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<Map<String, String>> _slides = [
{
'image': 'assets/onboard_1.jpg',
'title': 'Найдите самокат на карте и выберите нужный',
},
{
'image': 'assets/onboard_2.jpg',
'title': 'Отсканируйте QR-код или введите номер который указан под козлом',
},
{
'image': 'assets/onboard_3.png',
'title': 'Дождитесь звукового сигнала. При наличии замка отстегните его',
},
{
'image': 'assets/onboard_4.jpg',
'title': 'Выберите тариф и начните поездку',
},
{
'image': 'assets/onboard_5.jpg',
'title': 'Управляйте скоростью с помощью курка тормоза',
},
{
'image': 'assets/onboard_6.jpg',
'title': 'Для торможения используйте курок тормоза',
},
{
'image': 'assets/onboard_7.png',
'title': 'Для завершения аренды припаркуйте самокат в разрешённом месте',
},
];
void _nextPage() {
if (_currentPage < _slides.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// TODO: переход дальше (авторизация / главная)
Navigator.pop(context);
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
/// Фон — PageView
PageView.builder(
controller: _pageController,
itemCount: _slides.length,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemBuilder: (context, index) {
return Image.asset(
_slides[index]['image']!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
);
},
),
/// Градиент сверху для читаемости
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.35),
Colors.transparent,
],
stops: const [0.0, 0.7],
),
),
),
/// Контент
SafeArea(
child: Column(
children: [
const Expanded(child: SizedBox()),
/// Текст
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
_slides[_currentPage]['title']!,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
/// Кнопка
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GradientButton(
text: _currentPage == _slides.length - 1
? 'Начать'
: 'Далее',
onTap: _nextPage,
width: double.infinity,
height: 56,
fontSize: 18,
showArrows: true,
),
),
const SizedBox(height: 24),
/// Индикатор
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_slides.length, (index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: index == _currentPage ? 10 : 8,
height: index == _currentPage ? 10 : 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: index == _currentPage
? Colors.greenAccent
: Colors.white.withOpacity(0.3),
shape: BoxShape.circle,
),
);
}),
),
const SizedBox(height: 24),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,339 @@
import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/scooter_order.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../event/route_event.dart';
import '../state/route_state.dart';
import '../viewmodel/route_bloc.dart';
class OrderHistoryDetailScreen extends StatelessWidget {
final ScooterOrder order;
const OrderHistoryDetailScreen({
super.key,
required this.order,
});
@override
Widget build(BuildContext context) {
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
final scooterNumber = order.scooter?.title ?? '${order.scooterId}';
final price = order.totalPricePrint ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
// 🔹 HEADER
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Поездка $date'),
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocProvider(
create: (context) => RouteBloc(
getRouteUseCase: getIt<GetScooterOrderRouteHistoryUsecase>(), // Используем DI (Service Locator)
)..add(FetchRouteEvent(order.id)),
child: Container(
height: 280,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BlocBuilder<RouteBloc, RouteState>(
builder: (context, state) {
if (state is RouteLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is RouteLoaded) {
final List<Point> yandexPoints = state.points.map((p) =>
Point(latitude: p.latitude, longitude: p.longitude)
).toList();
return YandexMap(
rotateGesturesEnabled: false,
scrollGesturesEnabled: false,
tiltGesturesEnabled: false,
zoomGesturesEnabled: false,
mapObjects: [
PolylineMapObject(
mapId: const MapObjectId('route_line'),
polyline: Polyline(points: yandexPoints),
strokeColor: Colors.blue,
strokeWidth: 3,
),
],
onMapCreated: (controller) async {
final bounds = _calculateBounds(yandexPoints);
await controller.moveCamera(
CameraUpdate.newBounds(bounds),
);
await controller.moveCamera(CameraUpdate.zoomOut());
},
);
}
if (state is RouteError) {
return Center(child: Text(state.message, style: const TextStyle(color: Colors.grey)));
}
return const SizedBox.shrink();
},
),
),
),
),
const SizedBox(height: 16),
// 🔹 ИНФОРМАЦИЯ О ПОЕЗДКЕ
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E).withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
// Фото самоката
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.electric_scooter,
color: Colors.white,
size: 32,
);
},
),
),
const SizedBox(width: 12),
// Информация
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.qr_code_2,
color: Colors.white.withOpacity(0.6),
size: 16,
),
const SizedBox(width: 4),
Text(
scooterNumber,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
),
],
),
),
// Цена
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 100,
alignment: Alignment.center,
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов
),
child: Text(
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
style: TextStyle(
color: order.status == 'Paid'
? Colors.greenAccent
: Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 8),
Text(
price,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
if (order.status != 'Paid') ...[
const SizedBox(height: 16),
GradientButton(
text: 'Оплатить',
showArrows: true,
height: 56,
width: double.infinity,
fontSize: 16,
onTap: () {
context.go('/home/checkout/${order.id}');
},
),
],
const SizedBox(height: 16),
// 🔹 ДЕТАЛИ ПОЕЗДКИ
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E).withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
_DetailRow(
label: 'Старт',
value: _formatTime(order.startAt),
),
const SizedBox(height: 12),
_DetailRow(
label: 'Завершение',
value: _formatTime(order.finishAt),
),
const SizedBox(height: 12),
_DetailRow(
label: 'Расстояние',
value: '${order.mileage.toStringAsFixed(2)?? '0'} км',
),
const SizedBox(height: 12),
_DetailRow(
label: 'Скорость',
value: '${order.avgSpeed ?? '0'} км/ч',
),
const SizedBox(height: 12),
_DetailRow(
label: 'Тариф',
value: order.plan?.title ?? '',
),
const SizedBox(height: 12),
_DetailRow(
label: 'Страховка',
value: order.isInsurance ? '1 руб' : '',
),
],
),
),
const SizedBox(height: 24),
],
),
),
),
],
),
),
),
);
}
// 🔹 ВИДЖЕТ СТРОКИ С ДЕТАЛЯМИ
Widget _DetailRow({required String label, required String value}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 14,
),
),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
);
}
String _formatDate(DateTime date) {
final localDate = date.toLocal();
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return '${localDate.day} ${months[localDate.month - 1]}, ${_formatTime(date)}';
}
String _formatTime(DateTime? date) {
if (date == null) return '';
final localDate = date.toLocal();
return '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
}
BoundingBox _calculateBounds(List<Point> points) {
double minLat = points.first.latitude;
double minLng = points.first.longitude;
double maxLat = points.first.latitude;
double maxLng = points.first.longitude;
for (var p in points) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLng) minLng = p.longitude;
if (p.longitude > maxLng) maxLng = p.longitude;
}
return BoundingBox(
southWest: Point(latitude: minLat, longitude: minLng),
northEast: Point(latitude: maxLat, longitude: maxLng),
);
}
}

View File

@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/scooter_order.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../viewmodel/order_history_bloc.dart';
import 'order_history_detail_screen.dart';
class OrderHistoryScreen extends StatelessWidget {
const OrderHistoryScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<OrderHistoryBloc>()..add(OrderHistoryFetchRequested()),
child: const OrderHistoryView(),
);
}
}
class OrderHistoryView extends StatelessWidget {
const OrderHistoryView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'История поездок'),
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<OrderHistoryBloc, OrderHistoryState>(
builder: (context, state) {
if (state.status == OrderHistoryStatus.loading && state.orders.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (state.status == OrderHistoryStatus.failure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Ошибка загрузки',
style: TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
Text(
state.errorMessage ?? '',
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<OrderHistoryBloc>().add(OrderHistoryFetchRequested());
},
child: const Text('Повторить'),
),
],
),
);
}
if (state.status == OrderHistoryStatus.empty) {
return const _EmptyState();
}
final groupedOrders = _groupByMonth(state.orders);
return RefreshIndicator(
onRefresh: () async {
context.read<OrderHistoryBloc>().add(OrderHistoryRefreshRequested());
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: groupedOrders.length,
itemBuilder: (context, index) {
final monthKey = groupedOrders.keys.elementAt(index);
final orders = groupedOrders[monthKey]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
_getMonthTitle(monthKey),
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
...orders.map((order) => _OrderCard(order: order)),
],
);
},
),
);
},
),
),
],
),
),
),
);
}
Map<String, List<ScooterOrder>> _groupByMonth(List<ScooterOrder> orders) {
final Map<String, List<ScooterOrder>> grouped = {};
for (var order in orders) {
final date = order.startAt ?? order.finishAt ?? DateTime.now();
final localDate = date.toLocal();
final monthKey = '${localDate.year}-${localDate.month.toString().padLeft(2, '0')}';
if (!grouped.containsKey(monthKey)) {
grouped[monthKey] = [];
}
grouped[monthKey]!.add(order);
}
final sortedKeys = grouped.keys.toList()..sort((a, b) => b.compareTo(a));
return Map.fromEntries(
sortedKeys.map((key) => MapEntry(key, grouped[key]!)),
);
}
String _getMonthTitle(String monthKey) {
final parts = monthKey.split('-');
final year = int.parse(parts[0]);
final month = int.parse(parts[1]);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
return '${months[month - 1]} $year';
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/history.png',
width: 380,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 280,
height: 280,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.history_outlined,
size: 120,
color: Colors.white38,
),
);
},
),
const SizedBox(height: 40),
const Text(
'У вас пока нет завершенных поездок.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.4,
),
),
const SizedBox(height: 32),
GradientButton(
text: 'Прокатиться',
showArrows: true,
height: 56,
width: double.infinity,
fontSize: 16,
onTap: () {
context.go('/home');
},
),
],
),
),
);
}
}
// 🔹 КАРТОЧКА ОДНОГО ЗАКАЗА
class _OrderCard extends StatelessWidget {
final ScooterOrder order;
const _OrderCard({required this.order});
@override
Widget build(BuildContext context) {
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
final scooterNumber = order.scooter?.title ?? '${order.scooterId}';
final price = '${order.totalPrice?.toStringAsFixed(2)} BYN' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OrderHistoryDetailScreen(order: order),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.fromLTRB(4, 4, 16, 4),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E).withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.electric_scooter,
color: Colors.white,
size: 48,
);
},
),
),
),
const SizedBox(width: 16),
// 🔹 СТОЛБЕЦ 2: Дата, время, ID самоката
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Дата и время
Text(
date,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
// ID самоката
Row(
children: [
Icon(
Icons.qr_code_2,
color: Colors.white.withOpacity(0.6),
size: 18,
),
const SizedBox(width: 4),
Text(
scooterNumber,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
// 🔹 СТОЛБЕЦ 3: Цена
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 100,
alignment: Alignment.center,
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов
),
child: Text(
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
style: TextStyle(
color: order.status == 'Paid'
? Colors.greenAccent
: Colors.redAccent,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 8),
Text(
price,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
);
}
// 🔹 ФОРМАТИРОВАНИЕ ДАТЫ (с корректным сравнением)
String _formatDate(DateTime date) {
// ✅ Конвертируем в локальное время
final localDate = date.toLocal();
final now = DateTime.now();
// ✅ Сравниваем только дату (год, месяц, день), игнорируя время
final isToday = localDate.year == now.year &&
localDate.month == now.month &&
localDate.day == now.day;
final yesterday = now.subtract(const Duration(days: 1));
final isYesterday = localDate.year == yesterday.year &&
localDate.month == yesterday.month &&
localDate.day == yesterday.day;
if (isToday) {
return 'Сегодня, ${_formatTime(localDate)}';
} else if (isYesterday) {
return 'Вчера, ${_formatTime(localDate)}';
} else {
return '${_formatDateFull(localDate)}, ${_formatTime(localDate)}';
}
}
String _formatDateFull(DateTime date) {
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
return '${date.day} ${months[date.month - 1]}';
}
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,403 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/get_payment_cards_usecase.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../components/payment_option.dart';
import '../components/sheet/payment_method_sheet.dart';
import '../event/payment_confirm_event.dart';
import '../event/payment_method_sheet_event.dart';
import '../state/payment_confirm_state.dart';
import '../viewmodel/payment_confirm_bloc.dart';
import '../viewmodel/payment_method_sheet_bloc.dart';
class PaymentConfirmScreen extends StatelessWidget {
final int orderId;
final List<int> photoIds;
const PaymentConfirmScreen({
super.key,
required this.orderId,
required this.photoIds,
});
@override
Widget build(BuildContext context) {
return _PaymentConfirmScreenContent(orderId: orderId, photoIds: photoIds);
}
}
class _PaymentConfirmScreenContent extends StatelessWidget {
final int orderId;
final List<int> photoIds;
const _PaymentConfirmScreenContent({
required this.orderId,
required this.photoIds,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Stack(
children: [
Positioned(
bottom: 60,
left: 0,
right: 0,
child: Image.asset(
'assets/wave.png',
fit: BoxFit.fitWidth,
color: Colors.white.withOpacity(0.1),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Завершение поездки'),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
listenWhen: (previous, current) {
return current.status != previous.status;
},
listener: (context, state) {
if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) {
context.go('/home');
} else if (state.status == PaymentConfirmStatus.failure) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? 'Произошла ошибка при оплате',
style: const TextStyle(color: Colors.white),
),
backgroundColor: Colors.redAccent.withOpacity(0.9),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(20),
duration: const Duration(seconds: 3),
),
);
}
},
builder: (context, state) {
final order = state.order;
if (state.status == PaymentConfirmStatus.loading &&
order == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 30),
_buildInfoLabel('Самокат:'),
_buildValueRow(
'qr_icon.png',
"${order?.scooter?.number}",
),
const SizedBox(height: 24),
_buildInfoLabel('Расстояние:'),
_buildValueRow(
'distance_icon.png',
"${order?.mileage} km",
),
const SizedBox(height: 24),
_buildInfoLabel('Начислено баллов:'),
_buildValueRow('points_icon.png', '2 балла'),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_buildInfoLabel('Стоимость поездки:'),
_buildValueRow(
'money_icon.png',
'17,17 BYN',
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_buildInfoLabel('Оплачено:'),
_buildValueRow(
'money_icon.png',
'10,00 BYN',
),
],
),
),
],
),
const SizedBox(height: 40),
// 🔹 БЛОК СУММЫ К ОПЛАТЕ
Center(
child: Column(
children: [
Text(
'Сумма к оплате:',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Image.asset(
'assets/icons/money_icon.png',
width: 40,
),
const SizedBox(width: 12),
const Text(
'7,17 BYN',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
const SizedBox(height: 40),
// 🔹 КАРТА ОПЛАТЫ
(state.useBalance || state.selectedCard != null)
? Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
child: PaymentOption(
title: state.useBalance ? 'Баланс' : state.selectedCard!.type,
subtitle: state.useBalance
? '${state.userBalance.toStringAsFixed(2)} BYN'
: '****${state.selectedCard!.cardLastNumber}',
isSelected: true,
onTap: () async {
final result = await showModalBottomSheet<dynamic>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider(
create: (context) => PaymentMethodSheetBloc(
getIt<GetPaymentCardsUsecase>(),
)..add(PaymentMethodSheetStarted()),
child: PaymentMethodSheet(
initialSelectedCard: state.useBalance ? null : state.selectedCard,
),
),
);
if (result != null) {
if (result is PaymentCard) {
context.read<PaymentConfirmBloc>().add(PaymentCardChanged(result));
} else if (result == 'balance') {
context.read<PaymentConfirmBloc>().add(SelectBalancePressed());
}
}
},
),
)
: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Container(
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
24,
),
border: Border.all(
color: Colors.white.withOpacity(
0.4,
),
width: 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
context.pushReplacement(
'/home/payment-method-sheet',
);
},
borderRadius: BorderRadius.circular(
24,
),
child: Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Text(
'Способ оплаты',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white
.withOpacity(0.6),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white
.withOpacity(0.4),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white
.withOpacity(0.2),
),
],
),
),
),
),
),
),
],
);
},
),
),
),
Padding(
padding: const EdgeInsets.all(24.0),
child:
BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
listener: (context, state) {
if (state.status == PaymentConfirmStatus.success &&
state.paymentCompleted) {
context.go('/home');
} else if (state.status ==
PaymentConfirmStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? 'Ошибка оплаты',
),
),
);
}
},
builder: (context, state) {
return GradientButton(
text: state.status == PaymentConfirmStatus.loading
? 'Обработка...'
: 'Оплатить',
showArrows: true,
height: 56,
width: double.infinity,
onTap: (state.status == PaymentConfirmStatus.loading || (!state.useBalance && state.selectedCard == null))
? null
: () {
context.read<PaymentConfirmBloc>().add(
PayRide(
cardId: state.useBalance ? null : state.selectedCard?.id,
isBalance: state.useBalance,
orderId: orderId,
photoIds: photoIds,
),
);
},
);
},
),
),
],
),
],
),
),
),
);
}
// Вспомогательный метод для подзаголовков
Widget _buildInfoLabel(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 15),
),
);
}
// Вспомогательный метод для строк со значениями и иконками
Widget _buildValueRow(String iconName, String value) {
return Row(
children: [
Image.asset('assets/icons/$iconName', width: 24, height: 24),
const SizedBox(width: 12),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
],
);
}
String _formatDuration(int minutes) {
final h = minutes ~/ 60;
final m = minutes % 60;
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../../domain/entities/payment_card.dart';
import '../components/custom_app_bar.dart';
import '../event/payment_methods_event.dart';
import '../state/payment_methods_state.dart';
import '../viewmodel/payment_methods_bloc.dart';
class PaymentMethodsScreen extends StatelessWidget {
const PaymentMethodsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Способы оплаты'),
),
const SizedBox(height: 24),
Expanded(
child: BlocConsumer<PaymentMethodsBloc, PaymentMethodsState>(
listener: (context, state) {
if (state.status == PaymentMethodsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
);
}
},
builder: (context, state) {
if (state.status == PaymentMethodsStatus.loading && state.cards.isEmpty) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF00D4AA)));
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBalanceCard(context, state.balance),
const SizedBox(height: 20),
_buildCardsList(context, state),
],
),
);
},
),
),
],
),
),
),
);
}
Widget _buildBalanceCard(BuildContext context, int balance) {
return Container(
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: AppColors.activeButtonGradient,
borderRadius: BorderRadius.circular(20),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Баланс',
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
balance.toStringAsFixed(2),
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
Text(
'баллов',
style: TextStyle(color: const Color(0xFF0A0F2E).withOpacity(0.7), fontSize: 14),
),
],
),
const SizedBox(height: 20),
_buildTopUpBalanceButton(context),
],
),
Positioned(
right: -30,
top: -50,
child: Image.asset('assets/icons/card-screen.png', width: 100, height: 100, fit: BoxFit.contain),
),
],
),
);
}
Widget _buildCardsList(BuildContext context, PaymentMethodsState state) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E).withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Карты',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
if (state.cards.isEmpty && state.status == PaymentMethodsStatus.success)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text('У вас пока нет привязанных карт', style: TextStyle(color: Colors.white70)),
),
...state.cards.asMap().entries.map((entry) {
final index = entry.key;
final card = entry.value;
return Column(
children: [
_CardItem(
card: card,
onDelete: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsDeleteCard(card.id)),
onMakeMain: card.isMain
? null
: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsSetMainCard(card.id)),
),
if (index < state.cards.length - 1) const SizedBox(height: 16),
],
);
}).toList(),
const SizedBox(height: 20),
_buildAddCardButton(context),
],
),
);
}
Widget _buildAddCardButton(BuildContext context) {
return Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E),
borderRadius: BorderRadius.circular(24),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => context.go('/home/payment-methods/add-card'),
borderRadius: BorderRadius.circular(24),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(Icons.credit_card, color: Color(0xFF00D4AA), size: 24),
SizedBox(width: 12),
Expanded(
child: Text(
'Привязать карту',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
),
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
],
),
),
),
),
);
}
Widget _buildTopUpBalanceButton(BuildContext context) {
return Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF0A0F2E),
borderRadius: BorderRadius.circular(24),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => context.go('/home/payment-methods/top-up'),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
SizedBox(width: 12),
Expanded(
child: Text(
'Пополнить баланс',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
),
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
],
),
),
),
),
);
}
}
class _CardItem extends StatelessWidget {
final PaymentCard card;
final VoidCallback onDelete;
final VoidCallback? onMakeMain;
const _CardItem({
required this.card,
required this.onDelete,
this.onMakeMain,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Image.asset(
_getDefaultIconPath(card.type),
width: 40,
height: 40,
fit: BoxFit.contain,
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: onMakeMain, // Нажатие на текст карты делает её основной
behavior: HitTestBehavior.opaque,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${card.type} ****${card.cardLastNumber}',
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
card.isMain ? 'основная' : 'сделать основной',
style: TextStyle(
color: card.isMain ? const Color(0xFF66E3C4) : Colors.white.withOpacity(0.5),
fontSize: 12,
),
),
],
),
),
),
GestureDetector(
onTap: onDelete,
child: const Icon(Icons.close, color: Color(0xFF00D4AA), size: 20),
),
],
);
}
String _getDefaultIconPath(String cardType) {
switch (cardType) {
case 'Belcard': return 'assets/icons/belcard.png';
case 'Visa': return 'assets/icons/visa.png';
case 'Maestro': return 'assets/icons/maestro.png';
case 'Mir': return 'assets/icons/mir.png';
case 'Mastercard': return 'assets/icons/mastercard.png';
default: return 'assets/icons/belcard.png';
}
}
}

View File

@@ -0,0 +1,183 @@
import 'package:be_happy/presentation/event/spalsh_event.dart';
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/app_colors.dart';
import '../components/code_dots.dart';
import '../components/gradient_button.dart';
import '../event/verify_code_event.dart';
import '../state/verify_code_state.dart';
import '../viewmodel/verify_code_bloc.dart';
import 'pin_login_screen.dart';
class PhoneLoginScreen extends StatefulWidget {
final String phoneNumber;
final String tempToken;
const PhoneLoginScreen({
Key? key,
required this.phoneNumber,
required this.tempToken,
}) : super(key: key);
@override
State<PhoneLoginScreen> createState() => _PhoneLoginScreenState();
}
class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
final TextEditingController codeController = TextEditingController();
final FocusNode codeFocusNode = FocusNode();
bool _isFirstTry = true;
@override
void initState() {
super.initState();
context.read<VerifyCodeBloc>().add(
VerifyCodeStarted(
phoneNumber: widget.phoneNumber,
tempToken: widget.tempToken,
),
);
codeController.addListener(() {
context.read<VerifyCodeBloc>().add(CodeChanged(codeController.text));
if (codeController.text.length == 6) {
Future.delayed(const Duration(milliseconds: 150), () {
context.read<VerifyCodeBloc>().add(VerifyCodeSubmitted());
});
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(codeFocusNode);
});
}
void openKeyboard() {
if (codeFocusNode.hasFocus) {
codeFocusNode.unfocus();
}
Future.delayed(const Duration(milliseconds: 50), () {
FocusScope.of(context).requestFocus(codeFocusNode);
});
}
@override
void dispose() {
codeController.dispose();
codeFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<VerifyCodeBloc, VerifyCodeState>(
listener: (context, state) {
if (state.isSuccess) {
context.go("/pin");
} else if (state.error != null) {
setState(() {
_isFirstTry = false;
});
codeController.clear();
FocusScope.of(context).requestFocus(codeFocusNode);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error!)));
} else if (state.isBlocked) {
context.go("/block");
}
},
builder: (context, state) {
return Scaffold(
resizeToAvoidBottomInset: true,
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Stack(
children: [
Positioned(
top: 310,
left: 0,
right: 0,
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
),
Column(
children: [
const SizedBox(height: 60),
const Text(
"Код отправлен на номер",
style: TextStyle(
fontSize: 18,
color: AppColors.whiteText,
),
),
const SizedBox(height: 6),
Text(
widget.phoneNumber,
style: const TextStyle(
fontSize: 20,
color: AppColors.whiteText,
),
),
const SizedBox(height: 35),
GestureDetector(
onTap: openKeyboard,
child: CodeDots(code: state.code, length: 6),
),
const SizedBox(height: 8),
if (!_isFirstTry)
Text(
"Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}",
style: const TextStyle(
color: AppColors.pinError,
fontSize: 14,
),
),
const SizedBox(height: 40),
GradientButton(
text: state.secondsLeft == 0
? "Отправить код повторно"
: "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.",
onTap: state.secondsLeft == 0
? () {
context.read<VerifyCodeBloc>().add(
ResendCodePressed(),
);
}
: () {},
enabled: state.secondsLeft == 0,
width: 250,
),
const Spacer(),
SizedBox(
height: 50,
width: double.infinity,
child: Opacity(
opacity: 0.0,
child: TextField(
controller: codeController,
focusNode: codeFocusNode,
keyboardType: TextInputType.number,
maxLength: 6,
autofocus: true,
),
),
),
],
),
],
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,248 @@
import 'package:be_happy/presentation/event/spalsh_event.dart';
import 'package:be_happy/presentation/state/splash_state.dart';
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/app_colors.dart';
import '../components/app_checkbox.dart';
import '../components/gradient_button.dart';
import '../event/auth_event.dart';
import '../state/auth_state.dart';
import '../viewmodel/auth_bloc.dart';
import 'phone_login_screen.dart';
class PhoneScreen extends StatefulWidget {
const PhoneScreen({Key? key}) : super(key: key);
@override
State<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends State<PhoneScreen> {
final TextEditingController _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<PhoneAuthBloc, PhoneAuthState>(
listener: (context, state) {
if (state.isSuccess) {
context.go("/verify?phone=+375${state.phone}");
context.read<SplashBloc>().add(AuthStarted());
} else if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final isAdult = state.isAdult ?? false;
final privacyAccepted = state.privacyAccepted ?? false;
return Scaffold(
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: Container(
decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 30),
Image.asset(
'assets/wave.png',
width: double.infinity,
fit: BoxFit.cover,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 20),
const Text(
'Введите номер телефона',
style: TextStyle(
color: AppColors.whiteText,
fontSize: 20,
),
),
const SizedBox(height: 40),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
style: const TextStyle(color: AppColors.whiteText),
decoration: InputDecoration(
filled: true,
fillColor: AppColors.disabledButtonColor,
prefixText: '+375 ',
prefixStyle: const TextStyle(
color: AppColors.whiteText,
fontSize: 16,
),
hintText: 'Номер телефона',
hintStyle: const TextStyle(color: AppColors.hint),
suffixIcon: _phoneController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear,
color: AppColors.whiteText),
onPressed: () {
_phoneController.clear();
context.read<PhoneAuthBloc>().add(PhoneChanged(""));
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (value) {
context.read<PhoneAuthBloc>().add(PhoneChanged(value));
},
),
const SizedBox(height: 24),
Row(
children: [
AppCheckbox(
value: isAdult,
onChanged: (bool? value) {
if (value != null) {
context.read<PhoneAuthBloc>().add(IsAdultChanged(value));
}
},
isError: state.error != null && !isAdult,
),
const SizedBox(width: 12),
Flexible(
child: Text.rich(
TextSpan(
text: 'Подтверждаю, что мне исполнилось 18 лет и я принял ',
style: const TextStyle(
color: AppColors.white70,
fontSize: 12,
),
children: [
WidgetSpan(
child: ClickableText(
text: 'Условия использования сервиса',
onTap: () => context.push('/license-agreement'),
),
),
],
),
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
AppCheckbox(
value: privacyAccepted,
onChanged: (bool? value) {
if (value != null) {
context.read<PhoneAuthBloc>().add(PrivacyAcceptedChanged(value));
}
},
isError: state.error != null && !privacyAccepted,
),
const SizedBox(width: 12),
Expanded(
child: Text.rich(
TextSpan(
text: 'Подтверждаю, что я ознакомился с ',
style: const TextStyle(
color: AppColors.white70,
fontSize: 12,
),
children: [
WidgetSpan(
child: ClickableText(
text: 'Политикой обработки персональных данных',
onTap: () => context.push('/privacy-policy'),
),
),
],
),
),
),
],
),
const SizedBox(height: 47),
GradientButton(
text: "Получить код",
enabled: state.phone.isNotEmpty &&
isAdult &&
privacyAccepted &&
!state.isSubmitting,
onTap: state.isSubmitting
? null
: () {
context.read<PhoneAuthBloc>().add(SubmitPhonePressed());
},
showArrows: true,
height: 50,
width: 340,
fontSize: 14,
),
],
),
),
),
],
),
),
),
);
},
);
}
}
// 🔹 Кликабельный текст для политики
class ClickableText extends StatelessWidget {
final String text;
final VoidCallback onTap;
const ClickableText({
super.key,
required this.text,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Text(
text,
style: const TextStyle(
color: Colors.blueAccent,
decorationColor: Colors.blueAccent,
decoration: TextDecoration.underline,
fontSize: 12
),
),
);
}
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/code_dots.dart';
import '../event/spalsh_event.dart';
import '../viewmodel/pin_bloc.dart';
import '../event/pin_event.dart';
import '../state/pin_state.dart';
import '../viewmodel/splash_bloc.dart';
class PinLoginScreen extends StatefulWidget {
const PinLoginScreen({super.key});
@override
State<PinLoginScreen> createState() => _PinLoginScreenState();
}
class _PinLoginScreenState extends State<PinLoginScreen> {
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
controller.addListener(_onTextChanged);
}
void _onTextChanged() {
final text = controller.text;
context.read<PinBloc>().add(PinDigitChanged(text));
if (text.length == 6) {
context.read<PinBloc>().add(PinSubmitted(text));
}
}
@override
void dispose() {
controller.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<PinBloc, PinState>(
listener: (context, state) {
if (state is PinSuccess) {
context.read<SplashBloc>().add(PinVerificationSuccess());
context.go("/home");
}
if (state.error != null) {
controller.clear();
focusNode.requestFocus();
}
},
child: Scaffold(
body: GestureDetector(
onTap: () => focusNode.requestFocus(),
child: Container(
decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: SafeArea(
child: Stack(
children: [
Positioned(
top: 310, left: 0, right: 0,
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
),
BlocBuilder<PinBloc, PinState>(
builder: (context, state) {
final title = state is PinCreateInProgress
? "Создайте PIN-код"
: "Введите PIN-код";
final subtitle = state is PinCreateInProgress
? "Запомните PIN-код"
: "Введите ваш пароль для входа";
if (state is PinLoading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
const SizedBox(height: 60),
Text(
title,
style: const TextStyle(
color: AppColors.whiteText,
fontSize: 20,
),
),
const SizedBox(height: 35),
Stack(
alignment: Alignment.center,
children: [
CodeDots(
code: state.pin,
length: 6,
),
SizedBox(
width: 200,
height: 50,
child: TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.number,
maxLength: 6,
showCursor: false,
decoration: const InputDecoration(
border: InputBorder.none,
counterText: "",
),
style: const TextStyle(color: Colors.transparent),
),
),
],
),
const SizedBox(height: 12),
// Блок ошибки
if (state.error != null)
Text(
state.error!,
style: const TextStyle(
color: AppColors.pinError,
fontSize: 14,
),
),
const SizedBox(height: 40),
Text(
subtitle,
style: const TextStyle(color: AppColors.white70),
),
const SizedBox(height: 40),
],
);
},
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
class PrivacyPolicyScreen extends StatelessWidget {
const PrivacyPolicyScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: ''),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Политика конфиденциальности',
style: const TextStyle(
color: Colors.white,
fontSize: 18
),
),
const SizedBox(height: 16),
_buildParagraph(
'Настоящая Политика конфиденциальности (далее - Политика) действует в отношении всей информации, которую Общество с ограниченной ответственностью “БИХЕППИБЕЛ”, зарегистрированное по адресу: Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А УНП: 392050943 Регистрационный номер: 392026683, (далее Компания), получает о Пользователе в процессе регистрации, авторизации и иного использования Сайта https://behappybel.by, (далее - Сайта), Приложения Be Happy, Сервиса Be Happy в соответствии с размещенными на Сайте и в Приложении Be Happy Пользовательским соглашением и Договором присоединения.\n\n'
'Выраженное в соответствии с Политикой согласие Пользователя на обработку предоставляемых им Компании персональных данных и иной информации, считается, одновременно, предоставленным Пользователем указанным в Политике третьим лицам, привлекаемым Компанией для содействия в исполнении Пользовательского соглашения и Договора аренды.\n\n'
'Использование Сайта, Приложения Be Happy, Сервиса Be Happy (в том числе, осуществление Пользователем регистрации, авторизации) означает безоговорочное согласие Пользователя с Политикой и всеми указанными в ней условиями обработки его персональных данных и иной информации; в случае несогласия с Политикой и всеми ее условиями Пользователь должен воздержаться от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
),
const SizedBox(height: 24),
_buildSectionHeader('1. Персональные данные и иная информация Пользователя, которую получает и обрабатывает Компания\n'),
_buildParagraph(
'1.1. При регистрации, авторизации Пользователя, а также при использовании Сайта, Приложения Be Happy, Сервиса Be Happy, осуществлении оплат, проведении опросов, рассылке информационных и рекламных сообщений и во всех иных случаях, предусмотренных Пользовательским соглашением и Договором присоединения, Компания может запросить у Пользователя (п.1.1.1. Политики) и/или получить автоматически (п.1.1.2. Политики) следующую информацию о Пользователе:\n\n'
'1.1.1. имя, номер мобильного телефона, адрес электронной почты, информацию о логине и пароле для доступа к отдельным функциям Сайта, Приложения Be Happy, Сервиса Be Happy, историю пользования Сервисом Be Happy (информацию о количестве, стоимости, времени и порядке произведенных Пользователями заказов на услуги Сервиса Be Happy и их оплате, в том числе данные о банковском счете и/или счете банковской карты), информацию об участии в рекламных акциях, информацию о подписке на информационную рассылку или материалы службы поддержки), реквизиты банка для возврата денежных средств, а также иные данные и информация;\n\n'
'1.1.2. информация, которая автоматически передается Компании в процессе использования Сайта, Приложения Be Happy, Сервиса Be Happy, с помощью установленного на устройстве Пользователя программного обеспечения, в том числе IP-адрес, информация из cookie и tracking bugs, информация о стране и (или) городе нахождения Пользователя, информация об Интернет-браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy), время доступа, адрес запрашиваемой страницы об устройствах Пользователя, с помощью которых осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy.\n\n'
'1.2. Настоящая Политика применима только к Сайту, Приложению Be Happy, Сервису Be Happy. Компания не контролирует и не несет ответственность за сайты и программное обеспечение третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на Сайте, в Приложении Be Happy. На иных сайтах третьих лиц у Пользователя может собираться или запрашиваться иная информация, а также могут совершаться иные действия, за которые Компания не несет ответственности.\n\n'
'1.3. Компания исходит из того, что Пользователь является совершеннолетним дееспособным лицом, соответствующим требованиям Пользовательского соглашения и Договора присоединения, предоставляет достоверную и достаточную информацию и поддерживает эту информацию в актуальном состоянии. Компания вправе осуществить проверку предоставленной Пользователем информации в соответствии с положениями Пользовательского соглашения и Договора присоединения. В случае предоставления Пользователем недостоверной информации, Компания имеет право приостановить либо отменить регистрацию и/или отказать Пользователю в предоставлении доступа к Сайту, Приложению Be Happy, Сервису Be Happy. За предоставление недостоверной информации и возникшие вследствие этого негативные последствия Компания и/или иные третьи лица ответственности не несут. Если использование Сайта, Приложения Be Happy, Сервиса Be Happy осуществило несовершеннолетнее и/или недееспособное лицо, то ответственность за такое несанкционированное Компанией использование несут родители, усыновители и иные законные представители несовершеннолетнего и/или недееспособного лица.\n\n'
'1.4 Пользователь дает свое согласие на осуществление Арендодателем записи разговоров Пользователя со Службой поддержки и предоставление такой записи третьим лицам.',
),
const SizedBox(height: 24),
_buildSectionHeader('2. Цели сбора и обработки данных и иной информации Пользователя\n'),
_buildParagraph(
'2.1. Компания использует данные и иную информацию Пользователя для целей заключения и исполнения Пользовательского соглашения, заключения и исполнения Договора присоединения, оказания дополнительных услуг, повышения качества сервиса, участия Пользователя в проводимых Компанией акциях, опросах, исследованиях (включая, но не ограничиваясь проведением опросов, исследований посредством электронной, телефонной и сотовой связи), принятия решений или совершения иных действий, порождающих юридические последствия в отношении Пользователя или других лиц, представления Пользователю информации об оказываемых Компанией услугах, предоставления Компанией консультационных услуг. Указанные цели использования персональных данных распространяются на всю информацию, указанную в пункте 1.1 Политики.\n\n'
'2.2. Цели сбора и обработки персональных данных включают, без ограничений, следующие:\n\n'
'2.2.1. регистрацию, идентификацию и авторизацию Пользователя в рамках Сервиса Be Happy;\n'
'2.2.2. заключение и исполнение Пользовательского соглашения и Договора присоединения;\n'
'2.2.3. предоставление Пользователю Сервиса Be Happy, а также любого дополнительного функционала в рамках Сервиса Be Happy;\n'
'2.2.4. обработка запросов Пользователей Компанией в рамках Сервиса Be Happy;\n'
'2.2.5. анализ и исследования возможностей улучшения Сервиса Be Happy;\n'
'2.2.6. рассылка новостей и информации о продуктах, услугах, специальных предложениях, связанных с Сервисом Be Happy;\n'
'2.2.7. рассылка служебных сообщений (например, для информирования о статусе аренды самоката, восстановления/изменения логина и пароля Пользователя и пр.);\n'
'2.2.8. предотвращение и выявление мошенничества и незаконного использования Сервиса Be Happy;\n'
'2.2.9. проведение статистических и иных исследований на основе обезличенных данных.',
),
const SizedBox(height: 24),
_buildSectionHeader('3. Условия, способы и порядок обработки персональных данных и иной персональной информации Пользователя\n'),
_buildParagraph(
'3.1. Компания использует персональные данные и иную информацию Пользователя только для целей, указанных в Политике и в соответствии с Политикой.\n\n'
'3.2. В отношении персональных данных и иной информации Пользователя Компанией соблюдается конфиденциальность.\n\n'
'3.3. Компания не будет раскрывать третьим лицам, распространять, продавать или иным образом распоряжаться полученными персональными данными и иной информацией, кроме как для целей, способами и в пределах, предусмотренных настоящей Политикой.\n\n'
'3.4. Обработка персональных и иных данных Пользователя осуществляется Компанией в объеме, который необходим для достижения каждой из целей, указанных в разделе 2 Политики, следующими возможными способами: сбор, запись (в том числе на электронные носители), систематизация, накопление, хранение, составление перечней, маркировка, уточнение (обновление, изменение), извлечение, использование, передача (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение, трансграничная передача персональных данных, получение изображения путем фотографирования, а также осуществление любых иных действий с персональными данными Пользователя с учетом применимого права. Компания вправе осуществлять обработку персональных и иных данных Пользователя как с использованием автоматизированных средств обработки персональных данных Пользователя, так и без использования средств автоматизации.\n\n'
'3.5. Компания вправе передавать предоставленные ей Пользователем персональные данные для их обработки (давать поручение на обработку) (в объеме, необходимом для выполнения Компанией своих обязательств) третьим лицам, в том числе, организациям, которые привлекаются Компанией для осуществления информационной отправки сообщений посредством электронной почты/операторов мобильной связи, осуществляют списание/зачисление денежных средств с/на банковской(-ую) карты(- у)/расчетный счет - кредитным организациям (банкам), платежным системам, операторам мобильной связи, курьерским службам, организациями почтовой связи, включая трансграничную передачу персональных данных Пользователя в письменной либо электронной форме, в случаях и в порядке, предусмотренном соответствующими договорами с указанными третьими лицами, правилами Компании, применимым правом.\n\n'
'3.6. Компания также вправе передавать предоставленные ей Пользователем персональные данные государственным органам, суду, иным уполномоченным органам и организациям, в случаях и в порядке, когда это требуется в соответствии с применяемым к Политике правом.\n\n'
'3.7. Компания гарантирует добросовестную и законную обработку персональных и иных данных Пользователя в соответствии с предусмотренными в разделе 2 Политики целями.\n\n'
'3.8. Компания гарантирует незамедлительное обновление данных Пользователя в случае предоставления им обновленных данных.\n\n'
'3.9. Согласие на обработку персональных данных и иных данных дается Пользователем на бессрочной основе, либо до истечения сроков хранения соответствующей информации или документов, содержащих вышеуказанную информацию, определяемых в соответствии с применимым к Политике правом. По истечении указанного срока персональные данные подлежат уничтожению Компанией.',
),
const SizedBox(height: 24),
_buildSectionHeader('4. Изменение или удаление информации Пользователем. Отзыв согласия на обработку персональных данных\n\n'),
_buildParagraph(
'4.1. Пользователь может в любой момент изменить (обновить, дополнить) предоставленные им персональные данные и иную информацию обратившись к Компании, например, через службу поддержки или с использованием контактов, указанных на Сайте, в Приложении Be Happy с запросом об изменении (обновлении, дополнении) предоставленной им ранее информации (Компания изменяет (обновляет, дополняет) предоставленную Пользователем информацию только после проведения применяемой в Компании на момент соответствующего обращения процедурой идентификации Пользователя).\n\n'
'4.2. Пользователь вправе отозвать свое согласие на обработку персональных данных путем направления соответствующего письменного уведомления Компании не менее чем за 30 (тридцать календарных) дней до момента отзыва согласия, при этом Пользователь признает и понимает, что доступ к пользованию Сервисами Сайта и Приложения Be Happy, Сервису Be Happy не будет предоставляться Компанией с того момента, когда Компания лишилась возможности обрабатывать персональные данные Пользователя.',
),
const SizedBox(height: 24),
_buildSectionHeader('5. Защита информации Пользователей\n'),
_buildParagraph(
'5.1. Компания обеспечивает принятие необходимых и достаточных организационных и технических мер для защиты персональных данных и иной информации Пользователей от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий с ней третьих лиц.',
),
const SizedBox(height: 24),
_buildSectionHeader('6. Файлы cookies и tracking bugs\n'),
_buildParagraph(
'6.1. Для улучшения качества предоставления Сервиса Be Happy Компания может использовать (временные и постоянные) cookie-файлы, tracking bugs и/или другие технологии сбора не носящих личный характер данных (например, IP-адрес, тип браузера и данные о провайдере службы Интернет (ISP), а также (для Пользователей, которые пользуются услугами Компании через мобильное устройство), уникальный идентификатор устройства, данные об операционной системе и координаты с целью учёта количества Пользователей и их поведения при пользовании Сервисом Be Happy. Для повышения удобства Пользователей Компания вправе собирать и обрабатывать информацию об общем количестве операций, страниц, просмотренных Пользователем, ссылающихся/исходных страниц, типе платформы, дате/времени фиксирования информации, количестве и месте просмотров данной страницы, просмотра страницы и использованных (поисковых) слов.\n\n'
'6.2. Информация о cookies и tracking bugs:\n\n'
'6.2.1.Файл «cookie» - небольшой текстовый файл, отправляемый на браузер устройства Пользователя с используемого Компанией сервера. Cookies содержат информацию, которая позже может быть использована Компанией. Браузер будет хранить эту информацию и передавать ее обратно с каждым запросом Пользователя Компании. Одни значения cookies могут храниться только в течение одной сессии и удаляются после закрытия браузера. Другие, установленные на некоторый период времени, записываются в специальный файл на жестком диске и хранятся на устройстве Пользователя. Cookies используются для идентификации, отслеживания сессий (поддержания состояния) и сохранения информации о Пользователе, включая предпочтения при пользовании Сервисом Be Happy. Используемые Компанией сookies собирают только анонимные данные.\n\n'
'6.2.2. Файл tracking bugs - это графические объекты, встроенные в веб-страницы или в сообщения e-mail. Tracking bugs используются с различными целями включные отчёты о количестве Пользователей. Используемые Компанией tracking bugs собирают только анонимные данные.\n\n'
'6.3. Компания может использовать cookies и tracking bugs в целях контроля использования Сервиса Be Happy,сбора информации неличного характера о Пользователе, сохранения предпочтений и другой информации на устройстве Пользователя для того, чтобы сэкономить время Пользователя, необходимое для многократного введения в формах Сайта, Приложения Be Happy одной и той же информации, а также в целях отображения содержания в ходе последующих посещений Пользователем Сайта, Приложения Be Happy. Информация, полученная посредством cookies и tracking bugs, также может использоваться Компанией для статистических исследований, направленных на корректировку содержания Сайта, Приложения Be Happy в соответствии с предпочтениями Пользователя.\n\n'
'6.4. Компания может предоставить Пользователю возможность изменить настройки приема файлов cookies и tracking bugs в настройках своего браузера или отключить их полностью, однако в таком случае некоторые функции Сервиса Be Happy могут работать некорректно.',
),
const SizedBox(height: 24),
_buildSectionHeader('7. Внесение изменений в Политику. Согласие Пользователя с Политикой\n'),
_buildParagraph(
'7.1. Пользователь признает и соглашается, что регистрация Пользователя на Сайте, в Приложении Be Happy и последующее использование Сервиса Be Happy, любых его служб, функционала означает безоговорочное согласие Пользователя со всеми пунктами настоящей Политики и безоговорочное принятие ее условий.\n\n'
'7.2. Продолжение Пользователем использования Сервиса Be Happy после любых изменений и/или дополнений Политики означает его согласие с такими изменениями и/или дополнениями.\n\n'
'7.3. Пользователь обязуется регулярно знакомиться с содержанием Политики в целях своевременного ознакомления с ее изменениями/дополнениями.\n\n'
'7.4. Компания оставляет за собой право по своему усмотрению изменять и (или) дополнять Политику в любое время без предварительного и (или) последующего уведомления Пользователя. Новая редакция Политики вступает в силу с момента ее размещения на Сайте и/или в Приложении Be Happy, если иное не предусмотрено новой редакцией Политики. Действующая редакция Политики всегда доступна на Сайте и/или в Приложении Be Happy.\n\n'
'Уважаемый Пользователь, если Вы не согласны с положениями Политики, откажитесь от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
),
const SizedBox(height: 24),
_buildSectionHeader('8. Заключительные положения\n'),
_buildParagraph(
'8.1. К Политике и возникающими в связи с применением Политики отношениям между Пользователями и Компанией подлежит применению право Республики Беларусь.\n\n'
'8.2. Все возможные споры по поводу настоящей Политики конфиденциальности и отношений между пользователем и Сервисом будут разрешаться по нормам белорусского права в суде по месту нахождения Администрации сайта, если иное прямо не предусмотрено законодательством РБ.\n\n'
'8.3. Соглашаясь с условиями Политики, Пользователь дает согласие на обработку персональных и иных данных своей волей и в своем интересе.\n\n'
'8.4. Отказ от предоставления персональных данных и иной необходимой для использования Сайта, Приложения Be Happy, Сервиса Be Happy информации влечет невозможность для Компании предоставлять Пользователю Сервиса Be Happy.',
),
const SizedBox(height: 32),
],
),
),
),
],
),
),
),
);
}
Widget _buildParagraph(String text) {
return Text(
text,
style: const TextStyle(
color: Color(0xFFD1D1D6),
fontSize: 14,
height: 1.5,
),
);
}
Widget _buildSectionHeader(String text) {
return Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.4,
),
);
}
}

View File

@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import '../../core/app_colors.dart';
import '../../domain/entities/user_profile.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../viewmodel/profile_bloc.dart';
import 'edit_profile_screen.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../state/profile_state.dart';
import '../event/profile_event.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
bool notificationsEnabled = false;
XFile? _avatarImage;
Future<void> _pickImage() async {
try {
final pickedImage = await ImagePicker().pickImage(
source: ImageSource.gallery,
);
if (pickedImage == null) return;
context.read<ProfileBloc>().add(
ProfilePhotoUpdated(File(pickedImage.path)),
);
} catch (e) {
print("Error picking or uploading image: $e");
}
}
Future<void> _openEditProfile(UserProfile profile) async {
context.go("/home/profile/edit");
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (state.error != null) {
return Center(
child: Text(
state.error!,
style: const TextStyle(color: Colors.white),
),
);
}
final profile = state.profile!;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 16),
CustomAppBar(title: 'Профиль'),
const SizedBox(height: 32),
Stack(
alignment: Alignment.topRight,
children: [
CircleAvatar(
radius: 60,
backgroundColor: AppColors.checkboxFill,
backgroundImage: (profile.avatarUrl != null && profile.avatarUrl!.isNotEmpty)
? NetworkImage("${profile.avatarUrl!}?v=${DateTime.now().minute}")
: null,
child: (profile.avatarUrl == null || profile.avatarUrl!.isEmpty)
? Text(
profile.name.isNotEmpty ? profile.name[0].toUpperCase() : '',
style: const TextStyle(fontSize: 50, color: AppColors.darkBlue),
)
: null,
), GestureDetector(
onTap: _pickImage,
child: Container(
margin: const EdgeInsets.only(top: 0, right: 0),
child: Image.asset(
'assets/icons/edit.png',
width: 24,
height: 24,
),
),
),
],
),
const SizedBox(height: 32),
_ProfileInfoBlock(
profile: profile,
onEditTap: () => context.go("/home/profile/edit"),
),
const SizedBox(height: 24),
_SettingsBlock(
notificationsEnabled: notificationsEnabled,
onNotificationsChanged: (v) =>
setState(() => notificationsEnabled = v),
),
const SizedBox(height: 24),
],
),
);
},
),
),
),
);
}
}
class _ProfileInfoRow extends StatelessWidget {
final IconData icon;
final String value;
final Widget? trailing;
const _ProfileInfoRow({
required this.icon,
required this.value,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
);
}
}
class _ProfileInfoBlock extends StatelessWidget {
final UserProfile profile;
final VoidCallback onEditTap;
const _ProfileInfoBlock({required this.profile, required this.onEditTap});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Color(0xFF141530),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Личные данные',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
_ProfileInfoRow(icon: Icons.person, value: profile.name),
_ProfileInfoRow(icon: Icons.calendar_today, value: profile.birthDate),
_ProfileInfoRow(
icon: Icons.phone,
value: profile.phone,
trailing: const Icon(Icons.lock, color: Colors.white70, size: 16),
),
_ProfileInfoRow(icon: Icons.email, value: profile.email),
const SizedBox(height: 8),
Align(
alignment: Alignment.center,
child: OutlinedButton(
onPressed: onEditTap,
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Редактировать',
style: TextStyle(color: Colors.white, fontSize: 14),
),
const SizedBox(width: 4),
Icon(Icons.edit, size: 16, color: AppColors.smsDigit),
],
),
),
),
],
),
);
}
}
class _SettingsRow extends StatelessWidget {
final String title;
final String? value;
final Widget? trailing;
final VoidCallback? onTap;
const _SettingsRow({
required this.title,
this.value,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
if (value != null)
Text(
value!,
style: const TextStyle(color: AppColors.white70, fontSize: 13),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
),
);
}
}
class _SettingsBlock extends StatelessWidget {
final bool notificationsEnabled;
final ValueChanged<bool> onNotificationsChanged;
const _SettingsBlock({
required this.notificationsEnabled,
required this.onNotificationsChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Color(0xFF141530),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Настройки',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
_SettingsRow(
title: 'Уведомления',
trailing: Transform.scale(
scale: 0.8,
child: Switch(
value: notificationsEnabled,
onChanged: onNotificationsChanged,
activeColor: AppColors.checkboxFill,
),
),
),
_SettingsRow(
title: 'Тема приложения',
value: 'Системная',
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
onTap: () {},
),
_SettingsRow(
title: 'Язык',
value: 'Русский',
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
onTap: () {},
),
],
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
class PromoCodeScreen extends StatefulWidget {
const PromoCodeScreen({super.key});
@override
State<PromoCodeScreen> createState() => _PromoCodeScreenState();
}
class _PromoCodeScreenState extends State<PromoCodeScreen> {
final TextEditingController promoController = TextEditingController();
bool isError = false;
void _activatePromo() {
if (promoController.text == 'G17N160') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Промокод активирован!')),
);
} else {
setState(() {
isError = true;
});
}
}
void _retry() {
setState(() {
isError = false;
promoController.clear();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
CustomAppBar(title: 'Промокоды'),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(25),
decoration: BoxDecoration(
color: Color(0xFF141530),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'У вас есть промокод?',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height:24),
const Text(
'Введите промокод и получите скидку на поездку',
style: TextStyle(
color: AppColors.white70,
fontSize: 16,
),
),
const SizedBox(height: 20),
TextField(
controller: promoController,
style: TextStyle(
color: isError ? Colors.red : Colors.white,
),
decoration: InputDecoration(
hintText: 'Введите промокод',
hintStyle: const TextStyle(color: AppColors.white70),
filled: true,
fillColor: Colors.white.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: AppColors.smsDigit, width: 1.5),
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
side: BorderSide(color: AppColors.white70.withOpacity(0.4)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Отмена',
style: TextStyle(color: AppColors.white70),
),
),
),
const SizedBox(width: 22),
Expanded(
child: ElevatedButton(
onPressed: _activatePromo,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: AppColors.activeButtonGradient.colors[0],
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Активировать',
style: TextStyle(color: AppColors.activeButtonText),
),
),
),
],
),
],
),
),
const Spacer(),
],
),
),
),
Image.asset('assets/promo_bottom.png',
width: double.infinity,
fit: BoxFit.contain,
alignment: Alignment.center,
),
const SizedBox(height: 80),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
class QRScanInfoScreen extends StatelessWidget {
const QRScanInfoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: "Сканирование QR-кода"),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Column(
children: [
const SizedBox(height: 40),
const Text(
"Наведите рамку сканера на QR-код -\nномер будет распознан автоматически",
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.whiteText,
fontSize: 16,
height: 1.4,
),
),
const Spacer(),
Image.asset(
"assets/qr_phone_img.png",
height: 300,
fit: BoxFit.contain,
),
const Spacer(),
Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
),
),
child: ElevatedButton(
onPressed: () {
context.push("/home/qr-info/qr-scan");
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: const Text(
"Продолжить",
style: TextStyle(
color: Color(0xFF1D273A),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Кнопка "Ввести номер вручную"
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton(
onPressed: () => context.push("/home/qr-info/qr-input"),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white70),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: const Text(
"Ввести номер вручную",
style: TextStyle(
color: AppColors.whiteText,
fontSize: 16,
),
),
),
),
const SizedBox(height: 40),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,219 @@
import 'dart:ui' as ui;
import 'package:be_happy/core/result.dart';
import 'package:be_happy/domain/entities/scooter.dart';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart';
import 'package:be_happy/di/service_locator.dart';
import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart';
import '../components/gradient_button.dart';
class QrScanScreen extends StatefulWidget {
const QrScanScreen({super.key});
@override
State<QrScanScreen> createState() => _QrScanScreenState();
}
class _QrScanScreenState extends State<QrScanScreen> {
final MobileScannerController _controller = MobileScannerController();
String? _scannedData;
String? _scooterTitle;
bool _torchOn = false;
bool _isLoading = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool _isProcessing = false;
void _handleScannedData(String rawValue) async {
if (_isProcessing || _isLoading) return;
final uri = Uri.tryParse(rawValue);
if (uri == null || uri.host != 'behappybel.by') return;
final title = uri.pathSegments.last;
print("TITLE IS: $title");
if (title.isEmpty) return;
setState(() {
_isProcessing = true;
_isLoading = true;
});
await _controller.stop();
try {
final getScooterByTitleUsecase = getIt<GetScooterByTitleUsecase>();
print("UseCase успешно получен из DI");
final result = await getScooterByTitleUsecase(title);
print("UseCase успешно выполнен");
if (mounted) {
setState(() => _isLoading = false);
switch (result) {
case Success<Scooter?>():
final scooter = result.data;
if (scooter != null) {
context.pop();
context.push('/home/scooter/${scooter.id}');
} else {
_showErrorAndRestart('Самокат не найден');
}
case Failure<Scooter?>():
_showErrorAndRestart('Ошибка при поиске самоката');
}
}
} catch (e) {
print("Ошибка DI: $e");
}
}
void _showErrorAndRestart(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_isProcessing = false;
_scannedData = null;
});
_controller.start();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
MobileScanner(
controller: _controller,
onDetect: (capture) {
if (_isProcessing) return;
for (final barcode in capture.barcodes) {
final String? rawValue = barcode.rawValue;
if (rawValue != null) {
_handleScannedData(rawValue);
break;
}
}
},
),
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.7),
BlendMode.srcOut,
),
child: Stack(
children: [
Container(
decoration: const BoxDecoration(
color: Colors.black,
backgroundBlendMode: BlendMode.dstOut,
),
),
Center(
child: Container(
width: 280,
height: 280,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(0),
),
),
),
],
),
),
Center(
child: Container(
width: 280,
height: 280,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF6EE7B7), width: 3),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
children: [
const SizedBox(height: 60),
const Text(
'Наведите рамку на QR-код — номер будет распознан автоматически',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
shadows: [
Shadow(blurRadius: 4, color: Colors.black),
],
),
),
],
),
),
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Expanded(
child: GradientButton(
text: 'Ввести номер вручную',
onTap: () {
context.push('/home/qr-info/qr-input');
},
width: double.infinity,
height: 56,
fontSize: 16,
showArrows: true,
),
),
const SizedBox(width: 12),
CircleAvatar(
backgroundColor: Colors.black.withOpacity(0.5),
child: IconButton(
onPressed: () async {
final newState = !_torchOn;
await _controller.toggleTorch();
setState(() => _torchOn = newState);
},
icon: Icon(
_torchOn ? Icons.flashlight_on : Icons.flashlight_off,
color: Colors.white,
size: 24,
),
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/code_dots.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../event/scooter_code_event.dart';
import '../state/scooter_code_state.dart';
import '../viewmodel/pin_bloc.dart';
import '../event/pin_event.dart';
import '../state/pin_state.dart';
import '../viewmodel/scooter_code_bloc.dart';
class ScooterCodeInputScreen extends StatefulWidget {
const ScooterCodeInputScreen({super.key});
@override
State<ScooterCodeInputScreen> createState() => _ScooterCodeInputScreenState();
}
class _ScooterCodeInputScreenState extends State<ScooterCodeInputScreen> {
final TextEditingController controller = TextEditingController();
@override
void initState() {
super.initState();
controller.addListener(_onTextChanged);
}
void _onTextChanged() {
context.read<ScooterCodeBloc>().add(ScooterCodeChanged(controller.text));
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<ScooterCodeBloc, ScooterCodeState>(
listener: (context, state) {
if (state is ScooterCodeSuccess) {
context.go("/home/scooter/${state.scooter.id}");
}
if (state is ScooterCodeFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error ?? "Ошибка")),
);
}
},
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: "Ввод QR-кода"),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: BlocBuilder<ScooterCodeBloc, ScooterCodeState>(
builder: (context, state) {
final bool isCodeValid = state.code.length >= 5 && state.code.length <= 7;
return Column(
children: [
const SizedBox(height: 60),
const Text(
"Введите номер, расположенный\nпод QR-кодом",
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.whiteText,
fontSize: 16,
height: 1.4,
),
),
const SizedBox(height: 80),
TextField(
controller: controller,
keyboardType: TextInputType.number,
maxLength: 7,
textAlign: TextAlign.left,
style: const TextStyle(color: Colors.white, fontSize: 18),
decoration: InputDecoration(
hintText: "Ввести номер",
hintStyle: const TextStyle(color: AppColors.hint),
counterText: "",
filled: true,
fillColor: Colors.white.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
),
),
if (state is ScooterCodeLoading)
const Padding(
padding: EdgeInsets.only(top: 20),
child: CircularProgressIndicator(color: Colors.white),
),
const Spacer(),
GradientButton(
text: "Подтвердить",
enabled: isCodeValid && state is! ScooterCodeLoading,
onTap: () {
context.read<ScooterCodeBloc>().add(
ScooterCodeSubmitted(state.code),
);
},
showArrows: true,
height: 56,
width: double.infinity,
),
const SizedBox(height: 40),
],
);
},
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:be_happy/presentation/event/scooter_detail_event.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../components/scooter/battery_indicator.dart';
import '../components/scooter/scooter_info_section.dart';
import '../components/scooter/slide_to_reserve_button.dart';
import '../components/sheet/tariff_sheet.dart';
import '../state/scooter_detail_state.dart';
import '../viewmodel/scooter_detail_bloc.dart';
class ScooterDetailScreen extends StatelessWidget {
const ScooterDetailScreen({super.key});
@override
Widget build(BuildContext context) {
final id = GoRouterState.of(context).pathParameters['id'];
context.read<ScooterDetailBloc>().add(LoadScooterDetails(int.parse(id!)));
return Scaffold(
body: BlocBuilder<ScooterDetailBloc, ScooterDetailState>(
builder: (context, state) {
if (state.status == ScooterStatus.loading) {
return const Center(child: CircularProgressIndicator(color: Colors.white));
}
if (state.status == ScooterStatus.failure) {
return Center(
child: Text(
state.errorMessage ?? 'Ошибка',
style: const TextStyle(color: Colors.white),
),
);
}
final scooter = state.scooter;
return Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF1B2A4A), Color(0xFF0F1E3A)],
),
),
),
Positioned(
top: 0,
bottom: -270,
right: -200,
child: Opacity(
opacity: 0.3,
child: SizedBox(
width: 400,
height: 500,
child: Image.asset(
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png',
fit: BoxFit.contain,
),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
CustomAppBar(
title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат',
),
const SizedBox(height: 24),
BatteryIndicator(percent: (scooter?.batteryLevel ?? 0) / 100),
const SizedBox(height: 24),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(child: ScooterInfoSection()),
const SizedBox(width: 20),
],
),
),
const SizedBox(height: 14),
GradientButton(
text: "Забронировать",
showArrows: true,
height: 52,
width: double.infinity,
fontSize: 16,
onTap: () {
context.pop();
context.pushReplacement('/home/tarif-sheet', extra: scooter);
},
),
],
),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,306 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/app_colors.dart';
import '../../domain/usecase/finish_ride_usecase.dart';
import '../components/custom_app_bar.dart';
import '../components/gradient_button.dart';
import '../viewmodel/send_photo_bloc.dart';
import '../event/send_photo_event.dart';
import '../state/send_photo_state.dart';
import '../../domain/usecase/upload_scooter_photos_usecase.dart';
import '../../di/service_locator.dart' as di;
class SendPhotoScreen extends StatelessWidget {
final int orderId;
const SendPhotoScreen({super.key, required this.orderId});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SendPhotoBloc(
di.getIt<UploadScooterPhotosUsecase>(),
di.getIt<FinishRideUsecase>(),
),
child: SendPhotoView(orderId: orderId),
);
}
}
class SendPhotoView extends StatefulWidget {
final int orderId;
const SendPhotoView({super.key, required this.orderId});
@override
State<SendPhotoView> createState() => _SendPhotoViewState();
}
class _SendPhotoViewState extends State<SendPhotoView> {
final ImagePicker _imagePicker = ImagePicker();
// Метод для выбора фото (добавляет к существующим)
Future<void> _pickImage() async {
await showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
return SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Сделать фото'),
onTap: () {
Navigator.pop(context);
_getImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Выбрать из галереи'),
onTap: () {
Navigator.pop(context);
_getImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.cancel),
title: const Text('Отмена'),
onTap: () {
Navigator.pop(context);
},
),
],
),
);
},
);
}
// ✅ Метод для получения изображения из выбранного источника
Future<void> _getImage(ImageSource source) async {
final XFile? pickedFile = await _imagePicker.pickImage(
source: source,
imageQuality: 80,
);
if (pickedFile != null && mounted) {
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
context.read<SendPhotoBloc>().add(
PhotoSelected([...currentImages, pickedFile.path]),
);
}
}
// Метод для удаления конкретного фото
void _removeImage(String path) {
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
final updatedList = currentImages.where((p) => p != path).toList();
context.read<SendPhotoBloc>().add(PhotoSelected(updatedList));
}
// ✅ Динамический заголовок в зависимости от количества фото
String _getTopText(int photoCount) {
if (photoCount == 0) {
return 'Для завершения аренды\nсфотографируйте самокат';
} else if (photoCount == 1) {
return 'Сделайте фото руля самоката';
} else {
return 'Отправьте фото на проверку';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100),
child: Text(
_getTopText(context.select((SendPhotoBloc bloc) => bloc.state.selectedImages.length)),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
),
// Основной контент центрируем
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BlocBuilder<SendPhotoBloc, SendPhotoState>(
builder: (context, state) {
final photoCount = state.selectedImages.length;
return Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Список выбранных фото
...state.selectedImages.map((path) => _buildPhotoThumbnail(path)),
const SizedBox(height: 35),
// Кнопка "Добавить", если фото меньше лимита (например, 5)
if (photoCount < 2)
GestureDetector(
onTap: _pickImage,
child: _buildAddButton(),
),
],
);
},
),
const SizedBox(height: 200),
// Кнопка отправки
BlocConsumer<SendPhotoBloc, SendPhotoState>(
listener: (context, state) {
if (state.status == SendPhotoStatus.success) {
context.go('/home/checkout/${widget.orderId}', extra: state.recievedPhotoIds);
} else if (state.status == SendPhotoStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
);
}
},
builder: (context, state) {
final photoCount = state.selectedImages.length;
final isEnabled = photoCount >= 2;
return GradientButton(
text: 'Отправить',
onTap: isEnabled
? () => context.read<SendPhotoBloc>().add(PhotoUploadSubmitted(widget.orderId))
: null,
enabled: isEnabled,
showArrows: true,
height: 56,
width: double.infinity,
fontSize: 16,
);
},
),
],
),
),
),
),
],
),
),
),
);
}
// миниатюра с крестиком
Widget _buildPhotoThumbnail(String path) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(path),
fit: BoxFit.cover,
),
),
),
// Кнопка удаления (крестик)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () => _removeImage(path),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Color(0xFF75FBF0),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
size: 16,
color: Color(0xFF242F51),
),
),
),
),
],
);
}
// Виджет кнопки добавления
Widget _buildAddButton() {
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [
Color(0xFF242F51), // полупрозрачный белый
Color(0xFF242F51), // ещё более прозрачный
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
boxShadow: [
// Глубокое свечение
BoxShadow(
color: Color(0xFF8BFFAA).withOpacity(0.5),
blurRadius: 50,
spreadRadius: 6,
offset: Offset.zero,
),
// Внутреннее свечение (для объёма)
BoxShadow(
color: Color(0xFF8BFFAA).withOpacity(0.2),
blurRadius: 10,
spreadRadius: -2,
offset: Offset.zero,
),
],
),
child: const Center(
child: Icon(
Icons.add,
size: 32,
color: Colors.white,
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:be_happy/presentation/event/spalsh_event.dart';
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Подключи сюда свои реальные экраны:
import 'phone_screen.dart';
import 'pin_login_screen.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _revealAnimation;
static const double logoSize = 300;
@override
void initState() {
super.initState();
// контроллер анимации
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2500),
);
// анимация движения "затемняющего" прямоугольника
_revealAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
// запускаем анимацию
_controller.forward().then((_) async {
// небольшая пауза после анимации
await Future.delayed(const Duration(milliseconds: 800));
if (!mounted) {
return;
}
context.read<SplashBloc>().add(AuthCheckRequested());
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF3A3A3A),
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final double offset = _revealAnimation.value * (logoSize * 1.2);
return Stack(
alignment: Alignment.center,
children: [
// Цветной логотип (на заднем плане)
Image.asset(
'assets/logo_color.png',
width: logoSize,
height: logoSize,
fit: BoxFit.contain,
),
// Прямоугольник, который "уезжает" вправо, открывая логотип
ClipRect(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: 1,
child: Container(
width: logoSize,
height: logoSize,
color: const Color(0xFF3A3A3A),
transform: Matrix4.translationValues(offset, 0, 0),
),
),
),
),
// Обводка логотипа (поверх)
Image.asset(
'assets/logo_outline.png',
width: logoSize * 1.01,
height: logoSize * 1.01,
fit: BoxFit.contain,
),
],
);
},
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Text(
'Версия приложения 1.0',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
),
),
),
);
}
}

View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../components/app_checkbox.dart';
import '../components/gradient_button.dart';
import '../components/period_selector.dart';
import '../event/subscription_details_event.dart';
import '../state/susbcription_details_state.dart';
import '../viewmodel/susbcription_details_bloc.dart';
class SubscriptionDetailsScreen extends StatelessWidget {
final int subscriptionId;
const SubscriptionDetailsScreen({super.key, required this.subscriptionId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A2355),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => context.pop(),
),
title: BlocBuilder<SubscriptionDetailsBloc, SubscriptionDetailsState>(
builder: (context, state) {
if (state is DetailsContentState) {
return Text(state.subscription.title);
}
return const Text("Загрузка...");
},
),
),
body: Stack(
children: [
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Opacity(
opacity: 0.5,
child: Image.asset('assets/wave.png'),
),
),
BlocBuilder<SubscriptionDetailsBloc, SubscriptionDetailsState>(
builder: (context, state) {
if (state is DetailsLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF80FFD1)),
);
}
if (state is DetailsError) {
return Center(
child: Text(
state.message,
style: const TextStyle(color: Colors.white),
),
);
}
if (state is DetailsContentState) {
return _buildContent(context, state);
}
return const SizedBox();
},
),
],
)
);
}
Widget _buildContent(BuildContext context, DetailsContentState state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.subscription.fullDescription,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 30),
_ActionCard(state: state),
const SizedBox(height: 30),
GradientButton(
text: 'Активировать',
onTap: () => context.read<SubscriptionDetailsBloc>().add(
ActivateSubscriptionPressed(),
),
width: double.infinity,
height: 56,
fontSize: 16,
showArrows: true,
),
],
),
);
}
}
class _ActionCard extends StatelessWidget {
final DetailsContentState state;
const _ActionCard({required this.state});
@override
Widget build(BuildContext context) {
final List<String> periodTitles = state.subscription.options
.map((e) => e.title)
.toList();
final int selectedIndex = state.subscription.options.indexOf(
state.selectedPeriod,
);
context.read<SubscriptionDetailsBloc>().add(
SelectPeriodEvent(state.subscription.options[selectedIndex]));
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF131B47),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Column(
children: [
const Text(
"Выберите период действия",
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
),
const SizedBox(height: 20),
PeriodSelector(
periods: periodTitles,
currentIndex: selectedIndex != -1 ? selectedIndex : 0,
onSelect: (index) {
final selectedOption = state.subscription.options[index];
context.read<SubscriptionDetailsBloc>().add(
SelectPeriodEvent(selectedOption),
);
},
),
const SizedBox(height: 30),
_PriceRow(price: state.selectedPeriod.pricePrint),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppCheckbox(
value: state.isAgreed,
onChanged: (bool? value) {
if (value != null) {
context.read<SubscriptionDetailsBloc>().add(
ToggleAgreementEvent(value),
);
}
},
),
const SizedBox(width: 12),
const Flexible(
child: Text(
'Я подтверждаю, что ознакомился со всеми условиями предоставления подписки и принимаю их безоговорочно',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
],
),
);
}
}
class _PriceRow extends StatelessWidget {
final String price;
const _PriceRow({required this.price});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset("assets/icons/money_icon.png", width: 72, height: 72),
SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Стоимость:", style: TextStyle(color: Color(0xFF80FFD1))),
Text(
price,
style: const TextStyle(
color: Color(0xFF80FFD1),
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
import '../components/subscription_card.dart';
import '../state/subscription_list_state.dart';
import '../viewmodel/subscription_list_bloc.dart';
class SubscriptionsListScreen extends StatelessWidget {
const SubscriptionsListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: AppColors.phoneScreenBg,
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomAppBar(title: 'Абонементы'),
),
Expanded(
child: Stack(
children: [
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Opacity(
opacity: 0.5,
child: Image.asset('assets/wave.png'),
),
),
BlocBuilder<SubscriptionListBloc, SubscriptionState>(
builder: (context, state) {
if (state is SubscriptionsLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is SubscriptionsLoaded) {
final activeIds = state.activeSubscriptions.map((e) => e.id).toSet();
return ListView.builder(
padding: const EdgeInsets.only(top: 20),
itemCount: state.subscriptions.length,
itemBuilder: (context, index) {
final subscription = state.subscriptions[index];
final bool isActive = activeIds.contains(subscription.id);
return SubscriptionCard(
subscription: subscription,
isActive: isActive,
);
},
);
} else if (state is SubscriptionsError) {
return Center(
child: Text(
state.message,
style: const TextStyle(color: Colors.red),
),
);
}
return const SizedBox();
},
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/app_colors.dart';
import '../components/custom_app_bar.dart';
import '../components/link_row.dart';
class SupportScreen extends StatelessWidget {
const SupportScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
// ✅ Используем общий AppBar
const SizedBox(height: 16),
CustomAppBar(title: 'Техподдержка'),
const SizedBox(height: 32),
// Список ссылок
LinkRow(
icon: 'assets/icons/telegram.png',
title: 'Telegram',
onTap: () => openLink('https://t.me/...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/whatsapp.png',
title: 'WhatsApp',
onTap: () => openLink('https://wa.me/...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/viber.png',
title: 'Viber',
onTap: () => openLink('viber://chat?number=...'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
LinkRow(
icon: 'assets/icons/call.png',
title: 'Позвонить',
onTap: () => openLink('tel:+375000000000'),
),
const Divider(height: 1, color: Colors.white24),
const SizedBox(height: 12),
const Spacer(), // Отодвигаем картинку вниз
// Нижняя картинка
Image.asset(
'assets/support_bottom.png',
width: double.infinity,
fit: BoxFit.contain,
),
const SizedBox(height: 20),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/payment_card.dart';
import '../../domain/usecase/get_payment_cards_usecase.dart';
import '../components/custom_app_bar.dart'; // ✅ Уже есть
import '../components/gradient_button.dart';
import '../components/payment_option.dart';
import '../components/sheet/payment_method_sheet.dart';
import '../event/payment_method_sheet_event.dart';
import '../event/top_up_event.dart';
import '../state/top_up_state.dart';
import '../viewmodel/payment_method_sheet_bloc.dart';
import '../viewmodel/top_up_bloc.dart';
class TopUpScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A234E),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 16),
const CustomAppBar(title: 'Пополнение баланса'),
const SizedBox(height: 20),
BlocBuilder<TopUpBloc, TopUpState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Expanded(
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTariffList(state, context),
const SizedBox(height: 30),
_buildPriceInfo(state),
const SizedBox(height: 40),
const Text(
'Способ оплаты',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 15),
_buildCardSelector(state, context),
_buildAddCardButton(() => context.push("/home/payment-methods/add-card")),
const SizedBox(height: 20),
_buildAgreement(state, context),
const Spacer(),
_buildPayButton(state),
const SizedBox(height: 30),
],
),
],
),
);
},
),
],
),
),
),
);
}
Widget _buildTariffList(TopUpState state, BuildContext context) {
return SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.certificates.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final tariff = state.certificates[index];
final isSelected = state.selectedTariff == tariff;
return GestureDetector(
onTap: () =>
context.read<TopUpBloc>().add(SelectCertificate(tariff)),
child: Container(
width: 140,
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF80FFC1)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white24),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (tariff.discount != null)
Text(
'скидка: ${tariff.discount!.toInt()}%',
style: TextStyle(
color: isSelected ? Colors.black87 : Colors.tealAccent,
),
),
Text(
'${tariff.value} баллов',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.black : Colors.white,
),
),
],
),
),
);
},
),
);
}
Widget _buildPriceInfo(TopUpState state) {
if (state.selectedTariff == null) return const SizedBox.shrink();
return Center(
child: Column(
children: [
Text(
'Купить со скидкой ${state.selectedTariff!.discount?.toInt()}%:',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
const SizedBox(width: 8),
Text(
'${state.selectedTariff!.price.toStringAsFixed(2)} BYN',
style: const TextStyle(
color: Color(0xFF80FFC1),
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}
Widget _buildCardSelector(TopUpState state, BuildContext context) {
return state.selectedCard != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: PaymentOption(
title: state.selectedCard!.type,
subtitle: '****${state.selectedCard!.cardLastNumber}',
isSelected: true,
onTap: () async {
// Открываем модалку как вложенную, не закрывая текущую
final selectedCard = await showModalBottomSheet<PaymentCard>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (innerContext) => BlocProvider(
create: (context) =>
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>())
..add(PaymentMethodSheetStarted()),
child: const PaymentMethodSheet(),
),
);
if (selectedCard != null) {
context.read<TopUpBloc>().add(SelectCard(selectedCard));
}
},
),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withOpacity(0.4),
width: 1,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
context.pushReplacement('/home/payment-method-sheet');
},
borderRadius: BorderRadius.circular(24),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Способ оплаты',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white.withOpacity(0.6),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white.withOpacity(0.4),
),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.white.withOpacity(0.2),
),
],
),
),
),
),
),
);
}
Widget _buildAgreement(TopUpState state, BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: state.isAgreed,
onChanged: (v) =>
context.read<TopUpBloc>().add(ToggleAgreement(v ?? false)),
side: const BorderSide(color: Colors.white54),
),
const Expanded(
child: Text(
'Я принимаю условия покупки бонусного пакета, при котором денежные средства не подлежат возврату...',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
);
}
Widget _buildPayButton(TopUpState state) {
return GradientButton(
text: 'Оплатить',
onTap: state.isAgreed ? () {} : null,
enabled: state.isAgreed,
showArrows: true,
height: 56,
width: double.infinity,
fontSize: 16,
);
}
Widget _buildAddCardButton(VoidCallback onPressed) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'Добавить платежную карту',
style: TextStyle(color: Colors.white),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
),
);
}
}