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