new project stable version

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

View File

@@ -0,0 +1,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,
),
);
}
}

View 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,
),
),
),
);
}
}

View 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,
),
],
],
),
);
}
}

View 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,
),
),
);
}),
);
}
}

View 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,
),
),
],
);
}
}

View 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),
),
),
),
],
),
),
);
}
}

View File

@@ -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,
),
),
),
),
],
),
),
);
}
}

View 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),
),
),
),
],
),
);
}
}

View 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,
),
),
),
);
}
}

View 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');
}
}

View File

@@ -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,
),
);
}
}

View 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,
});
}

View 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,
),
),
],
),
),
),
);
}
}

View 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),
),
),
),
],
),
);
}
}

View 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,
),
],
),
),
);
}
}

View 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,
),
),
),
);
}),
);
}
}

View 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;
}

View File

@@ -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;
}

View 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,
),
),
],
),
);
}
}

View File

@@ -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 минута'),
],
),
],
);
}
}

View 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,
),
),
),
),
),
],
),
);
}
}

View 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,
),
),
),
],
);
}
}

View 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';
}
}

View 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';
}
}

View 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,
});
}

View 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';
}
}
}

View 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';
}
}

View 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,
),
),
),
],
);
}
}

View 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,
),
),
),
],
),
);
}
}

View 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,
),
),
),
],
),
),
);
}
}

View 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),
);
}
}

View 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)),
),
],
),
],
),
);
}
}

View 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 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),
),
),
),
],
),
);
}
}

View 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),
);
}
}