new project stable version
This commit is contained in:
517
lib/presentation/components/sheet/active_ride_sheet.dart
Normal file
517
lib/presentation/components/sheet/active_ride_sheet.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../event/active_ride_event.dart';
|
||||
import '../../state/active_ride_state.dart';
|
||||
import '../../viewmodel/active_ride_bloc.dart';
|
||||
import '../notification_toast.dart';
|
||||
|
||||
class ActiveRideSheet extends StatefulWidget {
|
||||
final int orderId;
|
||||
final String scooterNumber;
|
||||
final Duration initialElapsedTime;
|
||||
|
||||
const ActiveRideSheet({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.scooterNumber,
|
||||
this.initialElapsedTime = Duration.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ActiveRideSheet> createState() => _ActiveRideSheetState();
|
||||
}
|
||||
|
||||
class _ActiveRideSheetState extends State<ActiveRideSheet> {
|
||||
late final ActiveRideBloc _bloc;
|
||||
Timer? _localTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = getIt<ActiveRideBloc>();
|
||||
_bloc.add(LoadScooterOrder(widget.orderId));
|
||||
|
||||
// Локальный таймер для обновления UI каждую секунду
|
||||
_localTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted && !_bloc.state.isPaused) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
_localTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: BlocConsumer<ActiveRideBloc, ActiveRideState>(
|
||||
listenWhen: (previous, current) => previous.inZone != current.inZone,
|
||||
listener: (context, state) {
|
||||
if (!state.inZone) {
|
||||
BotToast.showCustomNotification(
|
||||
// duration: const Duration(seconds: 4),
|
||||
|
||||
toastBuilder: (_) {
|
||||
return NotificationToast(
|
||||
title: "Вы покинули зону разрешенную для езды",
|
||||
onClose: () {
|
||||
BotToast.cleanAll();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Логика отображения загрузки и ошибок остается прежней
|
||||
if (state.status == ActiveRideStatus.loading && state.order == null) {
|
||||
return _buildLoading();
|
||||
}
|
||||
|
||||
if (state.status == ActiveRideStatus.failure && state.order == null) {
|
||||
return _buildError(state.errorMessage);
|
||||
}
|
||||
|
||||
return _buildContent(state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: const CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String? message) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Text(
|
||||
message ?? 'Ошибка загрузки',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ActiveRideState state) {
|
||||
final displayTime = state.isPaused
|
||||
? state.elapsedTime
|
||||
: state.elapsedTime + (DateTime.now().difference(DateTime.now().subtract(const Duration(seconds: 1))));
|
||||
|
||||
// Для отображения текущего времени в реальном времени
|
||||
final effectiveElapsedTime = state.isPaused
|
||||
? state.elapsedTime
|
||||
: DateTime.now().difference(state.order?.startAt ?? state.order?.createdAt ?? DateTime.now());
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Самокат ${widget.scooterNumber}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔹 ТАЙМЕР
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(effectiveElapsedTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.isPaused ? 'на паузе' : 'время в пути',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔹 КНОПКИ УПРАВЛЕНИЯ
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: state.isPaused
|
||||
? null
|
||||
: const LinearGradient(
|
||||
colors: [Color(0xFF66E3C4), Color(0xFF4CD1B5)],
|
||||
),
|
||||
color: state.isPaused ? Colors.white.withOpacity(0.15) : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.status == ActiveRideStatus.loading
|
||||
? null
|
||||
: () {
|
||||
if (state.isPaused) {
|
||||
_bloc.add(ResumeRide(widget.orderId));
|
||||
} else {
|
||||
_bloc.add(PauseRide(widget.orderId));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
state.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: state.isPaused
|
||||
? Colors.white.withOpacity(0.8)
|
||||
: const Color(0xFF0A0F2E),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
state.isPaused ? 'ПРОДОЛЖИТЬ' : 'ПАУЗА',
|
||||
style: TextStyle(
|
||||
color: state.isPaused
|
||||
? Colors.white.withOpacity(0.8)
|
||||
: const Color(0xFF0A0F2E),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFB84949),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.status == ActiveRideStatus.loading
|
||||
? null
|
||||
: () {
|
||||
_bloc.add(FinishRide(widget.orderId));
|
||||
Navigator.pop(context);
|
||||
context.go("/home/order-photos/${widget.orderId}");
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.stop,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'ЗАВЕРШИТЬ',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Поддержка
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone_in_talk,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Поддержка',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 СТАТИСТИКА (2 равных столбца, правый на всю высоту)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
// 🔹 ЛЕВЫЙ СТОЛБЕЦ: Скорость + Расстояние
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Скорость
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
state.speed.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'скорость',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Расстояние
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
state.distance.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'расстояние',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 🔹 ПРАВЫЙ СТОЛБЕЦ: Стоимость (на всю высоту)
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Стоимость',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: state.cost.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Digital Numbers',
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' BYN',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final hours = twoDigits(duration.inHours);
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
400
lib/presentation/components/sheet/current_rides_sheet.dart
Normal file
400
lib/presentation/components/sheet/current_rides_sheet.dart
Normal file
@@ -0,0 +1,400 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../../domain/entities/scooter_order.dart';
|
||||
import '../../event/current_rides_event.dart';
|
||||
import '../../state/current_rides_state.dart';
|
||||
import '../../viewmodel/current_rides_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
import 'reserved_ride_sheet.dart';
|
||||
import 'active_ride_sheet.dart';
|
||||
|
||||
class CurrentRidesSheet extends StatefulWidget {
|
||||
|
||||
const CurrentRidesSheet({super.key});
|
||||
|
||||
@override
|
||||
State<CurrentRidesSheet> createState() => _CurrentRidesSheetState();
|
||||
}
|
||||
|
||||
class _CurrentRidesSheetState extends State<CurrentRidesSheet> {
|
||||
late final CurrentRidesBloc _bloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Текущие поездки',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: BlocBuilder<CurrentRidesBloc, CurrentRidesState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CurrentRidesStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == CurrentRidesStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.orders.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Нет активных поездок',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: state.orders.map((order) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _RideCard(order: order),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Взять ещё самокат',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RideCard extends StatefulWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const _RideCard({required this.order});
|
||||
|
||||
@override
|
||||
State<_RideCard> createState() => _RideCardState();
|
||||
}
|
||||
|
||||
class _RideCardState extends State<_RideCard> {
|
||||
late Timer _timer;
|
||||
late Duration _elapsedTime;
|
||||
late Duration _reservationTime;
|
||||
late DateTime _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTime = widget.order.startAt ?? widget.order.createdAt;
|
||||
_elapsedTime = DateTime.now().difference(_startTime);
|
||||
_reservationTime = const Duration(minutes: 5);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_elapsedTime = DateTime.now().difference(_startTime);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReserved =
|
||||
widget.order.status == 'Booking' || //Drive, Finish
|
||||
widget.order.status == 'holding';
|
||||
Duration displayTime;
|
||||
if (isReserved) {
|
||||
displayTime = _reservationTime - _elapsedTime;
|
||||
if (displayTime.isNegative) {
|
||||
displayTime = Duration.zero;
|
||||
}
|
||||
} else {
|
||||
displayTime = _elapsedTime;
|
||||
}
|
||||
final timeString = _formatDuration(displayTime);
|
||||
final statusText = _getStatusText(widget.order.status);
|
||||
final statusColor = _getStatusColor(widget.order.status);
|
||||
|
||||
final scooterNumber =
|
||||
widget.order.scooter?.number ?? widget.order.scooterId.toString();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
height: 70,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getLocationText(),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
timeString,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: GradientButton(
|
||||
text: 'Подробнее',
|
||||
showArrows: false,
|
||||
height: 32,
|
||||
width: 100,
|
||||
fontSize: 11,
|
||||
onTap: () {
|
||||
if (isReserved) {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ReservedRideSheet(
|
||||
orderId: widget.order.id,
|
||||
scooterNumber: scooterNumber,
|
||||
initialReservationTime:
|
||||
_reservationTime - _elapsedTime,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ActiveRideSheet(
|
||||
orderId: widget.order.id,
|
||||
scooterNumber: scooterNumber,
|
||||
initialElapsedTime: _elapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getStatusText(String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'reserved':
|
||||
case 'holding':
|
||||
return 'Забронировано';
|
||||
case 'active':
|
||||
case 'in_progress':
|
||||
return 'Активно';
|
||||
case 'completed':
|
||||
return 'Завершено';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'reserved':
|
||||
case 'holding':
|
||||
return const Color(0xFFFFB800);
|
||||
case 'active':
|
||||
case 'in_progress':
|
||||
return const Color(0xFF66E3C4);
|
||||
default:
|
||||
return Colors.white;
|
||||
}
|
||||
}
|
||||
|
||||
String _getLocationText() {
|
||||
/*if (widget.order.scooter != null && widget.order.scooter!.address != null) {
|
||||
return widget.order.scooter!.address!;
|
||||
}*/
|
||||
return 'Московский 33';
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
141
lib/presentation/components/sheet/map_settings_sheet.dart
Normal file
141
lib/presentation/components/sheet/map_settings_sheet.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/event/map_event.dart';
|
||||
import 'package:be_happy/presentation/event/map_settings_modal_event.dart';
|
||||
import 'package:be_happy/presentation/state/map_settings_modal_state.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../viewmodel/map_bloc.dart';
|
||||
|
||||
class MapSettingsSheet extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const MapSettingsSheet({super.key, this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MapSettingsModalBloc, MapSettingsModalState>(
|
||||
builder: (context, state) {
|
||||
final List<_SettingItemData> items = [
|
||||
_SettingItemData(
|
||||
label: 'Геоточки',
|
||||
icon: Icons.location_on_outlined,
|
||||
color: const Color(0xFF66E3C4),
|
||||
isActive: state.isAllGeomarksActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeomarksToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Геозоны',
|
||||
icon: Icons.gps_fixed_outlined,
|
||||
color: const Color(0xFF86EFAC),
|
||||
isActive: state.isAllGeozonesActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(AllGeozonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Парковка',
|
||||
icon: Icons.home_outlined,
|
||||
color: const Color(0xFFA78BFA),
|
||||
isActive: state.isParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(ParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Разрешено кататься',
|
||||
icon: Icons.block_outlined,
|
||||
color: const Color(0xFF5ECD4C),
|
||||
isActive: state.isRestrictedParkingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedParkingZonesToggled(val)),
|
||||
),
|
||||
_SettingItemData(
|
||||
label: 'Запрещено кататься',
|
||||
icon: Icons.warning_amber_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
isActive: state.isRestrictedDrivingZoneActive,
|
||||
onChanged: (val) => context.read<MapSettingsModalBloc>().add(RestrictedDrivingZonesToggled(val)),
|
||||
),
|
||||
];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 365,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Параметры карты',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<MapSettingsModalBloc>().add(ApllyButtonClick());
|
||||
context.read<MapBloc>().add(UpdateMap());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Готово',
|
||||
style: TextStyle(color: Color(0xFF66E3C4), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
leading: Icon(item.icon, color: item.color),
|
||||
title: Text(item.label, style: const TextStyle(color: Colors.white)),
|
||||
trailing: Switch.adaptive(
|
||||
value: item.isActive,
|
||||
onChanged: item.onChanged,
|
||||
activeTrackColor: const Color(0xFF66E3C4),
|
||||
inactiveThumbColor: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательный класс для описания строк
|
||||
class _SettingItemData {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isActive;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
_SettingItemData({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.isActive,
|
||||
required this.onChanged,
|
||||
});
|
||||
}
|
||||
278
lib/presentation/components/sheet/payment_method_sheet.dart
Normal file
278
lib/presentation/components/sheet/payment_method_sheet.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/event/tariff_sheet_event.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:be_happy/presentation/components/payment_option.dart';
|
||||
|
||||
import '../../../domain/entities/payment_card.dart';
|
||||
import '../../event/payment_method_sheet_event.dart';
|
||||
import '../../state/payment_method_sheet_state.dart';
|
||||
import '../../viewmodel/payment_method_sheet_bloc.dart';
|
||||
|
||||
class PaymentMethodSheet extends StatefulWidget {
|
||||
final PaymentCard? initialSelectedCard; // Добавляем это поле
|
||||
|
||||
const PaymentMethodSheet({
|
||||
super.key,
|
||||
this.initialSelectedCard, // Инициализируем в конструкторе
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentMethodSheet> createState() => _PaymentMethodSheetState();
|
||||
}
|
||||
|
||||
class _PaymentMethodSheetState extends State<PaymentMethodSheet> {
|
||||
int? _selectedPaymentMethod = -2;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<PaymentMethodSheetBloc>().add(PaymentMethodSheetStarted());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PaymentMethodSheetBloc, PaymentMethodSheetState>(
|
||||
builder: (context, state) {
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.loading) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.failure) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки карт',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == PaymentMethodSheetStatus.success && _selectedPaymentMethod == -2) {
|
||||
if (widget.initialSelectedCard != null) {
|
||||
final initialIndex = state.cards.indexWhere(
|
||||
(card) => card.cardLastNumber == widget.initialSelectedCard!.cardLastNumber
|
||||
);
|
||||
_selectedPaymentMethod = initialIndex != -1 ? initialIndex : -1;
|
||||
} else {
|
||||
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
|
||||
_selectedPaymentMethod = mainCardIndex != -1 ? mainCardIndex : -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Находим карту с isMain: true при загрузке
|
||||
/*if (_selectedPaymentMethod == null) {
|
||||
final mainCardIndex = state.cards.indexWhere((card) => card.isMain);
|
||||
if (mainCardIndex != -1) {
|
||||
_selectedPaymentMethod = mainCardIndex;
|
||||
}
|
||||
}*/
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 450,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x00000032).withOpacity(0.6),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите способ оплаты',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
PaymentOption(
|
||||
title: 'Баланс',
|
||||
subtitle: '${state.balance.toStringAsFixed(2)} BYN',
|
||||
isSelected: _selectedPaymentMethod == -1,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = -1;
|
||||
});
|
||||
Navigator.pop(context, 'balance');
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...state.cards.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final card = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PaymentOption(
|
||||
title: card.type,
|
||||
subtitle: '****${card.cardLastNumber}',
|
||||
isSelected: _selectedPaymentMethod == index,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = index;
|
||||
});
|
||||
Navigator.pop(context, card);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/home/payment-methods/add-card');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: const Color(0xFF66E3C4),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Добавить платежную карту',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getCardType(String lastNumber) {
|
||||
if (lastNumber.isEmpty) return 'Card';
|
||||
final firstDigit = lastNumber[0];
|
||||
switch (firstDigit) {
|
||||
case '4':
|
||||
return 'Visa';
|
||||
case '5':
|
||||
return 'Mastercard';
|
||||
case '9':
|
||||
return 'BelCard';
|
||||
default:
|
||||
return 'Card';
|
||||
}
|
||||
}
|
||||
}
|
||||
335
lib/presentation/components/sheet/reserved_ride_sheet.dart
Normal file
335
lib/presentation/components/sheet/reserved_ride_sheet.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../event/reserved_ride_event.dart';
|
||||
import '../../state/reserved_ride_state.dart';
|
||||
import '../../viewmodel/reserved_ride_bloc.dart';
|
||||
import '../dialog/cancel_booking_dialog.dart';
|
||||
import '../gradient_button.dart';
|
||||
import 'active_ride_sheet.dart';
|
||||
|
||||
class ReservedRideSheet extends StatefulWidget {
|
||||
final String scooterNumber;
|
||||
final int orderId;
|
||||
final Duration initialReservationTime;
|
||||
|
||||
const ReservedRideSheet({
|
||||
super.key,
|
||||
required this.scooterNumber,
|
||||
required this.orderId,
|
||||
this.initialReservationTime = const Duration(minutes: 3, seconds: 17),
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReservedRideSheet> createState() => _ReservedRideSheetState();
|
||||
}
|
||||
|
||||
class _ReservedRideSheetState extends State<ReservedRideSheet> {
|
||||
late final ReservedRideBloc _bloc;
|
||||
late Duration _reservationTime;
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = getIt<ReservedRideBloc>();
|
||||
_reservationTime = widget.initialReservationTime;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_reservationTime = _reservationTime - const Duration(seconds: 1);
|
||||
if (_reservationTime.isNegative) {
|
||||
_reservationTime = Duration.zero;
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// HEADER
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Бесплатное бронирование',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ТАЙМЕР + ИНФО О САМОКАТЕ (КОМПАКТНЫЙ)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
// Таймер
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
_formatDuration(_reservationTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: 'Digital Numbers',
|
||||
),
|
||||
),
|
||||
),
|
||||
// Иконка и информация (ВЫСОКИЙ БЛОК)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Иконка самоката (ВЫШЕ)
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 56,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Инфо
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFB800),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Забронирован',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'№${widget.scooterNumber}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// КНОПКА "НАЧАТЬ ПОЕЗДКУ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<ReservedRideBloc, ReservedRideState>(
|
||||
listener: (context, state) {
|
||||
if (state.rideStarted) {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ActiveRideSheet(
|
||||
scooterNumber: widget.scooterNumber,
|
||||
initialElapsedTime: Duration.zero,
|
||||
orderId: widget.orderId,
|
||||
),
|
||||
);
|
||||
} else if (state.status == ReservedRideStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: GradientButton(
|
||||
text: 'Начать поездку',
|
||||
showArrows: true,
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
fontSize: 15,
|
||||
onTap: () {
|
||||
_bloc.add(StartRide(widget.orderId));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// КНОПКА "ОТМЕНИТЬ БРОНИРОВАНИЕ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<ReservedRideBloc, ReservedRideState>(
|
||||
listener: (context, state) {
|
||||
if (state.rideCancelled) {
|
||||
Navigator.pop(context);
|
||||
} else if (state.status == ReservedRideStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const CancelBookingDialog(),
|
||||
);
|
||||
if (result != null && result) {
|
||||
_bloc.add(CancelRide(widget.orderId));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BlocBuilder<ReservedRideBloc, ReservedRideState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ReservedRideStatus.loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Отменить бронирование',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
286
lib/presentation/components/sheet/scooter_bottom_sheet.dart
Normal file
286
lib/presentation/components/sheet/scooter_bottom_sheet.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/components/scooter/mini_battery_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../domain/entities/scooter.dart';
|
||||
import '../../state/scooter_detail_modal_state.dart';
|
||||
import '../../viewmodel/scooter_detail_modal_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
|
||||
class ScooterData {
|
||||
final String distance;
|
||||
final String number;
|
||||
final double batteryPercent;
|
||||
|
||||
ScooterData({
|
||||
required this.distance,
|
||||
required this.number,
|
||||
required this.batteryPercent,
|
||||
});
|
||||
}
|
||||
|
||||
class ScooterBottomSheet extends StatefulWidget {
|
||||
const ScooterBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
State<ScooterBottomSheet> createState() => _ScooterBottomSheetState();
|
||||
}
|
||||
|
||||
class _ScooterBottomSheetState extends State<ScooterBottomSheet> {
|
||||
final PageController _pageController = PageController(viewportFraction: 0.5);
|
||||
double _currentPage = 0;
|
||||
|
||||
_ScooterBottomSheetState();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController.addListener(() {
|
||||
setState(() {
|
||||
_currentPage = _pageController.page ?? 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ScooterDetailModalBloc, ScooterDetailModalState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ScooterDetailModalStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == ScooterDetailModalStatus.success) {
|
||||
return Dismissible(
|
||||
key: const Key('scooter-modal'),
|
||||
direction: DismissDirection.down, // Закрытие только вниз
|
||||
onDismissed: (_) => context.pop(),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header с адресом (без изменений)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.address ?? "Unknown address",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PageView с динамическим списком
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
padEnds: false,
|
||||
// Оставляем false, чтобы первый элемент прилипал к левому краю
|
||||
itemCount: state.scooters!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final scooter = state.scooters![index];
|
||||
final diff = (_currentPage - index).abs();
|
||||
final isActive = diff < 0.5;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
// 2. Добавляем левый отступ ТОЛЬКО для первого элемента,
|
||||
// чтобы он совпадал с заголовком
|
||||
margin: EdgeInsets.only(
|
||||
left: index == 0 ? 10 : 0,
|
||||
right: 10,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Transform.scale(
|
||||
scale: isActive ? 1.0 : 0.9,
|
||||
child: _ScooterCard(
|
||||
scooter: scooter,
|
||||
isActive: isActive,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(child: Text("Error"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScooterCard extends StatelessWidget {
|
||||
final Scooter scooter;
|
||||
final bool isActive;
|
||||
|
||||
const _ScooterCard({required this.scooter, required this.isActive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(isActive ? 0.35 : 0.25),
|
||||
Colors.white.withOpacity(isActive ? 0.25 : 0.18),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"${(scooter.distance?.toInt()) ?? 0}m",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.qr_code_scanner_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
scooter.number,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
MiniBatteryIndicator(percent: scooter.batteryLevel),
|
||||
const SizedBox(width: 8),
|
||||
Transform.translate(
|
||||
offset: const Offset(-40, 0),
|
||||
child: Text(
|
||||
'${(scooter.batteryLevel)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
GradientButton(
|
||||
text: "Подробнеe",
|
||||
showArrows: true,
|
||||
height: 32,
|
||||
width: double.infinity,
|
||||
fontSize: 12,
|
||||
onTap: () {
|
||||
Navigator.pop(context, scooter);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: isActive ? -30 : -5,
|
||||
top: isActive ? -10 : 15,
|
||||
child: SizedBox(
|
||||
height: isActive ? 190 : 160,
|
||||
child: Image.asset(
|
||||
"assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/presentation/components/sheet/tariff_info_sheet.dart
Normal file
177
lib/presentation/components/sheet/tariff_info_sheet.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/domain/entities/tariff.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class TariffInfoSheet extends StatefulWidget {
|
||||
final Tariff tariff;
|
||||
|
||||
const TariffInfoSheet({super.key, required this.tariff});
|
||||
|
||||
@override
|
||||
State<TariffInfoSheet> createState() => _TariffInfoSheetState();
|
||||
}
|
||||
|
||||
class _TariffInfoSheetState extends State<TariffInfoSheet> {
|
||||
bool _isInsuranceEnabled = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.9),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Полоска сверху (Handle)
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Контент со скроллом
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: [
|
||||
// Заголовок
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.timer_outlined, color: Color(0xFF66E3C4), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.tariff.title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF66E3C4),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Таблица цен
|
||||
_buildPriceRow('Старт поездки', '${widget.tariff.startPrice} BYN'),
|
||||
_buildPriceRow('Последующая минута', '${widget.tariff.drivePrice} BYN'),
|
||||
_buildPriceRow('Пауза', '${widget.tariff.pausePrice} BYN/мин'),
|
||||
_buildPriceRow('КЕШБЭК', '${widget.tariff.cashback * 100}%', isAccent: true),
|
||||
|
||||
const Divider(color: Colors.white24, height: 40),
|
||||
|
||||
// Страховка
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Страховка',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Image.asset('assets/icons/info_icon.png', width: 18, height: 18),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${widget.tariff.insurance} BYN',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: CupertinoSwitch(
|
||||
value: _isInsuranceEnabled,
|
||||
activeColor: const Color(0xFF66E3C4),
|
||||
onChanged: (val) => setState(() => _isInsuranceEnabled = val),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white24, height: 40),
|
||||
|
||||
// Список правил (Bullet points)
|
||||
_buildInfoBullet('Оплата страховки осуществляется только по банковской карте отдельным платежом'),
|
||||
_buildInfoBullet('В режиме паузы время тарифа приостанавливается'),
|
||||
_buildInfoBullet('При старте заказа будет заблокирована сумма в размере 7 рублей для проверки платежеспособности. Сумма разблокируется по факту списания средств за поездку.'),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceRow(String label, String value, {bool isAccent = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 15),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: isAccent ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBullet(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Icon(Icons.circle, size: 4, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
551
lib/presentation/components/sheet/tariff_sheet.dart
Normal file
551
lib/presentation/components/sheet/tariff_sheet.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
import 'dart:ui';
|
||||
import 'package:be_happy/presentation/components/payment_option.dart';
|
||||
import 'package:be_happy/presentation/components/sheet/payment_method_sheet.dart';
|
||||
import 'package:be_happy/presentation/components/sheet/tariff_info_sheet.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/payment_method_sheet_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../di/service_locator.dart';
|
||||
import '../../../domain/entities/payment_card.dart';
|
||||
import '../../../domain/entities/scooter.dart';
|
||||
import '../../../domain/entities/tariff.dart';
|
||||
import '../../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../event/payment_method_sheet_event.dart';
|
||||
import '../../event/tariff_sheet_event.dart';
|
||||
import '../../state/tariff_sheet_state.dart';
|
||||
import '../../viewmodel/tariff_sheet_bloc.dart';
|
||||
import '../gradient_button.dart';
|
||||
import '../scooter/mini_battery_indicator.dart';
|
||||
|
||||
class TariffSheet extends StatefulWidget {
|
||||
final Scooter scooter;
|
||||
|
||||
const TariffSheet({super.key, required this.scooter});
|
||||
|
||||
@override
|
||||
State<TariffSheet> createState() => _TariffSheetState();
|
||||
}
|
||||
|
||||
class _TariffSheetState extends State<TariffSheet> {
|
||||
int? _selectedTariffIndex;
|
||||
bool _hasPaymentCard = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<TariffSheetBloc>().add(TariffSheetStarted(widget.scooter.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TariffSheetBloc, TariffSheetState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == TariffSheetStatus.loading) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == TariffSheetStatus.failure) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка загрузки тарифов',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
height: 520,
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF000032).withOpacity(0.88),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔹 HEADER
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x99FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x66FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_back_ios_sharp,
|
||||
color: const Color(0x22FFFFFF),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Самокат ${widget.scooter.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
MiniBatteryIndicator(
|
||||
percent: widget.scooter.batteryLevel,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${widget.scooter.batteryLevel.toInt()}%', // ✅ Цифры
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Заряда хватит на 4 часа 17 минут\nили 47 км',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
itemCount: state.tariffs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tariff = state.tariffs[index];
|
||||
return Row(
|
||||
children: [
|
||||
_TariffCard(
|
||||
title: tariff.title,
|
||||
price: tariff.startPrice.toStringAsFixed(2),
|
||||
currency: tariff.currency,
|
||||
subtitle: 'Старт поездки',
|
||||
details: [
|
||||
'Далее ${tariff.drivePrice.toStringAsFixed(2)} ${tariff.currency}/мин.',
|
||||
'Минута на паузе ${tariff.pausePrice.toStringAsFixed(0)} ${tariff.currency}',
|
||||
],
|
||||
isSelected: _selectedTariffIndex == index,
|
||||
tariff: tariff,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedTariffIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (index < state.tariffs.length - 1)
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (state.useBalance || state.selectedCard != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: PaymentOption(
|
||||
title: state.useBalance
|
||||
? 'Баланс'
|
||||
: state.selectedCard!.type,
|
||||
subtitle: state.useBalance
|
||||
? '${state.userBalance.toStringAsFixed(2)} BYN'
|
||||
: '****${state.selectedCard!.cardLastNumber}',
|
||||
isSelected: true,
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet<dynamic>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: PaymentMethodSheet(
|
||||
initialSelectedCard: state.useBalance ? null : state.selectedCard,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
PaymentCardChanged(result),
|
||||
);
|
||||
} else if (result == 'balance') {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
SelectBalancePressed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushReplacement(
|
||||
'/home/payment-method-sheet',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 🔹 КНОПКА "ЗАБРОНИРОВАТЬ"
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GradientButton(
|
||||
text: 'Забронировать',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
enabled: _selectedTariffIndex != null && (state.selectedCard != null || state.useBalance),
|
||||
onTap: (_selectedTariffIndex != null && (state.selectedCard != null || state.useBalance))
|
||||
? () {
|
||||
context.read<TariffSheetBloc>().add(
|
||||
BookScooterPressed(
|
||||
widget.scooter.id,
|
||||
state.tariffs[_selectedTariffIndex!].id,
|
||||
0,
|
||||
state.useBalance ? null : state.selectedCard?.id,
|
||||
state.useBalance,
|
||||
false
|
||||
)
|
||||
);
|
||||
context.pushReplacement('/home/current-rides-sheet');
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getCardType(String lastNumber) {
|
||||
if (lastNumber.isEmpty) return 'Card';
|
||||
final firstDigit = lastNumber[0];
|
||||
switch (firstDigit) {
|
||||
case '4':
|
||||
return 'Visa';
|
||||
case '5':
|
||||
return 'Mastercard';
|
||||
case '9':
|
||||
return 'BelCard';
|
||||
default:
|
||||
return 'Card';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TariffCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Tariff tariff;
|
||||
final String price;
|
||||
final String currency;
|
||||
final String subtitle;
|
||||
final List<String> details;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TariffCard({
|
||||
required this.title,
|
||||
required this.tariff,
|
||||
required this.price,
|
||||
required this.currency,
|
||||
required this.subtitle,
|
||||
required this.details,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF1A1F3E)
|
||||
: Colors.white.withOpacity(0.19),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// Используем Stack, чтобы наложить кнопку поверх контента
|
||||
child: Stack(
|
||||
children: [
|
||||
// Основной контент карточки
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Заголовок с иконкой часов
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF66E3C4),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF66E3C4),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Резервируем место под иконку инфо справа,
|
||||
// чтобы текст не залез под неё
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Цена + текст рядом
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$price $currency',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Детали
|
||||
...details.map(
|
||||
(detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
detail,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Кнопка-иконка в верхнем правом углу
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => TariffInfoSheet(tariff: tariff),
|
||||
);
|
||||
print('Info pressed for $title');
|
||||
},
|
||||
child: Image.asset(
|
||||
'assets/icons/info_icon.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user