new project stable version
This commit is contained in:
44
lib/presentation/components/app_checkbox.dart
Normal file
44
lib/presentation/components/app_checkbox.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
|
||||
class AppCheckbox extends StatelessWidget {
|
||||
final bool value;
|
||||
final Function(bool?) onChanged;
|
||||
final bool isError;
|
||||
|
||||
|
||||
const AppCheckbox({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.isError = false,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isError ? AppColors.checkboxErrorBorder : AppColors.checkboxBorder,
|
||||
width: 2,
|
||||
),
|
||||
color: value ? AppColors.checkboxFill : Colors.transparent,
|
||||
),
|
||||
child: value
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Color(0xFF000032),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/presentation/components/arrow_button.dart
Normal file
46
lib/presentation/components/arrow_button.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onTap;
|
||||
final bool enabled;
|
||||
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 350, // Максимальная ширина кнопки
|
||||
),
|
||||
height: 66,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: enabled ? AppColors.activeButtonGradient : null,
|
||||
color: enabled ? null : AppColors.disabledButtonColor,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/presentation/components/card_input_field.dart
Normal file
80
lib/presentation/components/card_input_field.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CardInputField extends StatelessWidget {
|
||||
final String hintText;
|
||||
final IconData? icon;
|
||||
final TextEditingController? controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final TextInputType keyboardType;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool obscureText;
|
||||
final int? maxLength;
|
||||
final double letterSpacing;
|
||||
final TextCapitalization textCapitalization; // ← новый параметр
|
||||
|
||||
const CardInputField({
|
||||
super.key,
|
||||
required this.hintText,
|
||||
this.icon,
|
||||
this.controller,
|
||||
this.onChanged,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.inputFormatters,
|
||||
this.obscureText = false,
|
||||
this.maxLength,
|
||||
this.letterSpacing = 0,
|
||||
this.textCapitalization = TextCapitalization.none, // ← по умолчанию none
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 46,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
obscureText: obscureText,
|
||||
maxLength: maxLength,
|
||||
textCapitalization: textCapitalization, // ← передаём в TextField
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
letterSpacing: letterSpacing,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
fontSize: 16,
|
||||
letterSpacing: letterSpacing,
|
||||
),
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (icon != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/presentation/components/code_dots.dart
Normal file
43
lib/presentation/components/code_dots.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
|
||||
class CodeDots extends StatelessWidget {
|
||||
final String code;
|
||||
final int length;
|
||||
final bool isError;
|
||||
|
||||
|
||||
const CodeDots({
|
||||
super.key,
|
||||
required this.code,
|
||||
required this.length,
|
||||
this.isError = false,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(length, (i) {
|
||||
bool filled = i < code.length;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
filled ? code[i] : '●',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isError
|
||||
? AppColors.pinError
|
||||
: filled
|
||||
? AppColors.smsDigit
|
||||
: AppColors.digitPlaceholder,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/presentation/components/custom_app_bar.dart
Normal file
34
lib/presentation/components/custom_app_bar.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomAppBar extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const CustomAppBar({super.key, required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_back_ios_sharp, color: const Color(0x99FFFFFF), size: 20),
|
||||
Icon(Icons.arrow_back_ios_sharp, color: const Color(0x66FFFFFF), size: 20),
|
||||
Icon(Icons.arrow_back_ios_sharp, color: const Color(0x22FFFFFF), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/presentation/components/dialog/cancel_booking_dialog.dart
Normal file
102
lib/presentation/components/dialog/cancel_booking_dialog.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CancelBookingDialog extends StatelessWidget {
|
||||
const CancelBookingDialog({super.key});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool result = false;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2E3253).withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"Отменить\nбронирование?",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"Вы действительно хотите отменить\nбронирование",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
|
||||
),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => {
|
||||
result = false,
|
||||
Navigator.pop(context, result)
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Оставить",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1D273A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
result = true;
|
||||
Navigator.pop(context, result);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0D1024),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Отменить",
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CannotFinishRideDialog extends StatelessWidget {
|
||||
const CannotFinishRideDialog({super.key});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2E3253).withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"Завершить поездку невозможно",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"Вы находитесь далеко от парковки.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
|
||||
),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => {
|
||||
Navigator.pop(context)
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Понятно",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1D273A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/presentation/components/fine_notification_card.dart
Normal file
90
lib/presentation/components/fine_notification_card.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class FineNotificationCard extends StatelessWidget {
|
||||
final VoidCallback onClose;
|
||||
|
||||
const FineNotificationCard({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 20, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2B4D),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 64),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'У вас имеются неоплаченные штрафы',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Оплатите штраф, чтобы начать поездку',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: -20,
|
||||
left: -30,
|
||||
child: Image.asset(
|
||||
'assets/icons/card-screen.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 15,
|
||||
right: 15,
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close, color: Colors.white70, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
lib/presentation/components/gradient_button.dart
Normal file
119
lib/presentation/components/gradient_button.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
// import 'package:flutter/material.dart';
|
||||
// import '../core/app_colors.dart';
|
||||
//
|
||||
//
|
||||
// class GradientButton extends StatelessWidget {
|
||||
// final String text;
|
||||
// final VoidCallback? onTap;
|
||||
// final bool enabled;
|
||||
//
|
||||
//
|
||||
// const GradientButton({
|
||||
// super.key,
|
||||
// required this.text,
|
||||
// required this.onTap,
|
||||
// this.enabled = true,
|
||||
// });
|
||||
//
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return GestureDetector(
|
||||
// onTap: enabled ? onTap : null,
|
||||
// child: Container(
|
||||
// constraints: const BoxConstraints(
|
||||
// maxWidth: 350, // Максимальная ширина кнопки
|
||||
// ),
|
||||
// height: 66,
|
||||
// decoration: BoxDecoration(
|
||||
// borderRadius: BorderRadius.circular(24),
|
||||
// gradient: enabled ? AppColors.activeButtonGradient : null,
|
||||
// color: enabled ? null : AppColors.disabledButtonColor,
|
||||
// ),
|
||||
// alignment: Alignment.center,
|
||||
// child: Text(
|
||||
// text,
|
||||
// textAlign: TextAlign.center,
|
||||
// style: TextStyle(
|
||||
// color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText,
|
||||
// fontSize: 20,
|
||||
// fontWeight: FontWeight.w600,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onTap;
|
||||
final bool enabled;
|
||||
final bool showArrows; // Новый параметр для стрелок
|
||||
final double height; // Параметр высоты
|
||||
final double width;
|
||||
final double fontSize; // Параметр размера шрифта
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
this.showArrows = false,
|
||||
this.width = 220,
|
||||
this.height = 50,
|
||||
this.fontSize = 12,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color arrow1 = enabled ? const Color(0x33000032) : const Color(0x33FFFFFF);
|
||||
final Color arrow2 = enabled ? const Color(0x66000032) : const Color(0x66FFFFFF);
|
||||
final Color arrow3 = enabled ? const Color(0x99000032) : const Color(0x99FFFFFF);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: enabled ? AppColors.activeButtonGradient : null,
|
||||
color: enabled ? null : AppColors.disabledButtonColor,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: showArrows
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: arrow1, size: 12),
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: arrow2, size: 12),
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: arrow3, size: 12),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: enabled ? AppColors.activeButtonText : AppColors.disabledButtonText,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/presentation/components/link_row.dart
Normal file
67
lib/presentation/components/link_row.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart'; // ✅ Добавлен
|
||||
|
||||
class LinkRow extends StatelessWidget {
|
||||
final String icon;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const LinkRow({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
icon,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x99FFFFFF), size: 12),
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x66FFFFFF), size: 12),
|
||||
Icon(Icons.arrow_forward_ios_sharp, color: const Color(0x33FFFFFF), size: 12),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Выносим метод открытия ссылки
|
||||
void openLink(String url) async {
|
||||
final Uri uri = Uri.parse(url);
|
||||
|
||||
try {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} catch (e) {
|
||||
// TODO: Показать ошибку пользователю
|
||||
print('Не удалось открыть ссылку: $e');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ClusterIconPainter {
|
||||
final int clusterSize;
|
||||
|
||||
static ui.Image? _scooterImageCache;
|
||||
|
||||
const ClusterIconPainter(this.clusterSize);
|
||||
|
||||
static Future<void> initImage(String assetPath) async {
|
||||
if (_scooterImageCache != null) return;
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
|
||||
final frame = await codec.getNextFrame();
|
||||
_scooterImageCache = frame.image;
|
||||
}
|
||||
|
||||
Future<Uint8List> getClusterIconBytes() async {
|
||||
const size = Size(180, 180);
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
final mainCenter = Offset(size.width * 0.45, size.height * 0.55);
|
||||
final mainRadius = size.width * 0.35;
|
||||
|
||||
final greenPaint = Paint()
|
||||
..color = const Color(0xFF8BFFAA)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(mainCenter, mainRadius, greenPaint);
|
||||
|
||||
if (_scooterImageCache != null) {
|
||||
final imageSize = mainRadius * 1.3;
|
||||
final rect = Rect.fromCenter(
|
||||
center: mainCenter,
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
);
|
||||
paintImage(
|
||||
canvas: canvas,
|
||||
image: _scooterImageCache!,
|
||||
rect: rect,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
|
||||
if (clusterSize > 1) {
|
||||
_drawBadge(canvas, mainCenter, mainRadius);
|
||||
}
|
||||
|
||||
final image = await recorder.endRecording().toImage(
|
||||
size.width.toInt(),
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return byteData!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void _drawBadge(Canvas canvas, Offset mainCenter, double mainRadius) {
|
||||
final badgeCenter = Offset(
|
||||
mainCenter.dx + mainRadius * 0.7,
|
||||
mainCenter.dy - mainRadius * 0.7,
|
||||
);
|
||||
final badgeRadius = mainRadius * 0.45;
|
||||
|
||||
final badgePaint = Paint()..color = Color(0xFF000032);
|
||||
|
||||
|
||||
canvas.drawCircle(badgeCenter, badgeRadius, Paint()
|
||||
..color = Colors.black26
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3));
|
||||
|
||||
canvas.drawCircle(badgeCenter, badgeRadius, badgePaint);
|
||||
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: clusterSize > 99 ? '99+' : clusterSize.toString(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: badgeRadius * 1.1,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
badgeCenter.dx - textPainter.width / 2,
|
||||
badgeCenter.dy - textPainter.height / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/presentation/components/map_settings_sheet.dart
Normal file
141
lib/presentation/components/map_settings_sheet.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/event/map_event.dart';
|
||||
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
|
||||
import 'package:be_happy/presentation/state/map_settings_modal_state.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../viewmodel/map_bloc.dart';
|
||||
|
||||
class MapSettingsSheet extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const MapSettingsSheet({super.key, this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>(
|
||||
builder: (context, state) {
|
||||
final List<_SettingItemData> items = [
|
||||
_SettingItemData(
|
||||
label: 'Геоточки',
|
||||
icon: Icons.location_on_outlined,
|
||||
color: const Color(0xFF66E3C4),
|
||||
isActive: state.isAllGeomarksActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeomarksToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Геозоны',
|
||||
icon: Icons.gps_fixed_outlined,
|
||||
color: const Color(0xFF86EFAC),
|
||||
isActive: state.isAllGeozonesActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeozonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Парковка',
|
||||
icon: Icons.home_outlined,
|
||||
color: const Color(0xFFA78BFA),
|
||||
isActive: state.isParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Парковка запрещена',
|
||||
icon: Icons.block_outlined,
|
||||
color: const Color(0xFFF59E0B),
|
||||
isActive: state.isRestrictedParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Запрещено кататься',
|
||||
icon: Icons.warning_amber_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
isActive: state.isRestrictedDrivingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedDrivingZonesToggled(val)),
|
||||
),
|
||||
];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 365,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Параметры карты',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<MapSettingsModalBloc>().add(ApllyButtonClick());
|
||||
context.read<MapBloc>().add(UpdateMap());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Готово',
|
||||
style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
leading: Icon(item.icon, color: item.color),
|
||||
title: Text(item.label, style: const TextStyle(color: Colors.white)),
|
||||
trailing: Switch.adaptive(
|
||||
value: item.isActive,
|
||||
onChanged: item.onChanged,
|
||||
activeTrackColor: const Color(0xFF66E3C4),
|
||||
inactiveThumbColor: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательный класс для описания строк
|
||||
class _SettingItemData {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isActive;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
_SettingItemData({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.isActive,
|
||||
required this.onChanged,
|
||||
});
|
||||
}
|
||||
73
lib/presentation/components/notification_toast.dart
Normal file
73
lib/presentation/components/notification_toast.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NotificationToast extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const NotificationToast({required this.title, required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 50),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
color: Colors.red,
|
||||
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(
|
||||
"Данная территория не предназначена для катания!",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
// maxLines: 1,
|
||||
// overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Самокат 123-456! Вернитесь на разрешенный участок дороги",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
// maxLines: 2,
|
||||
// overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onClose,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
131
lib/presentation/components/payment_notification_card.dart
Normal file
131
lib/presentation/components/payment_notification_card.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class PaymentNotificationCard extends StatelessWidget {
|
||||
final VoidCallback onBindCard;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const PaymentNotificationCard({
|
||||
super.key,
|
||||
required this.onBindCard,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 20, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2B4D),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 64),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Не привязана карта',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Привяжите карту, чтобы начать поездку',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: onBindCard,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0D0B26),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.credit_card,
|
||||
color: Color(0xFF80FFD1),
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Привязать карту',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: Colors.cyanAccent,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: -20,
|
||||
left: -30,
|
||||
child: Image.asset(
|
||||
'assets/icons/card-screen.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 15,
|
||||
right: 15,
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close, color: Colors.white70, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/presentation/components/payment_option.dart
Normal file
86
lib/presentation/components/payment_option.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PaymentOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const PaymentOption({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 🔹 РАДИО-КНОПКА
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF66E3C4)
|
||||
: Colors.white.withOpacity(0.4),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF66E3C4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/presentation/components/period_selector.dart
Normal file
37
lib/presentation/components/period_selector.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PeriodSelector extends StatelessWidget {
|
||||
final List<String> periods;
|
||||
final int currentIndex;
|
||||
final Function(int) onSelect;
|
||||
|
||||
PeriodSelector({required this.currentIndex, required this.onSelect, required this.periods});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(periods.length, (index) {
|
||||
bool isSelected = currentIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF80FFD1) : const Color(0xFF1E2652),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
periods[index],
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.black : Colors.white.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/presentation/components/scooter/battery_indicator.dart
Normal file
170
lib/presentation/components/scooter/battery_indicator.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BatteryIndicator extends StatelessWidget {
|
||||
final double percent;
|
||||
|
||||
const BatteryIndicator({super.key, required this.percent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 400,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: const Size(320, 320),
|
||||
painter: _BatteryRingPainter(percent: percent),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Заряд батареи',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${(percent * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BatteryRingPainter extends CustomPainter {
|
||||
final double percent;
|
||||
|
||||
_BatteryRingPainter({required this.percent});
|
||||
|
||||
// 🔹 Возвращает цвета и stops для текущего диапазона
|
||||
(List<Color>, List<double>?) _getGradientForPercent() {
|
||||
final p = percent * 100;
|
||||
|
||||
if (p >= 51) {
|
||||
return (
|
||||
const [
|
||||
Color(0xFF86EFAC),
|
||||
Color(0xFF67E8F9),
|
||||
Color(0xFF86EFAC),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
} else if (p >= 16) {
|
||||
return (
|
||||
const [
|
||||
Color(0xFFF1FF8B),
|
||||
Color(0xFF8BFFAA),
|
||||
Color(0xFFF1FF8B),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
const [
|
||||
Color(0xFFFF5757),
|
||||
Color(0xFFF1FF8B),
|
||||
Color(0xFFFF5757),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = size.width / 2 - 20;
|
||||
|
||||
// Фоновое кольцо
|
||||
final backgroundPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.08)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 40;
|
||||
canvas.drawCircle(center, radius, backgroundPaint);
|
||||
|
||||
// Деления
|
||||
final tickPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.15)
|
||||
..strokeWidth = 2;
|
||||
for (int i = 0; i < 100; i++) {
|
||||
final angle = 2 * pi * i / 100 - pi / 2;
|
||||
final isMajor = i % 10 == 0;
|
||||
|
||||
final innerRadius = radius - 40;
|
||||
final outerRadius = isMajor ? radius - 28 : radius - 32;
|
||||
|
||||
final p1 = Offset(
|
||||
center.dx + cos(angle) * innerRadius,
|
||||
center.dy + sin(angle) * innerRadius,
|
||||
);
|
||||
final p2 = Offset(
|
||||
center.dx + cos(angle) * outerRadius,
|
||||
center.dy + sin(angle) * outerRadius,
|
||||
);
|
||||
|
||||
canvas.drawLine(p1, p2, tickPaint);
|
||||
}
|
||||
|
||||
// Получаем градиент по проценту
|
||||
final (colors, stops) = _getGradientForPercent();
|
||||
|
||||
// Glow дуга
|
||||
final glowPaint = Paint()
|
||||
..shader = SweepGradient(
|
||||
colors: colors,
|
||||
stops: stops,
|
||||
).createShader(Rect.fromCircle(center: center, radius: radius))
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 25
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 85)
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
2 * pi * percent,
|
||||
false,
|
||||
glowPaint,
|
||||
);
|
||||
|
||||
// Основная дуга
|
||||
final progressPaint = Paint()
|
||||
..shader = SweepGradient(
|
||||
colors: colors,
|
||||
stops: stops,
|
||||
).createShader(Rect.fromCircle(center: center, radius: radius))
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 20
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
2 * pi * percent,
|
||||
false,
|
||||
progressPaint,
|
||||
);
|
||||
|
||||
// Внутренний круг
|
||||
final innerCircle = Paint()
|
||||
..color = const Color(0xFF16233F)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, radius - 60, innerCircle);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MiniBatteryIndicator extends StatelessWidget {
|
||||
final int percent;
|
||||
|
||||
const MiniBatteryIndicator({super.key, required this.percent});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CustomPaint(
|
||||
painter: _MiniBatteryRingPainter(percent: percent),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniBatteryRingPainter extends CustomPainter {
|
||||
final int percent;
|
||||
|
||||
_MiniBatteryRingPainter({required this.percent});
|
||||
|
||||
(List<Color>, List<double>?) _getGradientForPercent() {
|
||||
final p = percent;
|
||||
|
||||
if (p >= 51) {
|
||||
return (
|
||||
const [
|
||||
Color(0xFF86EFAC),
|
||||
Color(0xFF67E8F9),
|
||||
Color(0xFF86EFAC),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
} else if (p >= 16) {
|
||||
return (
|
||||
const [
|
||||
Color(0xFFF1FF8B),
|
||||
Color(0xFF8BFFAA),
|
||||
Color(0xFFF1FF8B),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
const [
|
||||
Color(0xFFFF5757),
|
||||
Color(0xFFF1FF8B),
|
||||
Color(0xFFFF5757),
|
||||
],
|
||||
const [0.0, 0.5, 1.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = size.width / 2 - 4; // поменьше
|
||||
|
||||
// Фоновое кольцо
|
||||
final backgroundPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.08)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4;
|
||||
canvas.drawCircle(center, radius, backgroundPaint);
|
||||
|
||||
// Получаем цвета
|
||||
final (colors, stops) = _getGradientForPercent();
|
||||
|
||||
// Основная дуга
|
||||
final progressPaint = Paint()
|
||||
..shader = SweepGradient(
|
||||
colors: colors,
|
||||
stops: stops,
|
||||
).createShader(Rect.fromCircle(center: center, radius: radius))
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
2 * pi * (percent / 100),
|
||||
false,
|
||||
progressPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
33
lib/presentation/components/scooter/scooter_info_item.dart
Normal file
33
lib/presentation/components/scooter/scooter_info_item.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
|
||||
class ScooterInfoItem extends StatelessWidget {
|
||||
final String icon;
|
||||
final String text;
|
||||
|
||||
const ScooterInfoItem({super.key, required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
icon,
|
||||
height: 20,
|
||||
width: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'scooter_info_item.dart';
|
||||
|
||||
class ScooterInfoSection extends StatelessWidget {
|
||||
const ScooterInfoSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: const [
|
||||
ScooterInfoItem(icon: 'assets/icons/bolt.png', text: '47 км или 4 часа 17 минут'),
|
||||
ScooterInfoItem(icon: 'assets/icons/speed.png', text: 'max = 25 км/ч'),
|
||||
ScooterInfoItem(icon: 'assets/icons/location.png', text: 'пр. Московский, 33'),
|
||||
Row(
|
||||
children: [
|
||||
ScooterInfoItem(icon: 'assets/icons/person.png', text: '120 м'),
|
||||
SizedBox(width: 16),
|
||||
ScooterInfoItem(icon: 'assets/icons/time.png', text: '1 минута'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/presentation/components/scooter/slide_to_reserve_button.dart
Normal file
172
lib/presentation/components/scooter/slide_to_reserve_button.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SlideToReserveButton extends StatefulWidget {
|
||||
final VoidCallback onSlideComplete;
|
||||
|
||||
const SlideToReserveButton({
|
||||
super.key,
|
||||
required this.onSlideComplete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SlideToReserveButton> createState() => _SlideToReserveButtonState();
|
||||
}
|
||||
|
||||
class _SlideToReserveButtonState extends State<SlideToReserveButton>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _dragAnimation;
|
||||
double _dragOffset = 0;
|
||||
final double _maxDrag = 240; // ширина кнопки - ширина круга
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_dragAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_dragOffset += details.delta.dx;
|
||||
if (_dragOffset < 0) _dragOffset = 0;
|
||||
if (_dragOffset > _maxDrag) _dragOffset = _maxDrag;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
if (_dragOffset >= _maxDrag * 0.8) {
|
||||
_controller.forward();
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
widget.onSlideComplete();
|
||||
});
|
||||
} else {
|
||||
_controller.reverse();
|
||||
setState(() {
|
||||
_dragOffset = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSlided = _dragOffset >= _maxDrag * 0.8;
|
||||
final progress = _dragOffset / _maxDrag;
|
||||
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Фон кнопки: темный → градиент
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
color: isSlided
|
||||
? Colors.transparent
|
||||
: const Color(0xFF141530),
|
||||
),
|
||||
),
|
||||
|
||||
// Градиентный фон (появляется при свайпе)
|
||||
if (isSlided)
|
||||
ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return const LinearGradient(
|
||||
colors: [Color(0xFF67E8F9), Color(0xFF86EFAC)],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.srcIn,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Текст «Забронировать» — виден только в начальном состоянии
|
||||
if (!isSlided)
|
||||
Center(
|
||||
child: Text(
|
||||
'Забронировать',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Три стрелки — справа от текста (только когда текст виден)
|
||||
if (!isSlided)
|
||||
Positioned(
|
||||
right: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white),
|
||||
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.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Круг с иконкой самоката — двигается слева направо
|
||||
Transform.translate(
|
||||
offset: Offset(_dragOffset, 0),
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Color(0xFF141530),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Иконка самоката справа — появляется при полном свайпе
|
||||
if (isSlided)
|
||||
Positioned(
|
||||
right: 16,
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
267
lib/presentation/components/scooter_bottom_sheet.dart
Normal file
267
lib/presentation/components/scooter_bottom_sheet.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/components/scooter/mini_battery_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../domain/entities/scooter.dart';
|
||||
import '../state/scooter_detail_modal_state.dart';
|
||||
import '../viewmodel/scooter_detail_modal_bloc.dart';
|
||||
import 'gradient_button.dart';
|
||||
|
||||
class ScooterData {
|
||||
final String distance;
|
||||
final String number;
|
||||
final double batteryPercent;
|
||||
|
||||
ScooterData({
|
||||
required this.distance,
|
||||
required this.number,
|
||||
required this.batteryPercent,
|
||||
});
|
||||
}
|
||||
|
||||
class ScooterBottomSheet extends StatefulWidget {
|
||||
|
||||
const ScooterBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ScooterBottomSheet> createState() => _ScooterBottomSheetState();
|
||||
}
|
||||
|
||||
class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
|
||||
|
||||
final PageController _pageController = PageController(viewportFraction: 0.5);
|
||||
double _currentPage = 0;
|
||||
|
||||
_ScooterBottomSheetState();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController.addListener(() {
|
||||
setState(() {
|
||||
_currentPage = _pageController.page ?? 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ScooterDetailModalBloc, ScooterDetailModalState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ScooterDetailModalStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == ScooterDetailModalStatus.success) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header с адресом (без изменений)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(Icons.arrow_back_ios, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.address ?? "Unknown address",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PageView с динамическим списком
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
padEnds: false, // Оставляем false, чтобы первый элемент прилипал к левому краю
|
||||
itemCount: state.scooters!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final scooter = state.scooters![index];
|
||||
final diff = (_currentPage - index).abs();
|
||||
final isActive = diff < 0.5;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
// 2. Добавляем левый отступ ТОЛЬКО для первого элемента,
|
||||
// чтобы он совпадал с заголовком
|
||||
margin: EdgeInsets.only(
|
||||
left: index == 0 ? 20 : 0,
|
||||
right: 16,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Transform.scale(
|
||||
scale: isActive ? 1.0 : 0.9,
|
||||
child: _ScooterCard(
|
||||
scooter: scooter,
|
||||
isActive: isActive,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(child: Text("Error"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScooterCard extends StatelessWidget {
|
||||
final Scooter scooter;
|
||||
final bool isActive;
|
||||
|
||||
const _ScooterCard({required this.scooter, required this.isActive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(isActive ? 0.35 : 0.25),
|
||||
Colors.white.withOpacity(isActive ? 0.25 : 0.18),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"${(scooter.distance?.toInt()) ?? 0}m",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code_2, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
scooter.number,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
MiniBatteryIndicator(percent: scooter.batteryLevel),
|
||||
const SizedBox(width: 8),
|
||||
Transform.translate(
|
||||
offset: const Offset(-40, 0),
|
||||
child: Text(
|
||||
'${(scooter.batteryLevel)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.go('/home/scooter/${scooter.id}');
|
||||
},
|
||||
child: Container(
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
child: GradientButton(
|
||||
text: "Подробнеe",
|
||||
showArrows: true,
|
||||
height: 32,
|
||||
width: double.infinity,
|
||||
fontSize: 12,
|
||||
onTap: () {
|
||||
context.go('/home/scooter/${scooter.id}');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: isActive ? -30 : -5,
|
||||
top: isActive ? -10 : 15,
|
||||
child: SizedBox(
|
||||
height: isActive ? 170 : 120,
|
||||
child: Image.asset(
|
||||
"assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
517
lib/presentation/components/sheet/active_ride_sheet.dart
Normal file
517
lib/presentation/components/sheet/active_ride_sheet.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
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 '../../event/active_ride_event.dart';
|
||||
import '../../state/active_ride_state.dart';
|
||||
import '../../viewmodel/active_ride_bloc.dart';
|
||||
import '../notification_toast.dart';
|
||||
|
||||
class ActiveRideSheet extends StatefulWidget {
|
||||
final int orderId;
|
||||
final String scooterNumber;
|
||||
final Duration initialElapsedTime;
|
||||
|
||||
const ActiveRideSheet({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.scooterNumber,
|
||||
this.initialElapsedTime = Duration.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ActiveRideSheet> createState() => _ActiveRideSheetState();
|
||||
}
|
||||
|
||||
class _ActiveRideSheetState extends State<ActiveRideSheet> {
|
||||
late final ActiveRideBloc _bloc;
|
||||
Timer? _localTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = getIt<ActiveRideBloc>();
|
||||
_bloc.add(LoadScooterOrder(widget.orderId));
|
||||
|
||||
// Локальный таймер для обновления UI каждую секунду
|
||||
_localTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted && !_bloc.state.isPaused) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
_localTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: BlocConsumer<ActiveRideBloc, ActiveRideState>(
|
||||
listenWhen: (previous, current) => previous.inZone != current.inZone,
|
||||
listener: (context, state) {
|
||||
if (!state.inZone) {
|
||||
BotToast.showCustomNotification(
|
||||
// duration: const Duration(seconds: 4),
|
||||
|
||||
toastBuilder: (_) {
|
||||
return NotificationToast(
|
||||
title: "Вы покинули зону разрешенную для езды",
|
||||
onClose: () {
|
||||
BotToast.cleanAll();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Логика отображения загрузки и ошибок остается прежней
|
||||
if (state.status == ActiveRideStatus.loading && state.order == null) {
|
||||
return _buildLoading();
|
||||
}
|
||||
|
||||
if (state.status == ActiveRideStatus.failure && state.order == null) {
|
||||
return _buildError(state.errorMessage);
|
||||
}
|
||||
|
||||
return _buildContent(state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: const CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String? message) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Text(
|
||||
message ?? 'Ошибка загрузки',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ActiveRideState state) {
|
||||
final displayTime = state.isPaused
|
||||
? state.elapsedTime
|
||||
: state.elapsedTime + (DateTime.now().difference(DateTime.now().subtract(const Duration(seconds: 1))));
|
||||
|
||||
// Для отображения текущего времени в реальном времени
|
||||
final effectiveElapsedTime = state.isPaused
|
||||
? state.elapsedTime
|
||||
: DateTime.now().difference(state.order?.startAt ?? state.order?.createdAt ?? DateTime.now());
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Самокат ${widget.scooterNumber}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔹 ТАЙМЕР
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(effectiveElapsedTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.isPaused ? 'на паузе' : 'время в пути',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔹 КНОПКИ УПРАВЛЕНИЯ
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: state.isPaused
|
||||
? null
|
||||
: const LinearGradient(
|
||||
colors: [Color(0xFF66E3C4), Color(0xFF4CD1B5)],
|
||||
),
|
||||
color: state.isPaused ? Colors.white.withOpacity(0.15) : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.status == ActiveRideStatus.loading
|
||||
? null
|
||||
: () {
|
||||
if (state.isPaused) {
|
||||
_bloc.add(ResumeRide(widget.orderId));
|
||||
} else {
|
||||
_bloc.add(PauseRide(widget.orderId));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
state.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: state.isPaused
|
||||
? Colors.white.withOpacity(0.8)
|
||||
: const Color(0xFF0A0F2E),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.isPaused ? 'ПРОДОЛЖИТЬ' : 'ПАУЗА',
|
||||
style: TextStyle(
|
||||
color: state.isPaused
|
||||
? Colors.white.withOpacity(0.8)
|
||||
: const Color(0xFF0A0F2E),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFB84949),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.status == ActiveRideStatus.loading
|
||||
? null
|
||||
: () {
|
||||
_bloc.add(FinishRide(widget.orderId));
|
||||
Navigator.pop(context);
|
||||
context.go("/home/order-photos/${widget.orderId}");
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.stop,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'ЗАВЕРШИТЬ',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Поддержка
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone_in_talk,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Поддержка',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 СТАТИСТИКА (2 равных столбца, правый на всю высоту)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
// 🔹 ЛЕВЫЙ СТОЛБЕЦ: Скорость + Расстояние
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Скорость
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
state.speed.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'скорость',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Расстояние
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
state.distance.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'расстояние',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 🔹 ПРАВЫЙ СТОЛБЕЦ: Стоимость (на всю высоту)
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Стоимость',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: state.cost.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Digital Numbers',
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' BYN',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final hours = twoDigits(duration.inHours);
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
400
lib/presentation/components/sheet/current_rides_sheet.dart
Normal file
400
lib/presentation/components/sheet/current_rides_sheet.dart
Normal file
@@ -0,0 +1,400 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../../domain/entities/scooter_order.dart';
|
||||
import '../../event/current_rides_event.dart';
|
||||
import '../../state/current_rides_state.dart';
|
||||
import '../../viewmodel/current_rides_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
import 'reserved_ride_sheet.dart';
|
||||
import 'active_ride_sheet.dart';
|
||||
|
||||
class CurrentRidesSheet extends StatefulWidget {
|
||||
|
||||
const CurrentRidesSheet({super.key});
|
||||
|
||||
@override
|
||||
State<CurrentRidesSheet> createState() => _CurrentRidesSheetState();
|
||||
}
|
||||
|
||||
class _CurrentRidesSheetState extends State<CurrentRidesSheet> {
|
||||
late final CurrentRidesBloc _bloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Текущие поездки',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: BlocBuilder<CurrentRidesBloc, CurrentRidesState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CurrentRidesStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == CurrentRidesStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.orders.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Нет активных поездок',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: state.orders.map((order) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _RideCard(order: order),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Взять ещё самокат',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RideCard extends StatefulWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const _RideCard({required this.order});
|
||||
|
||||
@override
|
||||
State<_RideCard> createState() => _RideCardState();
|
||||
}
|
||||
|
||||
class _RideCardState extends State<_RideCard> {
|
||||
late Timer _timer;
|
||||
late Duration _elapsedTime;
|
||||
late Duration _reservationTime;
|
||||
late DateTime _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTime = widget.order.startAt ?? widget.order.createdAt;
|
||||
_elapsedTime = DateTime.now().difference(_startTime);
|
||||
_reservationTime = const Duration(minutes: 5);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_elapsedTime = DateTime.now().difference(_startTime);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReserved =
|
||||
widget.order.status == 'Booking' || //Drive, Finish
|
||||
widget.order.status == 'holding';
|
||||
Duration displayTime;
|
||||
if (isReserved) {
|
||||
displayTime = _reservationTime - _elapsedTime;
|
||||
if (displayTime.isNegative) {
|
||||
displayTime = Duration.zero;
|
||||
}
|
||||
} else {
|
||||
displayTime = _elapsedTime;
|
||||
}
|
||||
final timeString = _formatDuration(displayTime);
|
||||
final statusText = _getStatusText(widget.order.status);
|
||||
final statusColor = _getStatusColor(widget.order.status);
|
||||
|
||||
final scooterNumber =
|
||||
widget.order.scooter?.number ?? widget.order.scooterId.toString();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
height: 70,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getLocationText(),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
timeString,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: GradientButton(
|
||||
text: 'Подробнее',
|
||||
showArrows: false,
|
||||
height: 32,
|
||||
width: 100,
|
||||
fontSize: 11,
|
||||
onTap: () {
|
||||
if (isReserved) {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ReservedRideSheet(
|
||||
orderId: widget.order.id,
|
||||
scooterNumber: scooterNumber,
|
||||
initialReservationTime:
|
||||
_reservationTime - _elapsedTime,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ActiveRideSheet(
|
||||
orderId: widget.order.id,
|
||||
scooterNumber: scooterNumber,
|
||||
initialElapsedTime: _elapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getStatusText(String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'reserved':
|
||||
case 'holding':
|
||||
return 'Забронировано';
|
||||
case 'active':
|
||||
case 'in_progress':
|
||||
return 'Активно';
|
||||
case 'completed':
|
||||
return 'Завершено';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'reserved':
|
||||
case 'holding':
|
||||
return const Color(0xFFFFB800);
|
||||
case 'active':
|
||||
case 'in_progress':
|
||||
return const Color(0xFF66E3C4);
|
||||
default:
|
||||
return Colors.white;
|
||||
}
|
||||
}
|
||||
|
||||
String _getLocationText() {
|
||||
/*if (widget.order.scooter != null && widget.order.scooter!.address != null) {
|
||||
return widget.order.scooter!.address!;
|
||||
}*/
|
||||
return 'Московский 33';
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
141
lib/presentation/components/sheet/map_settings_sheet.dart
Normal file
141
lib/presentation/components/sheet/map_settings_sheet.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/event/map_event.dart';
|
||||
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
|
||||
import 'package:be_happy/presentation/state/map_settings_modal_state.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../viewmodel/map_bloc.dart';
|
||||
|
||||
class MapSettingsSheet extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const MapSettingsSheet({super.key, this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>(
|
||||
builder: (context, state) {
|
||||
final List<_SettingItemData> items = [
|
||||
_SettingItemData(
|
||||
label: 'Геоточки',
|
||||
icon: Icons.location_on_outlined,
|
||||
color: const Color(0xFF66E3C4),
|
||||
isActive: state.isAllGeomarksActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeomarksToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Геозоны',
|
||||
icon: Icons.gps_fixed_outlined,
|
||||
color: const Color(0xFF86EFAC),
|
||||
isActive: state.isAllGeozonesActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeozonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Парковка',
|
||||
icon: Icons.home_outlined,
|
||||
color: const Color(0xFFA78BFA),
|
||||
isActive: state.isParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Разрешено кататься',
|
||||
icon: Icons.block_outlined,
|
||||
color: const Color(0xFF5ECD4C),
|
||||
isActive: state.isRestrictedParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Запрещено кататься',
|
||||
icon: Icons.warning_amber_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
isActive: state.isRestrictedDrivingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedDrivingZonesToggled(val)),
|
||||
),
|
||||
];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 365,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Параметры карты',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<MapSettingsModalBloc>().add(ApllyButtonClick());
|
||||
context.read<MapBloc>().add(UpdateMap());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Готово',
|
||||
style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
leading: Icon(item.icon, color: item.color),
|
||||
title: Text(item.label, style: const TextStyle(color: Colors.white)),
|
||||
trailing: Switch.adaptive(
|
||||
value: item.isActive,
|
||||
onChanged: item.onChanged,
|
||||
activeTrackColor: const Color(0xFF66E3C4),
|
||||
inactiveThumbColor: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательный класс для описания строк
|
||||
class _SettingItemData {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isActive;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
_SettingItemData({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.isActive,
|
||||
required this.onChanged,
|
||||
});
|
||||
}
|
||||
278
lib/presentation/components/sheet/payment_method_sheet.dart
Normal file
278
lib/presentation/components/sheet/payment_method_sheet.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/event/tariff_sheet_event.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:go_router/go_router.dart';
|
||||
import 'package:be_happy/presentation/components/payment_option.dart';
|
||||
|
||||
import '../../../domain/entities/payment_card.dart';
|
||||
import '../../event/payment_method_sheet_event.dart';
|
||||
import '../../state/payment_method_sheet_state.dart';
|
||||
import '../../viewmodel/payment_method_sheet_bloc.dart';
|
||||
|
||||
class PaymentMethodSheet extends StatefulWidget {
|
||||
final PaymentCard? initialSelectedCard; // Добавляем это поле
|
||||
|
||||
const PaymentMethodSheet({
|
||||
super.key,
|
||||
this.initialSelectedCard, // Инициализируем в конструкторе
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentMethodSheet> createState() => _PaymentMethodSheetState();
|
||||
}
|
||||
|
||||
class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
|
||||
int? _selectedPaymentMethod = -2;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<PaymentMethodSheetBloc>().add(PaymentMethodSheetStarted());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PaymentMethodSheetBloc, PaymentMethodSheetState>(
|
||||
builder: (context, state) {
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.loading) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.failure) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки карт',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.success && _selectedPaymentMethod == -2) {
|
||||
if (widget.initialSelectedCard != null) {
|
||||
final initialIndex = state.cards.indexWhere(
|
||||
(card) => card.cardLastNumber == widget.initialSelectedCard!.cardLastNumber
|
||||
);
|
||||
_selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1;
|
||||
} else {
|
||||
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
|
||||
_selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Находим карту с isMain: true при загрузке
|
||||
/*if (_selectedPaymentMethod == null) {
|
||||
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
|
||||
if (mainCardIndex != -1) {
|
||||
_selectedPaymentMethod = mainCardIndex;
|
||||
}
|
||||
}*/
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите способ оплаты',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
PaymentOption(
|
||||
title: 'Баланс',
|
||||
subtitle: '${state.balance.toStringAsFixed(2)} BYN',
|
||||
isSelected: _selectedPaymentMethod == -1,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = -1;
|
||||
});
|
||||
Navigator.pop(context, 'balance');
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...state.cards.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final card = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PaymentOption(
|
||||
title: card.type,
|
||||
subtitle: '****${card.cardLastNumber}',
|
||||
isSelected: _selectedPaymentMethod == index,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = index;
|
||||
});
|
||||
Navigator.pop(context, card);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
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: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/home/payment-methods/add-card');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: const Color(0xFF66E3C4),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Добавить платежную карту',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getCardType(String lastNumber) {
|
||||
if (lastNumber.isEmpty) return 'Card';
|
||||
final firstDigit = lastNumber[0];
|
||||
switch (firstDigit) {
|
||||
case '4':
|
||||
return 'Visa';
|
||||
case '5':
|
||||
return 'Mastercard';
|
||||
case '9':
|
||||
return 'BelCard';
|
||||
default:
|
||||
return 'Card';
|
||||
}
|
||||
}
|
||||
}
|
||||
335
lib/presentation/components/sheet/reserved_ride_sheet.dart
Normal file
335
lib/presentation/components/sheet/reserved_ride_sheet.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../event/reserved_ride_event.dart';
|
||||
import '../../state/reserved_ride_state.dart';
|
||||
import '../../viewmodel/reserved_ride_bloc.dart';
|
||||
import '../dialog/cancel_booking_dialog.dart';
|
||||
import '../gradient_button.dart';
|
||||
import 'active_ride_sheet.dart';
|
||||
|
||||
class ReservedRideSheet extends StatefulWidget {
|
||||
final String scooterNumber;
|
||||
final int orderId;
|
||||
final Duration initialReservationTime;
|
||||
|
||||
const ReservedRideSheet({
|
||||
super.key,
|
||||
required this.scooterNumber,
|
||||
required this.orderId,
|
||||
this.initialReservationTime = const Duration(minutes: 3, seconds: 17),
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReservedRideSheet> createState() => _ReservedRideSheetState();
|
||||
}
|
||||
|
||||
class _ReservedRideSheetState extends State<ReservedRideSheet> {
|
||||
late final ReservedRideBloc _bloc;
|
||||
late Duration _reservationTime;
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = getIt<ReservedRideBloc>();
|
||||
_reservationTime = widget.initialReservationTime;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_reservationTime = _reservationTime - const Duration(seconds: 1);
|
||||
if (_reservationTime.isNegative) {
|
||||
_reservationTime = Duration.zero;
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// HEADER
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Бесплатное бронирование',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
// Таймер
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
_formatDuration(_reservationTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
),
|
||||
// Иконка и информация (ВЫСОКИЙ БЛОК)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Иконка самоката (ВЫШЕ)
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 56,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Инфо
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFB800),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Забронирован',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'№${widget.scooterNumber}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// КНОПКА "НАЧАТЬ ПОЕЗДКУ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<ReservedRideBloc, ReservedRideState>(
|
||||
listener: (context, state) {
|
||||
if (state.rideStarted) {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ActiveRideSheet(
|
||||
scooterNumber: widget.scooterNumber,
|
||||
initialElapsedTime: Duration.zero,
|
||||
orderId: widget.orderId,
|
||||
),
|
||||
);
|
||||
} else if (state.status == ReservedRideStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: GradientButton(
|
||||
text: 'Начать поездку',
|
||||
showArrows: true,
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
fontSize: 15,
|
||||
onTap: () {
|
||||
_bloc.add(StartRide(widget.orderId));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<ReservedRideBloc, ReservedRideState>(
|
||||
listener: (context, state) {
|
||||
if (state.rideCancelled) {
|
||||
Navigator.pop(context);
|
||||
} else if (state.status == ReservedRideStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
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: () async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const CancelBookingDialog(),
|
||||
);
|
||||
if (result != null && result) {
|
||||
_bloc.add(CancelRide(widget.orderId));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BlocBuilder<ReservedRideBloc, ReservedRideState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ReservedRideStatus.loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Отменить бронирование',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
286
lib/presentation/components/sheet/scooter_bottom_sheet.dart
Normal file
286
lib/presentation/components/sheet/scooter_bottom_sheet.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/components/scooter/mini_battery_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../domain/entities/scooter.dart';
|
||||
import '../../state/scooter_detail_modal_state.dart';
|
||||
import '../../viewmodel/scooter_detail_modal_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
|
||||
class ScooterData {
|
||||
final String distance;
|
||||
final String number;
|
||||
final double batteryPercent;
|
||||
|
||||
ScooterData({
|
||||
required this.distance,
|
||||
required this.number,
|
||||
required this.batteryPercent,
|
||||
});
|
||||
}
|
||||
|
||||
class ScooterBottomSheet extends StatefulWidget {
|
||||
const ScooterBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ScooterBottomSheet> createState() => _ScooterBottomSheetState();
|
||||
}
|
||||
|
||||
class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
|
||||
final PageController _pageController = PageController(viewportFraction: 0.5);
|
||||
double _currentPage = 0;
|
||||
|
||||
_ScooterBottomSheetState();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController.addListener(() {
|
||||
setState(() {
|
||||
_currentPage = _pageController.page ?? 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ScooterDetailModalBloc, ScooterDetailModalState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ScooterDetailModalStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == ScooterDetailModalStatus.success) {
|
||||
return Dismissible(
|
||||
key: const Key('scooter-modal'),
|
||||
direction: DismissDirection.down, // Закрытие только вниз
|
||||
onDismissed: (_) => context.pop(),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header с адресом (без изменений)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.address ?? "Unknown address",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PageView с динамическим списком
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
padEnds: false,
|
||||
// Оставляем false, чтобы первый элемент прилипал к левому краю
|
||||
itemCount: state.scooters!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final scooter = state.scooters![index];
|
||||
final diff = (_currentPage - index).abs();
|
||||
final isActive = diff < 0.5;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
// 2. Добавляем левый отступ ТОЛЬКО для первого элемента,
|
||||
// чтобы он совпадал с заголовком
|
||||
margin: EdgeInsets.only(
|
||||
left: index == 0 ? 10 : 0,
|
||||
right: 10,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Transform.scale(
|
||||
scale: isActive ? 1.0 : 0.9,
|
||||
child: _ScooterCard(
|
||||
scooter: scooter,
|
||||
isActive: isActive,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(child: Text("Error"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScooterCard extends StatelessWidget {
|
||||
final Scooter scooter;
|
||||
final bool isActive;
|
||||
|
||||
const _ScooterCard({required this.scooter, required this.isActive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(isActive ? 0.35 : 0.25),
|
||||
Colors.white.withOpacity(isActive ? 0.25 : 0.18),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"${(scooter.distance?.toInt()) ?? 0}m",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.qr_code_scanner_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
scooter.number,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
MiniBatteryIndicator(percent: scooter.batteryLevel),
|
||||
const SizedBox(width: 8),
|
||||
Transform.translate(
|
||||
offset: const Offset(-40, 0),
|
||||
child: Text(
|
||||
'${(scooter.batteryLevel)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
GradientButton(
|
||||
text: "Подробнеe",
|
||||
showArrows: true,
|
||||
height: 32,
|
||||
width: double.infinity,
|
||||
fontSize: 12,
|
||||
onTap: () {
|
||||
Navigator.pop(context, scooter);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: isActive ? -30 : -5,
|
||||
top: isActive ? -10 : 15,
|
||||
child: SizedBox(
|
||||
height: isActive ? 190 : 160,
|
||||
child: Image.asset(
|
||||
"assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/presentation/components/sheet/tariff_info_sheet.dart
Normal file
177
lib/presentation/components/sheet/tariff_info_sheet.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/domain/entities/tariff.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class TariffInfoSheet extends StatefulWidget {
|
||||
final Tariff tariff;
|
||||
|
||||
const TariffInfoSheet({super.key, required this.tariff});
|
||||
|
||||
@override
|
||||
State<TariffInfoSheet> createState() => _TariffInfoSheetState();
|
||||
}
|
||||
|
||||
class _TariffInfoSheetState extends State<TariffInfoSheet> {
|
||||
bool _isInsuranceEnabled = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.9),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Полоска сверху (Handle)
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Контент со скроллом
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: [
|
||||
// Заголовок
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.timer_outlined, color: Color(0xFF66E3C4), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.tariff.title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF66E3C4),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Таблица цен
|
||||
_buildPriceRow('Старт поездки', '${widget.tariff.startPrice} BYN'),
|
||||
_buildPriceRow('Последующая минута', '${widget.tariff.drivePrice} BYN'),
|
||||
_buildPriceRow('Пауза', '${widget.tariff.pausePrice} BYN/мин'),
|
||||
_buildPriceRow('КЕШБЭК', '${widget.tariff.cashback * 100}%', isAccent: true),
|
||||
|
||||
const Divider(color: Colors.white24, height: 40),
|
||||
|
||||
// Страховка
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Страховка',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Image.asset('assets/icons/info_icon.png', width: 18, height: 18),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${widget.tariff.insurance} BYN',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: CupertinoSwitch(
|
||||
value: _isInsuranceEnabled,
|
||||
activeColor: const Color(0xFF66E3C4),
|
||||
onChanged: (val) => setState(() => _isInsuranceEnabled = val),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white24, height: 40),
|
||||
|
||||
// Список правил (Bullet points)
|
||||
_buildInfoBullet('Оплата страховки осуществляется только по банковской карте отдельным платежом'),
|
||||
_buildInfoBullet('В режиме паузы время тарифа приостанавливается'),
|
||||
_buildInfoBullet('При старте заказа будет заблокирована сумма в размере 7 рублей для проверки платежеспособности. Сумма разблокируется по факту списания средств за поездку.'),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceRow(String label, String value, {bool isAccent = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 15),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: isAccent ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBullet(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Icon(Icons.circle, size: 4, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
551
lib/presentation/components/sheet/tariff_sheet.dart
Normal file
551
lib/presentation/components/sheet/tariff_sheet.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/components/payment_option.dart';
|
||||
import 'package:be_happy/presentation/components/sheet/payment_method_sheet.dart';
|
||||
import 'package:be_happy/presentation/components/sheet/tariff_info_sheet.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/payment_method_sheet_bloc.dart';
|
||||
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/entities/scooter.dart';
|
||||
import '../../../domain/entities/tariff.dart';
|
||||
import '../../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../event/payment_method_sheet_event.dart';
|
||||
import '../../event/tariff_sheet_event.dart';
|
||||
import '../../state/tariff_sheet_state.dart';
|
||||
import '../../viewmodel/tariff_sheet_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
import '../scooter/mini_battery_indicator.dart';
|
||||
|
||||
class TariffSheet extends StatefulWidget {
|
||||
final Scooter scooter;
|
||||
|
||||
const TariffSheet({super.key, required this.scooter});
|
||||
|
||||
@override
|
||||
State<TariffSheet> createState() => _TariffSheetState();
|
||||
}
|
||||
|
||||
class _TariffSheetState extends State<TariffSheet> {
|
||||
int? _selectedTariffIndex;
|
||||
bool _hasPaymentCard = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<TariffSheetBloc>().add(TariffSheetStarted(widget.scooter.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TariffSheetBloc, TariffSheetState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == TariffSheetStatus.loading) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == TariffSheetStatus.failure) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки тарифов',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔹 HEADER
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Самокат ${widget.scooter.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
MiniBatteryIndicator(
|
||||
percent: widget.scooter.batteryLevel,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${widget.scooter.batteryLevel.toInt()}%', // ✅ Цифры
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Заряда хватит на 4 часа 17 минут\nили 47 км',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
itemCount: state.tariffs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tariff = state.tariffs[index];
|
||||
return Row(
|
||||
children: [
|
||||
_TariffCard(
|
||||
title: tariff.title,
|
||||
price: tariff.startPrice.toStringAsFixed(2),
|
||||
currency: tariff.currency,
|
||||
subtitle: 'Старт поездки',
|
||||
details: [
|
||||
'Далее ${tariff.drivePrice.toStringAsFixed(2)} ${tariff.currency}/мин.',
|
||||
'Минута на паузе ${tariff.pausePrice.toStringAsFixed(0)} ${tariff.currency}',
|
||||
],
|
||||
isSelected: _selectedTariffIndex == index,
|
||||
tariff: tariff,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedTariffIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (index < state.tariffs.length - 1)
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (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 && mounted) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
PaymentCardChanged(result),
|
||||
);
|
||||
} else if (result == 'balance') {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
SelectBalancePressed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 🔹 КНОПКА "ЗАБРОНИРОВАТЬ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GradientButton(
|
||||
text: 'Забронировать',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
enabled: _selectedTariffIndex != null && (state.selectedCard != null || state.useBalance),
|
||||
onTap: (_selectedTariffIndex != null && (state.selectedCard != null || state.useBalance))
|
||||
? () {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
BookScooterPressed(
|
||||
widget.scooter.id,
|
||||
state.tariffs[_selectedTariffIndex!].id,
|
||||
0,
|
||||
state.useBalance ? null : state.selectedCard?.id,
|
||||
state.useBalance,
|
||||
false
|
||||
)
|
||||
);
|
||||
context.pushReplacement('/home/current-rides-sheet');
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getCardType(String lastNumber) {
|
||||
if (lastNumber.isEmpty) return 'Card';
|
||||
final firstDigit = lastNumber[0];
|
||||
switch (firstDigit) {
|
||||
case '4':
|
||||
return 'Visa';
|
||||
case '5':
|
||||
return 'Mastercard';
|
||||
case '9':
|
||||
return 'BelCard';
|
||||
default:
|
||||
return 'Card';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TariffCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Tariff tariff;
|
||||
final String price;
|
||||
final String currency;
|
||||
final String subtitle;
|
||||
final List<String> details;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TariffCard({
|
||||
required this.title,
|
||||
required this.tariff,
|
||||
required this.price,
|
||||
required this.currency,
|
||||
required this.subtitle,
|
||||
required this.details,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF1A1F3E)
|
||||
: Colors.white.withOpacity(0.19),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// Используем Stack, чтобы наложить кнопку поверх контента
|
||||
child: Stack(
|
||||
children: [
|
||||
// Основной контент карточки
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Заголовок с иконкой часов
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF66E3C4),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF66E3C4),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Резервируем место под иконку инфо справа,
|
||||
// чтобы текст не залез под неё
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Цена + текст рядом
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$price $currency',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Детали
|
||||
...details.map(
|
||||
(detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
detail,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Кнопка-иконка в верхнем правом углу
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => TariffInfoSheet(tariff: tariff),
|
||||
);
|
||||
print('Info pressed for $title');
|
||||
},
|
||||
child: Image.asset(
|
||||
'assets/icons/info_icon.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/presentation/components/side_menu.dart
Normal file
187
lib/presentation/components/side_menu.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:be_happy/presentation/viewmodel/map_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 '../event/map_event.dart';
|
||||
|
||||
class SideMenu extends StatelessWidget {
|
||||
|
||||
const SideMenu({super.key,});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
final state = context.watch<MapBloc>().state;
|
||||
|
||||
return Drawer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Заголовок
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Добро пожаловать!',
|
||||
style: TextStyle(
|
||||
color: AppColors.smsDigit,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.phoneNumber ?? "Загрузка...",
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Баланс ${state.balance} руб.',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white30, height: 1),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Пункты меню
|
||||
Column(
|
||||
children: [
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/person_icon.png',
|
||||
title: 'Профиль',
|
||||
onTap: () => {
|
||||
context.go('/home/profile'),
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/list_star_icon.png',
|
||||
title: 'История поездок',
|
||||
onTap: () => context.go("/home/order-history"),
|
||||
),
|
||||
const Divider(color: Colors.white30, height: 1),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/creditcard_icon.png',
|
||||
title: 'Способ оплаты',
|
||||
onTap: () => context.go("/home/payment-methods"),
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/promo_icon.png',
|
||||
title: 'Промокоды',
|
||||
onTap: () => context.go("/home/promo"),
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/plans_icon.png',
|
||||
title: 'Абонементы',
|
||||
onTap: () => context.push("/home/subscriptions"),
|
||||
),
|
||||
const Divider(color: Colors.white30, height: 1),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/magazine_icon.png',
|
||||
title: 'Правила пользования самокатом',
|
||||
onTap: () => context.go("/home/rules")
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/headphones_icon.png',
|
||||
title: 'Техподдержка',
|
||||
onTap: () => {
|
||||
context.go('/home/support'),
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/doc_icon.png',
|
||||
title: 'Документы',
|
||||
onTap: () => context.go("/home/documents"),
|
||||
),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/news_icon.png',
|
||||
title: 'Новости',
|
||||
onTap: () => context.go("/home/news"),
|
||||
),
|
||||
const Divider(color: Colors.white30, height: 1),
|
||||
_MenuItem(
|
||||
icon: 'assets/icons/logout_icon.png',
|
||||
title: 'Выход',
|
||||
onTap: () => {
|
||||
context.go('/login'),
|
||||
context.read<MapBloc>().stopNotificationStream(),
|
||||
context.read<MapBloc>().add(LogoutPressed()),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Картинка внизу (внутри скролла)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem extends StatelessWidget {
|
||||
final String icon;
|
||||
final String title;
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MenuItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Image.asset(
|
||||
icon,
|
||||
width: 24,
|
||||
height: 24
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: color ?? Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/presentation/components/subscription_card.dart
Normal file
102
lib/presentation/components/subscription_card.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../domain/entities/subscription.dart';
|
||||
|
||||
class SubscriptionCard extends StatelessWidget {
|
||||
final Subscription subscription;
|
||||
final bool isActive;
|
||||
|
||||
const SubscriptionCard({super.key, required this.subscription, required this.isActive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final minPriceOption = subscription.options.isNotEmpty
|
||||
? subscription.options.reduce((a, b) => a.price < b.price ? a : b)
|
||||
: null;
|
||||
|
||||
final maxDaysOption = subscription.options.isNotEmpty
|
||||
? subscription.options.reduce((a, b) => a.days > b.days ? a : b)
|
||||
: null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D143C),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isActive ? MainAxisAlignment.spaceBetween : MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subscription.title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
width: 100,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов
|
||||
),
|
||||
child: Text(
|
||||
"АКТИВНА",
|
||||
style: TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
]
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
subscription.shortDescription,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (maxDaysOption != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Период действия: до ${maxDaysOption.days} дней",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (minPriceOption != null)
|
||||
Text(
|
||||
"от ${minPriceOption.pricePrint}",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.push("/home/subscriptions/${subscription.id}"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF80FFD1),
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
child: const Text("Подробнее", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class UnpaidOrderNotificationCard extends StatelessWidget {
|
||||
final VoidCallback onClose;
|
||||
|
||||
const UnpaidOrderNotificationCard({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 20, 24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2B4D),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 64),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'У вас имеются неоплаченные поездки!',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Оплатите поездки, чтобы начать новую поездку',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: -20,
|
||||
left: -30,
|
||||
child: Image.asset(
|
||||
'assets/icons/card-screen.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 15,
|
||||
right: 15,
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close, color: Colors.white70, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/presentation/components/utils/card_formatter.dart
Normal file
49
lib/presentation/components/utils/card_formatter.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CardNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var text = newValue.text.replaceAll(' ', '');
|
||||
var buffer = StringBuffer();
|
||||
for (int i = 0; i < text.length; i++) {
|
||||
buffer.write(text[i]);
|
||||
var nonZeroIndex = i + 1;
|
||||
if (nonZeroIndex % 4 == 0 && nonZeroIndex != text.length) {
|
||||
buffer.write(' '); // Добавляем пробел каждые 4 цифры
|
||||
}
|
||||
}
|
||||
var string = buffer.toString();
|
||||
return newValue.copyWith(
|
||||
text: string,
|
||||
selection: TextSelection.collapsed(offset: string.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CardMonthInputFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var newText = newValue.text;
|
||||
if (newValue.selection.baseOffset == 0) return newValue;
|
||||
|
||||
var buffer = StringBuffer();
|
||||
for (int i = 0; i < newText.length; i++) {
|
||||
buffer.write(newText[i]);
|
||||
var nonZeroIndex = i + 1;
|
||||
if (nonZeroIndex % 2 == 0 && nonZeroIndex != newText.length) {
|
||||
buffer.write('/'); // Добавляем слэш после 2-й цифры
|
||||
}
|
||||
}
|
||||
var string = buffer.toString();
|
||||
return newValue.copyWith(
|
||||
text: string,
|
||||
selection: TextSelection.collapsed(offset: string.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/presentation/event/active_ride_event.dart
Normal file
31
lib/presentation/event/active_ride_event.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
sealed class ActiveRideEvent {}
|
||||
|
||||
class LoadScooterOrder extends ActiveRideEvent {
|
||||
final int orderId;
|
||||
|
||||
LoadScooterOrder(this.orderId);
|
||||
}
|
||||
|
||||
class PauseRide extends ActiveRideEvent {
|
||||
final int orderId;
|
||||
|
||||
PauseRide(this.orderId);
|
||||
}
|
||||
|
||||
class ResumeRide extends ActiveRideEvent {
|
||||
final int orderId;
|
||||
|
||||
ResumeRide(this.orderId);
|
||||
}
|
||||
|
||||
class FinishRide extends ActiveRideEvent {
|
||||
final int orderId;
|
||||
|
||||
FinishRide(this.orderId);
|
||||
}
|
||||
|
||||
class SyncScooterOrder extends ActiveRideEvent {
|
||||
final int orderId;
|
||||
|
||||
SyncScooterOrder(this.orderId);
|
||||
}
|
||||
27
lib/presentation/event/add_card_event.dart
Normal file
27
lib/presentation/event/add_card_event.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
abstract class AddCardEvent {}
|
||||
|
||||
class AddCardSubmitted extends AddCardEvent {}
|
||||
|
||||
class CardNumberChanged extends AddCardEvent {
|
||||
final String cardNumber;
|
||||
|
||||
CardNumberChanged(this.cardNumber);
|
||||
}
|
||||
|
||||
class ExpiryDateChanged extends AddCardEvent {
|
||||
final String expiryDate;
|
||||
|
||||
ExpiryDateChanged(this.expiryDate);
|
||||
}
|
||||
|
||||
class CvvChanged extends AddCardEvent {
|
||||
final String cvv;
|
||||
|
||||
CvvChanged(this.cvv);
|
||||
}
|
||||
|
||||
class CardHolderChanged extends AddCardEvent {
|
||||
final String cardHolder;
|
||||
|
||||
CardHolderChanged(this.cardHolder);
|
||||
}
|
||||
23
lib/presentation/event/auth_event.dart
Normal file
23
lib/presentation/event/auth_event.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
abstract class PhoneAuthEvent {}
|
||||
|
||||
class PhoneAuthStarted extends PhoneAuthEvent {}
|
||||
|
||||
class PhoneChanged extends PhoneAuthEvent {
|
||||
final String phone;
|
||||
|
||||
PhoneChanged(this.phone);
|
||||
}
|
||||
|
||||
class IsAdultChanged extends PhoneAuthEvent {
|
||||
final bool isAdult;
|
||||
|
||||
IsAdultChanged(this.isAdult);
|
||||
}
|
||||
|
||||
class PrivacyAcceptedChanged extends PhoneAuthEvent {
|
||||
final bool accepted;
|
||||
|
||||
PrivacyAcceptedChanged(this.accepted);
|
||||
}
|
||||
|
||||
class SubmitPhonePressed extends PhoneAuthEvent {}
|
||||
3
lib/presentation/event/current_rides_event.dart
Normal file
3
lib/presentation/event/current_rides_event.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
sealed class CurrentRidesEvent {}
|
||||
|
||||
class LoadClientOrders extends CurrentRidesEvent {}
|
||||
13
lib/presentation/event/edit_profile_event.dart
Normal file
13
lib/presentation/event/edit_profile_event.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
|
||||
abstract class EditProfileEvent {}
|
||||
|
||||
class EditProfileStarted extends EditProfileEvent {}
|
||||
|
||||
|
||||
class EditProfileSubmitted extends EditProfileEvent {
|
||||
final UserProfile profile;
|
||||
|
||||
EditProfileSubmitted(this.profile);
|
||||
}
|
||||
|
||||
33
lib/presentation/event/map_event.dart
Normal file
33
lib/presentation/event/map_event.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import '../../domain/entities/client_notification.dart';
|
||||
import '../../domain/entities/point.dart';
|
||||
import '../../domain/entities/scooter.dart';
|
||||
import '../../domain/entities/zone.dart';
|
||||
|
||||
abstract class ScooterEvent {}
|
||||
|
||||
class CheckUser extends ScooterEvent {}
|
||||
|
||||
class FetchScooters extends ScooterEvent {
|
||||
final List<double> area;
|
||||
final List<double> areaScooters;
|
||||
FetchScooters(this.area, this.areaScooters);
|
||||
}
|
||||
|
||||
class UpdateMap extends ScooterEvent {}
|
||||
|
||||
class FetchProfileData extends ScooterEvent {}
|
||||
|
||||
class LogoutPressed extends ScooterEvent {}
|
||||
|
||||
class UpdateUserLocation extends ScooterEvent {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
UpdateUserLocation(this.latitude, this.longitude);
|
||||
}
|
||||
|
||||
class NotificationReceived extends ScooterEvent {
|
||||
final ClientNotification notification;
|
||||
NotificationReceived(this.notification);
|
||||
}
|
||||
|
||||
|
||||
33
lib/presentation/event/map_settings_modal_event.dart
Normal file
33
lib/presentation/event/map_settings_modal_event.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
sealed class MapSettingsModalEvent {}
|
||||
|
||||
class AllGeomarksToggled extends MapSettingsModalEvent {
|
||||
final bool value;
|
||||
AllGeomarksToggled(this.value);
|
||||
}
|
||||
|
||||
class AllGeozonesToggled extends MapSettingsModalEvent {
|
||||
final bool value;
|
||||
AllGeozonesToggled(this.value);
|
||||
}
|
||||
|
||||
class RestrictedDrivingZonesToggled extends MapSettingsModalEvent {
|
||||
final bool value;
|
||||
RestrictedDrivingZonesToggled(this.value);
|
||||
}
|
||||
|
||||
class RestrictedParkingZonesToggled extends MapSettingsModalEvent {
|
||||
final bool value;
|
||||
RestrictedParkingZonesToggled(this.value);
|
||||
}
|
||||
|
||||
class ParkingZonesToggled extends MapSettingsModalEvent {
|
||||
final bool value;
|
||||
ParkingZonesToggled(this.value);
|
||||
}
|
||||
|
||||
class ApllyButtonClick extends MapSettingsModalEvent {}
|
||||
|
||||
class MapSettingsModalStarted extends MapSettingsModalEvent {}
|
||||
|
||||
|
||||
|
||||
7
lib/presentation/event/news_event.dart
Normal file
7
lib/presentation/event/news_event.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
abstract class NewsEvent {
|
||||
const NewsEvent();
|
||||
}
|
||||
|
||||
class NewsFetchRequested extends NewsEvent {
|
||||
const NewsFetchRequested();
|
||||
}
|
||||
29
lib/presentation/event/payment_confirm_event.dart
Normal file
29
lib/presentation/event/payment_confirm_event.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
|
||||
sealed class PaymentConfirmEvent {}
|
||||
|
||||
class PaymentConfirmStarted extends PaymentConfirmEvent {
|
||||
final int orderId;
|
||||
PaymentConfirmStarted(this.orderId);
|
||||
}
|
||||
|
||||
class PaymentCardChanged extends PaymentConfirmEvent {
|
||||
final PaymentCard card;
|
||||
PaymentCardChanged(this.card);
|
||||
}
|
||||
|
||||
class SelectBalancePressed extends PaymentConfirmEvent {}
|
||||
|
||||
class PayRide extends PaymentConfirmEvent {
|
||||
final int orderId;
|
||||
final int? cardId;
|
||||
final bool isBalance;
|
||||
final List<int> photoIds;
|
||||
|
||||
PayRide({
|
||||
required this.orderId,
|
||||
required this.cardId,
|
||||
required this.isBalance,
|
||||
required this.photoIds,
|
||||
});
|
||||
}
|
||||
3
lib/presentation/event/payment_method_sheet_event.dart
Normal file
3
lib/presentation/event/payment_method_sheet_event.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
sealed class PaymentMethodSheetEvent {}
|
||||
|
||||
class PaymentMethodSheetStarted extends PaymentMethodSheetEvent {}
|
||||
15
lib/presentation/event/payment_methods_event.dart
Normal file
15
lib/presentation/event/payment_methods_event.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
sealed class PaymentMethodsEvent {}
|
||||
|
||||
class PaymentMethodsStarted extends PaymentMethodsEvent {}
|
||||
|
||||
class PaymentMethodsDeleteCard extends PaymentMethodsEvent {
|
||||
final int cardId;
|
||||
|
||||
PaymentMethodsDeleteCard(this.cardId);
|
||||
}
|
||||
|
||||
class PaymentMethodsSetMainCard extends PaymentMethodsEvent {
|
||||
final int cardId;
|
||||
|
||||
PaymentMethodsSetMainCard(this.cardId);
|
||||
}
|
||||
15
lib/presentation/event/pin_event.dart
Normal file
15
lib/presentation/event/pin_event.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
abstract class PinEvent {}
|
||||
|
||||
class PinScreenStarted extends PinEvent {}
|
||||
|
||||
class PinDigitChanged extends PinEvent {
|
||||
final String pin;
|
||||
|
||||
PinDigitChanged(this.pin);
|
||||
}
|
||||
|
||||
class PinSubmitted extends PinEvent {
|
||||
final String pin;
|
||||
|
||||
PinSubmitted(this.pin);
|
||||
}
|
||||
15
lib/presentation/event/profile_event.dart
Normal file
15
lib/presentation/event/profile_event.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
|
||||
abstract class ProfileEvent {}
|
||||
|
||||
class ProfileStarted extends ProfileEvent {}
|
||||
|
||||
class ProfileUpdated extends ProfileEvent {}
|
||||
|
||||
class ProfilePhotoUpdated extends ProfileEvent{
|
||||
final File imageFile;
|
||||
|
||||
ProfilePhotoUpdated(this.imageFile);
|
||||
}
|
||||
13
lib/presentation/event/reserved_ride_event.dart
Normal file
13
lib/presentation/event/reserved_ride_event.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
sealed class ReservedRideEvent {}
|
||||
|
||||
class StartRide extends ReservedRideEvent {
|
||||
final int orderId;
|
||||
|
||||
StartRide(this.orderId);
|
||||
}
|
||||
|
||||
class CancelRide extends ReservedRideEvent {
|
||||
final int orderId;
|
||||
|
||||
CancelRide(this.orderId);
|
||||
}
|
||||
7
lib/presentation/event/route_event.dart
Normal file
7
lib/presentation/event/route_event.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
abstract class RouteEvent {}
|
||||
|
||||
class FetchRouteEvent extends RouteEvent {
|
||||
final int orderId;
|
||||
FetchRouteEvent(this.orderId);
|
||||
}
|
||||
|
||||
11
lib/presentation/event/scooter_code_event.dart
Normal file
11
lib/presentation/event/scooter_code_event.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
abstract class ScooterCodeEvent {}
|
||||
|
||||
class ScooterCodeChanged extends ScooterCodeEvent {
|
||||
final String code;
|
||||
ScooterCodeChanged(this.code);
|
||||
}
|
||||
|
||||
class ScooterCodeSubmitted extends ScooterCodeEvent {
|
||||
final String code;
|
||||
ScooterCodeSubmitted(this.code);
|
||||
}
|
||||
6
lib/presentation/event/scooter_detail_event.dart
Normal file
6
lib/presentation/event/scooter_detail_event.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
sealed class ScooterDetailEvent {}
|
||||
|
||||
class LoadScooterDetails extends ScooterDetailEvent {
|
||||
final int scooterId;
|
||||
LoadScooterDetails(this.scooterId);
|
||||
}
|
||||
13
lib/presentation/event/scooter_detail_modal_event.dart
Normal file
13
lib/presentation/event/scooter_detail_modal_event.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import '../../domain/entities/scooter.dart';
|
||||
|
||||
sealed class ScooterDetailModalEvent {}
|
||||
|
||||
class ScooterDetailModalStarted extends ScooterDetailModalEvent {
|
||||
final List<Scooter> scooters;
|
||||
final double userLatitude;
|
||||
final double userLongitude;
|
||||
|
||||
ScooterDetailModalStarted(this.scooters, this.userLatitude, this.userLongitude);
|
||||
}
|
||||
|
||||
|
||||
13
lib/presentation/event/send_photo_event.dart
Normal file
13
lib/presentation/event/send_photo_event.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
abstract class SendPhotoEvent {}
|
||||
|
||||
class PhotoSelected extends SendPhotoEvent {
|
||||
final List<String> imagePaths;
|
||||
|
||||
PhotoSelected(this.imagePaths);
|
||||
}
|
||||
|
||||
class PhotoUploadSubmitted extends SendPhotoEvent {
|
||||
final int orderId;
|
||||
|
||||
PhotoUploadSubmitted(this.orderId);
|
||||
}
|
||||
15
lib/presentation/event/spalsh_event.dart
Normal file
15
lib/presentation/event/spalsh_event.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class SplashEvent extends Equatable {
|
||||
const SplashEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
// Событие, которое будет отправляться со SplashScreen
|
||||
class AuthCheckRequested extends SplashEvent {}
|
||||
|
||||
class AuthStarted extends SplashEvent {}
|
||||
|
||||
class PinVerificationSuccess extends SplashEvent {}
|
||||
20
lib/presentation/event/subscription_details_event.dart
Normal file
20
lib/presentation/event/subscription_details_event.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import '../../domain/entities/subscription_period.dart';
|
||||
|
||||
abstract class SubscriptionDetailsEvent {}
|
||||
|
||||
class LoadDetailsEvent extends SubscriptionDetailsEvent {
|
||||
final int subscriptionId;
|
||||
LoadDetailsEvent(this.subscriptionId);
|
||||
}
|
||||
|
||||
class SelectPeriodEvent extends SubscriptionDetailsEvent {
|
||||
final SubscriptionPeriod period;
|
||||
SelectPeriodEvent(this.period);
|
||||
}
|
||||
|
||||
class ToggleAgreementEvent extends SubscriptionDetailsEvent {
|
||||
final bool value;
|
||||
ToggleAgreementEvent(this.value);
|
||||
}
|
||||
|
||||
class ActivateSubscriptionPressed extends SubscriptionDetailsEvent {}
|
||||
5
lib/presentation/event/subscription_list_event.dart
Normal file
5
lib/presentation/event/subscription_list_event.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
// subscription_event.dart
|
||||
abstract class SubscriptionEvent {}
|
||||
|
||||
class LoadSubscriptionsEvent extends SubscriptionEvent {}
|
||||
|
||||
31
lib/presentation/event/tariff_sheet_event.dart
Normal file
31
lib/presentation/event/tariff_sheet_event.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:be_happy/domain/entities/payment_card.dart';
|
||||
|
||||
sealed class TariffSheetEvent {}
|
||||
|
||||
class TariffSheetStarted extends TariffSheetEvent {
|
||||
final int scooterId;
|
||||
|
||||
TariffSheetStarted(this.scooterId);
|
||||
}
|
||||
|
||||
class PaymentCardChanged extends TariffSheetEvent {
|
||||
final PaymentCard card;
|
||||
|
||||
PaymentCardChanged(this.card);
|
||||
}
|
||||
|
||||
class SelectBalancePressed extends TariffSheetEvent {}
|
||||
|
||||
class BookScooterPressed extends TariffSheetEvent {
|
||||
final int scooterId;
|
||||
final int planId;
|
||||
final int? subscriptionId;
|
||||
final int? cardId;
|
||||
final bool isBalance;
|
||||
final bool isInsurance;
|
||||
|
||||
BookScooterPressed(this.scooterId, this.planId, this.subscriptionId,
|
||||
this.cardId, this.isBalance, this.isInsurance);
|
||||
|
||||
|
||||
}
|
||||
23
lib/presentation/event/top_up_event.dart
Normal file
23
lib/presentation/event/top_up_event.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:be_happy/domain/entities/certificate.dart';
|
||||
import 'package:be_happy/domain/entities/payment_card.dart';
|
||||
|
||||
import '../../domain/entities/top_up_tariff.dart';
|
||||
|
||||
abstract class TopUpEvent {}
|
||||
|
||||
class LoadTopUpData extends TopUpEvent {}
|
||||
|
||||
class SelectCertificate extends TopUpEvent {
|
||||
final Certificate certificate;
|
||||
SelectCertificate(this.certificate);
|
||||
}
|
||||
|
||||
class SelectCard extends TopUpEvent {
|
||||
final PaymentCard card;
|
||||
SelectCard(this.card);
|
||||
}
|
||||
|
||||
class ToggleAgreement extends TopUpEvent {
|
||||
final bool value;
|
||||
ToggleAgreement(this.value);
|
||||
}
|
||||
18
lib/presentation/event/verify_code_event.dart
Normal file
18
lib/presentation/event/verify_code_event.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
abstract class VerifyCodeEvent {}
|
||||
|
||||
class VerifyCodeStarted extends VerifyCodeEvent {
|
||||
final String phoneNumber;
|
||||
final String tempToken;
|
||||
|
||||
VerifyCodeStarted({required this.phoneNumber, required this.tempToken});
|
||||
}
|
||||
|
||||
class CodeChanged extends VerifyCodeEvent {
|
||||
final String code;
|
||||
|
||||
CodeChanged(this.code);
|
||||
}
|
||||
|
||||
class ResendCodePressed extends VerifyCodeEvent {}
|
||||
|
||||
class VerifyCodeSubmitted extends VerifyCodeEvent {}
|
||||
561
lib/presentation/navigation/app_router.dart
Normal file
561
lib/presentation/navigation/app_router.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:be_happy/core/app_colors.dart';
|
||||
import 'package:be_happy/di/service_locator.dart';
|
||||
import 'package:be_happy/domain/entities/user_profile.dart';
|
||||
import 'package:be_happy/domain/usecase/get_available_subscriptions_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_certificates_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_client_subscriptions_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_profile_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_subscription_by_id_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/is_pin_set_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/purchase_certificate_usecase.dart';
|
||||
import 'package:be_happy/presentation/event/payment_confirm_event.dart';
|
||||
import 'package:be_happy/presentation/event/pin_event.dart';
|
||||
import 'package:be_happy/presentation/event/subscription_list_event.dart';
|
||||
import 'package:be_happy/presentation/screens/block_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/documents_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/edit_profile_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/map_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/news_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/onboarding_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/order_history_detail_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/payment_confirm_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/profile_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/promo_code_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/qr_scan_info_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/scooter_code_input_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/scooter_detail_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/send_photo_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/subscription_list_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/support_screen.dart';
|
||||
import 'package:be_happy/presentation/screens/top_up_screen.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/susbcription_details_bloc.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/top_up_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:path/path.dart';
|
||||
|
||||
import '../../domain/entities/scooter.dart';
|
||||
import '../../domain/usecase/activate_subscription_usecase.dart';
|
||||
import '../../domain/usecase/book_scooter_usecase.dart';
|
||||
import '../../domain/usecase/create_pin_usecase.dart';
|
||||
import '../../domain/usecase/get_address_by_point_usecase.dart';
|
||||
import '../../domain/usecase/get_available_tariffs_usecase.dart';
|
||||
import '../../domain/usecase/get_client_orders_usecase.dart';
|
||||
import '../../domain/usecase/get_map_settings_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_pedestrian_routes_usecase.dart';
|
||||
import '../../domain/usecase/get_scooter_order_by_id_usecase.dart';
|
||||
import '../../domain/usecase/get_scooter_usecase.dart';
|
||||
import '../../domain/usecase/pay_ride_usecase.dart';
|
||||
import '../../domain/usecase/remove_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/save_map_settings_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/verify_pin_usecase.dart';
|
||||
import '../components/map_settings_sheet.dart';
|
||||
import '../components/scooter_bottom_sheet.dart';
|
||||
import '../components/sheet/current_rides_sheet.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../components/sheet/reserved_ride_sheet.dart';
|
||||
import '../components/sheet/tariff_sheet.dart';
|
||||
import '../event/current_rides_event.dart';
|
||||
import '../event/edit_profile_event.dart';
|
||||
import '../event/map_settings_modal_event.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../event/profile_event.dart';
|
||||
import '../event/scooter_detail_modal_event.dart';
|
||||
import '../event/subscription_details_event.dart';
|
||||
import '../event/tariff_sheet_event.dart';
|
||||
import '../event/top_up_event.dart';
|
||||
import '../screens/add_card_screen.dart'; // ← новый импорт
|
||||
import '../screens/license_agreement_screen.dart';
|
||||
import '../screens/order_history_screen.dart';
|
||||
import '../screens/payment_methods_screen.dart';
|
||||
import '../screens/phone_login_screen.dart';
|
||||
import '../screens/phone_screen.dart';
|
||||
import '../screens/pin_login_screen.dart';
|
||||
import '../screens/privacy_policy_screen.dart';
|
||||
import '../screens/qr_scan_screen.dart';
|
||||
import '../screens/splash_screen.dart';
|
||||
import '../screens/subscription_details_screen.dart';
|
||||
import '../state/splash_state.dart';
|
||||
import '../viewmodel/add_card_bloc.dart';
|
||||
import '../viewmodel/current_rides_bloc.dart';
|
||||
import '../viewmodel/edit_profile_bloc.dart';
|
||||
import '../viewmodel/map_settings_modal_bloc.dart';
|
||||
import '../viewmodel/payment_confirm_bloc.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
import '../viewmodel/payment_methods_bloc.dart';
|
||||
import '../viewmodel/pin_bloc.dart';
|
||||
import '../viewmodel/profile_bloc.dart';
|
||||
import '../viewmodel/scooter_code_bloc.dart';
|
||||
import '../viewmodel/scooter_detail_bloc.dart';
|
||||
import '../viewmodel/scooter_detail_modal_bloc.dart';
|
||||
import '../viewmodel/subscription_list_bloc.dart';
|
||||
import '../viewmodel/tariff_sheet_bloc.dart'; // ← новый импорт
|
||||
|
||||
class AppRouter {
|
||||
final SplashBloc splashBloc;
|
||||
|
||||
AppRouter(this.splashBloc);
|
||||
|
||||
late final GoRouter router = GoRouter(
|
||||
debugLogDiagnostics: true,
|
||||
initialLocation: '/splash',
|
||||
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(path: '/login', builder: (context, state) => const PhoneScreen()),
|
||||
GoRoute(
|
||||
path: '/verify',
|
||||
builder: (context, state) {
|
||||
final phone = state.uri.queryParameters['phone'];
|
||||
if (phone != null) {
|
||||
return PhoneLoginScreen(phoneNumber: phone, tempToken: '');
|
||||
}
|
||||
throw Exception("Incorrect phone");
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/block',
|
||||
builder: (context, state) => const BlockedScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/pin',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
PinBloc(
|
||||
createPinUseCase: getIt<CreatePinUseCase>(),
|
||||
verifyPinUsecase: getIt<VerifyPinUseCase>(),
|
||||
isPinSetUsecase: getIt<IsPinSetUsecase>(),
|
||||
)..add(PinScreenStarted()),
|
||||
child: const PinLoginScreen(),
|
||||
)
|
||||
),
|
||||
GoRoute(
|
||||
path: '/privacy-policy',
|
||||
builder: (context, state) => const PrivacyPolicyScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/license-agreement',
|
||||
builder: (context, state) => const LicenseAgreementScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const MapScreen(),
|
||||
routes: [
|
||||
//Modal Bottom Sheets
|
||||
GoRoute(
|
||||
path: 'scooter-sheet',
|
||||
pageBuilder: (context, state) {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
|
||||
final scooters = data['scooters'] as List<Scooter>;
|
||||
final location = data['currentLocation'] as Position;
|
||||
|
||||
return modalPage(
|
||||
state: state,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
ScooterDetailModalBloc(
|
||||
getIt<GetAddressByPointUsecase>(),
|
||||
getIt<GetScooterUsecase>(),
|
||||
getIt<GetPedestrianRoutesUsecase>(),
|
||||
)
|
||||
..add(
|
||||
ScooterDetailModalStarted(
|
||||
scooters,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
),
|
||||
),
|
||||
child: ScooterBottomSheet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'tarif-sheet',
|
||||
pageBuilder: (context, state) {
|
||||
final scooter = state.extra as Scooter;
|
||||
|
||||
return modalPage(
|
||||
state: state,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
TariffSheetBloc(
|
||||
getIt<GetAvailableTariffsUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<BookScooterUsecase>(),
|
||||
),
|
||||
child: TariffSheet(scooter: scooter),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'current-rides-sheet',
|
||||
pageBuilder: (context, state) {
|
||||
return modalPage(
|
||||
state: state,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
|
||||
..add(LoadClientOrders()),
|
||||
child: CurrentRidesSheet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'payment-method-sheet',
|
||||
pageBuilder: (context, state) {
|
||||
return modalPage(
|
||||
state: state,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
PaymentMethodSheetBloc(getIt<GetPaymentCardsUsecase>()),
|
||||
child: PaymentMethodSheet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: 'map-settings-sheet',
|
||||
pageBuilder: (context, state) {
|
||||
return modalPage(
|
||||
state: state,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
MapSettingsModalBloc(
|
||||
getIt<GetMapSettingsUsecase>(),
|
||||
getIt<SaveMapSettingsUsecase>(),
|
||||
)
|
||||
..add(MapSettingsModalStarted()),
|
||||
child: MapSettingsSheet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
//Sub screens
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
getIt<ProfileBloc>()
|
||||
..add(ProfileStarted()),
|
||||
child: ProfileScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'edit',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
getIt<EditProfileBloc>()
|
||||
..add(EditProfileStarted()),
|
||||
child: EditProfileScreen(
|
||||
profile: UserProfile(
|
||||
name: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
balance: 23,
|
||||
email: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'scooter/:id',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) => getIt<ScooterDetailBloc>(),
|
||||
child: ScooterDetailScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'order-photos/:orderId',
|
||||
builder: (context, state) {
|
||||
int orderId = int.parse(state.pathParameters['orderId']!);
|
||||
return SendPhotoScreen(orderId: orderId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checkout/:orderId',
|
||||
builder: (context, state) {
|
||||
int orderId = int.parse(state.pathParameters['orderId']!);
|
||||
List<int> photoIds = [];
|
||||
if (state.extra != null) {
|
||||
photoIds = state.extra as List<int>;
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
PaymentConfirmBloc(
|
||||
getIt<PayRideUsecase>(),
|
||||
getIt<GetScooterOrderByIdUsecase>(),
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
)
|
||||
..add(PaymentConfirmStarted(orderId)),
|
||||
child: PaymentConfirmScreen(
|
||||
orderId: orderId,
|
||||
photoIds: photoIds,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'support',
|
||||
builder: (context, state) => const SupportScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'documents',
|
||||
builder: (context, state) => const DocumentsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'promo',
|
||||
builder: (context, state) => const PromoCodeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'subscriptions',
|
||||
builder: (context, state) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
SubscriptionListBloc(
|
||||
getAvailableSubscriptionsUsecase:
|
||||
getIt<GetAvailableSubscriptionsUsecase>(),
|
||||
getClientSubscriptionsUsecase:
|
||||
getIt<GetClientSubscriptionsUsecase>(),
|
||||
)
|
||||
..add(LoadSubscriptionsEvent()),
|
||||
child: SubscriptionsListScreen(),
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
SubscriptionDetailsBloc(
|
||||
getIt<GetSubscriptionByIdUsecase>(),
|
||||
getIt<ActivateSubscriptionUsecase>(),
|
||||
)
|
||||
..add(
|
||||
LoadDetailsEvent(
|
||||
int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
child: SubscriptionDetailsScreen(subscriptionId: 1),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'rules',
|
||||
builder: (context, state) => const OnboardingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'news',
|
||||
builder: (context, state) => const NewsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'qr-info',
|
||||
builder: (context, state) => const QRScanInfoScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'qr-scan',
|
||||
builder: (context, state) => const QrScanScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'qr-input',
|
||||
builder: (context, state) => BlocProvider(
|
||||
create: (context) =>
|
||||
ScooterCodeBloc(
|
||||
getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>(),
|
||||
),
|
||||
child: ScooterCodeInputScreen(),
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
GoRoute(
|
||||
path: 'payment-methods',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
PaymentMethodsBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<RemovePaymentCardUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
)
|
||||
..add(PaymentMethodsStarted()),
|
||||
child: PaymentMethodsScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'add-card',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) => getIt<AddCardBloc>(),
|
||||
child: const AddCardScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'top-up',
|
||||
builder: (context, state) =>
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
TopUpBloc(
|
||||
getCertificatesUsecase: getIt<GetCertificatesUsecase>(),
|
||||
purchaseCertificateUsecase:
|
||||
getIt<PurchaseCertificateUsecase>(),
|
||||
getUserCards: getIt<GetPaymentCardsUsecase>(),
|
||||
)
|
||||
..add(LoadTopUpData()),
|
||||
child: TopUpScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'order-history',
|
||||
builder: (context, state) => const OrderHistoryScreen(),
|
||||
routes: []
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
observers: [BotToastNavigatorObserver()],
|
||||
|
||||
redirect: (BuildContext context, GoRouterState state) {
|
||||
final authState = splashBloc.state;
|
||||
final currentLocation = state.uri.toString();
|
||||
|
||||
print("inside redirect");
|
||||
print(splashBloc.state);
|
||||
print(state.uri.toString());
|
||||
|
||||
if (authState is AuthInitial && currentLocation != '/splash') {
|
||||
print("splash");
|
||||
return '/splash';
|
||||
}
|
||||
|
||||
if (authState is AuthFirstLaunch &&
|
||||
currentLocation != '/login' &&
|
||||
currentLocation != '/privacy-policy' &&
|
||||
currentLocation != '/license-agreement') {
|
||||
print("login");
|
||||
return '/login';
|
||||
}
|
||||
|
||||
if (authState is AuthUnauthenticated &&
|
||||
currentLocation != '/login' &&
|
||||
currentLocation != '/privacy-policy' &&
|
||||
currentLocation != '/license-agreement') {
|
||||
print("login2");
|
||||
return '/login';
|
||||
}
|
||||
|
||||
if (authState is AuthAuthenticated) {
|
||||
final isComingFromStart =
|
||||
currentLocation == '/splash' ||
|
||||
currentLocation == '/login' ||
|
||||
currentLocation == '/phone';
|
||||
|
||||
if (isComingFromStart) {
|
||||
print("redirecting to pin check");
|
||||
return '/pin';
|
||||
}
|
||||
}
|
||||
|
||||
if (authState is AuthPinVerified) {
|
||||
if (currentLocation == '/splash' ||
|
||||
currentLocation == '/login' ||
|
||||
currentLocation == '/pin') {
|
||||
return '/home';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
refreshListenable: GoRouterRefreshStream(splashBloc.stream),
|
||||
);
|
||||
|
||||
CustomTransitionPage modalPage({
|
||||
required GoRouterState state,
|
||||
required Widget child,
|
||||
}) {
|
||||
return CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
opaque: false,
|
||||
barrierDismissible: true,
|
||||
barrierColor: Colors.black54,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: animation.drive(
|
||||
Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoRouterRefreshStream extends ChangeNotifier {
|
||||
late final StreamSubscription<dynamic> _subscription;
|
||||
|
||||
GoRouterRefreshStream(Stream<dynamic> stream) {
|
||||
notifyListeners();
|
||||
_subscription = stream.asBroadcastStream().listen((dynamic _) {
|
||||
print("Stream updated");
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
238
lib/presentation/screens/add_card_screen.dart
Normal file
238
lib/presentation/screens/add_card_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/presentation/screens/block_screen.dart
Normal file
50
lib/presentation/screens/block_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/presentation/screens/documents_screen.dart
Normal file
66
lib/presentation/screens/documents_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/presentation/screens/edit_profile_screen.dart
Normal file
239
lib/presentation/screens/edit_profile_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/presentation/screens/license_agreement_screen.dart
Normal file
168
lib/presentation/screens/license_agreement_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
834
lib/presentation/screens/map_screen.dart
Normal file
834
lib/presentation/screens/map_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
387
lib/presentation/screens/news_detail_screen.dart
Normal file
387
lib/presentation/screens/news_detail_screen.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
273
lib/presentation/screens/news_screen.dart
Normal file
273
lib/presentation/screens/news_screen.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
170
lib/presentation/screens/onboarding_screen.dart
Normal file
170
lib/presentation/screens/onboarding_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
339
lib/presentation/screens/order_history_detail_screen.dart
Normal file
339
lib/presentation/screens/order_history_detail_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/presentation/screens/order_history_screen.dart
Normal file
398
lib/presentation/screens/order_history_screen.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
403
lib/presentation/screens/payment_confirm_screen.dart
Normal file
403
lib/presentation/screens/payment_confirm_screen.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
288
lib/presentation/screens/payment_methods_screen.dart
Normal file
288
lib/presentation/screens/payment_methods_screen.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
183
lib/presentation/screens/phone_login_screen.dart
Normal file
183
lib/presentation/screens/phone_login_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
248
lib/presentation/screens/phone_screen.dart
Normal file
248
lib/presentation/screens/phone_screen.dart
Normal 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
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
160
lib/presentation/screens/pin_login_screen.dart
Normal file
160
lib/presentation/screens/pin_login_screen.dart
Normal 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),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/presentation/screens/privacy_policy_screen.dart
Normal file
170
lib/presentation/screens/privacy_policy_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
345
lib/presentation/screens/profile_screen.dart
Normal file
345
lib/presentation/screens/profile_screen.dart
Normal 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: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/presentation/screens/promo_code_screen.dart
Normal file
162
lib/presentation/screens/promo_code_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/presentation/screens/qr_scan_info_screen.dart
Normal file
112
lib/presentation/screens/qr_scan_info_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
219
lib/presentation/screens/qr_scan_screen.dart
Normal file
219
lib/presentation/screens/qr_scan_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/presentation/screens/scooter_code_input_screen.dart
Normal file
140
lib/presentation/screens/scooter_code_input_screen.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
114
lib/presentation/screens/scooter_detail_screen.dart
Normal file
114
lib/presentation/screens/scooter_detail_screen.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
306
lib/presentation/screens/send_photo_screen.dart
Normal file
306
lib/presentation/screens/send_photo_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/presentation/screens/splash_screen.dart
Normal file
124
lib/presentation/screens/splash_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
228
lib/presentation/screens/subscription_details_screen.dart
Normal file
228
lib/presentation/screens/subscription_details_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
80
lib/presentation/screens/subscription_list_screen.dart
Normal file
80
lib/presentation/screens/subscription_list_screen.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/presentation/screens/support_screen.dart
Normal file
71
lib/presentation/screens/support_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/presentation/screens/top_up_screen.dart
Normal file
288
lib/presentation/screens/top_up_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/presentation/state/active_ride_state.dart
Normal file
56
lib/presentation/state/active_ride_state.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
|
||||
enum ActiveRideStatus { initial, loading, success, failure }
|
||||
|
||||
class ActiveRideState {
|
||||
final ActiveRideStatus status;
|
||||
final ScooterOrder? order;
|
||||
final String? errorMessage;
|
||||
final Duration elapsedTime;
|
||||
final double speed;
|
||||
final double distance;
|
||||
final double cost;
|
||||
final bool isPaused;
|
||||
final bool inZone;
|
||||
|
||||
const ActiveRideState({
|
||||
this.status = ActiveRideStatus.initial,
|
||||
this.order,
|
||||
this.errorMessage,
|
||||
this.elapsedTime = Duration.zero,
|
||||
this.speed = 0.0,
|
||||
this.distance = 0.0,
|
||||
this.cost = 0.0,
|
||||
this.isPaused = false,
|
||||
this.inZone = true,
|
||||
});
|
||||
|
||||
ActiveRideState copyWith({
|
||||
ActiveRideStatus? status,
|
||||
ScooterOrder? order,
|
||||
String? errorMessage,
|
||||
Duration? elapsedTime,
|
||||
double? speed,
|
||||
double? distance,
|
||||
double? cost,
|
||||
bool? isPaused,
|
||||
bool? inZone,
|
||||
}) {
|
||||
return ActiveRideState(
|
||||
status: status ?? this.status,
|
||||
order: order ?? this.order,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||
speed: speed ?? this.speed,
|
||||
distance: distance ?? this.distance,
|
||||
cost: cost ?? this.cost,
|
||||
isPaused: isPaused ?? this.isPaused,
|
||||
inZone: inZone ?? this.inZone,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ActiveRideState{status: $status, cost: $cost, isPaused: $isPaused}';
|
||||
}
|
||||
}
|
||||
45
lib/presentation/state/add_card_state.dart
Normal file
45
lib/presentation/state/add_card_state.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
enum AddCardStatus { initial, loading, success, failure }
|
||||
|
||||
class AddCardState {
|
||||
final AddCardStatus status;
|
||||
final String cardNumber;
|
||||
final String expiryDate;
|
||||
final String cvv;
|
||||
final String cardHolder;
|
||||
final String errorMessage;
|
||||
|
||||
const AddCardState({
|
||||
this.status = AddCardStatus.initial,
|
||||
this.cardNumber = '',
|
||||
this.expiryDate = '',
|
||||
this.cvv = '',
|
||||
this.cardHolder = '',
|
||||
this.errorMessage = '',
|
||||
});
|
||||
|
||||
AddCardState copyWith({
|
||||
AddCardStatus? status,
|
||||
String? cardNumber,
|
||||
String? expiryDate,
|
||||
String? cvv,
|
||||
String? cardHolder,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AddCardState(
|
||||
status: status ?? this.status,
|
||||
cardNumber: cardNumber ?? this.cardNumber,
|
||||
expiryDate: expiryDate ?? this.expiryDate,
|
||||
cvv: cvv ?? this.cvv,
|
||||
cardHolder: cardHolder ?? this.cardHolder,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isFormValid {
|
||||
final cleanCardNumber = cardNumber.replaceAll(' ', '');
|
||||
return cleanCardNumber.length == 16 &&
|
||||
expiryDate.length == 5 &&
|
||||
cvv.length == 3 &&
|
||||
cardHolder.trim().isNotEmpty;
|
||||
}
|
||||
}
|
||||
45
lib/presentation/state/auth_state.dart
Normal file
45
lib/presentation/state/auth_state.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
class PhoneAuthState {
|
||||
final String phone;
|
||||
final bool isAdult;
|
||||
final bool privacyAccepted;
|
||||
final bool isSubmitting;
|
||||
final bool isSuccess;
|
||||
final String? error;
|
||||
|
||||
PhoneAuthState({
|
||||
required this.phone,
|
||||
required this.isAdult,
|
||||
required this.privacyAccepted,
|
||||
required this.isSubmitting,
|
||||
required this.isSuccess,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory PhoneAuthState.initial() {
|
||||
return PhoneAuthState(
|
||||
phone: '',
|
||||
isAdult: false,
|
||||
privacyAccepted: false,
|
||||
isSubmitting: false,
|
||||
isSuccess: false,
|
||||
);
|
||||
}
|
||||
|
||||
PhoneAuthState copyWith({
|
||||
String? phone,
|
||||
bool? isAdult,
|
||||
bool? privacyAccepted,
|
||||
bool? isSubmitting,
|
||||
bool? isSuccess,
|
||||
String? error,
|
||||
}) {
|
||||
return PhoneAuthState(
|
||||
phone: phone ?? this.phone,
|
||||
isAdult: isAdult ?? this.isAdult,
|
||||
privacyAccepted: privacyAccepted ?? this.privacyAccepted,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/presentation/state/current_rides_state.dart
Normal file
27
lib/presentation/state/current_rides_state.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
|
||||
enum CurrentRidesStatus { initial, loading, success, failure }
|
||||
|
||||
class CurrentRidesState {
|
||||
final CurrentRidesStatus status;
|
||||
final List<ScooterOrder> orders;
|
||||
final String? errorMessage;
|
||||
|
||||
const CurrentRidesState({
|
||||
this.status = CurrentRidesStatus.initial,
|
||||
this.orders = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
CurrentRidesState copyWith({
|
||||
CurrentRidesStatus? status,
|
||||
List<ScooterOrder>? orders,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CurrentRidesState(
|
||||
status: status ?? this.status,
|
||||
orders: orders ?? this.orders,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/presentation/state/edit_profile_state.dart
Normal file
42
lib/presentation/state/edit_profile_state.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
|
||||
class EditProfileState {
|
||||
final bool isSaving;
|
||||
final bool isSuccess;
|
||||
final bool isLoading;
|
||||
final UserProfile? profile;
|
||||
final String? error;
|
||||
|
||||
const EditProfileState({
|
||||
required this.isSaving,
|
||||
required this.isSuccess,
|
||||
required this.isLoading,
|
||||
this.profile,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory EditProfileState.initial() {
|
||||
return const EditProfileState(
|
||||
isSaving: false,
|
||||
isSuccess: false,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
|
||||
EditProfileState copyWith({
|
||||
bool? isSaving,
|
||||
bool? isSuccess,
|
||||
bool? isLoading,
|
||||
UserProfile? profile,
|
||||
String? error,
|
||||
}) {
|
||||
return EditProfileState(
|
||||
isSaving: isSaving ?? this.isSaving,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
profile: profile ?? this.profile,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
lib/presentation/state/map_settings_modal_state.dart
Normal file
29
lib/presentation/state/map_settings_modal_state.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
class MapSettingsModalState {
|
||||
final bool isAllGeomarksActive;
|
||||
final bool isAllGeozonesActive;
|
||||
final bool isRestrictedDrivingZoneActive;
|
||||
final bool isParkingZoneActive;
|
||||
final bool isRestrictedParkingZoneActive;
|
||||
|
||||
MapSettingsModalState({
|
||||
required this.isAllGeomarksActive,
|
||||
required this.isAllGeozonesActive,
|
||||
required this.isRestrictedDrivingZoneActive,
|
||||
required this.isParkingZoneActive,
|
||||
required this.isRestrictedParkingZoneActive,
|
||||
});
|
||||
|
||||
MapSettingsModalState copyWith({
|
||||
bool? isGeomarksActive,
|
||||
bool? isAllGeozonesActive,
|
||||
bool? isRestrictedDrivingZoneActive,
|
||||
bool? isParkingZoneActive,
|
||||
bool? isRestrictedParkingZoneActive,
|
||||
}) => MapSettingsModalState(
|
||||
isAllGeomarksActive: isGeomarksActive ?? this.isAllGeomarksActive,
|
||||
isAllGeozonesActive: isAllGeozonesActive ?? this.isAllGeozonesActive,
|
||||
isRestrictedDrivingZoneActive: isRestrictedDrivingZoneActive ?? this.isRestrictedDrivingZoneActive,
|
||||
isParkingZoneActive: isParkingZoneActive ?? this.isParkingZoneActive,
|
||||
isRestrictedParkingZoneActive: isRestrictedParkingZoneActive ?? this.isRestrictedParkingZoneActive,
|
||||
);
|
||||
}
|
||||
68
lib/presentation/state/map_state.dart
Normal file
68
lib/presentation/state/map_state.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:be_happy/domain/entities/user_check_flags.dart';
|
||||
|
||||
import '../../domain/entities/point.dart';
|
||||
import '../../domain/entities/scooter.dart';
|
||||
import '../../domain/entities/zone.dart';
|
||||
import '../../domain/entities/client_notification.dart';
|
||||
|
||||
enum ScooterStatus { initial, loading, success, failure }
|
||||
|
||||
class ScooterState {
|
||||
final List<Scooter> scooters;
|
||||
final List<Zone> zones;
|
||||
final List<double> area;
|
||||
final List<double> areaScooters;
|
||||
final ScooterStatus status;
|
||||
final bool isGeomarksShowed;
|
||||
final String? address;
|
||||
final String? errorMessage;
|
||||
final String phoneNumber;
|
||||
final int balance;
|
||||
final UserCheckFlags flags;
|
||||
final ClientNotification? lastNotification;
|
||||
|
||||
ScooterState({
|
||||
this.scooters = const [],
|
||||
this.zones = const [],
|
||||
this.area = const [],
|
||||
this.areaScooters = const [],
|
||||
this.status = ScooterStatus.initial,
|
||||
required this.isGeomarksShowed,
|
||||
this.address,
|
||||
this.errorMessage,
|
||||
this.phoneNumber = "+375XXXXXXXXX",
|
||||
this.balance = 999,
|
||||
this.flags = const UserCheckFlags(hasFine: false, hasUnpaidOrder: false, hasCard: false),
|
||||
this.lastNotification,
|
||||
});
|
||||
|
||||
ScooterState copyWith({
|
||||
List<Scooter>? scooters,
|
||||
List<Zone>? zones,
|
||||
List<double>? area,
|
||||
List<double>? areaScooters,
|
||||
ScooterStatus? status,
|
||||
bool? isGeomarksShowed,
|
||||
String? address,
|
||||
String? errorMessage,
|
||||
String? phoneNumber,
|
||||
int? balance,
|
||||
UserCheckFlags? flags,
|
||||
ClientNotification? lastNotification,
|
||||
}) {
|
||||
return ScooterState(
|
||||
scooters: scooters ?? this.scooters,
|
||||
zones: zones ?? this.zones,
|
||||
area: area ?? this.area,
|
||||
areaScooters: areaScooters ?? this.areaScooters,
|
||||
status: status ?? this.status,
|
||||
address: address ?? this.address,
|
||||
isGeomarksShowed: isGeomarksShowed?? this.isGeomarksShowed,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
balance: balance ?? this.balance,
|
||||
flags: flags ?? this.flags,
|
||||
lastNotification: lastNotification ?? this.lastNotification,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/presentation/state/news_state.dart
Normal file
31
lib/presentation/state/news_state.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import '../../../domain/entities/news.dart';
|
||||
|
||||
enum NewsStatus { initial, loading, success, failure }
|
||||
|
||||
class NewsState {
|
||||
final NewsStatus status;
|
||||
final List<NewsEntity> news;
|
||||
final String? errorMessage;
|
||||
|
||||
const NewsState({
|
||||
required this.status,
|
||||
this.news = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
NewsStatus? status,
|
||||
List<NewsEntity>? news,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return NewsState(
|
||||
status: status ?? this.status,
|
||||
news: news ?? this.news,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isLoading => status == NewsStatus.loading;
|
||||
bool get isSuccess => status == NewsStatus.success;
|
||||
bool get isFailure => status == NewsStatus.failure;
|
||||
}
|
||||
45
lib/presentation/state/payment_confirm_state.dart
Normal file
45
lib/presentation/state/payment_confirm_state.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:be_happy/domain/entities/payment_card.dart';
|
||||
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
|
||||
enum PaymentConfirmStatus { initial, loading, success, failure }
|
||||
|
||||
class PaymentConfirmState {
|
||||
final PaymentConfirmStatus status;
|
||||
final PaymentCard? selectedCard;
|
||||
final String? errorMessage;
|
||||
final bool paymentCompleted;
|
||||
final ScooterOrder? order;
|
||||
final bool useBalance;
|
||||
final int userBalance;
|
||||
|
||||
const PaymentConfirmState({
|
||||
this.status = PaymentConfirmStatus.initial,
|
||||
this.errorMessage,
|
||||
this.selectedCard,
|
||||
this.paymentCompleted = false,
|
||||
this.order,
|
||||
this.useBalance = false,
|
||||
this.userBalance = 0,
|
||||
});
|
||||
|
||||
PaymentConfirmState copyWith({
|
||||
PaymentConfirmStatus? status,
|
||||
String? errorMessage,
|
||||
PaymentCard? selectedCard,
|
||||
bool? paymentCompleted,
|
||||
ScooterOrder? order,
|
||||
bool? useBalance,
|
||||
int? userBalance,
|
||||
}) {
|
||||
return PaymentConfirmState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
selectedCard: selectedCard ?? this.selectedCard,
|
||||
paymentCompleted: paymentCompleted ?? this.paymentCompleted,
|
||||
order: order ?? this.order,
|
||||
useBalance: useBalance ?? this.useBalance,
|
||||
userBalance: userBalance ?? this.userBalance,
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/presentation/state/payment_method_sheet_state.dart
Normal file
30
lib/presentation/state/payment_method_sheet_state.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
|
||||
enum PaymentMethodSheetStatus { initial, loading, success, failure }
|
||||
|
||||
class PaymentMethodSheetState {
|
||||
final PaymentMethodSheetStatus status;
|
||||
final List<PaymentCard> cards;
|
||||
final double balance;
|
||||
final String? errorMessage;
|
||||
|
||||
PaymentMethodSheetState({
|
||||
this.status = PaymentMethodSheetStatus.initial,
|
||||
this.cards = const [],
|
||||
this.balance = 0.0,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
PaymentMethodSheetState copyWith({
|
||||
PaymentMethodSheetStatus? status,
|
||||
List<PaymentCard>? cards,
|
||||
double? balance,
|
||||
String? errorMessage,
|
||||
}) =>
|
||||
PaymentMethodSheetState(
|
||||
status: status ?? this.status,
|
||||
cards: cards ?? this.cards,
|
||||
balance: balance ?? this.balance,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
38
lib/presentation/state/payment_methods_state.dart
Normal file
38
lib/presentation/state/payment_methods_state.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
|
||||
enum PaymentMethodsStatus { initial, loading, success, failure }
|
||||
|
||||
class PaymentMethodsState {
|
||||
final PaymentMethodsStatus status;
|
||||
final List<PaymentCard> cards;
|
||||
final int balance;
|
||||
final String? errorMessage;
|
||||
final bool isDeleting;
|
||||
final bool isSettingMain;
|
||||
|
||||
PaymentMethodsState({
|
||||
this.status = PaymentMethodsStatus.initial,
|
||||
this.cards = const [],
|
||||
this.balance = 99,
|
||||
this.errorMessage,
|
||||
this.isDeleting = false,
|
||||
this.isSettingMain = false,
|
||||
});
|
||||
|
||||
PaymentMethodsState copyWith({
|
||||
PaymentMethodsStatus? status,
|
||||
List<PaymentCard>? cards,
|
||||
int? balance,
|
||||
String? errorMessage,
|
||||
bool? isDeleting,
|
||||
bool? isSettingMain,
|
||||
}) =>
|
||||
PaymentMethodsState(
|
||||
status: status ?? this.status,
|
||||
cards: cards ?? this.cards,
|
||||
balance: balance ?? this.balance,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
isDeleting: isDeleting ?? this.isDeleting,
|
||||
isSettingMain: isSettingMain ?? this.isSettingMain,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user