some fix
# Conflicts: # lib/presentation/navigation/app_router.dart # pubspec.yaml
This commit is contained in:
@@ -1,233 +1,233 @@
|
||||
import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/card_input_field.dart'; // ← новый импорт
|
||||
import '../components/utils/card_formatter.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../viewmodel/add_card_bloc.dart';
|
||||
import '../event/add_card_event.dart';
|
||||
import '../state/add_card_state.dart';
|
||||
|
||||
class AddCardScreen extends StatelessWidget {
|
||||
const AddCardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: BlocListener<AddCardBloc, AddCardState>(
|
||||
listenWhen: (previous, current) {
|
||||
print(
|
||||
'Смена статуса: ${previous.status} -> ${current.status} ${current.errorMessage}');
|
||||
return previous.status != current.status &&
|
||||
current.status == AddCardStatus.success;
|
||||
},
|
||||
listener: (context, state) {
|
||||
context.pop(true);
|
||||
},
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const CustomAppBar(title: 'Добавление карты'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: '0000 0000 0000 0000',
|
||||
icon: Icons.credit_card,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(16),
|
||||
CardNumberFormatter(),
|
||||
],
|
||||
letterSpacing: 2,
|
||||
onChanged: (value) {
|
||||
final cleanValue = value.replaceAll(' ', '');
|
||||
context.read<AddCardBloc>().add(CardNumberChanged(cleanValue));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'MM/YY',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(4),
|
||||
CardMonthInputFormatter(),
|
||||
],
|
||||
onChanged: (value) {
|
||||
final cleanValue = value.replaceAll('/', '');
|
||||
context.read<AddCardBloc>().add(ExpiryDateChanged(cleanValue));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: BlocBuilder<
|
||||
AddCardBloc,
|
||||
AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'CVV',
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter
|
||||
.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(
|
||||
3),
|
||||
],
|
||||
onChanged: (value) {
|
||||
context.read<AddCardBloc>().add(
|
||||
CvvChanged(value));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'Имя и фамилия на карте',
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization
|
||||
.words,
|
||||
onChanged: (value) {
|
||||
context.read<AddCardBloc>().add(
|
||||
CardHolderChanged(value));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.isFormValid
|
||||
? () => {
|
||||
context.read<AddCardBloc>().add(AddCardSubmitted()),
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(
|
||||
24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: state.isFormValid
|
||||
? const Color(0xFF66E3C4)
|
||||
: Colors.white
|
||||
.withOpacity(0.3),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Добавить карту',
|
||||
style: TextStyle(
|
||||
color: state.isFormValid
|
||||
? Colors.white
|
||||
: Colors.white
|
||||
.withOpacity(0.3),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Мы не сохраняем данные карты у себя. Оплата происходит '
|
||||
'через сертифицированный провайдер Беларуси bePaid. '
|
||||
'Платежная страница системы bePaid отвечает требованиям '
|
||||
'безопасности передачи данных PCI DSS Level I. '
|
||||
'Все конфиденциальные данные хранятся в зашифрованном виде.',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/card_input_field.dart'; // ← новый импорт
|
||||
import '../components/utils/card_formatter.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../viewmodel/add_card_bloc.dart';
|
||||
import '../event/add_card_event.dart';
|
||||
import '../state/add_card_state.dart';
|
||||
|
||||
class AddCardScreen extends StatelessWidget {
|
||||
const AddCardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: BlocListener<AddCardBloc, AddCardState>(
|
||||
listenWhen: (previous, current) {
|
||||
print(
|
||||
'Смена статуса: ${previous.status} -> ${current.status} ${current.errorMessage}');
|
||||
return previous.status != current.status &&
|
||||
current.status == AddCardStatus.success;
|
||||
},
|
||||
listener: (context, state) {
|
||||
context.pop(true);
|
||||
},
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const CustomAppBar(title: 'Добавление карты'),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: '0000 0000 0000 0000',
|
||||
icon: Icons.credit_card,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(16),
|
||||
CardNumberFormatter(),
|
||||
],
|
||||
letterSpacing: 2,
|
||||
onChanged: (value) {
|
||||
final cleanValue = value.replaceAll(' ', '');
|
||||
context.read<AddCardBloc>().add(CardNumberChanged(cleanValue));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'MM/YY',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(4),
|
||||
CardMonthInputFormatter(),
|
||||
],
|
||||
onChanged: (value) {
|
||||
final cleanValue = value.replaceAll('/', '');
|
||||
context.read<AddCardBloc>().add(ExpiryDateChanged(cleanValue));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: BlocBuilder<
|
||||
AddCardBloc,
|
||||
AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'CVV',
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter
|
||||
.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(
|
||||
3),
|
||||
],
|
||||
onChanged: (value) {
|
||||
context.read<AddCardBloc>().add(
|
||||
CvvChanged(value));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return CardInputField(
|
||||
hintText: 'Имя и фамилия на карте',
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization
|
||||
.words,
|
||||
onChanged: (value) {
|
||||
context.read<AddCardBloc>().add(
|
||||
CardHolderChanged(value));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
BlocBuilder<AddCardBloc, AddCardState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: state.isFormValid
|
||||
? () => {
|
||||
context.read<AddCardBloc>().add(AddCardSubmitted()),
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(
|
||||
24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: state.isFormValid
|
||||
? const Color(0xFF66E3C4)
|
||||
: Colors.white
|
||||
.withOpacity(0.3),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Добавить карту',
|
||||
style: TextStyle(
|
||||
color: state.isFormValid
|
||||
? Colors.white
|
||||
: Colors.white
|
||||
.withOpacity(0.3),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Мы не сохраняем данные карты у себя. Оплата происходит '
|
||||
'через сертифицированный провайдер Беларуси bePaid. '
|
||||
'Платежная страница системы bePaid отвечает требованиям '
|
||||
'безопасности передачи данных PCI DSS Level I. '
|
||||
'Все конфиденциальные данные хранятся в зашифрованном виде.',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/gradient_button.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
class BlockedScreen extends StatelessWidget {
|
||||
const BlockedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Аккаунт заблокирован",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white, fontSize: 22),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
Image.asset(
|
||||
'assets/ban.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
GradientButton(
|
||||
text: "Обратиться в техподдержку",
|
||||
onTap: () {},
|
||||
showArrows: true,
|
||||
height: 50,
|
||||
width: 290,
|
||||
fontSize: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/gradient_button.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
class BlockedScreen extends StatelessWidget {
|
||||
const BlockedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"Аккаунт заблокирован",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white, fontSize: 22),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
Image.asset(
|
||||
'assets/ban.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
GradientButton(
|
||||
text: "Обратиться в техподдержку",
|
||||
onTap: () {},
|
||||
showArrows: true,
|
||||
height: 50,
|
||||
width: 290,
|
||||
fontSize: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +1,173 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../components/gradient_button.dart';
|
||||
|
||||
enum CardBindingStatus { success, failed }
|
||||
|
||||
class CardBindingResultScreen extends StatefulWidget {
|
||||
final CardBindingStatus status;
|
||||
final String title;
|
||||
final String cardImagePath;
|
||||
|
||||
const CardBindingResultScreen({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.title,
|
||||
required this.cardImagePath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CardBindingResultScreen> createState() => _CardBindingResultScreenState();
|
||||
}
|
||||
|
||||
class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
|
||||
Timer? _autoPopTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.status == CardBindingStatus.success) {
|
||||
_autoPopTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (mounted) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoPopTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSuccess = widget.status == CardBindingStatus.success;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF1B284F),
|
||||
Color(0xFF0F172A),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Positioned(
|
||||
// bottom: 50,
|
||||
// left: 0,
|
||||
// right: 0,
|
||||
// child: Image.asset(
|
||||
// 'assets/wave.png',
|
||||
// fit: BoxFit.cover,
|
||||
// ),
|
||||
// ),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// const Spacer(),
|
||||
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
Image.asset(
|
||||
isSuccess
|
||||
? 'assets/icons/success.png'
|
||||
: 'assets/icons/failed.png',
|
||||
width: 140,
|
||||
height: 140,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (isSuccess) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40.0),
|
||||
// child: Image.asset(
|
||||
// widget.cardImagePath,
|
||||
// width: double.infinity,
|
||||
// fit: BoxFit.contain,
|
||||
// ),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
'Проверьте подключение к\nинтернету и попробуйте снова',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
GradientButton(
|
||||
text: "Попробовать снова",
|
||||
onTap: () {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
},
|
||||
enabled: true,
|
||||
showArrows: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: TextButton(
|
||||
onPressed: ()
|
||||
{
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
backgroundColor: const Color(0xFF0F172A).withOpacity(0.4),
|
||||
),
|
||||
child: const Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../components/gradient_button.dart';
|
||||
|
||||
enum CardBindingStatus { success, failed }
|
||||
|
||||
class CardBindingResultScreen extends StatefulWidget {
|
||||
final CardBindingStatus status;
|
||||
final String title;
|
||||
final String cardImagePath;
|
||||
|
||||
const CardBindingResultScreen({
|
||||
super.key,
|
||||
required this.status,
|
||||
required this.title,
|
||||
required this.cardImagePath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CardBindingResultScreen> createState() => _CardBindingResultScreenState();
|
||||
}
|
||||
|
||||
class _CardBindingResultScreenState extends State<CardBindingResultScreen> {
|
||||
Timer? _autoPopTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.status == CardBindingStatus.success) {
|
||||
_autoPopTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (mounted) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoPopTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSuccess = widget.status == CardBindingStatus.success;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF1B284F),
|
||||
Color(0xFF0F172A),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Positioned(
|
||||
// bottom: 50,
|
||||
// left: 0,
|
||||
// right: 0,
|
||||
// child: Image.asset(
|
||||
// 'assets/wave.png',
|
||||
// fit: BoxFit.cover,
|
||||
// ),
|
||||
// ),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// const Spacer(),
|
||||
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
Image.asset(
|
||||
isSuccess
|
||||
? 'assets/icons/success.png'
|
||||
: 'assets/icons/failed.png',
|
||||
width: 140,
|
||||
height: 140,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (isSuccess) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40.0),
|
||||
// child: Image.asset(
|
||||
// widget.cardImagePath,
|
||||
// width: double.infinity,
|
||||
// fit: BoxFit.contain,
|
||||
// ),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
'Проверьте подключение к\nинтернету и попробуйте снова',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
GradientButton(
|
||||
text: "Попробовать снова",
|
||||
onTap: () {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
},
|
||||
enabled: true,
|
||||
showArrows: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: TextButton(
|
||||
onPressed: ()
|
||||
{
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
context.go("/home/payment-methods?refreshId=$timestamp");
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
backgroundColor: const Color(0xFF0F172A).withOpacity(0.4),
|
||||
),
|
||||
child: const Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/link_row.dart';
|
||||
|
||||
class DocumentsScreen extends StatelessWidget {
|
||||
const DocumentsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Документы'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Договор аренды',
|
||||
onTap: () => openLink('https://...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Политика конфиденциальности',
|
||||
onTap: () => context.push('/privacy-policy')
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Правила вождения',
|
||||
onTap: () => openLink('https://behappybel.by/#rule'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Правила оплаты картой',
|
||||
onTap: () => openLink('https://...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Spacer(), // Отодвигаем картинку вниз
|
||||
|
||||
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/link_row.dart';
|
||||
|
||||
class DocumentsScreen extends StatelessWidget {
|
||||
const DocumentsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Документы'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Договор аренды',
|
||||
onTap: () => context.push('/home/documents/lease-agreement'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Политика конфиденциальности',
|
||||
onTap: () => context.push('/privacy-policy'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Правила вождения',
|
||||
onTap: () => openLink('https://behappybel.by/#rule'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/doc.png',
|
||||
title: 'Правила оплаты картой',
|
||||
onTap: () => context.push('/home/documents/payment-rules'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Spacer(), // Отодвигаем картинку вниз
|
||||
|
||||
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,240 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/edit_profile_event.dart';
|
||||
import '../state/edit_profile_state.dart';
|
||||
import '../event/profile_event.dart';
|
||||
import '../viewmodel/edit_profile_bloc.dart';
|
||||
import '../viewmodel/profile_bloc.dart';
|
||||
|
||||
class EditProfileScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
|
||||
const EditProfileScreen({super.key, required this.profile});
|
||||
|
||||
@override
|
||||
State<EditProfileScreen> createState() => _EditProfileScreenState();
|
||||
}
|
||||
|
||||
class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
late final TextEditingController nameController;
|
||||
late final TextEditingController birthDateController;
|
||||
late final TextEditingController phoneController;
|
||||
late final TextEditingController emailController;
|
||||
|
||||
DateTime? _selectedBirthDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<EditProfileBloc>().add(EditProfileStarted());
|
||||
|
||||
nameController = TextEditingController();
|
||||
phoneController = TextEditingController();
|
||||
emailController = TextEditingController();
|
||||
|
||||
String initialDateText = '';
|
||||
|
||||
if (widget.profile.birthDate.isNotEmpty) {
|
||||
try {
|
||||
_selectedBirthDate = DateFormat('dd.MM.yyyy').parse(widget.profile.birthDate);
|
||||
initialDateText = DateFormat('dd.MM.yyyy').format(_selectedBirthDate!);
|
||||
} catch (e) {
|
||||
initialDateText = widget.profile.birthDate;
|
||||
print("EXCEPTION: $e");
|
||||
}
|
||||
}
|
||||
birthDateController = TextEditingController(text: initialDateText);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
final profileFromState = context.read<EditProfileBloc>().state.profile;
|
||||
if (profileFromState == null) return;
|
||||
|
||||
final String birthDateForApi = _selectedBirthDate?.toIso8601String() ?? '';
|
||||
|
||||
final updatedProfile = profileFromState.copyWith(
|
||||
name: nameController.text,
|
||||
birthDate: birthDateForApi.isNotEmpty ? "${birthDateForApi}Z" : '',
|
||||
email: emailController.text,
|
||||
);
|
||||
|
||||
context.read<EditProfileBloc>().add(EditProfileSubmitted(updatedProfile));
|
||||
context.read<ProfileBloc>().add(ProfileUpdated());
|
||||
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedBirthDate ?? DateTime.now(),
|
||||
firstDate: DateTime(DateTime.now().year - 100),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedBirthDate) {
|
||||
setState(() {
|
||||
_selectedBirthDate = picked;
|
||||
birthDateController.text = DateFormat('dd.MM.yyyy').format(picked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocConsumer<EditProfileBloc, EditProfileState>(
|
||||
listener: (context, state) {
|
||||
final profile = state.profile;
|
||||
if (profile != null && nameController.text.isEmpty) {
|
||||
nameController.text = profile.name;
|
||||
phoneController.text = profile.phone;
|
||||
emailController.text = profile.email;
|
||||
birthDateController.text = profile.birthDate;
|
||||
|
||||
}
|
||||
|
||||
if (state.isSuccess) {
|
||||
getIt<ProfileBloc>().add(ProfileUpdated());
|
||||
context.pop();
|
||||
}
|
||||
if (state.error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.profile == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Личные данные'),
|
||||
const SizedBox(height: 32),
|
||||
_Field(
|
||||
'Имя',
|
||||
nameController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
),
|
||||
_Field(
|
||||
'Дата рождения',
|
||||
birthDateController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
readOnly: true,
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
),
|
||||
),
|
||||
_Field(
|
||||
'Телефон',
|
||||
phoneController,
|
||||
iconPath: 'assets/icons/lock.png',
|
||||
enabled: false,
|
||||
),
|
||||
_Field(
|
||||
'E-mail',
|
||||
emailController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
),
|
||||
const Spacer(),
|
||||
state.isSaving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: GradientButton(
|
||||
text: 'Сохранить изменения',
|
||||
onTap: () => _submit(context),
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String iconPath;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _Field(
|
||||
this.label,
|
||||
this.controller, {
|
||||
required this.iconPath,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.onTap
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderColor = enabled
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.15);
|
||||
final iconOpacity = enabled ? 0.7 : 0.3;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: AppColors.white70),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: BorderSide(color: borderColor),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: BorderSide(color: borderColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: AppColors.smsDigit, width: 1.5),
|
||||
),
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Opacity(
|
||||
opacity: iconOpacity,
|
||||
child: Image.asset(iconPath, width: 20, height: 20),
|
||||
),
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/edit_profile_event.dart';
|
||||
import '../state/edit_profile_state.dart';
|
||||
import '../event/profile_event.dart';
|
||||
import '../viewmodel/edit_profile_bloc.dart';
|
||||
import '../viewmodel/profile_bloc.dart';
|
||||
|
||||
class EditProfileScreen extends StatefulWidget {
|
||||
final UserProfile profile;
|
||||
|
||||
const EditProfileScreen({super.key, required this.profile});
|
||||
|
||||
@override
|
||||
State<EditProfileScreen> createState() => _EditProfileScreenState();
|
||||
}
|
||||
|
||||
class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
late final TextEditingController nameController;
|
||||
late final TextEditingController birthDateController;
|
||||
late final TextEditingController phoneController;
|
||||
late final TextEditingController emailController;
|
||||
|
||||
DateTime? _selectedBirthDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<EditProfileBloc>().add(EditProfileStarted());
|
||||
|
||||
nameController = TextEditingController();
|
||||
phoneController = TextEditingController();
|
||||
emailController = TextEditingController();
|
||||
|
||||
String initialDateText = '';
|
||||
|
||||
if (widget.profile.birthDate.isNotEmpty) {
|
||||
try {
|
||||
_selectedBirthDate = DateFormat('dd.MM.yyyy').parse(widget.profile.birthDate);
|
||||
initialDateText = DateFormat('dd.MM.yyyy').format(_selectedBirthDate!);
|
||||
} catch (e) {
|
||||
initialDateText = widget.profile.birthDate;
|
||||
print("EXCEPTION: $e");
|
||||
}
|
||||
}
|
||||
birthDateController = TextEditingController(text: initialDateText);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
final profileFromState = context.read<EditProfileBloc>().state.profile;
|
||||
if (profileFromState == null) return;
|
||||
|
||||
final String birthDateForApi = _selectedBirthDate?.toIso8601String() ?? '';
|
||||
|
||||
final updatedProfile = profileFromState.copyWith(
|
||||
name: nameController.text,
|
||||
birthDate: birthDateForApi.isNotEmpty ? "${birthDateForApi}Z" : '',
|
||||
email: emailController.text,
|
||||
);
|
||||
|
||||
context.read<EditProfileBloc>().add(EditProfileSubmitted(updatedProfile));
|
||||
context.read<ProfileBloc>().add(ProfileUpdated());
|
||||
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedBirthDate ?? DateTime.now(),
|
||||
firstDate: DateTime(DateTime.now().year - 100),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedBirthDate) {
|
||||
setState(() {
|
||||
_selectedBirthDate = picked;
|
||||
birthDateController.text = DateFormat('dd.MM.yyyy').format(picked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocConsumer<EditProfileBloc, EditProfileState>(
|
||||
listener: (context, state) {
|
||||
final profile = state.profile;
|
||||
if (profile != null && nameController.text.isEmpty) {
|
||||
nameController.text = profile.name;
|
||||
phoneController.text = profile.phone;
|
||||
emailController.text = profile.email;
|
||||
birthDateController.text = profile.birthDate;
|
||||
|
||||
}
|
||||
|
||||
if (state.isSuccess) {
|
||||
getIt<ProfileBloc>().add(ProfileUpdated());
|
||||
context.pop();
|
||||
}
|
||||
if (state.error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.profile == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Личные данные'),
|
||||
const SizedBox(height: 32),
|
||||
_Field(
|
||||
'Имя',
|
||||
nameController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
),
|
||||
_Field(
|
||||
'Дата рождения',
|
||||
birthDateController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
readOnly: true,
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
),
|
||||
),
|
||||
_Field(
|
||||
'Телефон',
|
||||
phoneController,
|
||||
iconPath: 'assets/icons/lock.png',
|
||||
enabled: false,
|
||||
),
|
||||
_Field(
|
||||
'E-mail',
|
||||
emailController,
|
||||
iconPath: 'assets/icons/edit.png',
|
||||
),
|
||||
const Spacer(),
|
||||
state.isSaving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: GradientButton(
|
||||
text: 'Сохранить изменения',
|
||||
onTap: () => _submit(context),
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String iconPath;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _Field(
|
||||
this.label,
|
||||
this.controller, {
|
||||
required this.iconPath,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.onTap
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderColor = enabled
|
||||
? Colors.white.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.15);
|
||||
final iconOpacity = enabled ? 0.7 : 0.3;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: AppColors.white70),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: BorderSide(color: borderColor),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: BorderSide(color: borderColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: AppColors.smsDigit, width: 1.5),
|
||||
),
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Opacity(
|
||||
opacity: iconOpacity,
|
||||
child: Image.asset(iconPath, width: 20, height: 20),
|
||||
),
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
421
lib/presentation/screens/fine_payment_screen.dart
Normal file
421
lib/presentation/screens/fine_payment_screen.dart
Normal file
@@ -0,0 +1,421 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/add_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/full_screen_image_viewer.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/fine_payment_event.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../state/fine_payment_state.dart';
|
||||
import '../viewmodel/fine_payment_bloc.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
|
||||
class FinePaymentScreen extends StatelessWidget {
|
||||
final int fineId;
|
||||
|
||||
const FinePaymentScreen({
|
||||
super.key,
|
||||
required this.fineId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => FinePaymentBloc(
|
||||
getFineDetailsUsecase: getIt(),
|
||||
payFineUsecase: getIt(),
|
||||
)..add(FetchFineDetails(fineId)),
|
||||
child: const _FinePaymentScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FinePaymentScreenContent extends StatelessWidget {
|
||||
const _FinePaymentScreenContent();
|
||||
|
||||
void _openFullScreenImage(BuildContext context, List<String> photos, int initialIndex) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => FullScreenImageViewer(
|
||||
photoUrls: photos,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
fit: BoxFit.fitWidth,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Оплата штрафа'),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: BlocConsumer<FinePaymentBloc, FinePaymentState>(
|
||||
listenWhen: (previous, current) => previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == FinePaymentStatus.success && state.paymentCompleted) {
|
||||
context.go('/home');
|
||||
} else if (state.status == FinePaymentStatus.failure) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Произошла ошибка при оплате штрафа',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.9),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(20),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final fine = state.fine;
|
||||
|
||||
if (state.status == FinePaymentStatus.loading && fine == null) {
|
||||
return const SizedBox(
|
||||
height: 400,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (fine == null) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (fine.photoUrls.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: fine.photoUrls.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final photo = fine.photoUrls[index];
|
||||
final isNetwork = photo.startsWith('http');
|
||||
return GestureDetector(
|
||||
onTap: () => _openFullScreenImage(
|
||||
context,
|
||||
fine.photoUrls,
|
||||
index,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isNetwork
|
||||
? Image.network(
|
||||
photo,
|
||||
width: 110,
|
||||
height: 110,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Image.asset(
|
||||
photo,
|
||||
width: 110,
|
||||
height: 110,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildInfoLabel('Самокат:'),
|
||||
_buildValueRow(
|
||||
'qr_icon.png',
|
||||
fine.scooterNumber,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildInfoLabel('Дата:'),
|
||||
Text(
|
||||
fine.date,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildInfoLabel('Описание:'),
|
||||
Text(
|
||||
fine.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.07),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Сумма штрафа:',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/money_icon.png',
|
||||
width: 36,
|
||||
height: 36,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
fine.amountPrint,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
(state.useBalance || state.selectedCard != null)
|
||||
? PaymentOption(
|
||||
title: state.useBalance ? 'Баланс' : "",
|
||||
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>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: PaymentMethodSheet(
|
||||
initialSelectedCard: state.useBalance ? null : state.selectedCard,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<FinePaymentBloc>().add(FinePaymentCardChanged(result));
|
||||
} else if (result == 'balance') {
|
||||
context.read<FinePaymentBloc>().add(SelectFineBalancePressed());
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
: 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: () async {
|
||||
final result = await showModalBottomSheet<dynamic>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: const PaymentMethodSheet(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<FinePaymentBloc>().add(FinePaymentCardChanged(result));
|
||||
} else if (result == 'balance') {
|
||||
context.read<FinePaymentBloc>().add(SelectFineBalancePressed());
|
||||
}
|
||||
}
|
||||
},
|
||||
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: 20),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: BlocBuilder<FinePaymentBloc, FinePaymentState>(
|
||||
builder: (context, state) {
|
||||
final isButtonDisabled = state.status == FinePaymentStatus.loading ||
|
||||
(!state.useBalance && state.selectedCard == null);
|
||||
|
||||
return GradientButton(
|
||||
text: state.status == FinePaymentStatus.loading
|
||||
? 'Обработка...'
|
||||
: 'Оплатить',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
onTap: isButtonDisabled
|
||||
? null
|
||||
: () {
|
||||
context.read<FinePaymentBloc>().add(PayFineSubmitted());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValueRow(String iconName, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Image.asset('assets/icons/$iconName', width: 24, height: 24),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
383
lib/presentation/screens/lease_agreement_screen.dart
Normal file
383
lib/presentation/screens/lease_agreement_screen.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class LeaseAgreementScreen extends StatelessWidget {
|
||||
const LeaseAgreementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ''),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Договор (публичная оферта) на аренду имущества',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'ООО “БИХЕППИБЕЛ” Республика Беларусь, г. Витебск, г. Полоцк, г. Новополоцк, г. Борисов, г. Могилев, г. Молодечно, является «Арендодатель». Арендодатель (юридическое лицо) указывается при выборе объекта аренды в акте приема-передачи публичной оферты аренды имущества, с одной стороны и полностью дееспособное физическое лицо (гражданин РБ, иностранный гражданин, лицо без гражданства), акцептующее настоящий договор аренды, именуемое в дальнейшем «Арендатор», с другой стороны, совместно именуемые «Стороны» заключили настоящий Договор публичный оферты (далее - Договор) о нижеследующем: Стороны заключают настоящий Договор, который создает для Арендатора юридические обязательства. Арендодатель при заключение руководствуется законодательством об аренде имущества, Конвенция Совета Европы о защите физических лиц при автоматизированной обработке персональных данных от 28 января 1981 года ETS N 108. Если вы не согласны с каким-либо из условий настоящего Договора, пожалуйста, не переходите к сервису по аренде объекта в приложение BeHappy, не используйте любые услуги, предлагаемые через приложения, и не создавайте учетную запись для таких услуг. Используя электронные Платформы, вы соглашаетесь с условиями настоящего Договора. Вы подтверждаете, что внимательно прочитали и поняли условия настоящего Договора.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('1. Основные термины и определения, используемые в Договоре'),
|
||||
_buildParagraph(
|
||||
'В настоящем Договоре используются следующие термины, имеющие определенное ниже значение. В случае, если в Договоре используются термины и понятия, не определенные в настоящем разделе, они имеют значения, которые обычно придаются им в соответствующей отрасли/сфере.\n\n'
|
||||
'1.1. Акцепт – ответ физического лица (гражданина РБ, иностранного гражданина, лица без гражданства), достигшего 18 лет и не состоящего под опекой или попечительством, о принятии настоящей Оферты, который совершается в порядке, предусмотренным настоящим Договором. Ответ должен быть полным и безоговорочным;\n\n'
|
||||
'1.2. Аренда – использование имущества (объекта) в соответствии с его целевым назначением, с момента начала аренды до момента его завершения, по правилам, определенным настоящим Договором;\n\n'
|
||||
'1.3. Арендатор - физическое лицо (гражданин РБ, иностранный гражданин, лицо без гражданства), достигшего 18 лет и не состоящего под опекой или попечительством;\n\n'
|
||||
'1.4. Объект (имущество), технические характеристики объекта и собственник объекта указывается в акте-приема передачи в момент выбора объекта на аренду в приложение BeHappy;\n\n'
|
||||
'1.5. Стоимость аренды объекта – расценка имущества указаны в личном кабинете Арендатора, отображаемые в Приложении BeHappy. Иные Тарифы (в т.ч. используемые в рамках отдельных акций) размещаются на Сайте и/или в Приложении " BeHappy " и являются неотъемлемой частью к настоящему Договору;\n\n'
|
||||
'1.6. Безакцептное списание – списание денежных средств с банковской карты, указанной Арендатором во исполнение условий настоящего Договора. Списание денежных средств производится без получения дополнительного согласия со стороны Арендатора, по правилам и в порядке, предусмотренными законодательством Республики Беларусь и настоящим Договором;\n\n'
|
||||
'1.7. Несезон – это то время, когда погодные или иные условия не позволяют предоставить качественный и безопасный сервис и Объекта изымаются из города без возможности аренды;\n\n'
|
||||
'1.8. Служба поддержки – структурное подразделение Арендодателя, осуществляющее консультационную поддержку Пользователей в круглосуточном режиме семь дней в неделю, без выходных и праздничных дней, в дни работы сервиса (кроме несезона, см. п.1.7), оказывающая помощь Пользователям и их информирование в процессе предоставления Арендодателем Сервиса BeHappy. Поддержка и информирование осуществляются при обращении Пользователя в Службу поддержки через приложение в устройстве по мессенджерам (WhatsApp, Viber, Telegram);\n\n'
|
||||
'1.9. Лицевой счет Арендатора - счётчик, на котором фиксируются платежи Арендатора и суммы денежных средств, внесенных в качестве оплаты за аренду имущества;\n\n'
|
||||
'1.10. Зона начала/завершения аренды - территория города, обозначенная в приложении BeHappy - «Разрешено кататься», кроме территории, обозначенной «Парковка запрещена»;\n\n'
|
||||
'1.11. Зона, разрешенная для поездок - территория города, обозначенная в приложении BeHappy - «Разрешено кататься»;\n\n'
|
||||
'1.12. Парковка — место, отмеченное на карте в мобильном приложении Арендодателя, где Арендатор может завершить сессию аренды. Кроме территории, обозначенной «Парковка запрещена»;\n\n'
|
||||
'1.13. Бронирование – закрепление выбранного Арендатором Объекта за данным Пользователем на период бронирования;\n\n'
|
||||
'1.14. Групповая поездка — функция в Приложении, позволяющая одному Арендатору начать аренду сразу на нескольких Объектах.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('2. Предмет Договора и общие положения'),
|
||||
_buildParagraph(
|
||||
'2.1. Арендодатель передает во временное владение и пользование Арендатору Объект, а Арендатор принимает Объект для использования в потребительских целях (для личных нужд), не связанных с осуществлением предпринимательской деятельности;\n\n'
|
||||
'2.2. Арендатор гарантирует, что не будет использовать Приложение BeHappy с нарушением требований действующего законодательства, а также условий Договора Аренды, либо в целях причинения вреда Арендодателю или третьим лицам;\n\n'
|
||||
'2.3. На момент заключения настоящего договора сдаваемый в аренду Объект принадлежит Арендодателю на праве собственности. Переход права собственности на указанный Объект к другому лицу не является основанием для изменения или расторжения настоящего договора.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('3. Условия и порядок заключения Договора'),
|
||||
_buildParagraph(
|
||||
'3.1. На момент заключения договора Арендатор должен достигнуть 18-летнего возраста, не состоять под опекой или попечительством;\n\n'
|
||||
'3.2. Акцепт считается совершенным Арендатором при условии выполнения им следующей совокупности действий:\n'
|
||||
'3.2.1. Арендатор самостоятельно совершает загрузку (скачивание) на Устройство Приложения с использованием магазинов приложений AppStore (itunes.apple.com) и / или Google Play (play.google.com) в сети Интернет;\n'
|
||||
'3.2.2. Заполняет анкету в полном объеме, предоставляя требуемую в этой анкете информацию: номер своего мобильного телефона, адрес электронной почты, имя фамилию и отчество, а также дату рождения, в соответствии с данными паспорта;\n'
|
||||
'3.2.3. Ознакомление Арендатора в полном объеме с условиями настоящего Договора и политики конфиденциальности, путем нажатия кнопки «продолжить» при регистрации в Мобильном приложении, которую Арендодатель и Арендатор признают аналогом собственноручной подписи Арендатора, равным по юридической силе собственноручной подписи Арендатора на бумажном носителе и принятие условия настоящего Договора и Политики конфиденциальности путем проставления галочки в чек-боксе «Я принимаю условия использования сервиса» и «Я даю согласие на обработку моих персональных данных» и нажатия кнопки «продолжить»;\n\n'
|
||||
'3.3. Арендатор не может завершить Регистрацию, не приняв условия настоящего Договора путем проставления галочки в чек-боксе «Я принимаю условия лицензионного договора», «Я принимаю условия договора оферты аренды» и «Я даю согласие на обработку моих персональных данных»;\n\n'
|
||||
'3.4. Договор заключается Арендатором к установленным Договором условиям в целом, без каких-либо условий и оговорок;\n\n'
|
||||
'3.5. Стороны договорились о том, что в случае возникновения у Арендодателя сомнений в достоверности указанных в Анкете персональных данных, он вправе через Мобильное приложение или иным способом направить запрос о предоставлении дополнительной информации о личности Арендатора, в том числе, фотографий страниц его паспорта, или иного документа, удостоверяющего личность. Стороны договорились, что при не предоставлении дополнительной информации, позволяющей устранить сомнения в достоверности указанных в Анкете персональных данных, акцепт может считаться не совершенным, а Договор может считаться не заключенным;\n\n'
|
||||
'3.6. Арендодатель не несет ответственности или каких-либо обязательств в случае, если оферта не акцептована и Договор не заключен по причине невозможности обработки предоставленных Арендатором сведений и информации по техническим причинам;\n\n'
|
||||
'3.7. Посредством акцепта настоящей оферты Арендатор подтверждает, что любые действия, осуществляемые в Мобильном приложении под его учетной записью, совершены лично Арендатором. Все риски, связанные с несанкционированным использованием учетной записи или мобильного устройства Арендатора, лежат на Арендаторе;\n\n'
|
||||
'3.8. Посредством акцепта настоящей оферты Арендатор подтверждает с записью разговоров/сообщений/уведомлений при обращении в Службу Поддержки BeHappy;\n\n'
|
||||
'3.9. Арендатор обязуется сохранять в тайне данные анкеты, а также иные данные, посредством которых может быть получен доступ к Приложению от имени Пользователя. В случае утраты номера телефона, или при наличии оснований полагать, что этими данными (а также иными данными) завладело третье лицо, Арендатор обязан с использованием контактных данных, указанных им при Регистрации, подать Службе Поддержки запрос (путем отправки на электронный адрес указанный в разделе мобильного приложения BeHappy ПОДДЕРЖКА. Запрос о блокировании доступа в личный кабинет рассматривается Арендодателем в течение 1 (одного) часа в рабочее время с момента направления запроса. Все риски, вытекающие из невыполнения данного обязательства Пользователем, лежат на Арендаторе;\n\n'
|
||||
'3.10. По окончании Регистрации все вызовы в Службу поддержки осуществляются Арендатором с номера мобильного телефона, указанного при заполнении анкеты, либо иного номера телефона при условии предоставления Арендатором дополнительных данных по запросу сотрудника Службы поддержки;',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('4. Заявления Арендатора'),
|
||||
_buildParagraph(
|
||||
'Принимая условия настоящего Договора, Арендатор заявляет, что он:\n\n'
|
||||
'4.1. владеет русским языком (на уровне, достаточном для прочтения и осознания смысла и значения настоящего Договора);\n\n'
|
||||
'4.2. прочитал условия Договора, осознал значение и смысл указанного документа, согласен с его содержанием и принимает его без каких-либо оговорок, условий и изъятий и обязуется выполнять установленные им требования, нести ответственность за неисполнение/ненадлежащее исполнение изложенных в нем требований и условий, а также понимает все последствия своих действий по Регистрации, пользованию Сервисами Приложения, Сервисом BeHappy;\n\n'
|
||||
'4.3. Арендатор дает свое согласие на обработку Арендодателем предоставленных им (а также полученных от любых третьих лиц) своих персональных данных: ФИО, дата, месяц и год рождения, место рождения, серия и номер удостоверяющего личность документа (паспорта или иного заменяющего его документа), сведения об адресе регистрации по месту жительства, данные водительского удостоверения, контактные данные, в том числе (но не ограничиваясь) номер телефона, адрес электронной почты, данные о наличии (отсутствии) медицинских противопоказаний к использованию транспортного средства, биометрические данные (фотографии), данные о месте работы, должности, также иные персональные данные, связанные с заключением и исполнением Договора, и подтверждает, что давая такое согласие, действует по своей воле и в своих интересах;\n\n'
|
||||
'4.4. прочитал Политику конфиденциальности, осознал значение и смысл указанного документа, согласен с его содержанием;\n\n'
|
||||
'4.5. предоставил Арендодателю при Регистрации полные, действительные и достоверные данные (в том числе, персональные данные);\n\n'
|
||||
'4.6. соответствует всем требованиям, предъявляемым к нему настоящим Договором, в частности, обладает правом управления транспортными средствами в соответствии с требованиями действующего законодательства, того государства, на чей территории подписывается Договор аренды, у Арендатора отсутствуют предусмотренные действующим законодательством, того государства (страны) противопоказания для управления транспортными средствами, его возраст составляет не менее 18 (восемнадцати) лет, он соответствует требованиям, предъявляемым действующим законодательством , того государства (страны), которое дает право управлять транспортными средствами. Указанные требования к Арендатору не являются исчерпывающими. Сервис BeHappy вправе отказать в Регистрации, а также не заключать Договор присоединения с Арендатором, не соответствующим указанным требованиям, равно как в случае, если у Сервиса BeHappy есть основания полагать, что Арендатор может допустить нарушение настоящего Договора;\n\n'
|
||||
'4.7. согласен с размещением в Объекта, позволяющего отследить и зафиксировать точные координаты Объекта, а также нарушения обязательств по Договору со стороны Арендатора;\n\n'
|
||||
'4.8. безусловно и безоговорочно согласен с тем, что размер причиненных Арендатором повреждений или иного рода ухудшений Объекту, а также иных убытков Арендодателю, возникших в результате ненадлежащего использования (эксплуатации) Арендатором Объекта и/или нарушения Арендатором иных условий Аренды по настоящему Договору, определяются (оцениваются) Арендодателем самостоятельно и единолично, исходя из имеющихся у Арендодателя данных и сведений, полученных, в том числе (но не ограничиваясь), при использовании данных технической поддержки Приложения " BeHappy " (включая, но не ограничиваясь, сведения и данные, в том числе фотографии, предоставленные Арендатором с использованием Приложения "BeHappy"), а Арендатор безусловно и безоговорочно соглашается возмещать их Арендодателю в полном объеме по первому требованию Арендодателя, заявленному (направленному) как с использованием Приложения " BeHappy ", так и предъявленному Арендатору в ином порядке и иным доступным способом;\n\n'
|
||||
'4.9. признает юридическую силу за документами, направленными Арендодателем посредством Сайта или Приложения, как за собственноручно подписанными документами, за действиями, совершенными посредством Сайта или Приложения;\n\n'
|
||||
'4.10. признает, что средства идентификации, используемые Арендодателем, являются достаточными для установления лица, от которого исходят соответствующие документы и действия;\n\n'
|
||||
'4.11. Ознакомление с условиями настоящего Договора должно быть произведено Пользователем до момента проставления галочки в чек-боксе «Я принимаю условия использования сервиса» и «Я даю согласие на обработку моих персональных данных» и нажатия кнопки «продолжить». Арендатор, своевременно не ознакомившийся с условиями настоящего Договора, принимает на себя все риски связанных с этим неблагоприятных последствий.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('5. Содержание Договора'),
|
||||
_buildParagraph(
|
||||
'5.1. Арендодатель обязан:\n'
|
||||
'5.1.1. предоставить Арендатору через Мобильное приложение и путем размещения в мобильном приложение BeHappy полной информации об объекте Арендодателя, его технических характеристиках, размере арендных платежей и прочих условиях Договора;\n'
|
||||
'5.1.2. выставить в доступ Объекты в технически исправном состоянии;\n'
|
||||
'5.1.3. нести расходы на содержание Объекта, возникающие в связи с его правильной эксплуатацией (за исключением расходов, которые прямо возложены на Арендатора Договором);\n'
|
||||
'5.1.4. обеспечивать соблюдение требований законодательства при получении и обработке персональных данных Арендатора;\n'
|
||||
'5.1.5. надлежащим образом исполнять обязательства, вытекающие из настоящего Договора.\n\n'
|
||||
'5.2. Арендодатель вправе:\n'
|
||||
'5.2.1. требовать от Арендатора исполнения обязательств по Договору;\n'
|
||||
'5.2.2. осуществлять проверку сведений, в том числе персональных данных, предоставляемых Арендатором в момент регистрации в Мобильном приложении;\n'
|
||||
'5.2.3. обрабатывать персональные данные Арендатора, предоставленные в момент регистрации в Мобильном приложении, а также полученные при заключении и исполнении настоящего Договора;\n'
|
||||
'5.2.4. осуществлять контроль за обеспечением сохранности Объекта, а также за его техническим состоянием, контролировать соответствие эксплуатации Объекта целям, установленным настоящим Договором;\n'
|
||||
'5.2.5. отказать в заключении Договора, в случае предоставления Арендатором в момент регистрации в Мобильном приложении недостоверных или неполных персональных данных;\n'
|
||||
'5.2.6. отказать в заключении Договора, приостановить либо прекратить его действие, при наличии у Арендатора задолженности, вытекающей из настоящего и иных Договоров, заключенных между Арендатором и Арендодателем, до момента погашения задолженности в полном объеме, а также в случае нарушения Арендатором иных условий данного Договора;\n'
|
||||
'5.2.7. в одностороннем порядке вносить изменения в настоящий Договор (в т.ч. в стоимость и условия аренды);\n'
|
||||
'5.2.8. по истечении 24 часов после возникновения задолженности по оплате арендных платежей без предупреждения изъять Объект у Арендатора, а также прибегнуть к иным предусмотренным законодательством действующим законодательством, того государства (страны) противопоказания Республики Беларусь способам защиты своих прав;\n'
|
||||
'5.2.9. в случае удаления Объекта Арендатором от Зоны, разрешенной для поездок, более чем на 150 метров, за отказ вернуться в разрешенную зону, за невозможность связаться по указанному номеру телефона, Арендодатель предпринимает меры для возврата Объекта в разрешенную зону: отправляет уведомление на телефон о факте нарушения и с просьбой вернуться в зону для катания, снижает скорость до 5 км/ч, блокирует колесо Объекта. Если после всех мер Арендодателя, Объект не вернулся в разрешенную зону, Арендодатель заявляет в правоохранительные органы о факте кражи, а также прибегнуть к иным предусмотренным законодательством Республики Беларусь способам защиты своих прав;\n'
|
||||
'5.2.10. в случае наличия у Арендодателя достаточных оснований полагать о возможности совершения Арендатором мошеннических действий, а также иных проявлениях недобросовестного поведения, в одностороннем порядке прекратить исполнение по данному Договору, уведомив Арендатора через Мобильное приложение, а также прибегнуть к иным предусмотренным законодательством РБ способам защиты своих прав;\n'
|
||||
'5.2.11. перевести Объект в режим экономии энергии (снижение скорости) в случае снижения заряда аккумулятора ниже 20%;\n'
|
||||
'5.2.12. осуществлять списание с банковской карты Арендатора денежных средств в оплату денежных обязательств Арендатора перед Арендодателем в соответствии с Договором и Приложениями к нему, в том числе, в случаях, предусмотренных Договором, в безакцептном порядке (без получения дополнительного согласия Арендатора);\n'
|
||||
'5.2.13. уступить свои права по взысканию задолженностей, образовавшихся за Аренду по настоящему Договору, любым третьим лицам, в соответствии с действующим законодательством без согласия Арендатора, но с уведомлением Арендатора;\n'
|
||||
'5.2.14. осуществлять исходящие вызовы Арендатору и отправлять голосовые и иные сообщения уведомительного характера, содержащие информацию о состоянии задолженности, иную информацию, касающуюся предоставления Аренды, информацию об изменениях условий Договора (в т.ч. Тарифов), о новых услугах, новости, а также сообщения рекламного характера на номер мобильного телефона Арендатора, указанный при Регистрации (или на иные номера, сообщенные Арендатора Арендодателю на условиях настоящего Договора), а также направлять всю вышеуказанную информацию на адрес электронной почты, указанный при Регистрации (или на иной электронный адрес, сообщенный Арендатором Арендодателю на условиях настоящего Договора);\n\n'
|
||||
'5.3. Арендатор вправе:\n'
|
||||
'5.3.1. Арендатор вправе использовать Объект в сроки, в порядке и на условиях, предусмотренных настоящим Договором.\n\n'
|
||||
'5.4. Арендатор обязан:\n'
|
||||
'5.4.1. использовать сервисы иных сайтов приложения BeHappy, Сервисы Сайта и Приложения только в личных некоммерческих целях, не связанных с осуществлением предпринимательской деятельности и только в соответствии с условиями настоящего Договора;\n'
|
||||
'5.4.2. Арендатор понимает и соглашается, что для надлежащего использования Сервисов Сайта и Приложения, он сам должен обеспечить надлежащую скорость интернет-подключения устройств. Арендатор перед использованием Сервисов Сайта и Приложения должен самостоятельно убедиться в том, что его устройство обладает необходимыми для этого характеристиками. Все вопросы приобретения прав доступа в Интернет, покупки и наладки для этого соответствующих устройств и программных продуктов решаются Пользователем самостоятельно и за свой счет и не подпадают под действие Договора;\n'
|
||||
'5.4.3. до начала использования Объекта произвести его осмотр на предмет наличия повреждений;\n'
|
||||
'5.4.4. при обнаружении повреждений или иного рода ухудшений Объекта до начала его использования, сообщить об этом Арендодателю путем отправки ему фотографии при помощи Мобильного приложения. В случае если Арендатор до начала использования не уведомил Арендодателя о существующих повреждениях или иного рода ухудшениях Объекта, Арендатор признает себя виновным в причинении повреждений во время Аренды им Объекта, если такие были обнаружены другим Арендатором или Арендодателем в дальнейшем;\n'
|
||||
'5.4.5. при отсутствии повреждений на Объекта, принять Объект во временное владение и пользование в порядке, предусмотренном разделом 10 настоящего Договора;\n'
|
||||
'5.4.6. использовать Объект в строгом соответствии с его целевым назначением без коммерческого использования и без использования, подразумевающего мошеннические действия;\n'
|
||||
'5.4.7. своевременно, в соответствии с условиями настоящего Договора, вносить любые платежи, предусмотренные Договором и Приложениями к нему, обеспечивать остаток денежных средств на банковской карте в количестве, достаточном для осуществления всех платежей, предусмотренных настоящим Договором и Приложениями к нему;\n'
|
||||
'5.4.8. осуществлять управление Объектом лично. Арендатор не вправе передавать Объект в пользование третьим лицам в субаренду;\n'
|
||||
'5.4.9. Арендатор не вправе передавать Объект в пользование третьим лицам, не достигшим возраста 18 лет;\n'
|
||||
'5.4.10. обеспечивать сохранность Объекта с момента приема до момента прекращения Аренды;\n'
|
||||
'5.4.11. бережно использовать имущество Арендодателя. Принимать меры по предотвращению ущерба имуществу Арендодателя. В связи с этим, Арендатору запрещается ряд действий;\n'
|
||||
'5.4.12. Арендатору запрещается использовать Объект на неровных поверхностях, вне тротуаров и дорожек/дорог с твердым покрытием, на песке, переезжать через любые ямы, выбоины, бордюры, горки, бугры, выпуклости и иные неровности, отличающиеся от основного полотна более чем на 3 см, равно как и имеющие острые края, либо иные особенности дорожного полотна, создающие дополнительную нагрузку для колес Объекта, а также лужи, глубиной более 2 см вне зависимости от возможности внешнего определения глубины;\n'
|
||||
'5.4.13. Арендатору запрещается использовать Объект для езды по бездорожью, пересеченной местности;\n'
|
||||
'5.4.14. Арендатору запрещается выполнять акробатические трюки, совершать прыжки, наезды на препятствия, бордюры, ступени, а также использовать Объект в соревнованиях;\n'
|
||||
'5.4.15. Арендатору запрещается использовать Объект для транспортировки, буксирования грузов, передвигаться на Объекте более чем одному человеку, а также перевозить детей и животных, перемещаться на Объекте при превышении суммарного веса (с одеждой, сумкой, рюкзаком и т.д.) свыше 100 кг;\n'
|
||||
'5.4.16. Арендатору запрещается оставлять Объект вовремя и после окончания аренды не пристегнутым, при условии оснащения объекта электромеханическим замком. Арендатору запрещается парковка Объекта в горизонтальном состоянии. Арендатору запрещается эксплуатировать Объект, если блокировочный трос не находится в замке, а также Арендатору запрещается оставлять Объект после окончания аренды не пристегнутым к стойке или другому парковочному объекту, указанному на карте в зоне Парковки. Так же запрещается, парковка Объекта в труднодоступных местах, пристегивание его к неразрешенным конструкциям или иным образом блокирование Объекта;\n'
|
||||
'5.4.17. Арендатору запрещается складывать Объект (в том числе с использованием штатного механизма);\n'
|
||||
'5.4.18. Арендатору запрещается снимать наклейки, идентификационные номера, штрихкоды, равно как и наносить надписи, наклеивать наклейки, наносить пометки, повреждать лакокрасочное покрытие Объекта, пачкать его (за исключением нормального загрязнения платформы уличной обувью);\n'
|
||||
'5.4.19. Арендатору запрещается допускать полный разряд аккумулятора Объекта;\n'
|
||||
'5.4.20. Арендатору запрещается использовать технологии или предпринимать действия, которые могут нанести вред сервисам иных сайтов мобильного приложения BeHappy, Объекту, иному имуществу Арендодателя;\n'
|
||||
'5.4.21. Арендатору запрещается вносить любые изменения в Объект, изменять какие-либо характеристики, производить улучшения или ухудшения Объекта;\n'
|
||||
'5.4.22. незамедлительно известить Арендодателя через Мобильное приложение о любых повреждениях Объекта, полученных во время Аренды, отключении любой из систем, иных неисправностях, дорожно-транспортных происшествиях с участием Объекта; нарушениях функционирования Мобильного приложения. При таких обстоятельствах Арендатор должен прекратить использование Объекта, о чем требуется проинформировать Арендодателя через Мобильное приложение BeHappy;\n'
|
||||
'5.4.23. принимать надлежащие меры для защиты своей учетной записи, включая свои имя и пароль, в Мобильном приложении от несанкционированного использования другими лицами, и незамедлительно сообщить Арендодателю соответствующую информацию, в случае обнаружения такого использования;\n'
|
||||
'5.4.24. Арендатор не вправе передавать данные своей учетной записи третьим лицам;\n'
|
||||
'5.4.25. в случае возникновения у Арендодателя сомнений относительно правомочности использования зарегистрированной учетной записи, Арендодатель вправе направить запрос о предоставлении дополнительной информации о личности Арендатора, в том числе, фотографий страниц его паспорта, или иного документа, удостоверяющего личность. Стороны договорились, что при непредставлении ответа на запрос, Арендодатель вправе приостановить, а по истечении трех суток прекратить действие настоящего Договора;\n'
|
||||
'5.4.26. своевременно, в соответствии с условиями настоящего Договора вносить арендную плату, оплачивать иные платежи, предусмотренные Договором, обеспечивать на банковской карте, указанной Арендатором во исполнение условий настоящего Договора, денежные средства в количестве, достаточном для оплаты Аренды Объекта;\n'
|
||||
'5.4.27. своевременно, в соответствии с условиями настоящего Договора оплатить в полной мере аренду Объекта, даже если Объект из-за снижения заряда перешёл в экономный режим, либо отключился из-за того, что был разряжен во время предыдущего периода аренды этим Арендодателем;\n'
|
||||
'5.4.28. в случае списания денежных средств со счета Арендатора в размере, не соответствующим тарифам, немедленно уведомить Арендодателя в разделе мобильного приложения BeHappy ПОДДЕРЖКА;\n'
|
||||
'5.4.29. по завершении использования и прекращении аренды Арендатор обязан припарковать Объект в зоне начала/завершения аренды на велопарковке или иной парковке, разрешенной в Мобильном Приложении и не противоречащей действующим законам Республики Беларусь. В случае её отсутствия Объект следует припарковать на обочине дороги или на тротуаре, но таким образом, чтобы Объект не препятствовал движению пешеходов или транспортных средств и не находился на проезжей части. Арендатору запрещено допускать парковки Объекта на проезжей части дороги и на расстоянии ближе 1 метра от неё, на мостах и путепроводах, на железнодорожных переездах, в тоннелях, на эстокадах, мостах, путепроводах, вблизи опасных поворотов, на велосипедных дорожках, на пешеходных переходах (наземных, подземных и надземных) и на расстоянии ближе 5 метров от них, на тротуарах шириной менее 1,5 метров, на остановочных пунктах общественного транспорта и на расстоянии ближе 5 метров от них, ближе 15 метров от мест остановки маршрутных транспортных средств или стоянки легковых такси, на газонах и других участках с зелёными насаждениями (клумбы, цветники и иные подобные элементы благоустройства), на территориях, прилегающих к памятникам и мемориалам, ближе 10 метров от служебных дверей и рамп магазинов, через которые производятся погрузочно-разгрузочные работы, ворот, в том числе ворот гаражей, и на других участках, где может быть создано препятствие для движения пешеходов и транспорта, под навесами из железобетонных перекрытий;\n'
|
||||
'5.4.30. при завершении использования Объекта и прекращении Аренды Арендатор обязан закрепить Объект тросом и замком к конструкции велопарковки (где она предусмотрена), в случае ее отсутствия к иной замкнутой металлической конструкции, в разрешенном, обозначенном на карте в Мобильном приложении месте. Трос должен быть закреплен через замкнутую часть конструкции, замок плотно закрыт. Парковка и закрытие замка подтверждается путем отправки фотографий Объекта через мобильное приложение. В случае отсутствия в конструкции Объекта механизма фиксации (замка), Арендатор обязан припарковать объект строго в разрешённом месте в соответствии с нанесённой парковочной разметкой, а в случае её отсутствия, припарковать объект так, чтобы он не создавал помех другим участникам движения и не нарушал правила благоустройства города;\n'
|
||||
'5.4.31. объект должен быть возвращен Арендодателю в технически исправном состоянии, без каких-либо повреждений, в порядке, предусмотренном настоящим Договором;\n'
|
||||
'5.4.32. во время использования Объекта Арендатор обязан соблюдать ПДД и нести ответственность за их нарушение. В этой связи, Арендатор при использовании Объекта, в частности, обязан:\n'
|
||||
'5.4.32.1. безоговорочно уступать движение пешеходам;\n'
|
||||
'5.4.32.2. осуществлять движение по велосипедной, велопешеходной дорожкам или полосе для велосипедистов не создавая препятствий;\n'
|
||||
'5.4.32.3. в случае, если отсутствуют велосипедная и велопешеходная дорожки, полоса для велосипедистов либо отсутствует возможность двигаться по ним или по правому краю проезжей части, допускается движение по обочине не создавая препятствий;\n'
|
||||
'5.4.32.4. в случае, если отсутствуют велосипедная и велопешеходная дорожки, полоса для велосипедистов либо отсутствует возможность двигаться по ним, а также по правому краю проезжей части или обочине, допускается движение по тротуару или пешеходной дорожке не создавая препятствий;\n'
|
||||
'5.4.32.5. если движение по тротуару, пешеходной дорожке, обочине или в пределах пешеходных зон подвергает опасности или создает помехи для движения иных лиц, рекомендуется спешиться и уступить дорогу пешеходам;\n'
|
||||
'5.4.32.6. при движении в темное время суток или в условиях недостаточной видимости необходимо включить фару и фонарь самоката, а также необходимо обозначить себя световозвращающими элементами и обеспечивать видимость этих предметов другим участникам дорожного движения;\n'
|
||||
'5.4.32.7. пересекать проезжаю часть со скоростью идущего шагом пешехода по велосипедному переезду или пешеходному переходу, не создавая препятствий;\n'
|
||||
'5.4.32.8. при использовании Объекта рекомендуется надевать защитный шлем и другую защиту.\n'
|
||||
'5.4.33. Для предотвращения вероятности привлечения к ответственности за нарушение ПДД, Арендатору запрещается:\n'
|
||||
'5.4.33.1. управлять Объектом в состоянии алкогольного, наркотического, токсического или иного опьянения, а также после приема медицинских препаратов вызывающих аналогичные свойства организма, равно как и отказываться от проведения процедуры медицинского освидетельствования по запросу уполномоченного должностного лица;\n'
|
||||
'5.4.33.2. управлять Объектом, не держась за руль хотя бы одной рукой;\n'
|
||||
'5.4.33.3. пересекать проезжую часть по надземным, наземным, подземным и иным переходам, не спускаясь с Объектом;\n'
|
||||
'5.4.33.4. спускаться на Объекте в метро, катание непосредственно по зоне метро не допускается, заезжать на нем внутрь зданий, территорий ограниченного доступа и т.д.\n'
|
||||
'5.4.34. не допускать причинение вреда жизни, здоровью и имуществу третьих лиц, в том числе других участников дорожного движения. В случае причинения вреда с использованием Объекта, Арендатор привлекается к ответственности в порядке, предусмотренном действующим законодательством Республики Беларусь;\n'
|
||||
'5.4.35. своевременно, в соответствии с условиями настоящего договора, оплатить в полной мере аренду Объекта, даже если из-за снижения заряда аккумулятора во время аренды Арендатором Объекта перешел в экономный режим и/или колесо Объекта заблокировано;\n'
|
||||
'5.4.36. парковать Объект на тактильных дорожках, предназначенных для перемещения людей с инвалидностью по зрению;\n'
|
||||
'5.4.37. перевозить на Объекте других лиц.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('6. Гарантии и ответственность Арендодателя'),
|
||||
_buildParagraph(
|
||||
'6.1. Предоставляет Арендатору Сервисы иных Сайтов Приложения BeHappy, а также любую информацию, содержащуюся на Сайте и/или в Приложении «как есть» («as is») без гарантий любого рода. Это означает, среди прочего, что Арендодатель:\n'
|
||||
'6.1.1. не несет ответственности за скорость и бесперебойную работу Сайта, Приложения, их совместимость с программным обеспечением и операционными системами устройств Пользователя, а также скорость интернет-подключения устройств;\n'
|
||||
'6.1.2. не несет ответственности за отсутствие ошибок и/или вирусов при работе Сайта и Приложения, прерывание каналов связи и выход из строя серверной инфраструктуры на уровне магистральных каналов связи, центров обмена данными, вычислительных центров, а также линий связи регионального и местного значения;\n'
|
||||
'6.1.3. не несет ответственности за временную неработоспособность платежных систем, обеспечивающую прием и перевод платы Арендатором, вызванную не зависящими от Арендодателя причинами, а также обстоятельствами непреодолимой силы;\n'
|
||||
'6.1.4. не несет ответственности за нарушение условий Договора, если такое нарушение вызвано действием обстоятельств непреодолимой силы (форс-мажор), включая среди прочих: действия и решения органов государственной власти и/или местного самоуправления, пожар, наводнение, землетрясение, другие стихийные действия, отсутствие электроэнергии и/или сбои работы компьютерной сети, забастовки, гражданские волнения, беспорядки, любые иные обстоятельства, которые могут повлиять на выполнение Сервисом BeHappy условий настоящего Договора;\n'
|
||||
'6.1.5. не несет ответственности за неисполнение либо ненадлежащее исполнение своих обязательств, если это вызвано действиями/бездействием Арендатора, находящимися вне контроля Арендодателя, в том числе в результате совершения ошибочных действий/бездействия Арендатора;\n\n'
|
||||
'6.2. Сайт и Приложение могут содержать ссылки на сайты, приложения и иные ресурсы третьих лиц. Указанные сайты, приложения и иные ресурсы третьих лиц и их материалы не проверяются Арендодателем на соответствие требованиям законодательства и ответственность за их содержание Арендодатель не несет;\n\n'
|
||||
'6.3. Действия любых третьих лиц, а также действия платежных систем, операторов связи не являются действиями Арендодателя, и ответственности за них Арендодатель не несет;\n\n'
|
||||
'6.4. Арендодатель гарантирует добросовестную и законную обработку персональных данных Пользователя в соответствии с предусмотренными в настоящем Договоре целями;\n\n'
|
||||
'6.5. Арендодатель гарантирует своевременное обновление данных Арендатора в случае предоставления им обновленных данных;\n\n'
|
||||
'6.6. Арендодатель несёт ответственность только за прямой действительный ущерб, причиненный вследствие виновного невыполнения обязательств Арендодателя, в пределах суммы всех платежей за пользование имуществом (не включая возмещение расходов, штрафы и убытки), уплаченных Арендатору Арендодателем за весь срок, предшествующий предъявлению соответствующего требования, но не более 12 (двенадцати) месяцев.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('7. Гарантии и ответственность Арендатора'),
|
||||
_buildParagraph(
|
||||
'7.1. Арендатор лично (самостоятельно) отвечает за свою жизнь и здоровье во время аренды Объекта;\n\n'
|
||||
'7.2. Риск случайной гибели или случайного повреждения Объекта с момента нажатия в Мобильном приложении кнопки «Начало поездки» и до момента завершения аренды лично несет Арендатор;\n\n'
|
||||
'7.3. Арендатор несет лично (самостоятельно) полную ответственность за вред, причиненный третьим лицам, и их имуществу в результате использования Объекта;\n\n'
|
||||
'7.4. Невозврат Объекта в соответствии с правилами настоящего Договора в течение разрешенного периода пользования, будет считаться утратой Объекта до момента его обнаружения. В случае утраты Объекта Арендатор обязуется возместить ущерб в размере восстановительной стоимости Объекта в сумме 3500 (трех тысяч пятьсот) рублей Республики Беларусь (далее РБ). В случае обнаружения Объекта после разрешенного периода пользования, Арендатор обязуется оплатить стоимость пользования Объекта до момента фактического возврата Объекта Арендодателю по предусмотренным тарифам, но не более восстановительной стоимости;\n\n'
|
||||
'7.5. Удаление от разрешенной зоны катания более, чем на 800 метров во время аренды Объекта будет считаться попыткой кражи. Арендодатель будет обязан заявить в правоохранительные органы о факте кражи, а также прибегнуть к иным предусмотренным законодательством Республики Беларусь способам защиты своих прав;\n\n'
|
||||
'7.6. В случае хищения Объекта до его возврата, Арендатор незамедлительно обязуется сообщить об этом в Службу поддержки сразу же, как только установит факт хищения. При этом Арендатор должен обратиться в правоохранительные органы с заявлением о хищении имущества и получить соответствующую справку из правоохранительных органов о возбуждении уголовного дела либо об отказе в возбуждении уголовного дела. Копию справки пользователь обязан предоставить в Службу поддержки указанные в разделе мобильного приложения BeHappy ПОДДЕРЖКА.\n\n'
|
||||
'7.7. В случае обнаружения Объекта Арендодатель возвращает Арендатору списанные при утрате Объекта со счета банковской карты Арендатора денежные средства за вычетом стоимости повреждений обнаруженного Объекта, а также стоимости пользования Объекта. При этом Арендатор обязан предоставить справку из правоохранительных органов;\n\n'
|
||||
'7.8. В случае дорожно-транспортного или иного происшествия с участием Арендатора на Объекте, Арендатор обязан сообщить об этом незамедлительно в Службу поддержки, контакты указаны в мобильном приложение BeHappy в разделе ПОДДЕРЖКА. В любом случае Арендатор остается ответственным за Объектом до момента возврата Объекта, либо передачи его представителю Арендодателя;\n\n'
|
||||
'7.9. В случае причинения вреда (повреждения) Объекту, Арендатор обязан возместить Арендодателю причиненный ущерб в размере, определяемом Арендодателем исходя из прейскуранта перечня запчастей, указанного на сайте. В случае несогласия Арендатора с произведенным расчетом, он вправе обратиться к Арендодателю для разрешения споров в порядке, предусмотренном настоящим Договором;\n\n'
|
||||
'7.10. Арендатор гарантирует, что он физически способен и умеет пользоваться объектом и обладает достаточным опытом для езды в городских условиях по проезжей части, по тротуарам и велодорожкам. Арендатор подтверждает, что введенные им при регистрации Пользователя данные соответствуют действительности, а также что Арендатору принадлежит банковская карта, указанная при регистрации Арендатора и денежных средств, находящихся на счете указанной банковской карты достаточно для оплаты услуг пользования Объекта и Арендатор обязуется пополнять счет указанной карты в размере, достаточном для оплаты услуг пользования Объектом. Арендатор настоящим уполномочивает банк-эмитент, выпустивший банковскую карту, указанную при регистрации Пользователя, а также процессинговый центр банка, с которым Арендодателем заключен договор эквайринга и /или платежного агента Арендодателя на списание денежных средств в размере причиненного вреда;\n\n'
|
||||
'7.11. В случае невозврата или утраты Объекта Арендодателя в соответствии с правилами настоящего Договора, Арендатор несет ответственность за Объект в сумме 3500 (трех тысяч пятьсот) рублей Республики Беларусь;\n\n'
|
||||
'7.12. Арендатор дает свое согласие на получение от Арендодателя рекламных сообщений, содержащих рекламу товаров и услуг, реализуемых Арендодателем и/или его партнерами и/или иными третьими лицами, на адрес электронной почты и номер мобильного телефона (включая привязанные к нему аккаунты в мессенджерах WhatsApp, Viber, Telegram и пр.), указанные Пользователем в процессе Регистрации, а также иные адреса электронной почты и номера мобильных телефонов (включая привязанные к ним аккаунты в мессенджерах WhatsApp, Viber, Telegram и пр.), сообщенные Пользователем Арендодателю в соответствии с настоящим Договором. В случае, если Арендатор обращается к Арендодателю с требованием прекратить распространение в его адрес рекламных сообщений, Арендодатель обязуется немедленно прекратить такое распространение в отношении Арендатора, обратившегося с соответствующим требованием;\n\n'
|
||||
'7.13. В случае нарушения Арендатором условий Договора, Арендатор блокируется.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('8. Размер, порядок и форма оплаты'),
|
||||
_buildParagraph(
|
||||
'8.1. Осуществление арендных платежей, штрафов и иных платежей, предусмотренных настоящим Договором, производится посредством безакцептного списания денежных средств с банковской карты Арендатора в соответствие с тарифами Арендодателя;\n\n'
|
||||
'8.2. При акцепте настоящей Оферты Арендатор выражает свое безоговорочное согласие на отправление Арендодателем поручений, запросов, требований банку Арендатора, а также на безакцептное списание денежных средств, составляющих арендные платежи, штрафы и иные платежи, предусмотренные настоящим Договором;\n\n'
|
||||
'8.3. В момент регистрации у Арендатора в целях подтверждения верности введения данных банковской карты списывается сумма в размере до 1 (одного) рубля. Поступление вышеуказанных денежных средств на счет Арендодателя является одним из условий акцепта настоящего Договора. Данная денежная сумма не засчитывается в качестве арендного платежа и возвращается в течение суток (обычно сразу, но зависит от оператора) на банковскую карту Арендатора;\n\n'
|
||||
'8.4. За пользование Объектом Арендатор уплачивает Арендодателю арендную плату согласно тарифов, обозначенных в Мобильном приложении;\n\n'
|
||||
'8.5. В стоимость аренды Объекта, включены все налоги, в соответствии с законодательством страны, на чей территории арендуется Объект;\n\n'
|
||||
'8.6. Арендодатель вправе производить списание с банковской карты Арендатора причитающихся ему платежей в любой момент до наступления момента завершения аренды (оплата аренды производится согласно условиям выбранного тарифа аренды Объекта и могут осуществляться как разовым списанием, так и списанием несколькими транзакциями, согласно условий тарифа);\n\n'
|
||||
'8.7. В целях гарантии оплаты поездки Арендодатель имеет право заблокировать на карте Арендатора в момент начала аренды сумму в размере до 20 (двадцати) рублей. Данная денежная сумма может быть зачтена в качестве арендного платежа, если стоимость аренды более заблокированной суммы. По завершении аренды, если стоимость аренды менее заблокированной суммы, Арендодатель снимает блокировку и денежные средства остаются на карте Арендатора, а Арендодатель производит списание стоимости поездки. Поступление вышеуказанных денежных средств на счет Арендодателя является одним из условий акцепта настоящего Договора;\n\n'
|
||||
'8.8. Списание денежных средств за пользование Объектом производится сразу после прекращения аренды. В случае, когда длительность аренды превышает 30 минут, то возможны промежуточные списания денежных средств. Также в начале, в зависимости от выбранного тарифного плана, аренды возможно резервирование денежный средств на банковской карте Арендатора.\n\n'
|
||||
'8.9. В случае недостатка денежных средств или невозможности их списания со счета Арендатора, а также в случае достижения задолженности, Арендодатель вправе в принудительном порядке завершить аренду Объекта и установить блокировку учетной записи, не допускающей возобновление аренды, до момента полной оплаты Арендатором образовавшейся задолженности по Договору. Арендатор обязуется погасить возникшую задолженность в течение 48 часов с момента возникновения задолженности за аренду и отображавшиеся в личном кабинете Арендатора,обеспечив достаточный для проведения списания остаток средств на банковской карте, при этом до момента погашения возникшей задолженности Арендодатель вправе присвоить Пользователю статус должника и заблокировать ему доступ к Сервису BeHappy полностью или в части;\n\n'
|
||||
'8.10. Списание штрафов и иных платежей, предусмотренных Договором, за исключением арендных платежей, производится в течение 30 (тридцати) календарных дней с даты направления Арендатору требования об оплате с использованием Мобильного приложения. В случае недостаточности денежных средств на счете Арендатора, любой возникший в рамках данного Договора платеж признается задолженностью;\n\n'
|
||||
'8.11. В случае неисполнения Пользователем обязательств по оплате, предусмотренных настоящим Договором (в том числе, настоящим разделом Договора), Арендодатель вправе направить соответствующие сведения (включая персональные данные Пользователя) в уполномоченные органы для привлечения Пользователя к административной ответственности, а также в организации, осуществляющие взыскание денежных средств;\n\n'
|
||||
'8.12. В случае ошибочного списания денежных средств с банковской карты Пользователя Арендатор вправе обратиться к Арендодателю по реквизитам указанные в разделе мобильного приложения BeHappy ПОДДЕРЖКА. Арендодатель вправе потребовать от Пользователя предоставить копию паспорта и документов, подтверждающих ошибочное списание (выписки со счета и пр.). Арендодатель не позднее 10 (десяти) рабочих дней рассматривает данное обращение и принимает по нему меры, за исключением случаев, когда денежные средства Пользователя были списаны в соответствии с Договором и/или Приложениями. Возврат денежных средств осуществляется на банковский счет Пользователя, с которого было произведено списание, в течение 14 (четырнадцати) рабочих дней, если более длительный срок не установлен внутренними банковскими процедурами. Порядок возврата денежных средств регулируется правилами международных платежных систем. Возврат наличными денежными средствами не допускается.\n\n'
|
||||
'8.13. При отсутствии вины Арендатора в нарушении условий настоящего Договора Арендодатель в течение 7 календарных дней с даты направления Арендатором Претензии с использованием Мобильного приложения производит возврат ошибочно списанных денежных средств;\n\n'
|
||||
'8.14. В случае нарушения Арендатором положений, предусмотренных п.п. 5.4.12, 5.4.13, 5.4.17, 5.4.29 настоящего Договора, Арендатор уплачивает штраф в размере до 400 (четырехсот) рублей Республики Беларусь;\n\n'
|
||||
'8.15. В случае нарушения Арендатором положений, предусмотренных п.п. 5.4.4, 5.4.23, 5.4.24, настоящего Договора, Арендатор оплачивает штраф в размере до 300 (трехсот) рублей Республики Беларусь;\n\n'
|
||||
'8.16. В случае нарушения Арендатором положений, предусмотренных п.п.5.4.32 (5.4.32.1 - 5.4.32.8), 5.4.33 (5.4.33.1 – 5.4.33.4), 5.4.37 настоящего Договора, Арендатор оплачивает штраф в размере до 500 (пятьсот) рублей Республики Беларусь;\n\n'
|
||||
'8.17. В случае нарушения Арендатором положений, предусмотренных п.п.5.4.6, 5.4.8, 5.4.9 настоящего Договора, Арендатор оплачивает штраф в размере до 300 (трехсот) рублей;\n\n'
|
||||
'8.18. В случае нарушения Арендатором положений, предусмотренных п.п. 5.4.31 настоящего Договора, Арендатор оплачивает штраф в размере до 2 000 (двух тысяч) рублей;\n\n'
|
||||
'8.19. За нарушение ПДД на Арендатора возлагается ответственность, предусмотренная Кодексом об административных правонарушениях РБ. Если оплата штрафа за Арендатора была произведена Арендодателем, Арендатор обязан возместить Арендодателю понесенные им в этой связи расходы;\n\n'
|
||||
'8.20. В случае причинения Арендатором во время использования Объектом вреда жизни, здоровью или имуществу третьих лиц, в том числе других участников дорожного движения, Арендатор обязуется в полном объеме возместить ущерб, причиненный его действиями как третьим лицам, так и Арендодателю;\n\n'
|
||||
'8.21. В случае нарушения Арендатором положений, предусмотренных п.п. 5.4.30, 5.4.18, 5.4.19 настоящего Договора, Арендатор уплачивает штраф в размере до 100 (ста) рублей;\n\n'
|
||||
'8.22. В случае утраты (кражи) объекта Арендатором применяется пункт 7.4 настоящего договора;\n\n'
|
||||
'8.23. В случае повторного нарушения Арендатором положений, предусмотренных п.п. 5.4.29, которые не привели к утере или порче Объекта, Арендатор уплачивает штраф в размере до 300 (трехсот) рублей;\n\n'
|
||||
'8.24. В случае нарушения Арендатором положений, предусмотренных п.п.8.9 настоящего Договора, Арендатор уплачивает арендодателю пеню в размере 2% за каждый день просрочки оплаты задолженности;\n\n'
|
||||
'8.25. В случае нарушения Арендатором положений, предусмотренных п.п.5.4.14 настоящего Договора, Арендатор оплачивает штраф в размере до 30 (тридцати) рублей;\n\n'
|
||||
'8.26. В случае нарушения Арендатором положений, предусмотренных п.п.5.4.15 настоящего Договора, Арендатор оплачивает штраф в размере до 100 (ста) рублей;',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('9. Срок Аренды'),
|
||||
_buildParagraph(
|
||||
'9.1. Срок Аренды Объекта исчисляется минутами и часами и/или километрами и метрами. Это зависит от выбранного тарифа;\n\n'
|
||||
'9.2. Период Аренды Объекта исчисляется с момента нажатия в Мобильном приложении кнопки «Начать поездку» до фактического прекращения Аренды Объекта Арендатором путем нажатия соответствующей кнопки в Мобильном приложении, и, при запросе Арендодателя, направления в качестве подтверждения фотографии общего вида, припаркованного Объекта через Мобильное приложение.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('10. Прием-передача от Арендодателя к Арендатору осуществляется следующим образом:'),
|
||||
_buildParagraph(
|
||||
'10.1. Арендатор при помощи Мобильного приложения выбирает на карте конкретный Объект. Арендатор при помощи камеры телефона сканирует QR штрихкод, размещенный на Объекте либо вводит номер самоката. При этом в Мобильном приложении отражаются следующие сведения о выбранном Объекте: местоположение, тариф, уровень заряда. Во время выбора Объекта Арендатор должен проверить достаточность денежных средств на банковской карте, привязанной к его аккаунту в Мобильном приложении;\n\n'
|
||||
'10.2. Арендатор при помощи Мобильного приложения может забронировать конкретный Объект по тарифу и на срок, обозначенные в мобильном приложении. Бронирование может быть отменено Арендатором через мобильное приложение;\n\n'
|
||||
'10.3. Арендодатель вправе отказать Арендатору в бронировании Объекта в случае отмены хотя бы одного бронирования Объекта;\n\n'
|
||||
'10.4. Допускается бронирование нескольких Объектов одним Арендатором;\n\n'
|
||||
'10.5. Допускается аренда более чем одного Объекта одним Арендатором с одного аккаунта — Групповая поездка. В этом случае Арендатор может передать Объект для управления только третьим лица достигшим возраста 18 лет. В случае групповой поездки ответственность за всё взятое в аренду имущество Арендодателя и за соблюдение условий аренду Объекта в рамках этого Договора несёт Арендатор, а не третье лицо, которому был передан Объект. Начиная аренду сразу нескольких Объектов Арендатор обязуется оплатить все начатые аренды от его имени и в случае нарушений условий договора, оплатить наложенные штрафные санкций на все поездки, начатые от его аккаунта;\n'
|
||||
'В случае начала групповой поездки — каждая аренда начинается Арендатором отдельно, как и завершается. Максимальное число арендованных Объектов для Групповой поездки в одном аккаунте — 4 (четыре);\n\n'
|
||||
'10.6. В момент нажатия кнопки «Начало поездки» в Мобильном приложении Арендатор подтверждает фактический прием выбранного Объекта и применяемого к его аренде тарифа и с этого момента начинает исчисляться период Аренды конкретного Объекта.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('11. Прием-передача Объекта от Арендатора к Арендодателю осуществляется следующим образом:'),
|
||||
_buildParagraph(
|
||||
'11.1. После выбора Объекта Арендатор подписывает акт приема-передачи Объекта в аренду, где указывается наименование объекта, технические характеристики Объекта. Реквизиты Арендодателя: наименование ,регистрационный номер регистрации юридического лица, юридический адрес, банковские реквизиты, контакты.\n'
|
||||
'Арендатор паркует Объект в разрешенном месте в Зоне начала/завершения аренды, нажимает в Мобильном приложении кнопку «Завершить аренду», и по запросу Арендодателя отправляет через Мобильное приложение фотографию общего вида, припаркованного Объекта, а также пристегивает замком к парковке (при наличии таких мест и оборудовании Объектов замками);\n\n'
|
||||
'11.2. После получения Арендодателем фотографий общего вида, припаркованного Объекта, заканчивается срок его Аренды. При соблюдении Арендатором предусмотренного Договором порядка приема-передачи Объекта, ему в Мобильном приложении приходит сообщение от Арендодателя с указанием суммы арендной платы, подлежащей к оплате.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('12. Срок действия Договора'),
|
||||
_buildParagraph(
|
||||
'12.1. Договор вступает в силу с момента акцепта Оферты в порядке, предусмотренным настоящим Договором;\n\n'
|
||||
'12.2. Договор заключен сроком на 12 месяцев с даты акцепта. Если ни одна из Сторон за 10 календарных дней до окончания срока действия настоящего Договора письменно не сообщит другой о его прекращении, Договор считается продленным на следующие 12 месяцев на прежних условиях, при этом количество продлений не ограничено.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('13. Основания и порядок расторжения Договора'),
|
||||
_buildParagraph(
|
||||
'13.1. Настоящий Договор может быть расторгнут до истечения срока, предусмотренного настоящим Договором, по соглашению Сторон или по инициативе одной из Сторон выраженной в понятном обеим Сторонам виде;\n\n'
|
||||
'13.2. Настоящий Договор может быть расторгнут досрочно в одностороннем внесудебном порядке по инициативе Арендодателя по следующим основаниям:\n'
|
||||
'13.2.1. При неоднократном (более двух раз) нарушении Арендатором ПДД РБ при использовании Объекта;\n'
|
||||
'13.2.2. При неоднократном (более двух раз) нарушении Арендатором правил парковки Объекта;\n'
|
||||
'13.2.3. При установлении факта причинения Арендатором ущерба Объекта;\n'
|
||||
'13.2.4. При установлении факта применения Арендатором технических средств, блокирующих GPS-сигнал, а также иных способов отключения защитных систем или систем контроля, установленных на Объекта;\n\n'
|
||||
'13.3. При досрочном расторжении Договора по инициативе Арендодателя Договор считается расторгнутым с момента уведомления Арендатора через Мобильное приложение, сообщением на телефонный номер Арендатора или электронную почту, указанную при регистрации;\n\n'
|
||||
'13.4. Настоящий Договор может быть расторгнут досрочно в одностороннем внесудебном порядке по инициативе Арендатора, в случае направления им запроса на удаление аккаунта, при условии отсутствия задолженности по арендным платежам, штрафам и иным платежам, предусмотренным настоящим Договором;\n\n'
|
||||
'13.5. Запрос на удаление аккаунта направляется Арендатором Арендодателю на реквизиты указанные в разделе мобильного приложения BeHappy ПОДДЕРЖКА.\n\n'
|
||||
'13.6. В случае расторжения Договора по инициативе Арендатора, при отсутствии задолженности, Договор считается расторгнутым по истечении 10 календарных дней с даты направления Арендатором запроса на удаление аккаунта. При наличии у Арендатора задолженности, Договор считается расторгнутым по истечении 10 календарных дней с даты полного погашения Арендатором задолженности по арендным платежам, штрафам и иным платежам, предусмотренным настоящим Договором. В день расторжения Договора Арендодатель производит удаление аккаунта Арендатора;\n\n'
|
||||
'13.7. По всем другим основаниям, которые не перечислены в п.13.2 настоящего Договора, при отсутствии согласия Сторон, Договор расторгается в судебном порядке;\n\n'
|
||||
'13.8. Окончание срока действия Договора не освобождает Арендатора от обязанности оплатить арендные платежи, штрафы и иные платежи по Договору, а также не освобождает от ответственности за нарушение условий Договора.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('14. Заключительные положения'),
|
||||
_buildParagraph(
|
||||
'14.1. Любая переписка, телефонные и иные переговоры, имевшие место до заключения Договора, теряют юридическую силу с момента заключения Договора;\n\n'
|
||||
'14.2. При нахождении Арендатора за пределами территории Республики Беларусь или при указании Арендатора номера мобильного телефона зарубежного оператора мобильной связи при Регистрации, а также для целей получения сообщений/уведомлений, предусмотренных настоящим Договором, Арендатор принимает на себя все риски несвоевременного получения или неполучения Арендатор сообщений от Арендодателя;\n\n'
|
||||
'14.3. Сообщения, направленные на электронную почту, считаются полученными адресатом в момент их отправки;\n\n'
|
||||
'14.4. Арендатор обязуется уведомлять Арендодателя об изменении места жительства (регистрации), места пребывания, номера мобильного телефона, адреса электронной почты, иных данных, предоставленных Пользователем Арендодателю, о перемене имени, фамилии, изменении реквизитов документа, удостоверяющего личность, изменении реквизитов или лишении водительского удостоверения, изменении данных документа, подтверждающего право иностранного гражданина/лица без гражданства на пребывание (проживание) на территории Республики Беларусь, изменении гражданства, иных персональных данных, а также об утрате вышеуказанных данных, обратившись в Службу поддержки в раздел поддержка мобильного приложения BeHappy ПОДДЕРЖКА. Арендодатель не несет ответственность за какие-либо последствия, связанные с изменением указанных в настоящем пункте Договора данных Арендатора, если Арендатор не оповестил об указанных в настоящем пункте Договора обстоятельствах Арендодателя, и/или предоставил Арендодателю неверные данные;\n\n'
|
||||
'14.5. Настоящий Договор составлен на русском языке.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('15. Разрешение споров и подсудность'),
|
||||
_buildParagraph(
|
||||
'15.1. Все споры и разногласия по настоящему Договору разрешаются путем переговоров;\n\n'
|
||||
'15.2. В случае, если Арендодатель и Арендатор не пришли к решению посредством переговоров в течение 10 (десяти) рабочих дней, споры и разногласия передаются на разрешение суда по месту нахождения Арендодателя.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('16. Адрес и иные реквизиты Арендодателя:'),
|
||||
_buildParagraph(
|
||||
'ООО “БИХЕППИБЕЛ” (Оказывает услуги в городах Молодечно, Витебск, Полоцк, Новополоцк, Могилев, Борисов является организатором эквайринговой площадки)\n'
|
||||
'УНП 392026683\n'
|
||||
'Свидетельство о государственной регистрации № 0239895 от 22.01.2026 выдано администрацией Октябрьского района г.Витебска\n'
|
||||
'ОКПО 511318892000\n'
|
||||
'Юридический адрес: Республика Беларусь, 210017, г. Витебск, ул. Гагарина 105 кБ, офис 11А.\n'
|
||||
'Банковские реквизиты в белорусских рублях:\n'
|
||||
'Расчетный счет: BY86ALFA30122G27690010270000\n'
|
||||
'ЗАО «Альфа-Банк», 220013, Республика Беларусь,220013, г. Минск, ул. Сурганова, 43-47, 220013\n'
|
||||
'БИК ALFABY2X\n'
|
||||
'Почтовый адрес: behappybelarus@gmail.com',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class LicenseAgreementScreen extends StatelessWidget {
|
||||
const LicenseAgreementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ' '),
|
||||
),
|
||||
|
||||
// 🔹 ПРОКРУЧИВАЕМЫЙ ТЕКСТ
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Лицензионное соглашение на использование программы «Be Happy» для мобильных устройств',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Перед использованием программы, пожалуйста, ознакомьтесь с условиями нижеследующего лицензионного соглашения.\n\n'
|
||||
'Любое использование Вами программы означает полное и безоговорочное принятие Вами условий настоящего лицензионного соглашения.\n\n'
|
||||
'Если Вы не принимаете условия лицензионного соглашения в полном объёме, Вы не имеете права использовать программу в каких-либо целях.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('1. Общие положения'),
|
||||
_buildParagraph(
|
||||
'1.1. Настоящее Лицензионное соглашение («Лицензия») устанавливает условия использования программы «Be Happy» для мобильных устройств («Программа») и заключено между любым лицом, использующим Программу («Пользователь»), и ООО “БИХЕППИБЕЛ”, Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А, являющимся правообладателем исключительного права на Программу («Лицензиар»).\n\n'
|
||||
'1.2. Копируя Программу, устанавливая её на свое мобильное устройство или используя Программу любым образом, Пользователь выражает свое полное и безоговорочное согласие со всеми условиями Лицензии.\n\n'
|
||||
'1.3. Использование Программы разрешается только на условиях настоящей Лицензии. Если Пользователь не принимает условия Лицензии в полном объёме, Пользователь не имеет права использовать Программу в каких-либо целях. Использование Программы с нарушением (невыполнением) какого-либо из условий Лицензии запрещено.\n\n'
|
||||
'1.4. Использование Программы Пользователем на условиях настоящей Лицензии в личных некоммерческих целях осуществляется безвозмездно. Использование Программы на условиях и способами, не предусмотренными настоящей Лицензией, возможно только на основании отдельного соглашения с Лицензиаром.\n\n'
|
||||
'• Оферта на подключение к программе «Be Happy», размещенная в сети Интернет по адресу: https://behappybel.by,\n'
|
||||
'• Условия подключения к сервису «Be Happy», размещенные в сети Интернет по адресу: https://behappybel.by,\n'
|
||||
'• «Политика конфиденциальности», размещенная в сети Интернет по адресу: https://behappybel.by.\n\n'
|
||||
'Указанные документы (в том числе любые из их частей) могут быть изменены Правообладателем в одностороннем порядке без какого-либо специального уведомления, новая редакция документов вступает в силу с момента их опубликования, если иное не предусмотрено новыми редакциями документов.\n\n'
|
||||
'1.6. Все или некоторые функции Программы могут быть недоступны или ограничены в зависимости от наличия или отсутствия акцепта Пользователем документов, указанных в пункте 1.5 настоящей Лицензии.\n\n'
|
||||
'1.7. К настоящей Лицензии и всем отношениям, связанным с использованием Программы, подлежит применению право Республики Беларусь и любые претензии или иски, вытекающие из настоящей Лицензии или использования Программы, должны быть поданы и рассмотрены в суде по месту нахождения Правообладателя.\n\n'
|
||||
'1.8. Лицензиар может предоставить Пользователю перевод настоящей Лицензии с русского на другие языки, однако в случае противоречия между условиями Лицензии на русском языке и ее переводом, юридическую силу имеет исключительно русскоязычная версия Лицензии. Русскоязычная версия Лицензии размещена по адресу: https://behappybel.by.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('2. Права на Программу'),
|
||||
_buildParagraph(
|
||||
'2.1. Исключительное право на Программу принадлежит Лицензиар.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('3. Лицензия'),
|
||||
_buildParagraph(
|
||||
'3.1. Лицензиар безвозмездно, на условиях простой (неисключительной) лицензии, предоставляет Пользователю непередаваемое право использования Программы на территории всех стран мира следующими способами:\n\n'
|
||||
'3.1.1. Воспроизводить Программу путем её копирования и установки на мобильное (-ые) устройство (-ва) Пользователя. При установке на мобильное устройство каждой копии Программы присваивается индивидуальный номер, который автоматически сообщается Правообладателю;\n\n'
|
||||
'3.1.2. Применять Программу по прямому функциональному назначению только после принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов и ввода в интерфейсе Программы специального кода (пароля), предоставленного Пользователю в подтверждение принятия указанных документов.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('4. Ограничения'),
|
||||
_buildParagraph(
|
||||
'4.1. За исключением использования в объемах и способами, прямо предусмотренными настоящей Лицензией или законодательством Республики Беларусь, Пользователь не имеет права изменять, декомпилировать, дизассемблировать, дешифровать и производить иные действия с объектным кодом Программы, имеющие целью получение информации о реализации алгоритмов, используемых в Программе, создавать производные произведения с использованием Программы, а также осуществлять (разрешать осуществлять) иное использование Программы, без письменного согласия Лицензиара.\n\n'
|
||||
'4.2. Пользователь не имеет право воспроизводить и распространять Программу в коммерческих целях (в том числе за плату), в том числе в составе сборников программных продуктов, без письменного согласия Лицензиара.\n\n'
|
||||
'4.3. Пользователь не имеет права распространять Программу в виде, отличном от того, в котором он ее получил, без письменного согласия Правообладателя.\n\n'
|
||||
'4.4. Программа должна использоваться (в том числе распространяться) под наименованием: «Be Happy». Пользователь не вправе изменять наименование Программы, изменять и/или удалять знак охраны авторского права (copyright notice) или иные указания на Лицензиара.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('5. Условия использования отдельных функций Программы'),
|
||||
_buildParagraph(
|
||||
'5.1. Выполнение некоторых функций Программы возможно только при наличии доступа к сети Интернет. Пользователь самостоятельно получает и оплачивает такой доступ на условиях и по тарифам своего оператора связи или провайдера доступа к сети Интернет.\n\n'
|
||||
'5.2. Пользователь считается правомерно владеющим экземпляром Программы, при условии ввода в интерфейсе Программы специального кода (пароля), который выдается Лицензиар Пользователю в случае акцепта Пользователем Оферты на подключение к программе «Be Happy» размещенной в сети Интернет по адресу: https://behappybel.by и при условии соответствия Пользователя требованиям, указанным в Условиях подключения к сервису «Be Happy», размещенных в сети Интернет по адресу: https://behappybel.by В этом случае Пользователь вправе использовать функциональные возможности Программы.\n\n'
|
||||
'5.3. Специальных код (пароль), необходимый для подтверждения правомерности владения (использования) Пользователем экземпляром Программы, не считается действительным, если он был получен без принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('6. Ответственность по Лицензии'),
|
||||
_buildParagraph(
|
||||
'6.1. Программа (включая её части и содержание) предоставляется на условиях «как есть» (as is). Лицензиар не предоставляет никаких гарантий в отношении безошибочной и бесперебойной работы Программы или отдельных её компонентов и/или функций, соответствия Программы конкретным целям и ожиданиям Пользователя, не гарантирует достоверность, точность, полноту и своевременность Данных, а также не предоставляет никаких иных гарантий, прямо не указанных в настоящей Лицензии.\n\n'
|
||||
'6.2. Лицензиар не несет ответственности за какие-либо прямые или косвенные последствия какого-либо использования или невозможности использования Программы (включая ей части и содержание) и/или ущерб, причиненный Пользователю и/или третьим сторонам в результате какого-либо использования, неиспользования или невозможности использования Программы (включая ей части и содержание) или отдельных её компонентов и/или функций, в том числе из-за возможных ошибок или сбоев в работе Программы, за исключением случаев, прямо предусмотренных законодательством.\n\n'
|
||||
'6.3. Пользователь настоящим уведомлен и соглашается, что при использовании Программы Лицензиар в автоматическом передается следующая информация: тип операционной системы мобильного устройства Пользователя, версия и идентификатор Программы, статистика использования функций Программы, а также иная техническая информация.\n\n'
|
||||
'6.4. Программа может содержать ссылки на сайты и приложения третьих лиц. Правообладатель не контролирует и не несет ответственности за содержание и порядок использования таких сайтов и приложений. Условия использования таких сайтов и приложений определены в их соглашениях и политиках конфиденциальности.\n\n'
|
||||
'6.5. Все вопросы и претензии, связанные с использованием/невозможностью использования Программы, а также возможным нарушением Программой законодательства и/или прав третьих лиц, должны направляться через форму обратной связи по адресу:',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('7. Обновления/новые версии Программы'),
|
||||
_buildParagraph(
|
||||
'7.1. Действие настоящей Лицензии распространяется на все последующие обновления/новые версии Программы. Соглашаясь с установкой обновления/новой версии Программы, Пользователь принимает условия настоящей Лицензии для соответствующих обновлений/новых версий Программы, если обновление/установка новой версии Программы не сопровождается иным Лицензионным соглашением.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('8. Изменения условий настоящей Лицензии'),
|
||||
_buildParagraph(
|
||||
'8.1. Настоящее лицензионное соглашение может изменяться Лицензиром в одностороннем порядке. Уведомление Пользователя о внесенных изменениях в условия настоящей Лицензии публикуется на странице: https://behappybel.by. Указанные изменения в условиях лицензионного соглашения вступают в силу с даты их публикации, если иное не оговорено в соответствующей публикации.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('Лицензиар'),
|
||||
_buildParagraph(
|
||||
'ООО “БИХЕППИБЕЛ”\n'
|
||||
'УНП 392050943\n'
|
||||
'ОКПО 511318892000\n'
|
||||
'Свидетельство о государственной регистрации № 392026683 от 22.01.2026 выдано Инспекцией Министерства по налогам и сборам Республики Беларусь по Октябрьскому району г.Витебска\n\n'
|
||||
'Юридический адрес: Республика Беларусь, 210017, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class LicenseAgreementScreen extends StatelessWidget {
|
||||
const LicenseAgreementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ' '),
|
||||
),
|
||||
|
||||
// 🔹 ПРОКРУЧИВАЕМЫЙ ТЕКСТ
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Лицензионное соглашение на использование программы «Be Happy» для мобильных устройств',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Перед использованием программы, пожалуйста, ознакомьтесь с условиями нижеследующего лицензионного соглашения.\n\n'
|
||||
'Любое использование Вами программы означает полное и безоговорочное принятие Вами условий настоящего лицензионного соглашения.\n\n'
|
||||
'Если Вы не принимаете условия лицензионного соглашения в полном объёме, Вы не имеете права использовать программу в каких-либо целях.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('1. Общие положения'),
|
||||
_buildParagraph(
|
||||
'1.1. Настоящее Лицензионное соглашение («Лицензия») устанавливает условия использования программы «Be Happy» для мобильных устройств («Программа») и заключено между любым лицом, использующим Программу («Пользователь»), и ООО “БИХЕППИБЕЛ”, Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А, являющимся правообладателем исключительного права на Программу («Лицензиар»).\n\n'
|
||||
'1.2. Копируя Программу, устанавливая её на свое мобильное устройство или используя Программу любым образом, Пользователь выражает свое полное и безоговорочное согласие со всеми условиями Лицензии.\n\n'
|
||||
'1.3. Использование Программы разрешается только на условиях настоящей Лицензии. Если Пользователь не принимает условия Лицензии в полном объёме, Пользователь не имеет права использовать Программу в каких-либо целях. Использование Программы с нарушением (невыполнением) какого-либо из условий Лицензии запрещено.\n\n'
|
||||
'1.4. Использование Программы Пользователем на условиях настоящей Лицензии в личных некоммерческих целях осуществляется безвозмездно. Использование Программы на условиях и способами, не предусмотренными настоящей Лицензией, возможно только на основании отдельного соглашения с Лицензиаром.\n\n'
|
||||
'• Оферта на подключение к программе «Be Happy», размещенная в сети Интернет по адресу: https://behappybel.by,\n'
|
||||
'• Условия подключения к сервису «Be Happy», размещенные в сети Интернет по адресу: https://behappybel.by,\n'
|
||||
'• «Политика конфиденциальности», размещенная в сети Интернет по адресу: https://behappybel.by.\n\n'
|
||||
'Указанные документы (в том числе любые из их частей) могут быть изменены Правообладателем в одностороннем порядке без какого-либо специального уведомления, новая редакция документов вступает в силу с момента их опубликования, если иное не предусмотрено новыми редакциями документов.\n\n'
|
||||
'1.6. Все или некоторые функции Программы могут быть недоступны или ограничены в зависимости от наличия или отсутствия акцепта Пользователем документов, указанных в пункте 1.5 настоящей Лицензии.\n\n'
|
||||
'1.7. К настоящей Лицензии и всем отношениям, связанным с использованием Программы, подлежит применению право Республики Беларусь и любые претензии или иски, вытекающие из настоящей Лицензии или использования Программы, должны быть поданы и рассмотрены в суде по месту нахождения Правообладателя.\n\n'
|
||||
'1.8. Лицензиар может предоставить Пользователю перевод настоящей Лицензии с русского на другие языки, однако в случае противоречия между условиями Лицензии на русском языке и ее переводом, юридическую силу имеет исключительно русскоязычная версия Лицензии. Русскоязычная версия Лицензии размещена по адресу: https://behappybel.by.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('2. Права на Программу'),
|
||||
_buildParagraph(
|
||||
'2.1. Исключительное право на Программу принадлежит Лицензиар.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('3. Лицензия'),
|
||||
_buildParagraph(
|
||||
'3.1. Лицензиар безвозмездно, на условиях простой (неисключительной) лицензии, предоставляет Пользователю непередаваемое право использования Программы на территории всех стран мира следующими способами:\n\n'
|
||||
'3.1.1. Воспроизводить Программу путем её копирования и установки на мобильное (-ые) устройство (-ва) Пользователя. При установке на мобильное устройство каждой копии Программы присваивается индивидуальный номер, который автоматически сообщается Правообладателю;\n\n'
|
||||
'3.1.2. Применять Программу по прямому функциональному назначению только после принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов и ввода в интерфейсе Программы специального кода (пароля), предоставленного Пользователю в подтверждение принятия указанных документов.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('4. Ограничения'),
|
||||
_buildParagraph(
|
||||
'4.1. За исключением использования в объемах и способами, прямо предусмотренными настоящей Лицензией или законодательством Республики Беларусь, Пользователь не имеет права изменять, декомпилировать, дизассемблировать, дешифровать и производить иные действия с объектным кодом Программы, имеющие целью получение информации о реализации алгоритмов, используемых в Программе, создавать производные произведения с использованием Программы, а также осуществлять (разрешать осуществлять) иное использование Программы, без письменного согласия Лицензиара.\n\n'
|
||||
'4.2. Пользователь не имеет право воспроизводить и распространять Программу в коммерческих целях (в том числе за плату), в том числе в составе сборников программных продуктов, без письменного согласия Лицензиара.\n\n'
|
||||
'4.3. Пользователь не имеет права распространять Программу в виде, отличном от того, в котором он ее получил, без письменного согласия Правообладателя.\n\n'
|
||||
'4.4. Программа должна использоваться (в том числе распространяться) под наименованием: «Be Happy». Пользователь не вправе изменять наименование Программы, изменять и/или удалять знак охраны авторского права (copyright notice) или иные указания на Лицензиара.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('5. Условия использования отдельных функций Программы'),
|
||||
_buildParagraph(
|
||||
'5.1. Выполнение некоторых функций Программы возможно только при наличии доступа к сети Интернет. Пользователь самостоятельно получает и оплачивает такой доступ на условиях и по тарифам своего оператора связи или провайдера доступа к сети Интернет.\n\n'
|
||||
'5.2. Пользователь считается правомерно владеющим экземпляром Программы, при условии ввода в интерфейсе Программы специального кода (пароля), который выдается Лицензиар Пользователю в случае акцепта Пользователем Оферты на подключение к программе «Be Happy» размещенной в сети Интернет по адресу: https://behappybel.by и при условии соответствия Пользователя требованиям, указанным в Условиях подключения к сервису «Be Happy», размещенных в сети Интернет по адресу: https://behappybel.by В этом случае Пользователь вправе использовать функциональные возможности Программы.\n\n'
|
||||
'5.3. Специальных код (пароль), необходимый для подтверждения правомерности владения (использования) Пользователем экземпляром Программы, не считается действительным, если он был получен без принятия Пользователем указанных в пункте 1.5 настоящей Лицензии документов.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('6. Ответственность по Лицензии'),
|
||||
_buildParagraph(
|
||||
'6.1. Программа (включая её части и содержание) предоставляется на условиях «как есть» (as is). Лицензиар не предоставляет никаких гарантий в отношении безошибочной и бесперебойной работы Программы или отдельных её компонентов и/или функций, соответствия Программы конкретным целям и ожиданиям Пользователя, не гарантирует достоверность, точность, полноту и своевременность Данных, а также не предоставляет никаких иных гарантий, прямо не указанных в настоящей Лицензии.\n\n'
|
||||
'6.2. Лицензиар не несет ответственности за какие-либо прямые или косвенные последствия какого-либо использования или невозможности использования Программы (включая ей части и содержание) и/или ущерб, причиненный Пользователю и/или третьим сторонам в результате какого-либо использования, неиспользования или невозможности использования Программы (включая ей части и содержание) или отдельных её компонентов и/или функций, в том числе из-за возможных ошибок или сбоев в работе Программы, за исключением случаев, прямо предусмотренных законодательством.\n\n'
|
||||
'6.3. Пользователь настоящим уведомлен и соглашается, что при использовании Программы Лицензиар в автоматическом передается следующая информация: тип операционной системы мобильного устройства Пользователя, версия и идентификатор Программы, статистика использования функций Программы, а также иная техническая информация.\n\n'
|
||||
'6.4. Программа может содержать ссылки на сайты и приложения третьих лиц. Правообладатель не контролирует и не несет ответственности за содержание и порядок использования таких сайтов и приложений. Условия использования таких сайтов и приложений определены в их соглашениях и политиках конфиденциальности.\n\n'
|
||||
'6.5. Все вопросы и претензии, связанные с использованием/невозможностью использования Программы, а также возможным нарушением Программой законодательства и/или прав третьих лиц, должны направляться через форму обратной связи по адресу:',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('7. Обновления/новые версии Программы'),
|
||||
_buildParagraph(
|
||||
'7.1. Действие настоящей Лицензии распространяется на все последующие обновления/новые версии Программы. Соглашаясь с установкой обновления/новой версии Программы, Пользователь принимает условия настоящей Лицензии для соответствующих обновлений/новых версий Программы, если обновление/установка новой версии Программы не сопровождается иным Лицензионным соглашением.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('8. Изменения условий настоящей Лицензии'),
|
||||
_buildParagraph(
|
||||
'8.1. Настоящее лицензионное соглашение может изменяться Лицензиром в одностороннем порядке. Уведомление Пользователя о внесенных изменениях в условия настоящей Лицензии публикуется на странице: https://behappybel.by. Указанные изменения в условиях лицензионного соглашения вступают в силу с даты их публикации, если иное не оговорено в соответствующей публикации.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('Лицензиар'),
|
||||
_buildParagraph(
|
||||
'ООО “БИХЕППИБЕЛ”\n'
|
||||
'УНП 392050943\n'
|
||||
'ОКПО 511318892000\n'
|
||||
'Свидетельство о государственной регистрации № 392026683 от 22.01.2026 выдано Инспекцией Министерства по налогам и сборам Республики Беларусь по Октябрьскому району г.Витебска\n\n'
|
||||
'Юридический адрес: Республика Беларусь, 210017, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:be_happy/di/service_locator.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/content_parser.dart';
|
||||
import '../../domain/usecase/get_news_by_id_usecase.dart';
|
||||
|
||||
class NewsDetailScreen extends StatefulWidget {
|
||||
final int newsId;
|
||||
final String title;
|
||||
|
||||
const NewsDetailScreen({
|
||||
super.key,
|
||||
required this.newsId,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NewsDetailScreen> createState() => _NewsDetailScreenState();
|
||||
}
|
||||
|
||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
dynamic _news;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchNews();
|
||||
}
|
||||
|
||||
Future<void> _fetchNews() async {
|
||||
try {
|
||||
final usecase = getIt<GetNewsByIdUsecase>();
|
||||
final news = await usecase(widget.newsId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_news = news;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: widget.title),
|
||||
),
|
||||
|
||||
// 🔹 Контент
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки новости',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_fetchNews();
|
||||
});
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _news != null
|
||||
? _buildNewsContent(_news)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewsContent(dynamic news) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ✅ Используем вынесенный парсер
|
||||
...ContentParser.parseContent(
|
||||
textJson: news.textJson,
|
||||
text: news.text,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 Удалены старые методы: _parseHtmlText, _parseHtmlElements, _parseJsonText, _parseJsonNode
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
final localDate = date.toLocal();
|
||||
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:be_happy/di/service_locator.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/content_parser.dart';
|
||||
import '../../domain/usecase/get_news_by_id_usecase.dart';
|
||||
|
||||
class NewsDetailScreen extends StatefulWidget {
|
||||
final int newsId;
|
||||
final String title;
|
||||
|
||||
const NewsDetailScreen({
|
||||
super.key,
|
||||
required this.newsId,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NewsDetailScreen> createState() => _NewsDetailScreenState();
|
||||
}
|
||||
|
||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
dynamic _news;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchNews();
|
||||
}
|
||||
|
||||
Future<void> _fetchNews() async {
|
||||
try {
|
||||
final usecase = getIt<GetNewsByIdUsecase>();
|
||||
final news = await usecase(widget.newsId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_news = news;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: widget.title),
|
||||
),
|
||||
|
||||
// 🔹 Контент
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки новости',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_fetchNews();
|
||||
});
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _news != null
|
||||
? _buildNewsContent(_news)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewsContent(dynamic news) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ✅ Используем вынесенный парсер
|
||||
...ContentParser.parseContent(
|
||||
textJson: news.textJson,
|
||||
text: news.text,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 Удалены старые методы: _parseHtmlText, _parseHtmlElements, _parseJsonText, _parseJsonNode
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
final localDate = date.toLocal();
|
||||
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
|
||||
}
|
||||
}
|
||||
@@ -1,257 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:developer' as dev;
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/news_event.dart';
|
||||
import '../state/news_state.dart';
|
||||
import '../viewmodel/news_bloc.dart';
|
||||
import 'news_detail_screen.dart';
|
||||
|
||||
class NewsScreen extends StatelessWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
return getIt<NewsBloc>()..add(const NewsFetchRequested());
|
||||
},
|
||||
child: const NewsView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NewsView extends StatelessWidget {
|
||||
const NewsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Новости'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Expanded(
|
||||
child: BlocBuilder<NewsBloc, NewsState>(
|
||||
builder: (context, state) {
|
||||
|
||||
if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == NewsStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки новостей',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NewsBloc>().add(const NewsFetchRequested());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.smsDigit,
|
||||
),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.news.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: state.news.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _NewsCard(news: state.news[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/news_empty.png',
|
||||
width: 280,
|
||||
height: 280,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.newspaper_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
'Следите за нашими последними новостями и акциями! Сейчас их нет, но скоро они появятся.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewsCard extends StatelessWidget {
|
||||
final dynamic news;
|
||||
|
||||
const _NewsCard({required this.news});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(news.publishedAt);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
news.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/news_def.png'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
news.previewText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 150),
|
||||
child: GradientButton(
|
||||
text: 'Подробнее',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NewsDetailScreen(
|
||||
newsId: news.id,
|
||||
title: news.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
showArrows: true,
|
||||
fontSize: 14,
|
||||
height: 40,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:developer' as dev;
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/news_event.dart';
|
||||
import '../state/news_state.dart';
|
||||
import '../viewmodel/news_bloc.dart';
|
||||
import 'news_detail_screen.dart';
|
||||
|
||||
class NewsScreen extends StatelessWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
return getIt<NewsBloc>()..add(const NewsFetchRequested());
|
||||
},
|
||||
child: const NewsView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NewsView extends StatelessWidget {
|
||||
const NewsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Новости'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Expanded(
|
||||
child: BlocBuilder<NewsBloc, NewsState>(
|
||||
builder: (context, state) {
|
||||
|
||||
if (state.status == NewsStatus.initial || state.status == NewsStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == NewsStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки новостей',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NewsBloc>().add(const NewsFetchRequested());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.smsDigit,
|
||||
),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.news.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: state.news.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _NewsCard(news: state.news[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/news_empty.png',
|
||||
width: 280,
|
||||
height: 280,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.newspaper_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
'Следите за нашими последними новостями и акциями! Сейчас их нет, но скоро они появятся.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewsCard extends StatelessWidget {
|
||||
final dynamic news;
|
||||
|
||||
const _NewsCard({required this.news});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(news.publishedAt);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
news.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/news_def.png'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
news.previewText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 150),
|
||||
child: GradientButton(
|
||||
text: 'Подробнее',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NewsDetailScreen(
|
||||
newsId: news.id,
|
||||
title: news.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
showArrows: true,
|
||||
fontSize: 14,
|
||||
height: 40,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return 'Сегодня, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Вчера, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,394 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:developer' as dev;
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/client_notification.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/notifications_event.dart';
|
||||
import '../state/notifications_state.dart';
|
||||
import '../viewmodel/notifications_bloc.dart';
|
||||
|
||||
enum NotificationFilter {
|
||||
all,
|
||||
auth,
|
||||
payment,
|
||||
order,
|
||||
}
|
||||
|
||||
class NotificationsScreen extends StatelessWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<NotificationsBloc>()..add(NotificationsFetchRequested(page: 1)),
|
||||
child: _NotificationsScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationsScreenContent extends StatefulWidget {
|
||||
const _NotificationsScreenContent();
|
||||
|
||||
@override
|
||||
State<_NotificationsScreenContent> createState() => _NotificationsScreenContentState();
|
||||
}
|
||||
|
||||
class _NotificationsScreenContentState extends State<_NotificationsScreenContent> {
|
||||
NotificationFilter _filter = NotificationFilter.all;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setFilter(NotificationFilter filter) {
|
||||
setState(() => _filter = filter);
|
||||
_scrollController.jumpTo(0);
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationsView(
|
||||
filter: _filter,
|
||||
onFilterChanged: _setFilter,
|
||||
scrollController: _scrollController,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsView extends StatelessWidget {
|
||||
final NotificationFilter filter;
|
||||
final ValueChanged<NotificationFilter> onFilterChanged;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const NotificationsView({
|
||||
super.key,
|
||||
this.filter = NotificationFilter.all,
|
||||
required this.onFilterChanged,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Уведомления'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildFilterBar(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: BlocBuilder<NotificationsBloc, NotificationsState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotificationsStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
if (state.status == NotificationsStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки уведомлений',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filtered = state.notifications.where((n) {
|
||||
switch (filter) {
|
||||
case NotificationFilter.all:
|
||||
return true;
|
||||
case NotificationFilter.auth:
|
||||
return n.category == NotificationCategory.auth;
|
||||
case NotificationFilter.payment:
|
||||
return n.category == NotificationCategory.payment;
|
||||
case NotificationFilter.order:
|
||||
return n.category == NotificationCategory.scooter;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
},
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (scrollInfo) {
|
||||
if (scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 50 &&
|
||||
scrollInfo.metrics.maxScrollExtent > 0 &&
|
||||
state.hasMore &&
|
||||
!state.isLoadingMore &&
|
||||
state.status == NotificationsStatus.success) {
|
||||
context.read<NotificationsBloc>().add(NotificationsLoadMoreRequested());
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: filtered.length + (state.hasMore && filtered.length >= 10 ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length && state.hasMore && filtered.length >= 10) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _NotificationCard(notification: filtered[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
final items = [
|
||||
{'label': 'Все', 'value': NotificationFilter.all},
|
||||
{'label': 'Авторизация', 'value': NotificationFilter.auth},
|
||||
{'label': 'Оплата', 'value': NotificationFilter.payment},
|
||||
{'label': 'Поездка', 'value': NotificationFilter.order},
|
||||
];
|
||||
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: items.map((item) {
|
||||
final isActive = item['value'] == filter;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => onFilterChanged(item['value'] as NotificationFilter),
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive ? AppColors.activeButtonGradient : null,
|
||||
color: isActive ? null : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? Colors.transparent
|
||||
: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item['label'] as String,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? AppColors.activeButtonText
|
||||
: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/notification_empty.png',
|
||||
width: 280,
|
||||
height: 280,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.notifications_none_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
'У вас пока нет уведомлений.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
final ClientNotification notification;
|
||||
|
||||
const _NotificationCard({required this.notification});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(notification.createdAt);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF141530).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
notification.content,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final localDate = date.toLocal();
|
||||
final now = DateTime.now();
|
||||
|
||||
if (localDate.year == now.year &&
|
||||
localDate.month == now.month &&
|
||||
localDate.day == now.day) {
|
||||
return 'Сегодня, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
if (localDate.year == yesterday.year &&
|
||||
localDate.month == yesterday.month &&
|
||||
localDate.day == yesterday.day) {
|
||||
return 'Вчера, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
|
||||
}
|
||||
|
||||
String _getTypeLabel(NotificationType type) {
|
||||
switch (type) {
|
||||
case NotificationType.info:
|
||||
return 'Информация';
|
||||
case NotificationType.attention:
|
||||
return 'Внимание';
|
||||
case NotificationType.warning:
|
||||
return 'Предупреждение';
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryLabel(NotificationCategory category) {
|
||||
switch (category) {
|
||||
case NotificationCategory.auth:
|
||||
return 'Авторизация';
|
||||
case NotificationCategory.zone:
|
||||
return 'Зоны';
|
||||
case NotificationCategory.payment:
|
||||
return 'Оплата';
|
||||
case NotificationCategory.companyInfo:
|
||||
return 'Акции';
|
||||
case NotificationCategory.adminInfo:
|
||||
return 'Админ';
|
||||
case NotificationCategory.scooter:
|
||||
return 'Самокат';
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:developer' as dev;
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/client_notification.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/notifications_event.dart';
|
||||
import '../state/notifications_state.dart';
|
||||
import '../viewmodel/notifications_bloc.dart';
|
||||
|
||||
enum NotificationFilter {
|
||||
all,
|
||||
auth,
|
||||
payment,
|
||||
order,
|
||||
}
|
||||
|
||||
class NotificationsScreen extends StatelessWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<NotificationsBloc>()..add(NotificationsFetchRequested(page: 1)),
|
||||
child: _NotificationsScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationsScreenContent extends StatefulWidget {
|
||||
const _NotificationsScreenContent();
|
||||
|
||||
@override
|
||||
State<_NotificationsScreenContent> createState() => _NotificationsScreenContentState();
|
||||
}
|
||||
|
||||
class _NotificationsScreenContentState extends State<_NotificationsScreenContent> {
|
||||
NotificationFilter _filter = NotificationFilter.all;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setFilter(NotificationFilter filter) {
|
||||
setState(() => _filter = filter);
|
||||
_scrollController.jumpTo(0);
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationsView(
|
||||
filter: _filter,
|
||||
onFilterChanged: _setFilter,
|
||||
scrollController: _scrollController,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsView extends StatelessWidget {
|
||||
final NotificationFilter filter;
|
||||
final ValueChanged<NotificationFilter> onFilterChanged;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const NotificationsView({
|
||||
super.key,
|
||||
this.filter = NotificationFilter.all,
|
||||
required this.onFilterChanged,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Уведомления'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildFilterBar(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: BlocBuilder<NotificationsBloc, NotificationsState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotificationsStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
if (state.status == NotificationsStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки уведомлений',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filtered = state.notifications.where((n) {
|
||||
switch (filter) {
|
||||
case NotificationFilter.all:
|
||||
return true;
|
||||
case NotificationFilter.auth:
|
||||
return n.category == NotificationCategory.auth;
|
||||
case NotificationFilter.payment:
|
||||
return n.category == NotificationCategory.payment;
|
||||
case NotificationFilter.order:
|
||||
return n.category == NotificationCategory.scooter;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<NotificationsBloc>().add(NotificationsFetchRequested(page: 1));
|
||||
},
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (scrollInfo) {
|
||||
if (scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 50 &&
|
||||
scrollInfo.metrics.maxScrollExtent > 0 &&
|
||||
state.hasMore &&
|
||||
!state.isLoadingMore &&
|
||||
state.status == NotificationsStatus.success) {
|
||||
context.read<NotificationsBloc>().add(NotificationsLoadMoreRequested());
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: filtered.length + (state.hasMore && filtered.length >= 10 ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length && state.hasMore && filtered.length >= 10) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _NotificationCard(notification: filtered[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
final items = [
|
||||
{'label': 'Все', 'value': NotificationFilter.all},
|
||||
{'label': 'Авторизация', 'value': NotificationFilter.auth},
|
||||
{'label': 'Оплата', 'value': NotificationFilter.payment},
|
||||
{'label': 'Поездка', 'value': NotificationFilter.order},
|
||||
];
|
||||
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: items.map((item) {
|
||||
final isActive = item['value'] == filter;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => onFilterChanged(item['value'] as NotificationFilter),
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive ? AppColors.activeButtonGradient : null,
|
||||
color: isActive ? null : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? Colors.transparent
|
||||
: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item['label'] as String,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? AppColors.activeButtonText
|
||||
: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/notification_empty.png',
|
||||
width: 280,
|
||||
height: 280,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.notifications_none_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
'У вас пока нет уведомлений.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
final ClientNotification notification;
|
||||
|
||||
const _NotificationCard({required this.notification});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(notification.createdAt);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF141530).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
notification.content,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final localDate = date.toLocal();
|
||||
final now = DateTime.now();
|
||||
|
||||
if (localDate.year == now.year &&
|
||||
localDate.month == now.month &&
|
||||
localDate.day == now.day) {
|
||||
return 'Сегодня, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
if (localDate.year == yesterday.year &&
|
||||
localDate.month == yesterday.month &&
|
||||
localDate.day == yesterday.day) {
|
||||
return 'Вчера, ${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
return '${localDate.day.toString().padLeft(2, '0')}.${localDate.month.toString().padLeft(2, '0')}.${localDate.year}';
|
||||
}
|
||||
|
||||
String _getTypeLabel(NotificationType type) {
|
||||
switch (type) {
|
||||
case NotificationType.info:
|
||||
return 'Информация';
|
||||
case NotificationType.attention:
|
||||
return 'Внимание';
|
||||
case NotificationType.warning:
|
||||
return 'Предупреждение';
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryLabel(NotificationCategory category) {
|
||||
switch (category) {
|
||||
case NotificationCategory.auth:
|
||||
return 'Авторизация';
|
||||
case NotificationCategory.zone:
|
||||
return 'Зоны';
|
||||
case NotificationCategory.payment:
|
||||
return 'Оплата';
|
||||
case NotificationCategory.companyInfo:
|
||||
return 'Акции';
|
||||
case NotificationCategory.adminInfo:
|
||||
return 'Админ';
|
||||
case NotificationCategory.scooter:
|
||||
return 'Самокат';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
final List<Map<String, String>> _slides = [
|
||||
{
|
||||
'image': 'assets/onboard_1.jpg',
|
||||
'title': 'Найдите самокат на карте и выберите нужный',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_2.jpg',
|
||||
'title': 'Отсканируйте QR-код или введите номер который указан под козлом',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_3.png',
|
||||
'title': 'Дождитесь звукового сигнала. При наличии замка отстегните его',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_4.jpg',
|
||||
'title': 'Выберите тариф и начните поездку',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_5.jpg',
|
||||
'title': 'Управляйте скоростью с помощью курка тормоза',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_6.jpg',
|
||||
'title': 'Для торможения используйте курок тормоза',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_7.png',
|
||||
'title': 'Для завершения аренды припаркуйте самокат в разрешённом месте',
|
||||
},
|
||||
];
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _slides.length - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
// TODO: переход дальше (авторизация / главная)
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
/// Фон — PageView
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _slides.length,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return Image.asset(
|
||||
_slides[index]['image']!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
/// Градиент сверху для читаемости
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.35),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.7],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// Контент
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
|
||||
/// Текст
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
_slides[_currentPage]['title']!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
/// Кнопка
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GradientButton(
|
||||
text: _currentPage == _slides.length - 1
|
||||
? 'Начать'
|
||||
: 'Далее',
|
||||
onTap: _nextPage,
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 18,
|
||||
showArrows: true,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
/// Индикатор
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_slides.length, (index) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: index == _currentPage ? 10 : 8,
|
||||
height: index == _currentPage ? 10 : 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: index == _currentPage
|
||||
? Colors.greenAccent
|
||||
: Colors.white.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
final List<Map<String, String>> _slides = [
|
||||
{
|
||||
'image': 'assets/onboard_1.jpg',
|
||||
'title': 'Найдите самокат на карте и выберите нужный',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_2.jpg',
|
||||
'title': 'Отсканируйте QR-код или введите номер который указан под козлом',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_3.png',
|
||||
'title': 'Дождитесь звукового сигнала. При наличии замка отстегните его',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_4.jpg',
|
||||
'title': 'Выберите тариф и начните поездку',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_5.jpg',
|
||||
'title': 'Управляйте скоростью с помощью курка тормоза',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_6.jpg',
|
||||
'title': 'Для торможения используйте курок тормоза',
|
||||
},
|
||||
{
|
||||
'image': 'assets/onboard_7.png',
|
||||
'title': 'Для завершения аренды припаркуйте самокат в разрешённом месте',
|
||||
},
|
||||
];
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _slides.length - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
// TODO: переход дальше (авторизация / главная)
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
/// Фон — PageView
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _slides.length,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return Image.asset(
|
||||
_slides[index]['image']!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
/// Градиент сверху для читаемости
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.35),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.7],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// Контент
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
|
||||
/// Текст
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
_slides[_currentPage]['title']!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
/// Кнопка
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GradientButton(
|
||||
text: _currentPage == _slides.length - 1
|
||||
? 'Начать'
|
||||
: 'Далее',
|
||||
onTap: _nextPage,
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 18,
|
||||
showArrows: true,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
/// Индикатор
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_slides.length, (index) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: index == _currentPage ? 10 : 8,
|
||||
height: index == _currentPage ? 10 : 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: index == _currentPage
|
||||
? Colors.greenAccent
|
||||
: Colors.white.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,339 +1,353 @@
|
||||
import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:yandex_mapkit/yandex_mapkit.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/route_event.dart';
|
||||
import '../state/route_state.dart';
|
||||
import '../viewmodel/route_bloc.dart';
|
||||
|
||||
class OrderHistoryDetailScreen extends StatelessWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const OrderHistoryDetailScreen({
|
||||
super.key,
|
||||
required this.order,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
|
||||
final scooterNumber = order.scooter?.title ?? '№${order.scooterId}';
|
||||
final price = order.totalPricePrint ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Поездка $date'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BlocProvider(
|
||||
create: (context) => RouteBloc(
|
||||
getRouteUseCase: getIt<GetScooterOrderRouteHistoryUsecase>(), // Используем DI (Service Locator)
|
||||
)..add(FetchRouteEvent(order.id)),
|
||||
child: Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BlocBuilder<RouteBloc, RouteState>(
|
||||
builder: (context, state) {
|
||||
if (state is RouteLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is RouteLoaded) {
|
||||
final List<Point> yandexPoints = state.points.map((p) =>
|
||||
Point(latitude: p.latitude, longitude: p.longitude)
|
||||
).toList();
|
||||
return YandexMap(
|
||||
rotateGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
mapObjects: [
|
||||
PolylineMapObject(
|
||||
mapId: const MapObjectId('route_line'),
|
||||
polyline: Polyline(points: yandexPoints),
|
||||
strokeColor: Colors.blue,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
],
|
||||
onMapCreated: (controller) async {
|
||||
final bounds = _calculateBounds(yandexPoints);
|
||||
await controller.moveCamera(
|
||||
CameraUpdate.newBounds(bounds),
|
||||
);
|
||||
await controller.moveCamera(CameraUpdate.zoomOut());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (state is RouteError) {
|
||||
return Center(child: Text(state.message, style: const TextStyle(color: Colors.grey)));
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 ИНФОРМАЦИЯ О ПОЕЗДКЕ
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Фото самоката
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Информация
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_2,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Цена
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
|
||||
style: TextStyle(
|
||||
color: order.status == 'Paid'
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (order.status != 'Paid') ...[
|
||||
const SizedBox(height: 16),
|
||||
GradientButton(
|
||||
text: 'Оплатить',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.go('/home/checkout/${order.id}');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 ДЕТАЛИ ПОЕЗДКИ
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_DetailRow(
|
||||
label: 'Старт',
|
||||
value: _formatTime(order.startAt),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Завершение',
|
||||
value: _formatTime(order.finishAt),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Расстояние',
|
||||
value: '${order.mileage.toStringAsFixed(2)?? '0'} км',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Скорость',
|
||||
value: '${order.avgSpeed ?? '0'} км/ч',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Тариф',
|
||||
value: order.plan?.title ?? '—',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Страховка',
|
||||
value: order.isInsurance ? "${order.insurancePrice} руб." : '—',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 ВИДЖЕТ СТРОКИ С ДЕТАЛЯМИ
|
||||
Widget _DetailRow({required String label, required String value}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final localDate = date.toLocal();
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
return '${localDate.day} ${months[localDate.month - 1]}, ${_formatTime(date)}';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? date) {
|
||||
if (date == null) return '—';
|
||||
final localDate = date.toLocal();
|
||||
return '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
BoundingBox _calculateBounds(List<Point> points) {
|
||||
double minLat = points.first.latitude;
|
||||
double minLng = points.first.longitude;
|
||||
double maxLat = points.first.latitude;
|
||||
double maxLng = points.first.longitude;
|
||||
|
||||
for (var p in points) {
|
||||
if (p.latitude < minLat) minLat = p.latitude;
|
||||
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||
if (p.longitude < minLng) minLng = p.longitude;
|
||||
if (p.longitude > maxLng) maxLng = p.longitude;
|
||||
}
|
||||
|
||||
return BoundingBox(
|
||||
southWest: Point(latitude: minLat, longitude: minLng),
|
||||
northEast: Point(latitude: maxLat, longitude: maxLng),
|
||||
);
|
||||
}
|
||||
import 'package:be_happy/domain/usecase/get_scooter_order_route_history_usecase.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:yandex_mapkit/yandex_mapkit.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/route_event.dart';
|
||||
import '../state/route_state.dart';
|
||||
import '../viewmodel/route_bloc.dart';
|
||||
|
||||
class OrderHistoryDetailScreen extends StatelessWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const OrderHistoryDetailScreen({
|
||||
super.key,
|
||||
required this.order,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
|
||||
final scooterNumber = order.scooter?.title ?? '№${order.scooterId}';
|
||||
final price = order.totalPricePrint ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Поездка $date'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BlocProvider(
|
||||
create: (context) => RouteBloc(
|
||||
getRouteUseCase: getIt<GetScooterOrderRouteHistoryUsecase>(), // Используем DI (Service Locator)
|
||||
)..add(FetchRouteEvent(order.id)),
|
||||
child: Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BlocBuilder<RouteBloc, RouteState>(
|
||||
builder: (context, state) {
|
||||
if (state is RouteLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is RouteLoaded) {
|
||||
final List<Point> yandexPoints = state.points.map((p) =>
|
||||
Point(latitude: p.latitude, longitude: p.longitude)
|
||||
).toList();
|
||||
return YandexMap(
|
||||
rotateGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
mapObjects: [
|
||||
PolylineMapObject(
|
||||
mapId: const MapObjectId('route_line'),
|
||||
polyline: Polyline(points: yandexPoints),
|
||||
strokeColor: Colors.blue,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
],
|
||||
onMapCreated: (controller) async {
|
||||
final bounds = _calculateBounds(yandexPoints);
|
||||
await controller.moveCamera(
|
||||
CameraUpdate.newBounds(bounds),
|
||||
);
|
||||
await controller.moveCamera(CameraUpdate.zoomOut());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (state is RouteError) {
|
||||
return Center(child: Text(state.message, style: const TextStyle(color: Colors.grey)));
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 ИНФОРМАЦИЯ О ПОЕЗДКЕ
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Фото самоката
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Информация
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_2,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Цена
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
|
||||
style: TextStyle(
|
||||
color: order.status == 'Paid'
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (order.status != 'Paid') ...[
|
||||
const SizedBox(height: 16),
|
||||
GradientButton(
|
||||
text: 'Оплатить',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.go('/home/checkout/${order.id}');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
if (order.hasFine) ...[
|
||||
const SizedBox(height: 16),
|
||||
GradientButton(
|
||||
text: 'Штраф',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.go('/home/checkout/${order.id}');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔹 ДЕТАЛИ ПОЕЗДКИ
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_DetailRow(
|
||||
label: 'Старт',
|
||||
value: _formatTime(order.startAt),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Завершение',
|
||||
value: _formatTime(order.finishAt),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Расстояние',
|
||||
value: '${order.mileage.toStringAsFixed(2)?? '0'} км',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Скорость',
|
||||
value: '${order.avgSpeed ?? '0'} км/ч',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Тариф',
|
||||
value: order.plan?.title ?? '—',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(
|
||||
label: 'Страховка',
|
||||
value: order.isInsurance ? "${order.insurancePrice} руб." : '—',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 ВИДЖЕТ СТРОКИ С ДЕТАЛЯМИ
|
||||
Widget _DetailRow({required String label, required String value}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final localDate = date.toLocal();
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
return '${localDate.day} ${months[localDate.month - 1]}, ${_formatTime(date)}';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? date) {
|
||||
if (date == null) return '—';
|
||||
final localDate = date.toLocal();
|
||||
return '${localDate.hour.toString().padLeft(2, '0')}:${localDate.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
BoundingBox _calculateBounds(List<Point> points) {
|
||||
double minLat = points.first.latitude;
|
||||
double minLng = points.first.longitude;
|
||||
double maxLat = points.first.latitude;
|
||||
double maxLng = points.first.longitude;
|
||||
|
||||
for (var p in points) {
|
||||
if (p.latitude < minLat) minLat = p.latitude;
|
||||
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||
if (p.longitude < minLng) minLng = p.longitude;
|
||||
if (p.longitude > maxLng) maxLng = p.longitude;
|
||||
}
|
||||
|
||||
return BoundingBox(
|
||||
southWest: Point(latitude: minLat, longitude: minLng),
|
||||
northEast: Point(latitude: maxLat, longitude: maxLng),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,399 +1,399 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/order_history_bloc.dart';
|
||||
import 'order_history_detail_screen.dart';
|
||||
|
||||
class OrderHistoryScreen extends StatelessWidget {
|
||||
const OrderHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<OrderHistoryBloc>()..add(OrderHistoryFetchRequested()),
|
||||
child: const OrderHistoryView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderHistoryView extends StatelessWidget {
|
||||
const OrderHistoryView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'История поездок'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: BlocBuilder<OrderHistoryBloc, OrderHistoryState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == OrderHistoryStatus.loading && state.orders.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == OrderHistoryStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<OrderHistoryBloc>().add(OrderHistoryFetchRequested());
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == OrderHistoryStatus.empty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
final groupedOrders = _groupByMonth(state.orders);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<OrderHistoryBloc>().add(OrderHistoryRefreshRequested());
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: groupedOrders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final monthKey = groupedOrders.keys.elementAt(index);
|
||||
final orders = groupedOrders[monthKey]!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
_getMonthTitle(monthKey),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
...orders.map((order) => _OrderCard(order: order)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<ScooterOrder>> _groupByMonth(List<ScooterOrder> orders) {
|
||||
final Map<String, List<ScooterOrder>> grouped = {};
|
||||
|
||||
for (var order in orders) {
|
||||
final date = order.startAt ?? order.finishAt ?? DateTime.now();
|
||||
final localDate = date.toLocal();
|
||||
final monthKey = '${localDate.year}-${localDate.month.toString().padLeft(2, '0')}';
|
||||
|
||||
if (!grouped.containsKey(monthKey)) {
|
||||
grouped[monthKey] = [];
|
||||
}
|
||||
grouped[monthKey]!.add(order);
|
||||
}
|
||||
|
||||
final sortedKeys = grouped.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
|
||||
return Map.fromEntries(
|
||||
sortedKeys.map((key) => MapEntry(key, grouped[key]!)),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMonthTitle(String monthKey) {
|
||||
final parts = monthKey.split('-');
|
||||
final year = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
|
||||
const months = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
return '${months[month - 1]} $year';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/history.png',
|
||||
width: 380,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.history_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
const Text(
|
||||
'У вас пока нет завершенных поездок.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
GradientButton(
|
||||
text: 'Прокатиться',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.go('/home');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔹 КАРТОЧКА ОДНОГО ЗАКАЗА
|
||||
class _OrderCard extends StatelessWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const _OrderCard({required this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
|
||||
final scooterNumber = order.scooter?.title ?? '№${order.scooterId}';
|
||||
final price = '${order.totalPrice?.toStringAsFixed(2)} BYN' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OrderHistoryDetailScreen(order: order),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 16, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 🔹 СТОЛБЕЦ 2: Дата, время, ID самоката
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Дата и время
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ID самоката
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_2,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 🔹 СТОЛБЕЦ 3: Цена
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов
|
||||
),
|
||||
child: Text(
|
||||
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
|
||||
style: TextStyle(
|
||||
color: order.status == 'Paid'
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 ФОРМАТИРОВАНИЕ ДАТЫ (с корректным сравнением)
|
||||
String _formatDate(DateTime date) {
|
||||
// ✅ Конвертируем в локальное время
|
||||
final localDate = date.toLocal();
|
||||
final now = DateTime.now();
|
||||
|
||||
// ✅ Сравниваем только дату (год, месяц, день), игнорируя время
|
||||
final isToday = localDate.year == now.year &&
|
||||
localDate.month == now.month &&
|
||||
localDate.day == now.day;
|
||||
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
final isYesterday = localDate.year == yesterday.year &&
|
||||
localDate.month == yesterday.month &&
|
||||
localDate.day == yesterday.day;
|
||||
|
||||
if (isToday) {
|
||||
return 'Сегодня, ${_formatTime(localDate)}';
|
||||
} else if (isYesterday) {
|
||||
return 'Вчера, ${_formatTime(localDate)}';
|
||||
} else {
|
||||
return '${_formatDateFull(localDate)}, ${_formatTime(localDate)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateFull(DateTime date) {
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime date) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/scooter_order.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/order_history_bloc.dart';
|
||||
import 'order_history_detail_screen.dart';
|
||||
|
||||
class OrderHistoryScreen extends StatelessWidget {
|
||||
const OrderHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<OrderHistoryBloc>()..add(OrderHistoryFetchRequested()),
|
||||
child: const OrderHistoryView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderHistoryView extends StatelessWidget {
|
||||
const OrderHistoryView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'История поездок'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Expanded(
|
||||
child: BlocBuilder<OrderHistoryBloc, OrderHistoryState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == OrderHistoryStatus.loading && state.orders.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == OrderHistoryStatus.failure) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Ошибка загрузки',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.errorMessage ?? '',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<OrderHistoryBloc>().add(OrderHistoryFetchRequested());
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == OrderHistoryStatus.empty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
|
||||
final groupedOrders = _groupByMonth(state.orders);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<OrderHistoryBloc>().add(OrderHistoryRefreshRequested());
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: groupedOrders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final monthKey = groupedOrders.keys.elementAt(index);
|
||||
final orders = groupedOrders[monthKey]!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
_getMonthTitle(monthKey),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
...orders.map((order) => _OrderCard(order: order)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<ScooterOrder>> _groupByMonth(List<ScooterOrder> orders) {
|
||||
final Map<String, List<ScooterOrder>> grouped = {};
|
||||
|
||||
for (var order in orders) {
|
||||
final date = order.startAt ?? order.finishAt ?? DateTime.now();
|
||||
final localDate = date.toLocal();
|
||||
final monthKey = '${localDate.year}-${localDate.month.toString().padLeft(2, '0')}';
|
||||
|
||||
if (!grouped.containsKey(monthKey)) {
|
||||
grouped[monthKey] = [];
|
||||
}
|
||||
grouped[monthKey]!.add(order);
|
||||
}
|
||||
|
||||
final sortedKeys = grouped.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
|
||||
return Map.fromEntries(
|
||||
sortedKeys.map((key) => MapEntry(key, grouped[key]!)),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMonthTitle(String monthKey) {
|
||||
final parts = monthKey.split('-');
|
||||
final year = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
|
||||
const months = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
return '${months[month - 1]} $year';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/history.png',
|
||||
width: 380,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.history_outlined,
|
||||
size: 120,
|
||||
color: Colors.white38,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
const Text(
|
||||
'У вас пока нет завершенных поездок.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
GradientButton(
|
||||
text: 'Прокатиться',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.go('/home');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔹 КАРТОЧКА ОДНОГО ЗАКАЗА
|
||||
class _OrderCard extends StatelessWidget {
|
||||
final ScooterOrder order;
|
||||
|
||||
const _OrderCard({required this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = _formatDate(order.startAt ?? order.finishAt ?? DateTime.now());
|
||||
final scooterNumber = order.scooter?.title ?? '№${order.scooterId}';
|
||||
final price = '${order.totalPrice?.toStringAsFixed(2)} BYN' ?? '${order.totalPrice?.toStringAsFixed(2) ?? '0.00'} BYN';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OrderHistoryDetailScreen(order: order),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 16, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19-reverse.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.electric_scooter,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 🔹 СТОЛБЕЦ 2: Дата, время, ID самоката
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Дата и время
|
||||
Text(
|
||||
date,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ID самоката
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_2,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
scooterNumber,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 🔹 СТОЛБЕЦ 3: Цена
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12), // Опционально: скругление углов
|
||||
),
|
||||
child: Text(
|
||||
order.status == 'Paid' ? 'ОПЛАЧЕН' : 'НЕ ОПЛАЧЕН',
|
||||
style: TextStyle(
|
||||
color: order.status == 'Paid'
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔹 ФОРМАТИРОВАНИЕ ДАТЫ (с корректным сравнением)
|
||||
String _formatDate(DateTime date) {
|
||||
// ✅ Конвертируем в локальное время
|
||||
final localDate = date.toLocal();
|
||||
final now = DateTime.now();
|
||||
|
||||
// ✅ Сравниваем только дату (год, месяц, день), игнорируя время
|
||||
final isToday = localDate.year == now.year &&
|
||||
localDate.month == now.month &&
|
||||
localDate.day == now.day;
|
||||
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
final isYesterday = localDate.year == yesterday.year &&
|
||||
localDate.month == yesterday.month &&
|
||||
localDate.day == yesterday.day;
|
||||
|
||||
if (isToday) {
|
||||
return 'Сегодня, ${_formatTime(localDate)}';
|
||||
} else if (isYesterday) {
|
||||
return 'Вчера, ${_formatTime(localDate)}';
|
||||
} else {
|
||||
return '${_formatDateFull(localDate)}, ${_formatTime(localDate)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateFull(DateTime date) {
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime date) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -1,408 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/add_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/map_event.dart';
|
||||
import '../event/payment_confirm_event.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../state/payment_confirm_state.dart';
|
||||
import '../viewmodel/map_bloc.dart';
|
||||
import '../viewmodel/payment_confirm_bloc.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
|
||||
class PaymentConfirmScreen extends StatelessWidget {
|
||||
final int orderId;
|
||||
final List<int> photoIds;
|
||||
|
||||
const PaymentConfirmScreen({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.photoIds,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _PaymentConfirmScreenContent(orderId: orderId, photoIds: photoIds);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentConfirmScreenContent extends StatelessWidget {
|
||||
final int orderId;
|
||||
final List<int> photoIds;
|
||||
|
||||
const _PaymentConfirmScreenContent({
|
||||
required this.orderId,
|
||||
required this.photoIds,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
fit: BoxFit.fitWidth,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Завершение поездки'),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
|
||||
listenWhen: (previous, current) {
|
||||
return current.status != previous.status;
|
||||
},
|
||||
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) {
|
||||
context.read<MapBloc>().add(ClearMapPlacemarks());
|
||||
context.go('/home');
|
||||
} else if (state.status == PaymentConfirmStatus.failure) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Произошла ошибка при оплате',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.9),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(20),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final order = state.order;
|
||||
|
||||
if (state.status == PaymentConfirmStatus.loading &&
|
||||
order == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_buildInfoLabel('Самокат:'),
|
||||
_buildValueRow(
|
||||
'qr_icon.png',
|
||||
"${order?.scooter?.number}",
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildInfoLabel('Расстояние:'),
|
||||
_buildValueRow(
|
||||
'distance_icon.png',
|
||||
"${order?.mileage} km",
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoLabel('Стоимость поездки:'),
|
||||
_buildValueRow(
|
||||
'money_icon.png',
|
||||
order?.totalOrderPricePrint ?? '- BYN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoLabel('Оплачено:'),
|
||||
_buildValueRow(
|
||||
'money_icon.png',
|
||||
order?.payTotalPricePrint ?? '0,00 BYN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔹 БЛОК СУММЫ К ОПЛАТЕ
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Сумма к оплате:',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/money_icon.png',
|
||||
width: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
order?.unpayTotalPricePrint ?? '0,00 BYN',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔹 КАРТА ОПЛАТЫ
|
||||
(state.useBalance || state.selectedCard != null)
|
||||
? Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: PaymentOption(
|
||||
title: state.useBalance ? 'Баланс' : "",
|
||||
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>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: PaymentMethodSheet(
|
||||
initialSelectedCard: state.useBalance ? null : state.selectedCard,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<PaymentConfirmBloc>().add(PaymentCardChanged(result));
|
||||
} else if (result == 'balance') {
|
||||
context.read<PaymentConfirmBloc>().add(SelectBalancePressed());
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
24,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(
|
||||
0.4,
|
||||
),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushReplacement(
|
||||
'/home/payment-method-sheet',
|
||||
);
|
||||
|
||||
},
|
||||
borderRadius: BorderRadius.circular(
|
||||
24,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.4),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child:
|
||||
BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentConfirmStatus.success &&
|
||||
state.paymentCompleted) {
|
||||
context.go('/home');
|
||||
} else if (state.status ==
|
||||
PaymentConfirmStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Ошибка оплаты',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return GradientButton(
|
||||
text: state.status == PaymentConfirmStatus.loading
|
||||
? 'Обработка...'
|
||||
: 'Оплатить',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
onTap: (state.status == PaymentConfirmStatus.loading || (!state.useBalance && state.selectedCard == null))
|
||||
? null
|
||||
: () {
|
||||
context.read<PaymentConfirmBloc>().add(
|
||||
PayRide(
|
||||
cardId: state.useBalance ? null : state.selectedCard?.id,
|
||||
isBalance: state.useBalance,
|
||||
orderId: orderId,
|
||||
photoIds: photoIds,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Вспомогательный метод для подзаголовков
|
||||
Widget _buildInfoLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Вспомогательный метод для строк со значениями и иконками
|
||||
Widget _buildValueRow(String iconName, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Image.asset('assets/icons/$iconName', width: 24, height: 24),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int minutes) {
|
||||
final h = minutes ~/ 60;
|
||||
final m = minutes % 60;
|
||||
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/add_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/map_event.dart';
|
||||
import '../event/payment_confirm_event.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../state/payment_confirm_state.dart';
|
||||
import '../viewmodel/map_bloc.dart';
|
||||
import '../viewmodel/payment_confirm_bloc.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
|
||||
class PaymentConfirmScreen extends StatelessWidget {
|
||||
final int orderId;
|
||||
final List<int> photoIds;
|
||||
|
||||
const PaymentConfirmScreen({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.photoIds,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _PaymentConfirmScreenContent(orderId: orderId, photoIds: photoIds);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentConfirmScreenContent extends StatelessWidget {
|
||||
final int orderId;
|
||||
final List<int> photoIds;
|
||||
|
||||
const _PaymentConfirmScreenContent({
|
||||
required this.orderId,
|
||||
required this.photoIds,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
fit: BoxFit.fitWidth,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Завершение поездки'),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
|
||||
listenWhen: (previous, current) {
|
||||
return current.status != previous.status;
|
||||
},
|
||||
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentConfirmStatus.success && state.paymentCompleted) {
|
||||
context.read<MapBloc>().add(ClearMapPlacemarks());
|
||||
context.go('/home');
|
||||
} else if (state.status == PaymentConfirmStatus.failure) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Произошла ошибка при оплате',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.9),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(20),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final order = state.order;
|
||||
|
||||
if (state.status == PaymentConfirmStatus.loading &&
|
||||
order == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_buildInfoLabel('Самокат:'),
|
||||
_buildValueRow(
|
||||
'qr_icon.png',
|
||||
"${order?.scooter?.number}",
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildInfoLabel('Расстояние:'),
|
||||
_buildValueRow(
|
||||
'distance_icon.png',
|
||||
"${order?.mileage} km",
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoLabel('Стоимость поездки:'),
|
||||
_buildValueRow(
|
||||
'money_icon.png',
|
||||
order?.totalOrderPricePrint ?? '- BYN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoLabel('Оплачено:'),
|
||||
_buildValueRow(
|
||||
'money_icon.png',
|
||||
order?.payTotalPricePrint ?? '0,00 BYN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔹 БЛОК СУММЫ К ОПЛАТЕ
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Сумма к оплате:',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/money_icon.png',
|
||||
width: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
order?.unpayTotalPricePrint ?? '0,00 BYN',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔹 КАРТА ОПЛАТЫ
|
||||
(state.useBalance || state.selectedCard != null)
|
||||
? Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: PaymentOption(
|
||||
title: state.useBalance ? 'Баланс' : "",
|
||||
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>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: PaymentMethodSheet(
|
||||
initialSelectedCard: state.useBalance ? null : state.selectedCard,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
if (result is PaymentCard) {
|
||||
context.read<PaymentConfirmBloc>().add(PaymentCardChanged(result));
|
||||
} else if (result == 'balance') {
|
||||
context.read<PaymentConfirmBloc>().add(SelectBalancePressed());
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
24,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(
|
||||
0.4,
|
||||
),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushReplacement(
|
||||
'/home/payment-method-sheet',
|
||||
);
|
||||
|
||||
},
|
||||
borderRadius: BorderRadius.circular(
|
||||
24,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.4),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: Colors.white
|
||||
.withOpacity(0.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child:
|
||||
BlocConsumer<PaymentConfirmBloc, PaymentConfirmState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentConfirmStatus.success &&
|
||||
state.paymentCompleted) {
|
||||
context.go('/home');
|
||||
} else if (state.status ==
|
||||
PaymentConfirmStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Ошибка оплаты',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return GradientButton(
|
||||
text: state.status == PaymentConfirmStatus.loading
|
||||
? 'Обработка...'
|
||||
: 'Оплатить',
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
onTap: (state.status == PaymentConfirmStatus.loading || (!state.useBalance && state.selectedCard == null))
|
||||
? null
|
||||
: () {
|
||||
context.read<PaymentConfirmBloc>().add(
|
||||
PayRide(
|
||||
cardId: state.useBalance ? null : state.selectedCard?.id,
|
||||
isBalance: state.useBalance,
|
||||
orderId: orderId,
|
||||
photoIds: photoIds,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Вспомогательный метод для подзаголовков
|
||||
Widget _buildInfoLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Вспомогательный метод для строк со значениями и иконками
|
||||
Widget _buildValueRow(String iconName, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Image.asset('assets/icons/$iconName', width: 24, height: 24),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int minutes) {
|
||||
final h = minutes ~/ 60;
|
||||
final m = minutes % 60;
|
||||
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,313 +1,313 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../state/payment_methods_state.dart';
|
||||
import '../viewmodel/payment_methods_bloc.dart';
|
||||
|
||||
class PaymentMethodsScreen extends StatelessWidget {
|
||||
const PaymentMethodsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: BlocConsumer<PaymentMethodsBloc, PaymentMethodsState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentMethodsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isNetworkProcessing = state.status == PaymentMethodsStatus.loading ||
|
||||
(state.isDeleting ?? false) ||
|
||||
(state.isSettingMain ?? false);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Способы оплаты'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: state.cards.isEmpty && state.status == PaymentMethodsStatus.loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF00D4AA)),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildBalanceCard(context, state.balance),
|
||||
const SizedBox(height: 20),
|
||||
_buildCardsList(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (isNetworkProcessing && state.cards.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00D4AA),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceCard(BuildContext context, double balance) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.activeButtonGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Баланс',
|
||||
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
balance.toStringAsFixed(2),
|
||||
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'баллов',
|
||||
style: TextStyle(color: const Color(0xFF0A0F2E).withOpacity(0.7), fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTopUpBalanceButton(context),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: -30,
|
||||
top: -50,
|
||||
child: Image.asset('assets/icons/card-screen.png', width: 100, height: 100, fit: BoxFit.contain),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardsList(BuildContext context, PaymentMethodsState state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Карты',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (state.cards.isEmpty && state.status == PaymentMethodsStatus.success)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('У вас пока нет привязанных карт', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
|
||||
...state.cards.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final card = entry.value;
|
||||
return Column(
|
||||
children: [
|
||||
_CardItem(
|
||||
card: card,
|
||||
onDelete: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsDeleteCard(card.id)),
|
||||
onMakeMain: card.isMain
|
||||
? null
|
||||
: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsSetMainCard(card.id)),
|
||||
),
|
||||
if (index < state.cards.length - 1) const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildAddCardButton(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddCardButton(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
context.read<PaymentMethodsBloc>().add(AddPayemntCardPressed());
|
||||
/*final isCardAdded = await context.push<bool>('/home/payment-methods/add-card');
|
||||
|
||||
if (isCardAdded == true && context.mounted) {
|
||||
context.read<PaymentMethodsBloc>().add(PaymentMethodsStarted());
|
||||
}*/
|
||||
|
||||
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.credit_card, color: Color(0xFF00D4AA), size: 24),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Привязать карту',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopUpBalanceButton(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/home/payment-methods/top-up'),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Пополнить баланс',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
class _CardItem extends StatelessWidget {
|
||||
final PaymentCard card;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback? onMakeMain;
|
||||
|
||||
const _CardItem({
|
||||
required this.card,
|
||||
required this.onDelete,
|
||||
this.onMakeMain,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.credit_card, color: Color(0xFFFFFFFF), size: 40),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onMakeMain, // Нажатие на текст карты делает её основной
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Карта ${card.cardLastNumber}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
card.isMain ? 'основная' : 'сделать основной',
|
||||
style: TextStyle(
|
||||
color: card.isMain ? const Color(0xFF66E3C4) : Colors.white.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: const Icon(Icons.close, color: Color(0xFF00D4AA), size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getDefaultIconPath(String cardType) {
|
||||
switch (cardType) {
|
||||
case 'Belcard': return 'assets/icons/belcard.png';
|
||||
case 'Visa': return 'assets/icons/visa.png';
|
||||
case 'Maestro': return 'assets/icons/maestro.png';
|
||||
case 'Mir': return 'assets/icons/mir.png';
|
||||
case 'Mastercard': return 'assets/icons/mastercard.png';
|
||||
default: return 'assets/icons/belcard.png';
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../state/payment_methods_state.dart';
|
||||
import '../viewmodel/payment_methods_bloc.dart';
|
||||
|
||||
class PaymentMethodsScreen extends StatelessWidget {
|
||||
const PaymentMethodsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: BlocConsumer<PaymentMethodsBloc, PaymentMethodsState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PaymentMethodsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isNetworkProcessing = state.status == PaymentMethodsStatus.loading ||
|
||||
(state.isDeleting ?? false) ||
|
||||
(state.isSettingMain ?? false);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Способы оплаты'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: state.cards.isEmpty && state.status == PaymentMethodsStatus.loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF00D4AA)),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildBalanceCard(context, state.balance),
|
||||
const SizedBox(height: 20),
|
||||
_buildCardsList(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (isNetworkProcessing && state.cards.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00D4AA),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBalanceCard(BuildContext context, double balance) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.activeButtonGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Баланс',
|
||||
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
balance.toStringAsFixed(2),
|
||||
style: TextStyle(color: Color(0xFF0A0F2E), fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'баллов',
|
||||
style: TextStyle(color: const Color(0xFF0A0F2E).withOpacity(0.7), fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTopUpBalanceButton(context),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: -30,
|
||||
top: -50,
|
||||
child: Image.asset('assets/icons/card-screen.png', width: 100, height: 100, fit: BoxFit.contain),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardsList(BuildContext context, PaymentMethodsState state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E).withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Карты',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (state.cards.isEmpty && state.status == PaymentMethodsStatus.success)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('У вас пока нет привязанных карт', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
|
||||
...state.cards.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final card = entry.value;
|
||||
return Column(
|
||||
children: [
|
||||
_CardItem(
|
||||
card: card,
|
||||
onDelete: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsDeleteCard(card.id)),
|
||||
onMakeMain: card.isMain
|
||||
? null
|
||||
: () => context.read<PaymentMethodsBloc>().add(PaymentMethodsSetMainCard(card.id)),
|
||||
),
|
||||
if (index < state.cards.length - 1) const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildAddCardButton(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddCardButton(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
context.read<PaymentMethodsBloc>().add(AddPayemntCardPressed());
|
||||
/*final isCardAdded = await context.push<bool>('/home/payment-methods/add-card');
|
||||
|
||||
if (isCardAdded == true && context.mounted) {
|
||||
context.read<PaymentMethodsBloc>().add(PaymentMethodsStarted());
|
||||
}*/
|
||||
|
||||
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.credit_card, color: Color(0xFF00D4AA), size: 24),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Привязать карту',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopUpBalanceButton(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A0F2E),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/home/payment-methods/top-up'),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Пополнить баланс',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add, color: Color(0xFF00D4AA), size: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
class _CardItem extends StatelessWidget {
|
||||
final PaymentCard card;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback? onMakeMain;
|
||||
|
||||
const _CardItem({
|
||||
required this.card,
|
||||
required this.onDelete,
|
||||
this.onMakeMain,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.credit_card, color: Color(0xFFFFFFFF), size: 40),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onMakeMain, // Нажатие на текст карты делает её основной
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Карта ${card.cardLastNumber}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
card.isMain ? 'основная' : 'сделать основной',
|
||||
style: TextStyle(
|
||||
color: card.isMain ? const Color(0xFF66E3C4) : Colors.white.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: const Icon(Icons.close, color: Color(0xFF00D4AA), size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getDefaultIconPath(String cardType) {
|
||||
switch (cardType) {
|
||||
case 'Belcard': return 'assets/icons/belcard.png';
|
||||
case 'Visa': return 'assets/icons/visa.png';
|
||||
case 'Maestro': return 'assets/icons/maestro.png';
|
||||
case 'Mir': return 'assets/icons/mir.png';
|
||||
case 'Mastercard': return 'assets/icons/mastercard.png';
|
||||
default: return 'assets/icons/belcard.png';
|
||||
}
|
||||
}
|
||||
}
|
||||
122
lib/presentation/screens/payment_rules_screen.dart
Normal file
122
lib/presentation/screens/payment_rules_screen.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class PaymentRulesScreen extends StatelessWidget {
|
||||
const PaymentRulesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ''),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Правила оплаты и безопасность платежей, конфиденциальность информации',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Оплата банковскими картами осуществляется через ЗАО «Альфа-Банк».',
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildParagraph(
|
||||
'К оплате принимаются карты международных платежных систем VISA, MasterCard, платежной системы БЕЛКАРТ.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/payment_logo.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.credit_card, color: Colors.white70, size: 28),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'VISA • MasterCard • БЕЛКАРТ',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildParagraph(
|
||||
'Безопасность совершения платежа обеспечивается современными методами проверки, шифрования и передачи данных по закрытым каналам связи. Ввод данных карточки осуществляется на защищенной авторизационной странице банка. Для оплаты необходимо ввести реквизиты карточки: номер, имя держателя, срок действия и трехзначный код безопасности. Трёхзначный код безопасности (CVV2 для VISA, CVC2 для MasterCard) — это три цифры, находящиеся на обратной стороне карточки. Если карточка поддерживает технологию 3DSecure или Интернет-пароль для держателей карточек БЕЛКАРТ, Вы будете перенаправлены на страницу банка, выпустившего карточку, для ввода кода безопасности.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Предоставляемая Вами персональная информация (например: имя, адрес, телефон, e-mail, номер банковской карты и прочее) является конфиденциальной и не подлежит разглашению. Данные карточки передаются только в зашифрованном виде и не сохраняются на данном интернет-ресурсе.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,194 +1,194 @@
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/verify_code_event.dart';
|
||||
import '../state/verify_code_state.dart';
|
||||
import '../viewmodel/verify_code_bloc.dart';
|
||||
import 'pin_login_screen.dart';
|
||||
|
||||
class PhoneLoginScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String tempToken;
|
||||
|
||||
const PhoneLoginScreen({
|
||||
Key? key,
|
||||
required this.phoneNumber,
|
||||
required this.tempToken,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PhoneLoginScreen> createState() => _PhoneLoginScreenState();
|
||||
}
|
||||
|
||||
class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
|
||||
final TextEditingController codeController = TextEditingController();
|
||||
final FocusNode codeFocusNode = FocusNode();
|
||||
bool _isFirstTry = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<VerifyCodeBloc>().add(
|
||||
VerifyCodeStarted(
|
||||
phoneNumber: widget.phoneNumber,
|
||||
tempToken: widget.tempToken,
|
||||
),
|
||||
);
|
||||
|
||||
codeController.addListener(() {
|
||||
context.read<VerifyCodeBloc>().add(CodeChanged(codeController.text));
|
||||
|
||||
if (codeController.text.length == 6) {
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
context.read<VerifyCodeBloc>().add(VerifyCodeSubmitted());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
void openKeyboard() {
|
||||
if (codeFocusNode.hasFocus) {
|
||||
codeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
codeController.dispose();
|
||||
codeFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<VerifyCodeBloc, VerifyCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
|
||||
} else if (state.error != null) {
|
||||
setState(() { _isFirstTry = false; });
|
||||
codeController.clear();
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
|
||||
} else if (state.isBlocked) {
|
||||
context.go("/block");
|
||||
}
|
||||
|
||||
if (state.secondsLeft == 0 && codeFocusNode.hasFocus) {
|
||||
codeFocusNode.unfocus();
|
||||
codeController.clear();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 310,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
"Код отправлен на номер",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.whiteText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.phoneNumber,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.whiteText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 35),
|
||||
GestureDetector(
|
||||
onTap: state.secondsLeft > 0 ? openKeyboard : null,
|
||||
child: CodeDots(code: state.code, length: 6),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (state.secondsLeft == 0)
|
||||
const Text(
|
||||
"Время действия кода истекло. Запросите новый.",
|
||||
style: TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
else if (!_isFirstTry)
|
||||
Text(
|
||||
"Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}",
|
||||
style: const TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
GradientButton(
|
||||
text: state.secondsLeft == 0
|
||||
? "Отправить код повторно"
|
||||
: "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.",
|
||||
onTap: (state.secondsLeft == 0 && !state.isSubmitting)
|
||||
? () {
|
||||
context.read<VerifyCodeBloc>().add(
|
||||
ResendCodePressed(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
enabled: state.secondsLeft == 0 && !state.isSubmitting,
|
||||
width: 250,
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
child: Opacity(
|
||||
opacity: 0.0,
|
||||
child: TextField(
|
||||
controller: codeController,
|
||||
focusNode: codeFocusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
autofocus: true,
|
||||
enabled: state.secondsLeft > 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/verify_code_event.dart';
|
||||
import '../state/verify_code_state.dart';
|
||||
import '../viewmodel/verify_code_bloc.dart';
|
||||
import 'pin_login_screen.dart';
|
||||
|
||||
class PhoneLoginScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String tempToken;
|
||||
|
||||
const PhoneLoginScreen({
|
||||
Key? key,
|
||||
required this.phoneNumber,
|
||||
required this.tempToken,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PhoneLoginScreen> createState() => _PhoneLoginScreenState();
|
||||
}
|
||||
|
||||
class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
|
||||
final TextEditingController codeController = TextEditingController();
|
||||
final FocusNode codeFocusNode = FocusNode();
|
||||
bool _isFirstTry = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<VerifyCodeBloc>().add(
|
||||
VerifyCodeStarted(
|
||||
phoneNumber: widget.phoneNumber,
|
||||
tempToken: widget.tempToken,
|
||||
),
|
||||
);
|
||||
|
||||
codeController.addListener(() {
|
||||
context.read<VerifyCodeBloc>().add(CodeChanged(codeController.text));
|
||||
|
||||
if (codeController.text.length == 6) {
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
context.read<VerifyCodeBloc>().add(VerifyCodeSubmitted());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
void openKeyboard() {
|
||||
if (codeFocusNode.hasFocus) {
|
||||
codeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
codeController.dispose();
|
||||
codeFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<VerifyCodeBloc, VerifyCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
|
||||
} else if (state.error != null) {
|
||||
setState(() { _isFirstTry = false; });
|
||||
codeController.clear();
|
||||
FocusScope.of(context).requestFocus(codeFocusNode);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
|
||||
} else if (state.isBlocked) {
|
||||
context.go("/block");
|
||||
}
|
||||
|
||||
if (state.secondsLeft == 0 && codeFocusNode.hasFocus) {
|
||||
codeFocusNode.unfocus();
|
||||
codeController.clear();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 310,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
"Код отправлен на номер",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.whiteText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.phoneNumber,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.whiteText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 35),
|
||||
GestureDetector(
|
||||
onTap: state.secondsLeft > 0 ? openKeyboard : null,
|
||||
child: CodeDots(code: state.code, length: 6),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (state.secondsLeft == 0)
|
||||
const Text(
|
||||
"Время действия кода истекло. Запросите новый.",
|
||||
style: TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
else if (!_isFirstTry)
|
||||
Text(
|
||||
"Осталось ${state.attemptsLeft} попытк${state.attemptsLeft == 1 ? 'а' : 'и'}",
|
||||
style: const TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
GradientButton(
|
||||
text: state.secondsLeft == 0
|
||||
? "Отправить код повторно"
|
||||
: "Отправить код повторно\nчерез 00:${state.secondsLeft.toString().padLeft(2, '0')} сек.",
|
||||
onTap: (state.secondsLeft == 0 && !state.isSubmitting)
|
||||
? () {
|
||||
context.read<VerifyCodeBloc>().add(
|
||||
ResendCodePressed(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
enabled: state.secondsLeft == 0 && !state.isSubmitting,
|
||||
width: 250,
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
child: Opacity(
|
||||
opacity: 0.0,
|
||||
child: TextField(
|
||||
controller: codeController,
|
||||
focusNode: codeFocusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
autofocus: true,
|
||||
enabled: state.secondsLeft > 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,284 +1,288 @@
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/state/splash_state.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/auth_event.dart';
|
||||
import '../state/auth_state.dart';
|
||||
import '../viewmodel/auth_bloc.dart';
|
||||
import 'phone_login_screen.dart';
|
||||
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
const PhoneScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PhoneScreen> createState() => _PhoneScreenState();
|
||||
}
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Вызываем диалог сразу после отрисовки первого кадра экрана
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showTestModeDialog();
|
||||
});
|
||||
}
|
||||
|
||||
// Метод для отображения диалогового окна
|
||||
void _showTestModeDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // Запрещает закрывать окно кликом по фону
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'Внимание!',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: const Text('Приложение находится в тестовом режиме!'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // Закрывает диалоговое окно
|
||||
},
|
||||
child: const Text(
|
||||
'Продолжить',
|
||||
style: TextStyle(color: Colors.blueAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<PhoneAuthBloc, PhoneAuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
context.go("/verify?phone=+375${state.phone}");
|
||||
context.read<SplashBloc>().add(AuthStarted());
|
||||
} else if (state.error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.error!)),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isAdult = state.isAdult ?? false;
|
||||
final privacyAccepted = state.privacyAccepted ?? false;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Image.asset(
|
||||
'assets/wave.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const Text(
|
||||
'Введите номер телефона',
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
style: const TextStyle(color: AppColors.whiteText),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: AppColors.disabledButtonColor,
|
||||
prefixText: '+375 ',
|
||||
prefixStyle: const TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
),
|
||||
hintText: 'Номер телефона',
|
||||
hintStyle: const TextStyle(color: AppColors.hint),
|
||||
suffixIcon: _phoneController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear,
|
||||
color: AppColors.whiteText),
|
||||
onPressed: () {
|
||||
_phoneController.clear();
|
||||
context.read<PhoneAuthBloc>().add(PhoneChanged(""));
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
context.read<PhoneAuthBloc>().add(PhoneChanged(value));
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: isAdult,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<PhoneAuthBloc>().add(IsAdultChanged(value));
|
||||
}
|
||||
},
|
||||
isError: state.error != null && !isAdult,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Подтверждаю, что мне исполнилось 18 лет и я принял ',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: ClickableText(
|
||||
text: 'Условия использования сервиса',
|
||||
onTap: () => context.push('/license-agreement'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: privacyAccepted,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<PhoneAuthBloc>().add(PrivacyAcceptedChanged(value));
|
||||
}
|
||||
},
|
||||
isError: state.error != null && !privacyAccepted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Подтверждаю, что я ознакомился с ',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: ClickableText(
|
||||
text: 'Политикой обработки персональных данных',
|
||||
onTap: () => context.push('/privacy-policy'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 47),
|
||||
|
||||
GradientButton(
|
||||
text: "Получить код",
|
||||
enabled: state.phone.isNotEmpty &&
|
||||
isAdult &&
|
||||
privacyAccepted &&
|
||||
!state.isSubmitting,
|
||||
onTap: state.isSubmitting
|
||||
? null
|
||||
: () {
|
||||
context.read<PhoneAuthBloc>().add(SubmitPhonePressed());
|
||||
},
|
||||
showArrows: true,
|
||||
height: 50,
|
||||
width: 340,
|
||||
fontSize: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClickableText extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ClickableText({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.blueAccent,
|
||||
decorationColor: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/state/splash_state.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/auth_event.dart';
|
||||
import '../state/auth_state.dart';
|
||||
import '../viewmodel/auth_bloc.dart';
|
||||
import 'phone_login_screen.dart';
|
||||
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
const PhoneScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PhoneScreen> createState() => _PhoneScreenState();
|
||||
}
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showTestModeDialog();
|
||||
});
|
||||
}
|
||||
|
||||
void _showTestModeDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'Внимание!',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: const Text('Приложение находится в тестовом режиме!'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Продолжить',
|
||||
style: TextStyle(color: Colors.blueAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: BlocConsumer<PhoneAuthBloc, PhoneAuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
context.go("/verify?phone=+375${state.phone}");
|
||||
context.read<SplashBloc>().add(AuthStarted());
|
||||
} else if (state.error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.error!)),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isAdult = state.isAdult ?? false;
|
||||
final privacyAccepted = state.privacyAccepted ?? false;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Image.asset(
|
||||
'assets/wave.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const Text(
|
||||
'Введите номер телефона',
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
style: const TextStyle(color: AppColors.whiteText),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: AppColors.disabledButtonColor,
|
||||
prefixText: '+375 ',
|
||||
prefixStyle: const TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
),
|
||||
hintText: 'Номер телефона',
|
||||
hintStyle: const TextStyle(color: AppColors.hint),
|
||||
suffixIcon: _phoneController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear,
|
||||
color: AppColors.whiteText),
|
||||
onPressed: () {
|
||||
_phoneController.clear();
|
||||
context.read<PhoneAuthBloc>().add(PhoneChanged(""));
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
context.read<PhoneAuthBloc>().add(PhoneChanged(value));
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: isAdult,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<PhoneAuthBloc>().add(IsAdultChanged(value));
|
||||
}
|
||||
},
|
||||
isError: state.error != null && !isAdult,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Подтверждаю, что мне исполнилось 18 лет и я принял ',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: ClickableText(
|
||||
text: 'Условия использования сервиса',
|
||||
onTap: () => context.push('/license-agreement'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: privacyAccepted,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<PhoneAuthBloc>().add(PrivacyAcceptedChanged(value));
|
||||
}
|
||||
},
|
||||
isError: state.error != null && !privacyAccepted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Подтверждаю, что я ознакомился с ',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: ClickableText(
|
||||
text: 'Политикой обработки персональных данных',
|
||||
onTap: () => context.push('/privacy-policy'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 47),
|
||||
|
||||
GradientButton(
|
||||
text: "Получить код",
|
||||
enabled: state.phone.isNotEmpty &&
|
||||
isAdult &&
|
||||
privacyAccepted &&
|
||||
!state.isSubmitting,
|
||||
onTap: state.isSubmitting
|
||||
? null
|
||||
: () {
|
||||
context.read<PhoneAuthBloc>().add(SubmitPhonePressed());
|
||||
},
|
||||
showArrows: true,
|
||||
height: 50,
|
||||
width: 340,
|
||||
fontSize: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClickableText extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ClickableText({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.blueAccent,
|
||||
decorationColor: Colors.blueAccent,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../event/map_event.dart';
|
||||
import '../event/spalsh_event.dart';
|
||||
import '../viewmodel/map_bloc.dart';
|
||||
import '../viewmodel/pin_bloc.dart';
|
||||
import '../event/pin_event.dart';
|
||||
import '../state/pin_state.dart';
|
||||
import '../viewmodel/splash_bloc.dart';
|
||||
|
||||
class PinLoginScreen extends StatefulWidget {
|
||||
const PinLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PinLoginScreen> createState() => _PinLoginScreenState();
|
||||
}
|
||||
|
||||
class _PinLoginScreenState extends State<PinLoginScreen> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final text = controller.text;
|
||||
context.read<PinBloc>().add(PinDigitChanged(text));
|
||||
|
||||
if (text.length == 6) {
|
||||
context.read<PinBloc>().add(PinSubmitted(text));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PinBloc, PinState>(
|
||||
listener: (context, state) {
|
||||
if (state is PinSuccess) {
|
||||
context.read<SplashBloc>().add(PinVerificationSuccess());
|
||||
context.go("/home");
|
||||
}
|
||||
if (state.error != null) {
|
||||
controller.clear();
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: GestureDetector(
|
||||
onTap: () => focusNode.requestFocus(),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 310, left: 0, right: 0,
|
||||
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
|
||||
),
|
||||
BlocBuilder<PinBloc, PinState>(
|
||||
builder: (context, state) {
|
||||
final title = state is PinCreateInProgress
|
||||
? "Создайте PIN-код"
|
||||
: "Введите PIN-код";
|
||||
|
||||
final subtitle = state is PinCreateInProgress
|
||||
? "Запомните PIN-код"
|
||||
: "Введите ваш пароль для входа";
|
||||
|
||||
if (state is PinLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 35),
|
||||
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CodeDots(
|
||||
code: state.pin,
|
||||
length: 6,
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 50,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
showCursor: false,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
counterText: "",
|
||||
),
|
||||
style: const TextStyle(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Блок ошибки
|
||||
if (state.error != null)
|
||||
Text(
|
||||
state.error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: AppColors.white70),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (state is! PinCreateInProgress)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<MapBloc>().add(LogoutPressed());
|
||||
context.read<SplashBloc>().add(AuthCheckRequested());
|
||||
},
|
||||
child: const Text(
|
||||
"Забыли PIN-код?",
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../event/map_event.dart';
|
||||
import '../event/spalsh_event.dart';
|
||||
import '../viewmodel/map_bloc.dart';
|
||||
import '../viewmodel/pin_bloc.dart';
|
||||
import '../event/pin_event.dart';
|
||||
import '../state/pin_state.dart';
|
||||
import '../viewmodel/splash_bloc.dart';
|
||||
|
||||
class PinLoginScreen extends StatefulWidget {
|
||||
const PinLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PinLoginScreen> createState() => _PinLoginScreenState();
|
||||
}
|
||||
|
||||
class _PinLoginScreenState extends State<PinLoginScreen> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final text = controller.text;
|
||||
context.read<PinBloc>().add(PinDigitChanged(text));
|
||||
|
||||
if (text.length == 6) {
|
||||
context.read<PinBloc>().add(PinSubmitted(text));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PinBloc, PinState>(
|
||||
listener: (context, state) {
|
||||
if (state is PinSuccess) {
|
||||
context.read<SplashBloc>().add(PinVerificationSuccess());
|
||||
context.go("/home");
|
||||
}
|
||||
if (state.error != null) {
|
||||
controller.clear();
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: GestureDetector(
|
||||
onTap: () => focusNode.requestFocus(),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 310, left: 0, right: 0,
|
||||
child: Image.asset("assets/wave.png", fit: BoxFit.cover),
|
||||
),
|
||||
BlocBuilder<PinBloc, PinState>(
|
||||
builder: (context, state) {
|
||||
final title = state is PinCreateInProgress
|
||||
? "Создайте PIN-код"
|
||||
: "Введите PIN-код";
|
||||
|
||||
final subtitle = state is PinCreateInProgress
|
||||
? "Запомните PIN-код"
|
||||
: "Введите ваш пароль для входа";
|
||||
|
||||
if (state is PinLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 35),
|
||||
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CodeDots(
|
||||
code: state.pin,
|
||||
length: 6,
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 50,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
showCursor: false,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
counterText: "",
|
||||
),
|
||||
style: const TextStyle(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Блок ошибки
|
||||
if (state.error != null)
|
||||
Text(
|
||||
state.error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.pinError,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: AppColors.white70),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (state is! PinCreateInProgress)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<MapBloc>().add(LogoutPressed());
|
||||
context.read<SplashBloc>().add(AuthCheckRequested());
|
||||
},
|
||||
child: const Text(
|
||||
"Забыли PIN-код?",
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,171 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class PrivacyPolicyScreen extends StatelessWidget {
|
||||
const PrivacyPolicyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ''),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Политика конфиденциальности',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Настоящая Политика конфиденциальности (далее - Политика) действует в отношении всей информации, которую Общество с ограниченной ответственностью “БИХЕППИБЕЛ”, зарегистрированное по адресу: Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А УНП: 392050943 Регистрационный номер: 392026683, (далее – Компания), получает о Пользователе в процессе регистрации, авторизации и иного использования Сайта https://behappybel.by, (далее - Сайта), Приложения Be Happy, Сервиса Be Happy в соответствии с размещенными на Сайте и в Приложении Be Happy Пользовательским соглашением и Договором присоединения.\n\n'
|
||||
'Выраженное в соответствии с Политикой согласие Пользователя на обработку предоставляемых им Компании персональных данных и иной информации, считается, одновременно, предоставленным Пользователем указанным в Политике третьим лицам, привлекаемым Компанией для содействия в исполнении Пользовательского соглашения и Договора аренды.\n\n'
|
||||
'Использование Сайта, Приложения Be Happy, Сервиса Be Happy (в том числе, осуществление Пользователем регистрации, авторизации) означает безоговорочное согласие Пользователя с Политикой и всеми указанными в ней условиями обработки его персональных данных и иной информации; в случае несогласия с Политикой и всеми ее условиями Пользователь должен воздержаться от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('1. Персональные данные и иная информация Пользователя, которую получает и обрабатывает Компания\n'),
|
||||
_buildParagraph(
|
||||
'1.1. При регистрации, авторизации Пользователя, а также при использовании Сайта, Приложения Be Happy, Сервиса Be Happy, осуществлении оплат, проведении опросов, рассылке информационных и рекламных сообщений и во всех иных случаях, предусмотренных Пользовательским соглашением и Договором присоединения, Компания может запросить у Пользователя (п.1.1.1. Политики) и/или получить автоматически (п.1.1.2. Политики) следующую информацию о Пользователе:\n\n'
|
||||
'1.1.1. имя, номер мобильного телефона, адрес электронной почты, информацию о логине и пароле для доступа к отдельным функциям Сайта, Приложения Be Happy, Сервиса Be Happy, историю пользования Сервисом Be Happy (информацию о количестве, стоимости, времени и порядке произведенных Пользователями заказов на услуги Сервиса Be Happy и их оплате, в том числе данные о банковском счете и/или счете банковской карты), информацию об участии в рекламных акциях, информацию о подписке на информационную рассылку или материалы службы поддержки), реквизиты банка для возврата денежных средств, а также иные данные и информация;\n\n'
|
||||
'1.1.2. информация, которая автоматически передается Компании в процессе использования Сайта, Приложения Be Happy, Сервиса Be Happy, с помощью установленного на устройстве Пользователя программного обеспечения, в том числе IP-адрес, информация из cookie и tracking bugs, информация о стране и (или) городе нахождения Пользователя, информация об Интернет-браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy), время доступа, адрес запрашиваемой страницы об устройствах Пользователя, с помощью которых осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy.\n\n'
|
||||
'1.2. Настоящая Политика применима только к Сайту, Приложению Be Happy, Сервису Be Happy. Компания не контролирует и не несет ответственность за сайты и программное обеспечение третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на Сайте, в Приложении Be Happy. На иных сайтах третьих лиц у Пользователя может собираться или запрашиваться иная информация, а также могут совершаться иные действия, за которые Компания не несет ответственности.\n\n'
|
||||
'1.3. Компания исходит из того, что Пользователь является совершеннолетним дееспособным лицом, соответствующим требованиям Пользовательского соглашения и Договора присоединения, предоставляет достоверную и достаточную информацию и поддерживает эту информацию в актуальном состоянии. Компания вправе осуществить проверку предоставленной Пользователем информации в соответствии с положениями Пользовательского соглашения и Договора присоединения. В случае предоставления Пользователем недостоверной информации, Компания имеет право приостановить либо отменить регистрацию и/или отказать Пользователю в предоставлении доступа к Сайту, Приложению Be Happy, Сервису Be Happy. За предоставление недостоверной информации и возникшие вследствие этого негативные последствия Компания и/или иные третьи лица ответственности не несут. Если использование Сайта, Приложения Be Happy, Сервиса Be Happy осуществило несовершеннолетнее и/или недееспособное лицо, то ответственность за такое несанкционированное Компанией использование несут родители, усыновители и иные законные представители несовершеннолетнего и/или недееспособного лица.\n\n'
|
||||
'1.4 Пользователь дает свое согласие на осуществление Арендодателем записи разговоров Пользователя со Службой поддержки и предоставление такой записи третьим лицам.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('2. Цели сбора и обработки данных и иной информации Пользователя\n'),
|
||||
_buildParagraph(
|
||||
'2.1. Компания использует данные и иную информацию Пользователя для целей заключения и исполнения Пользовательского соглашения, заключения и исполнения Договора присоединения, оказания дополнительных услуг, повышения качества сервиса, участия Пользователя в проводимых Компанией акциях, опросах, исследованиях (включая, но не ограничиваясь проведением опросов, исследований посредством электронной, телефонной и сотовой связи), принятия решений или совершения иных действий, порождающих юридические последствия в отношении Пользователя или других лиц, представления Пользователю информации об оказываемых Компанией услугах, предоставления Компанией консультационных услуг. Указанные цели использования персональных данных распространяются на всю информацию, указанную в пункте 1.1 Политики.\n\n'
|
||||
'2.2. Цели сбора и обработки персональных данных включают, без ограничений, следующие:\n\n'
|
||||
'2.2.1. регистрацию, идентификацию и авторизацию Пользователя в рамках Сервиса Be Happy;\n'
|
||||
'2.2.2. заключение и исполнение Пользовательского соглашения и Договора присоединения;\n'
|
||||
'2.2.3. предоставление Пользователю Сервиса Be Happy, а также любого дополнительного функционала в рамках Сервиса Be Happy;\n'
|
||||
'2.2.4. обработка запросов Пользователей Компанией в рамках Сервиса Be Happy;\n'
|
||||
'2.2.5. анализ и исследования возможностей улучшения Сервиса Be Happy;\n'
|
||||
'2.2.6. рассылка новостей и информации о продуктах, услугах, специальных предложениях, связанных с Сервисом Be Happy;\n'
|
||||
'2.2.7. рассылка служебных сообщений (например, для информирования о статусе аренды самоката, восстановления/изменения логина и пароля Пользователя и пр.);\n'
|
||||
'2.2.8. предотвращение и выявление мошенничества и незаконного использования Сервиса Be Happy;\n'
|
||||
'2.2.9. проведение статистических и иных исследований на основе обезличенных данных.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('3. Условия, способы и порядок обработки персональных данных и иной персональной информации Пользователя\n'),
|
||||
_buildParagraph(
|
||||
'3.1. Компания использует персональные данные и иную информацию Пользователя только для целей, указанных в Политике и в соответствии с Политикой.\n\n'
|
||||
'3.2. В отношении персональных данных и иной информации Пользователя Компанией соблюдается конфиденциальность.\n\n'
|
||||
'3.3. Компания не будет раскрывать третьим лицам, распространять, продавать или иным образом распоряжаться полученными персональными данными и иной информацией, кроме как для целей, способами и в пределах, предусмотренных настоящей Политикой.\n\n'
|
||||
'3.4. Обработка персональных и иных данных Пользователя осуществляется Компанией в объеме, который необходим для достижения каждой из целей, указанных в разделе 2 Политики, следующими возможными способами: сбор, запись (в том числе на электронные носители), систематизация, накопление, хранение, составление перечней, маркировка, уточнение (обновление, изменение), извлечение, использование, передача (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение, трансграничная передача персональных данных, получение изображения путем фотографирования, а также осуществление любых иных действий с персональными данными Пользователя с учетом применимого права. Компания вправе осуществлять обработку персональных и иных данных Пользователя как с использованием автоматизированных средств обработки персональных данных Пользователя, так и без использования средств автоматизации.\n\n'
|
||||
'3.5. Компания вправе передавать предоставленные ей Пользователем персональные данные для их обработки (давать поручение на обработку) (в объеме, необходимом для выполнения Компанией своих обязательств) третьим лицам, в том числе, организациям, которые привлекаются Компанией для осуществления информационной отправки сообщений посредством электронной почты/операторов мобильной связи, осуществляют списание/зачисление денежных средств с/на банковской(-ую) карты(- у)/расчетный счет - кредитным организациям (банкам), платежным системам, операторам мобильной связи, курьерским службам, организациями почтовой связи, включая трансграничную передачу персональных данных Пользователя в письменной либо электронной форме, в случаях и в порядке, предусмотренном соответствующими договорами с указанными третьими лицами, правилами Компании, применимым правом.\n\n'
|
||||
'3.6. Компания также вправе передавать предоставленные ей Пользователем персональные данные государственным органам, суду, иным уполномоченным органам и организациям, в случаях и в порядке, когда это требуется в соответствии с применяемым к Политике правом.\n\n'
|
||||
'3.7. Компания гарантирует добросовестную и законную обработку персональных и иных данных Пользователя в соответствии с предусмотренными в разделе 2 Политики целями.\n\n'
|
||||
'3.8. Компания гарантирует незамедлительное обновление данных Пользователя в случае предоставления им обновленных данных.\n\n'
|
||||
'3.9. Согласие на обработку персональных данных и иных данных дается Пользователем на бессрочной основе, либо до истечения сроков хранения соответствующей информации или документов, содержащих вышеуказанную информацию, определяемых в соответствии с применимым к Политике правом. По истечении указанного срока персональные данные подлежат уничтожению Компанией.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('4. Изменение или удаление информации Пользователем. Отзыв согласия на обработку персональных данных\n\n'),
|
||||
_buildParagraph(
|
||||
'4.1. Пользователь может в любой момент изменить (обновить, дополнить) предоставленные им персональные данные и иную информацию обратившись к Компании, например, через службу поддержки или с использованием контактов, указанных на Сайте, в Приложении Be Happy с запросом об изменении (обновлении, дополнении) предоставленной им ранее информации (Компания изменяет (обновляет, дополняет) предоставленную Пользователем информацию только после проведения применяемой в Компании на момент соответствующего обращения процедурой идентификации Пользователя).\n\n'
|
||||
'4.2. Пользователь вправе отозвать свое согласие на обработку персональных данных путем направления соответствующего письменного уведомления Компании не менее чем за 30 (тридцать календарных) дней до момента отзыва согласия, при этом Пользователь признает и понимает, что доступ к пользованию Сервисами Сайта и Приложения Be Happy, Сервису Be Happy не будет предоставляться Компанией с того момента, когда Компания лишилась возможности обрабатывать персональные данные Пользователя.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('5. Защита информации Пользователей\n'),
|
||||
_buildParagraph(
|
||||
'5.1. Компания обеспечивает принятие необходимых и достаточных организационных и технических мер для защиты персональных данных и иной информации Пользователей от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий с ней третьих лиц.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('6. Файлы cookies и tracking bugs\n'),
|
||||
_buildParagraph(
|
||||
'6.1. Для улучшения качества предоставления Сервиса Be Happy Компания может использовать (временные и постоянные) cookie-файлы, tracking bugs и/или другие технологии сбора не носящих личный характер данных (например, IP-адрес, тип браузера и данные о провайдере службы Интернет (ISP), а также (для Пользователей, которые пользуются услугами Компании через мобильное устройство), уникальный идентификатор устройства, данные об операционной системе и координаты с целью учёта количества Пользователей и их поведения при пользовании Сервисом Be Happy. Для повышения удобства Пользователей Компания вправе собирать и обрабатывать информацию об общем количестве операций, страниц, просмотренных Пользователем, ссылающихся/исходных страниц, типе платформы, дате/времени фиксирования информации, количестве и месте просмотров данной страницы, просмотра страницы и использованных (поисковых) слов.\n\n'
|
||||
'6.2. Информация о cookies и tracking bugs:\n\n'
|
||||
'6.2.1.Файл «cookie» - небольшой текстовый файл, отправляемый на браузер устройства Пользователя с используемого Компанией сервера. Cookies содержат информацию, которая позже может быть использована Компанией. Браузер будет хранить эту информацию и передавать ее обратно с каждым запросом Пользователя Компании. Одни значения cookies могут храниться только в течение одной сессии и удаляются после закрытия браузера. Другие, установленные на некоторый период времени, записываются в специальный файл на жестком диске и хранятся на устройстве Пользователя. Cookies используются для идентификации, отслеживания сессий (поддержания состояния) и сохранения информации о Пользователе, включая предпочтения при пользовании Сервисом Be Happy. Используемые Компанией сookies собирают только анонимные данные.\n\n'
|
||||
'6.2.2. Файл tracking bugs - это графические объекты, встроенные в веб-страницы или в сообщения e-mail. Tracking bugs используются с различными целями включные отчёты о количестве Пользователей. Используемые Компанией tracking bugs собирают только анонимные данные.\n\n'
|
||||
'6.3. Компания может использовать cookies и tracking bugs в целях контроля использования Сервиса Be Happy,сбора информации неличного характера о Пользователе, сохранения предпочтений и другой информации на устройстве Пользователя для того, чтобы сэкономить время Пользователя, необходимое для многократного введения в формах Сайта, Приложения Be Happy одной и той же информации, а также в целях отображения содержания в ходе последующих посещений Пользователем Сайта, Приложения Be Happy. Информация, полученная посредством cookies и tracking bugs, также может использоваться Компанией для статистических исследований, направленных на корректировку содержания Сайта, Приложения Be Happy в соответствии с предпочтениями Пользователя.\n\n'
|
||||
'6.4. Компания может предоставить Пользователю возможность изменить настройки приема файлов cookies и tracking bugs в настройках своего браузера или отключить их полностью, однако в таком случае некоторые функции Сервиса Be Happy могут работать некорректно.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('7. Внесение изменений в Политику. Согласие Пользователя с Политикой\n'),
|
||||
_buildParagraph(
|
||||
'7.1. Пользователь признает и соглашается, что регистрация Пользователя на Сайте, в Приложении Be Happy и последующее использование Сервиса Be Happy, любых его служб, функционала означает безоговорочное согласие Пользователя со всеми пунктами настоящей Политики и безоговорочное принятие ее условий.\n\n'
|
||||
'7.2. Продолжение Пользователем использования Сервиса Be Happy после любых изменений и/или дополнений Политики означает его согласие с такими изменениями и/или дополнениями.\n\n'
|
||||
'7.3. Пользователь обязуется регулярно знакомиться с содержанием Политики в целях своевременного ознакомления с ее изменениями/дополнениями.\n\n'
|
||||
'7.4. Компания оставляет за собой право по своему усмотрению изменять и (или) дополнять Политику в любое время без предварительного и (или) последующего уведомления Пользователя. Новая редакция Политики вступает в силу с момента ее размещения на Сайте и/или в Приложении Be Happy, если иное не предусмотрено новой редакцией Политики. Действующая редакция Политики всегда доступна на Сайте и/или в Приложении Be Happy.\n\n'
|
||||
'Уважаемый Пользователь, если Вы не согласны с положениями Политики, откажитесь от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('8. Заключительные положения\n'),
|
||||
_buildParagraph(
|
||||
'8.1. К Политике и возникающими в связи с применением Политики отношениям между Пользователями и Компанией подлежит применению право Республики Беларусь.\n\n'
|
||||
'8.2. Все возможные споры по поводу настоящей Политики конфиденциальности и отношений между пользователем и Сервисом будут разрешаться по нормам белорусского права в суде по месту нахождения Администрации сайта, если иное прямо не предусмотрено законодательством РБ.\n\n'
|
||||
'8.3. Соглашаясь с условиями Политики, Пользователь дает согласие на обработку персональных и иных данных своей волей и в своем интересе.\n\n'
|
||||
'8.4. Отказ от предоставления персональных данных и иной необходимой для использования Сайта, Приложения Be Happy, Сервиса Be Happy информации влечет невозможность для Компании предоставлять Пользователю Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class PrivacyPolicyScreen extends StatelessWidget {
|
||||
const PrivacyPolicyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: ''),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Политика конфиденциальности',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildParagraph(
|
||||
'Настоящая Политика конфиденциальности (далее - Политика) действует в отношении всей информации, которую Общество с ограниченной ответственностью “БИХЕППИБЕЛ”, зарегистрированное по адресу: Республика Беларусь, 210017 Витебская область, Октябрьский район, г. Витебск, ул. Гагарина, дом 105, корп. Б, оф. 11А УНП: 392050943 Регистрационный номер: 392026683, (далее – Компания), получает о Пользователе в процессе регистрации, авторизации и иного использования Сайта https://behappybel.by, (далее - Сайта), Приложения Be Happy, Сервиса Be Happy в соответствии с размещенными на Сайте и в Приложении Be Happy Пользовательским соглашением и Договором присоединения.\n\n'
|
||||
'Выраженное в соответствии с Политикой согласие Пользователя на обработку предоставляемых им Компании персональных данных и иной информации, считается, одновременно, предоставленным Пользователем указанным в Политике третьим лицам, привлекаемым Компанией для содействия в исполнении Пользовательского соглашения и Договора аренды.\n\n'
|
||||
'Использование Сайта, Приложения Be Happy, Сервиса Be Happy (в том числе, осуществление Пользователем регистрации, авторизации) означает безоговорочное согласие Пользователя с Политикой и всеми указанными в ней условиями обработки его персональных данных и иной информации; в случае несогласия с Политикой и всеми ее условиями Пользователь должен воздержаться от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('1. Персональные данные и иная информация Пользователя, которую получает и обрабатывает Компания\n'),
|
||||
_buildParagraph(
|
||||
'1.1. При регистрации, авторизации Пользователя, а также при использовании Сайта, Приложения Be Happy, Сервиса Be Happy, осуществлении оплат, проведении опросов, рассылке информационных и рекламных сообщений и во всех иных случаях, предусмотренных Пользовательским соглашением и Договором присоединения, Компания может запросить у Пользователя (п.1.1.1. Политики) и/или получить автоматически (п.1.1.2. Политики) следующую информацию о Пользователе:\n\n'
|
||||
'1.1.1. имя, номер мобильного телефона, адрес электронной почты, информацию о логине и пароле для доступа к отдельным функциям Сайта, Приложения Be Happy, Сервиса Be Happy, историю пользования Сервисом Be Happy (информацию о количестве, стоимости, времени и порядке произведенных Пользователями заказов на услуги Сервиса Be Happy и их оплате, в том числе данные о банковском счете и/или счете банковской карты), информацию об участии в рекламных акциях, информацию о подписке на информационную рассылку или материалы службы поддержки), реквизиты банка для возврата денежных средств, а также иные данные и информация;\n\n'
|
||||
'1.1.2. информация, которая автоматически передается Компании в процессе использования Сайта, Приложения Be Happy, Сервиса Be Happy, с помощью установленного на устройстве Пользователя программного обеспечения, в том числе IP-адрес, информация из cookie и tracking bugs, информация о стране и (или) городе нахождения Пользователя, информация об Интернет-браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy), время доступа, адрес запрашиваемой страницы об устройствах Пользователя, с помощью которых осуществляется доступ к Сайту, Приложению Be Happy, Сервису Be Happy.\n\n'
|
||||
'1.2. Настоящая Политика применима только к Сайту, Приложению Be Happy, Сервису Be Happy. Компания не контролирует и не несет ответственность за сайты и программное обеспечение третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на Сайте, в Приложении Be Happy. На иных сайтах третьих лиц у Пользователя может собираться или запрашиваться иная информация, а также могут совершаться иные действия, за которые Компания не несет ответственности.\n\n'
|
||||
'1.3. Компания исходит из того, что Пользователь является совершеннолетним дееспособным лицом, соответствующим требованиям Пользовательского соглашения и Договора присоединения, предоставляет достоверную и достаточную информацию и поддерживает эту информацию в актуальном состоянии. Компания вправе осуществить проверку предоставленной Пользователем информации в соответствии с положениями Пользовательского соглашения и Договора присоединения. В случае предоставления Пользователем недостоверной информации, Компания имеет право приостановить либо отменить регистрацию и/или отказать Пользователю в предоставлении доступа к Сайту, Приложению Be Happy, Сервису Be Happy. За предоставление недостоверной информации и возникшие вследствие этого негативные последствия Компания и/или иные третьи лица ответственности не несут. Если использование Сайта, Приложения Be Happy, Сервиса Be Happy осуществило несовершеннолетнее и/или недееспособное лицо, то ответственность за такое несанкционированное Компанией использование несут родители, усыновители и иные законные представители несовершеннолетнего и/или недееспособного лица.\n\n'
|
||||
'1.4 Пользователь дает свое согласие на осуществление Арендодателем записи разговоров Пользователя со Службой поддержки и предоставление такой записи третьим лицам.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('2. Цели сбора и обработки данных и иной информации Пользователя\n'),
|
||||
_buildParagraph(
|
||||
'2.1. Компания использует данные и иную информацию Пользователя для целей заключения и исполнения Пользовательского соглашения, заключения и исполнения Договора присоединения, оказания дополнительных услуг, повышения качества сервиса, участия Пользователя в проводимых Компанией акциях, опросах, исследованиях (включая, но не ограничиваясь проведением опросов, исследований посредством электронной, телефонной и сотовой связи), принятия решений или совершения иных действий, порождающих юридические последствия в отношении Пользователя или других лиц, представления Пользователю информации об оказываемых Компанией услугах, предоставления Компанией консультационных услуг. Указанные цели использования персональных данных распространяются на всю информацию, указанную в пункте 1.1 Политики.\n\n'
|
||||
'2.2. Цели сбора и обработки персональных данных включают, без ограничений, следующие:\n\n'
|
||||
'2.2.1. регистрацию, идентификацию и авторизацию Пользователя в рамках Сервиса Be Happy;\n'
|
||||
'2.2.2. заключение и исполнение Пользовательского соглашения и Договора присоединения;\n'
|
||||
'2.2.3. предоставление Пользователю Сервиса Be Happy, а также любого дополнительного функционала в рамках Сервиса Be Happy;\n'
|
||||
'2.2.4. обработка запросов Пользователей Компанией в рамках Сервиса Be Happy;\n'
|
||||
'2.2.5. анализ и исследования возможностей улучшения Сервиса Be Happy;\n'
|
||||
'2.2.6. рассылка новостей и информации о продуктах, услугах, специальных предложениях, связанных с Сервисом Be Happy;\n'
|
||||
'2.2.7. рассылка служебных сообщений (например, для информирования о статусе аренды самоката, восстановления/изменения логина и пароля Пользователя и пр.);\n'
|
||||
'2.2.8. предотвращение и выявление мошенничества и незаконного использования Сервиса Be Happy;\n'
|
||||
'2.2.9. проведение статистических и иных исследований на основе обезличенных данных.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('3. Условия, способы и порядок обработки персональных данных и иной персональной информации Пользователя\n'),
|
||||
_buildParagraph(
|
||||
'3.1. Компания использует персональные данные и иную информацию Пользователя только для целей, указанных в Политике и в соответствии с Политикой.\n\n'
|
||||
'3.2. В отношении персональных данных и иной информации Пользователя Компанией соблюдается конфиденциальность.\n\n'
|
||||
'3.3. Компания не будет раскрывать третьим лицам, распространять, продавать или иным образом распоряжаться полученными персональными данными и иной информацией, кроме как для целей, способами и в пределах, предусмотренных настоящей Политикой.\n\n'
|
||||
'3.4. Обработка персональных и иных данных Пользователя осуществляется Компанией в объеме, который необходим для достижения каждой из целей, указанных в разделе 2 Политики, следующими возможными способами: сбор, запись (в том числе на электронные носители), систематизация, накопление, хранение, составление перечней, маркировка, уточнение (обновление, изменение), извлечение, использование, передача (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение, трансграничная передача персональных данных, получение изображения путем фотографирования, а также осуществление любых иных действий с персональными данными Пользователя с учетом применимого права. Компания вправе осуществлять обработку персональных и иных данных Пользователя как с использованием автоматизированных средств обработки персональных данных Пользователя, так и без использования средств автоматизации.\n\n'
|
||||
'3.5. Компания вправе передавать предоставленные ей Пользователем персональные данные для их обработки (давать поручение на обработку) (в объеме, необходимом для выполнения Компанией своих обязательств) третьим лицам, в том числе, организациям, которые привлекаются Компанией для осуществления информационной отправки сообщений посредством электронной почты/операторов мобильной связи, осуществляют списание/зачисление денежных средств с/на банковской(-ую) карты(- у)/расчетный счет - кредитным организациям (банкам), платежным системам, операторам мобильной связи, курьерским службам, организациями почтовой связи, включая трансграничную передачу персональных данных Пользователя в письменной либо электронной форме, в случаях и в порядке, предусмотренном соответствующими договорами с указанными третьими лицами, правилами Компании, применимым правом.\n\n'
|
||||
'3.6. Компания также вправе передавать предоставленные ей Пользователем персональные данные государственным органам, суду, иным уполномоченным органам и организациям, в случаях и в порядке, когда это требуется в соответствии с применяемым к Политике правом.\n\n'
|
||||
'3.7. Компания гарантирует добросовестную и законную обработку персональных и иных данных Пользователя в соответствии с предусмотренными в разделе 2 Политики целями.\n\n'
|
||||
'3.8. Компания гарантирует незамедлительное обновление данных Пользователя в случае предоставления им обновленных данных.\n\n'
|
||||
'3.9. Согласие на обработку персональных данных и иных данных дается Пользователем на бессрочной основе, либо до истечения сроков хранения соответствующей информации или документов, содержащих вышеуказанную информацию, определяемых в соответствии с применимым к Политике правом. По истечении указанного срока персональные данные подлежат уничтожению Компанией.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('4. Изменение или удаление информации Пользователем. Отзыв согласия на обработку персональных данных\n\n'),
|
||||
_buildParagraph(
|
||||
'4.1. Пользователь может в любой момент изменить (обновить, дополнить) предоставленные им персональные данные и иную информацию обратившись к Компании, например, через службу поддержки или с использованием контактов, указанных на Сайте, в Приложении Be Happy с запросом об изменении (обновлении, дополнении) предоставленной им ранее информации (Компания изменяет (обновляет, дополняет) предоставленную Пользователем информацию только после проведения применяемой в Компании на момент соответствующего обращения процедурой идентификации Пользователя).\n\n'
|
||||
'4.2. Пользователь вправе отозвать свое согласие на обработку персональных данных путем направления соответствующего письменного уведомления Компании не менее чем за 30 (тридцать календарных) дней до момента отзыва согласия, при этом Пользователь признает и понимает, что доступ к пользованию Сервисами Сайта и Приложения Be Happy, Сервису Be Happy не будет предоставляться Компанией с того момента, когда Компания лишилась возможности обрабатывать персональные данные Пользователя.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('5. Защита информации Пользователей\n'),
|
||||
_buildParagraph(
|
||||
'5.1. Компания обеспечивает принятие необходимых и достаточных организационных и технических мер для защиты персональных данных и иной информации Пользователей от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий с ней третьих лиц.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('6. Файлы cookies и tracking bugs\n'),
|
||||
_buildParagraph(
|
||||
'6.1. Для улучшения качества предоставления Сервиса Be Happy Компания может использовать (временные и постоянные) cookie-файлы, tracking bugs и/или другие технологии сбора не носящих личный характер данных (например, IP-адрес, тип браузера и данные о провайдере службы Интернет (ISP), а также (для Пользователей, которые пользуются услугами Компании через мобильное устройство), уникальный идентификатор устройства, данные об операционной системе и координаты с целью учёта количества Пользователей и их поведения при пользовании Сервисом Be Happy. Для повышения удобства Пользователей Компания вправе собирать и обрабатывать информацию об общем количестве операций, страниц, просмотренных Пользователем, ссылающихся/исходных страниц, типе платформы, дате/времени фиксирования информации, количестве и месте просмотров данной страницы, просмотра страницы и использованных (поисковых) слов.\n\n'
|
||||
'6.2. Информация о cookies и tracking bugs:\n\n'
|
||||
'6.2.1.Файл «cookie» - небольшой текстовый файл, отправляемый на браузер устройства Пользователя с используемого Компанией сервера. Cookies содержат информацию, которая позже может быть использована Компанией. Браузер будет хранить эту информацию и передавать ее обратно с каждым запросом Пользователя Компании. Одни значения cookies могут храниться только в течение одной сессии и удаляются после закрытия браузера. Другие, установленные на некоторый период времени, записываются в специальный файл на жестком диске и хранятся на устройстве Пользователя. Cookies используются для идентификации, отслеживания сессий (поддержания состояния) и сохранения информации о Пользователе, включая предпочтения при пользовании Сервисом Be Happy. Используемые Компанией сookies собирают только анонимные данные.\n\n'
|
||||
'6.2.2. Файл tracking bugs - это графические объекты, встроенные в веб-страницы или в сообщения e-mail. Tracking bugs используются с различными целями включные отчёты о количестве Пользователей. Используемые Компанией tracking bugs собирают только анонимные данные.\n\n'
|
||||
'6.3. Компания может использовать cookies и tracking bugs в целях контроля использования Сервиса Be Happy,сбора информации неличного характера о Пользователе, сохранения предпочтений и другой информации на устройстве Пользователя для того, чтобы сэкономить время Пользователя, необходимое для многократного введения в формах Сайта, Приложения Be Happy одной и той же информации, а также в целях отображения содержания в ходе последующих посещений Пользователем Сайта, Приложения Be Happy. Информация, полученная посредством cookies и tracking bugs, также может использоваться Компанией для статистических исследований, направленных на корректировку содержания Сайта, Приложения Be Happy в соответствии с предпочтениями Пользователя.\n\n'
|
||||
'6.4. Компания может предоставить Пользователю возможность изменить настройки приема файлов cookies и tracking bugs в настройках своего браузера или отключить их полностью, однако в таком случае некоторые функции Сервиса Be Happy могут работать некорректно.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('7. Внесение изменений в Политику. Согласие Пользователя с Политикой\n'),
|
||||
_buildParagraph(
|
||||
'7.1. Пользователь признает и соглашается, что регистрация Пользователя на Сайте, в Приложении Be Happy и последующее использование Сервиса Be Happy, любых его служб, функционала означает безоговорочное согласие Пользователя со всеми пунктами настоящей Политики и безоговорочное принятие ее условий.\n\n'
|
||||
'7.2. Продолжение Пользователем использования Сервиса Be Happy после любых изменений и/или дополнений Политики означает его согласие с такими изменениями и/или дополнениями.\n\n'
|
||||
'7.3. Пользователь обязуется регулярно знакомиться с содержанием Политики в целях своевременного ознакомления с ее изменениями/дополнениями.\n\n'
|
||||
'7.4. Компания оставляет за собой право по своему усмотрению изменять и (или) дополнять Политику в любое время без предварительного и (или) последующего уведомления Пользователя. Новая редакция Политики вступает в силу с момента ее размещения на Сайте и/или в Приложении Be Happy, если иное не предусмотрено новой редакцией Политики. Действующая редакция Политики всегда доступна на Сайте и/или в Приложении Be Happy.\n\n'
|
||||
'Уважаемый Пользователь, если Вы не согласны с положениями Политики, откажитесь от использования Сайта, Приложения Be Happy, Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSectionHeader('8. Заключительные положения\n'),
|
||||
_buildParagraph(
|
||||
'8.1. К Политике и возникающими в связи с применением Политики отношениям между Пользователями и Компанией подлежит применению право Республики Беларусь.\n\n'
|
||||
'8.2. Все возможные споры по поводу настоящей Политики конфиденциальности и отношений между пользователем и Сервисом будут разрешаться по нормам белорусского права в суде по месту нахождения Администрации сайта, если иное прямо не предусмотрено законодательством РБ.\n\n'
|
||||
'8.3. Соглашаясь с условиями Политики, Пользователь дает согласие на обработку персональных и иных данных своей волей и в своем интересе.\n\n'
|
||||
'8.4. Отказ от предоставления персональных данных и иной необходимой для использования Сайта, Приложения Be Happy, Сервиса Be Happy информации влечет невозможность для Компании предоставлять Пользователю Сервиса Be Happy.',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParagraph(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFD1D1D6),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +1,373 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/profile_bloc.dart';
|
||||
import 'edit_profile_screen.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../state/profile_state.dart';
|
||||
import '../event/profile_event.dart';
|
||||
|
||||
class ProfileScreen extends StatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
bool notificationsEnabled = false;
|
||||
XFile? _avatarImage;
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final pickedImage = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
|
||||
if (pickedImage == null) return;
|
||||
|
||||
context.read<ProfileBloc>().add(
|
||||
ProfilePhotoUpdated(File(pickedImage.path)),
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
print("Error picking or uploading image: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEditProfile(UserProfile profile) async {
|
||||
context.go("/home/profile/edit");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final profile = state.profile!;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Профиль'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: AppColors.checkboxFill,
|
||||
backgroundImage:
|
||||
(profile.avatarUrl != null &&
|
||||
profile.avatarUrl!.isNotEmpty)
|
||||
? NetworkImage(
|
||||
"${profile.avatarUrl!}?v=${DateTime.now().minute}",
|
||||
)
|
||||
: null,
|
||||
child:
|
||||
(profile.avatarUrl == null ||
|
||||
profile.avatarUrl!.isEmpty)
|
||||
? Text(
|
||||
profile.name.isNotEmpty
|
||||
? profile.name[0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
fontSize: 50,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
child: Image.asset(
|
||||
'assets/icons/edit.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_ProfileInfoBlock(
|
||||
profile: profile,
|
||||
onEditTap: () => context.go("/home/profile/edit"),
|
||||
),
|
||||
|
||||
// const SizedBox(height: 24),
|
||||
// _SettingsBlock(
|
||||
// notificationsEnabled: notificationsEnabled,
|
||||
// onNotificationsChanged: (v) =>
|
||||
// setState(() => notificationsEnabled = v),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileInfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String value;
|
||||
final Widget? trailing;
|
||||
|
||||
const _ProfileInfoRow({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileInfoBlock extends StatelessWidget {
|
||||
final UserProfile profile;
|
||||
final VoidCallback onEditTap;
|
||||
|
||||
const _ProfileInfoBlock({required this.profile, required this.onEditTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Личные данные',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_ProfileInfoRow(icon: Icons.person, value: profile.name),
|
||||
_ProfileInfoRow(icon: Icons.calendar_today, value: profile.birthDate),
|
||||
_ProfileInfoRow(
|
||||
icon: Icons.phone,
|
||||
value: profile.phone,
|
||||
trailing: const Icon(Icons.lock, color: Colors.white70, size: 16),
|
||||
),
|
||||
_ProfileInfoRow(icon: Icons.email, value: profile.email),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: OutlinedButton(
|
||||
onPressed: onEditTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Редактировать',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.edit, size: 16, color: AppColors.smsDigit),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? value;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SettingsRow({
|
||||
required this.title,
|
||||
this.value,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
|
||||
if (value != null)
|
||||
Text(
|
||||
value!,
|
||||
style: const TextStyle(color: AppColors.white70, fontSize: 13),
|
||||
),
|
||||
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsBlock extends StatelessWidget {
|
||||
final bool notificationsEnabled;
|
||||
final ValueChanged<bool> onNotificationsChanged;
|
||||
|
||||
const _SettingsBlock({
|
||||
required this.notificationsEnabled,
|
||||
required this.onNotificationsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Настройки',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Уведомления',
|
||||
trailing: Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: notificationsEnabled,
|
||||
onChanged: onNotificationsChanged,
|
||||
activeColor: AppColors.checkboxFill,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Тема приложения',
|
||||
value: 'Системная',
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Язык',
|
||||
value: 'Русский',
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/entities/user_profile.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/profile_bloc.dart';
|
||||
import 'edit_profile_screen.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../state/profile_state.dart';
|
||||
import '../event/profile_event.dart';
|
||||
|
||||
class ProfileScreen extends StatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
bool notificationsEnabled = false;
|
||||
XFile? _avatarImage;
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final pickedImage = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
|
||||
if (pickedImage == null) return;
|
||||
|
||||
context.read<ProfileBloc>().add(
|
||||
ProfilePhotoUpdated(File(pickedImage.path)),
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
print("Error picking or uploading image: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEditProfile(UserProfile profile) async {
|
||||
context.go("/home/profile/edit");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final profile = state.profile!;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Профиль'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: AppColors.checkboxFill,
|
||||
backgroundImage:
|
||||
(profile.avatarUrl != null &&
|
||||
profile.avatarUrl!.isNotEmpty)
|
||||
? NetworkImage(
|
||||
"${profile.avatarUrl!}?v=${DateTime.now().minute}",
|
||||
)
|
||||
: null,
|
||||
child:
|
||||
(profile.avatarUrl == null ||
|
||||
profile.avatarUrl!.isEmpty)
|
||||
? Text(
|
||||
profile.name.isNotEmpty
|
||||
? profile.name[0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
fontSize: 50,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Container(
|
||||
child: Image.asset(
|
||||
'assets/icons/edit.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_ProfileInfoBlock(
|
||||
profile: profile,
|
||||
onEditTap: () => context.go("/home/profile/edit"),
|
||||
),
|
||||
|
||||
// const SizedBox(height: 24),
|
||||
// _SettingsBlock(
|
||||
// notificationsEnabled: notificationsEnabled,
|
||||
// onNotificationsChanged: (v) =>
|
||||
// setState(() => notificationsEnabled = v),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileInfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String value;
|
||||
final Widget? trailing;
|
||||
|
||||
const _ProfileInfoRow({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileInfoBlock extends StatelessWidget {
|
||||
final UserProfile profile;
|
||||
final VoidCallback onEditTap;
|
||||
|
||||
const _ProfileInfoBlock({required this.profile, required this.onEditTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Личные данные',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_ProfileInfoRow(icon: Icons.person, value: profile.name),
|
||||
_ProfileInfoRow(icon: Icons.calendar_today, value: profile.birthDate),
|
||||
_ProfileInfoRow(
|
||||
icon: Icons.phone,
|
||||
value: profile.phone,
|
||||
trailing: const Icon(Icons.lock, color: Colors.white70, size: 16),
|
||||
),
|
||||
_ProfileInfoRow(icon: Icons.email, value: profile.email),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: OutlinedButton(
|
||||
onPressed: onEditTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
side: BorderSide(color: AppColors.smsDigit.withOpacity(0.3)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Редактировать',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.edit, size: 16, color: AppColors.smsDigit),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? value;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SettingsRow({
|
||||
required this.title,
|
||||
this.value,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
|
||||
if (value != null)
|
||||
Text(
|
||||
value!,
|
||||
style: const TextStyle(color: AppColors.white70, fontSize: 13),
|
||||
),
|
||||
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsBlock extends StatelessWidget {
|
||||
final bool notificationsEnabled;
|
||||
final ValueChanged<bool> onNotificationsChanged;
|
||||
|
||||
const _SettingsBlock({
|
||||
required this.notificationsEnabled,
|
||||
required this.onNotificationsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Настройки',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Уведомления',
|
||||
trailing: Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: notificationsEnabled,
|
||||
onChanged: onNotificationsChanged,
|
||||
activeColor: AppColors.checkboxFill,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Тема приложения',
|
||||
value: 'Системная',
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
_SettingsRow(
|
||||
title: 'Язык',
|
||||
value: 'Русский',
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white70),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/usecase/apply_promo_code_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/promo_code_event.dart';
|
||||
import '../state/promo_code_state.dart';
|
||||
import '../viewmodel/promo_code_bloc.dart';
|
||||
|
||||
class PromoCodeScreen extends StatelessWidget {
|
||||
const PromoCodeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
|
||||
child: const _PromoCodeScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoCodeScreenContent extends StatefulWidget {
|
||||
const _PromoCodeScreenContent();
|
||||
|
||||
@override
|
||||
State<_PromoCodeScreenContent> createState() => _PromoCodeScreenContentState();
|
||||
}
|
||||
|
||||
class _PromoCodeScreenContentState extends State<_PromoCodeScreenContent> {
|
||||
final TextEditingController promoController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
promoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Промокоды'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 🔹 LISTENER: Только сайд-эффекты (SnackBar, навигация)
|
||||
BlocListener<PromoCodeBloc, PromoCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PromoCodeStatus.success) {
|
||||
promoController.clear();
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<PromoCodeBloc, PromoCodeState>(
|
||||
builder: (context, state) {
|
||||
final bool hasError = state.status == PromoCodeStatus.failure;
|
||||
final bool isLoading = state.status == PromoCodeStatus.loading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(25),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'У вас есть промокод?',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Введите промокод и получите скидку на поездку',
|
||||
style: TextStyle(color: AppColors.white70, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
TextField(
|
||||
controller: promoController,
|
||||
enabled: !isLoading,
|
||||
style: TextStyle(color: hasError ? Colors.red : Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите промокод',
|
||||
hintStyle: const TextStyle(color: AppColors.white70),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.1),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(
|
||||
color: hasError ? Colors.red : AppColors.smsDigit,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
// ✅ Явно убираем ошибку при успехе
|
||||
errorText: hasError ? 'Неверный промокод' : null,
|
||||
),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: isLoading ? null : () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
side: BorderSide(color: AppColors.white70.withOpacity(0.4)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('Отмена', style: TextStyle(color: AppColors.white70)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 22),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : () => _submit(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
backgroundColor: AppColors.activeButtonGradient.colors[0],
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(color: AppColors.activeButtonText, strokeWidth: 2),
|
||||
)
|
||||
: const Text('Активировать', style: TextStyle(color: AppColors.activeButtonText)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Image.asset(
|
||||
'assets/promo_bottom.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
final code = promoController.text.trim();
|
||||
if (code.isEmpty) return;
|
||||
context.read<PromoCodeBloc>().add(PromoCodeApplyRequested(code));
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/usecase/apply_promo_code_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../event/promo_code_event.dart';
|
||||
import '../state/promo_code_state.dart';
|
||||
import '../viewmodel/promo_code_bloc.dart';
|
||||
|
||||
class PromoCodeScreen extends StatelessWidget {
|
||||
const PromoCodeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
|
||||
child: const _PromoCodeScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoCodeScreenContent extends StatefulWidget {
|
||||
const _PromoCodeScreenContent();
|
||||
|
||||
@override
|
||||
State<_PromoCodeScreenContent> createState() => _PromoCodeScreenContentState();
|
||||
}
|
||||
|
||||
class _PromoCodeScreenContentState extends State<_PromoCodeScreenContent> {
|
||||
final TextEditingController promoController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
promoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Промокоды'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 🔹 LISTENER: Только сайд-эффекты (SnackBar, навигация)
|
||||
BlocListener<PromoCodeBloc, PromoCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PromoCodeStatus.success) {
|
||||
promoController.clear();
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<PromoCodeBloc, PromoCodeState>(
|
||||
builder: (context, state) {
|
||||
final bool hasError = state.status == PromoCodeStatus.failure;
|
||||
final bool isLoading = state.status == PromoCodeStatus.loading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(25),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'У вас есть промокод?',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Введите промокод и получите скидку на поездку',
|
||||
style: TextStyle(color: AppColors.white70, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
TextField(
|
||||
controller: promoController,
|
||||
enabled: !isLoading,
|
||||
style: TextStyle(color: hasError ? Colors.red : Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите промокод',
|
||||
hintStyle: const TextStyle(color: AppColors.white70),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.1),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(
|
||||
color: hasError ? Colors.red : AppColors.smsDigit,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
// ✅ Явно убираем ошибку при успехе
|
||||
errorText: hasError ? 'Неверный промокод' : null,
|
||||
),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: isLoading ? null : () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
side: BorderSide(color: AppColors.white70.withOpacity(0.4)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('Отмена', style: TextStyle(color: AppColors.white70)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 22),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : () => _submit(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
backgroundColor: AppColors.activeButtonGradient.colors[0],
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(color: AppColors.activeButtonText, strokeWidth: 2),
|
||||
)
|
||||
: const Text('Активировать', style: TextStyle(color: AppColors.activeButtonText)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Image.asset(
|
||||
'assets/promo_bottom.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
final code = promoController.text.trim();
|
||||
if (code.isEmpty) return;
|
||||
context.read<PromoCodeBloc>().add(PromoCodeApplyRequested(code));
|
||||
}
|
||||
}
|
||||
@@ -1,113 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class QRScanInfoScreen extends StatelessWidget {
|
||||
const QRScanInfoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Сканирование QR-кода"),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
const Text(
|
||||
"Наведите рамку сканера на QR-код -\nномер будет распознан автоматически",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Image.asset(
|
||||
"assets/qr_phone_img.png",
|
||||
height: 300,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
|
||||
),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.push("/home/qr-info/qr-scan");
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Продолжить",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1D273A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Кнопка "Ввести номер вручную"
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.push("/home/qr-info/qr-input"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white70),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Ввести номер вручную",
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
|
||||
class QRScanInfoScreen extends StatelessWidget {
|
||||
const QRScanInfoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Сканирование QR-кода"),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
const Text(
|
||||
"Наведите рамку сканера на QR-код -\nномер будет распознан автоматически",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Image.asset(
|
||||
"assets/qr_phone_img.png",
|
||||
height: 300,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF8EFEB5), Color(0xFF86FEF1)],
|
||||
),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.push("/home/qr-info/qr-scan");
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Продолжить",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1D273A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Кнопка "Ввести номер вручную"
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.push("/home/qr-info/qr-input"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white70),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Ввести номер вручную",
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +1,249 @@
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:be_happy/core/result.dart';
|
||||
import 'package:be_happy/domain/entities/scooter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:be_happy/di/service_locator.dart';
|
||||
import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
|
||||
class QrScanScreen extends StatefulWidget {
|
||||
const QrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScanScreen> createState() => _QrScanScreenState();
|
||||
}
|
||||
|
||||
class _QrScanScreenState extends State<QrScanScreen> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
String? _scannedData;
|
||||
String? _scooterTitle;
|
||||
bool _torchOn = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
bool _isProcessing = false;
|
||||
|
||||
void _handleScannedData(String rawValue) async {
|
||||
if (_isProcessing || _isLoading) return;
|
||||
|
||||
final uri = Uri.tryParse(rawValue);
|
||||
if (uri == null || uri.host != 'behappybel.by') return;
|
||||
|
||||
final title = uri.pathSegments.last;
|
||||
print("TITLE IS: $title");
|
||||
if (title.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
await _controller.stop();
|
||||
|
||||
try {
|
||||
final getScooterByTitleUsecase = getIt<GetScooterByTitleUsecase>();
|
||||
print("UseCase успешно получен из DI");
|
||||
final result = await getScooterByTitleUsecase(title);
|
||||
print("UseCase успешно выполнен");
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
switch (result) {
|
||||
case Success<Scooter?>():
|
||||
final scooter = result.data;
|
||||
if (scooter != null) {
|
||||
context.pop();
|
||||
context.push('/home/scooter/${scooter.id}');
|
||||
|
||||
} else {
|
||||
_showErrorAndRestart('Самокат не найден');
|
||||
}
|
||||
case Failure<Scooter?>():
|
||||
_showErrorAndRestart('Ошибка при поиске самоката');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("Ошибка DI: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorAndRestart(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_scannedData = null;
|
||||
});
|
||||
_controller.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: (capture) {
|
||||
if (_isProcessing) return;
|
||||
for (final barcode in capture.barcodes) {
|
||||
final String? rawValue = barcode.rawValue;
|
||||
if (rawValue != null) {
|
||||
_handleScannedData(rawValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.black.withOpacity(0.7),
|
||||
BlendMode.srcOut,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
backgroundBlendMode: BlendMode.dstOut,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF6EE7B7), width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ✅ ИЗМЕНЕНО: прижимаем аппбар к левому краю
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // ✅ Выравнивание по левому краю
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: "Сканирование QR-кода"),
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
'Наведите рамку на QR-код — номер будет распознан автоматически',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: [
|
||||
Shadow(blurRadius: 4, color: Colors.black),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: 'Ввести номер вручную',
|
||||
onTap: () {
|
||||
context.push('/home/qr-info/qr-input');
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.black.withOpacity(0.5),
|
||||
child: IconButton(
|
||||
onPressed: () async {
|
||||
final newState = !_torchOn;
|
||||
await _controller.toggleTorch();
|
||||
setState(() => _torchOn = newState);
|
||||
},
|
||||
icon: Icon(
|
||||
_torchOn ? Icons.flashlight_on : Icons.flashlight_off,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:be_happy/core/result.dart';
|
||||
import 'package:be_happy/domain/entities/scooter.dart';
|
||||
import 'package:be_happy/domain/usecase/get_address_by_point_usecase.dart';
|
||||
import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:be_happy/di/service_locator.dart';
|
||||
import 'package:be_happy/domain/usecase/get_scooter_by_title_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import 'package:yandex_mapkit/yandex_mapkit.dart';
|
||||
|
||||
|
||||
class QrScanScreen extends StatefulWidget {
|
||||
const QrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScanScreen> createState() => _QrScanScreenState();
|
||||
}
|
||||
|
||||
class _QrScanScreenState extends State<QrScanScreen> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
String? _scannedData;
|
||||
String? _scooterTitle;
|
||||
bool _torchOn = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
bool _isProcessing = false;
|
||||
|
||||
void _handleScannedData(String rawValue) async {
|
||||
if (_isProcessing || _isLoading) return;
|
||||
|
||||
final uri = Uri.tryParse(rawValue);
|
||||
if (uri == null || uri.host != 'behappybel.by') return;
|
||||
|
||||
final title = uri.pathSegments.last;
|
||||
print("TITLE IS: $title");
|
||||
if (title.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
await _controller.stop();
|
||||
|
||||
try {
|
||||
final getScooterByTitleUsecase = getIt<GetScooterByTitleUsecase>();
|
||||
final getAddressByPointUsecase = getIt<GetAddressByPointUsecase>();
|
||||
final getPedestrianRoutesUsecase = getIt<GetPedestrianRoutesUsecase>();
|
||||
final result = await getScooterByTitleUsecase(title);
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
switch (result) {
|
||||
case Success<Scooter?>():
|
||||
final scooter = result.data;
|
||||
|
||||
final String currentAddress = await getAddressByPointUsecase(
|
||||
scooter!.longitude,
|
||||
scooter.latitude,
|
||||
);
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
|
||||
final routes = await getPedestrianRoutesUsecase(
|
||||
Point(latitude: position.latitude, longitude: position.longitude),
|
||||
Point(latitude: scooter.longitude, longitude: scooter.latitude),
|
||||
);
|
||||
|
||||
final distance =
|
||||
routes?.firstOrNull?.metadata.weight.walkingDistance.value;
|
||||
print("Distance is: $distance");
|
||||
final time = routes?.firstOrNull?.metadata.weight.time.value;
|
||||
|
||||
scooter.timeToTravel = time;
|
||||
scooter.distance = distance;
|
||||
|
||||
if (scooter != null) {
|
||||
context.pop();
|
||||
context.push('/home/scooter/${scooter.id}', extra: [currentAddress, distance]);
|
||||
|
||||
} else {
|
||||
_showErrorAndRestart('Самокат не найден');
|
||||
}
|
||||
case Failure<Scooter?>():
|
||||
_showErrorAndRestart('Ошибка при поиске самоката');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("Ошибка DI: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorAndRestart(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_scannedData = null;
|
||||
});
|
||||
_controller.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: (capture) {
|
||||
if (_isProcessing) return;
|
||||
for (final barcode in capture.barcodes) {
|
||||
final String? rawValue = barcode.rawValue;
|
||||
if (rawValue != null) {
|
||||
_handleScannedData(rawValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.black.withOpacity(0.7),
|
||||
BlendMode.srcOut,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
backgroundBlendMode: BlendMode.dstOut,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF6EE7B7), width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ✅ ИЗМЕНЕНО: прижимаем аппбар к левому краю
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // ✅ Выравнивание по левому краю
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: "Сканирование QR-кода"),
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
'Наведите рамку на QR-код — номер будет распознан автоматически',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: [
|
||||
Shadow(blurRadius: 4, color: Colors.black),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GradientButton(
|
||||
text: 'Ввести номер вручную',
|
||||
onTap: () {
|
||||
context.push('/home/qr-info/qr-input');
|
||||
},
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.black.withOpacity(0.5),
|
||||
child: IconButton(
|
||||
onPressed: () async {
|
||||
final newState = !_torchOn;
|
||||
await _controller.toggleTorch();
|
||||
setState(() => _torchOn = newState);
|
||||
},
|
||||
icon: Icon(
|
||||
_torchOn ? Icons.flashlight_on : Icons.flashlight_off,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/scooter_code_event.dart';
|
||||
import '../state/scooter_code_state.dart';
|
||||
import '../viewmodel/pin_bloc.dart';
|
||||
import '../event/pin_event.dart';
|
||||
import '../state/pin_state.dart';
|
||||
import '../viewmodel/scooter_code_bloc.dart';
|
||||
|
||||
class ScooterCodeInputScreen extends StatefulWidget {
|
||||
const ScooterCodeInputScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ScooterCodeInputScreen> createState() => _ScooterCodeInputScreenState();
|
||||
}
|
||||
|
||||
class _ScooterCodeInputScreenState extends State<ScooterCodeInputScreen> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
context.read<ScooterCodeBloc>().add(ScooterCodeChanged(controller.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ScooterCodeBloc, ScooterCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state is ScooterCodeSuccess) {
|
||||
context.go("/home/scooter/${state.scooter.id}");
|
||||
}
|
||||
if (state is ScooterCodeFailure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.error ?? "Ошибка")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Ввод QR-кода"),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: BlocBuilder<ScooterCodeBloc, ScooterCodeState>(
|
||||
builder: (context, state) {
|
||||
final bool isCodeValid = state.code.length >= 5 && state.code.length <= 7;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
"Введите номер, расположенный\nпод QR-кодом",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 80),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.text,
|
||||
maxLength: 7,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
hintText: "Ввести номер",
|
||||
hintStyle: const TextStyle(color: AppColors.hint),
|
||||
counterText: "",
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.1),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (state is ScooterCodeLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 20),
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
GradientButton(
|
||||
text: "Подтвердить",
|
||||
enabled: isCodeValid && state is! ScooterCodeLoading,
|
||||
onTap: () {
|
||||
context.read<ScooterCodeBloc>().add(
|
||||
ScooterCodeSubmitted(state.code),
|
||||
);
|
||||
},
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/code_dots.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../event/scooter_code_event.dart';
|
||||
import '../state/scooter_code_state.dart';
|
||||
import '../viewmodel/pin_bloc.dart';
|
||||
import '../event/pin_event.dart';
|
||||
import '../state/pin_state.dart';
|
||||
import '../viewmodel/scooter_code_bloc.dart';
|
||||
|
||||
class ScooterCodeInputScreen extends StatefulWidget {
|
||||
const ScooterCodeInputScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ScooterCodeInputScreen> createState() => _ScooterCodeInputScreenState();
|
||||
}
|
||||
|
||||
class _ScooterCodeInputScreenState extends State<ScooterCodeInputScreen> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
context.read<ScooterCodeBloc>().add(ScooterCodeChanged(controller.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ScooterCodeBloc, ScooterCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state is ScooterCodeSuccess) {
|
||||
context.go("/home/scooter/${state.scooter.id}", extra: [state.address, state.distance]);
|
||||
}
|
||||
if (state is ScooterCodeFailure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.error ?? "Ошибка")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Ввод QR-кода"),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: BlocBuilder<ScooterCodeBloc, ScooterCodeState>(
|
||||
builder: (context, state) {
|
||||
final bool isCodeValid = state.code.length >= 5 && state.code.length <= 7;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
const Text(
|
||||
"Введите номер, расположенный\nпод QR-кодом",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.whiteText,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 80),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.text,
|
||||
maxLength: 7,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
hintText: "Ввести номер",
|
||||
hintStyle: const TextStyle(color: AppColors.hint),
|
||||
counterText: "",
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.1),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (state is ScooterCodeLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 20),
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
GradientButton(
|
||||
text: "Подтвердить",
|
||||
enabled: isCodeValid && state is! ScooterCodeLoading,
|
||||
onTap: () {
|
||||
context.read<ScooterCodeBloc>().add(
|
||||
ScooterCodeSubmitted(state.code),
|
||||
);
|
||||
},
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,118 @@
|
||||
import 'package:be_happy/presentation/event/scooter_detail_event.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/scooter/battery_indicator.dart';
|
||||
import '../components/scooter/scooter_info_section.dart';
|
||||
import '../components/scooter/slide_to_reserve_button.dart';
|
||||
import '../components/sheet/tariff_sheet.dart';
|
||||
import '../state/scooter_detail_state.dart';
|
||||
import '../viewmodel/scooter_detail_bloc.dart';
|
||||
|
||||
class ScooterDetailScreen extends StatelessWidget {
|
||||
final String address;
|
||||
final double distance;
|
||||
const ScooterDetailScreen({super.key, required this.address, required this.distance});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = GoRouterState.of(context).pathParameters['id'];
|
||||
context.read<ScooterDetailBloc>().add(LoadScooterDetails(int.parse(id!)));
|
||||
return Scaffold(
|
||||
body: BlocBuilder<ScooterDetailBloc, ScooterDetailState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ScooterStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
if (state.status == ScooterStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final scooter = state.scooter;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF1B2A4A), Color(0xFF0F1E3A)],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: -270,
|
||||
right: -200,
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
height: 500,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(
|
||||
title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
BatteryIndicator(percent: (scooter?.batteryLevel ?? 0) / 100),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: ScooterInfoSection(distance: distance, location: address, batteryLevel: scooter?.batteryLevel ?? 0,)),
|
||||
const SizedBox(width: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
|
||||
GradientButton(
|
||||
text: "Забронировать",
|
||||
showArrows: true,
|
||||
height: 52,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.pop();
|
||||
context.pushReplacement('/home/tarif-sheet', extra: scooter);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:be_happy/presentation/event/scooter_detail_event.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/scooter/battery_indicator.dart';
|
||||
import '../components/scooter/scooter_info_section.dart';
|
||||
import '../components/scooter/slide_to_reserve_button.dart';
|
||||
import '../components/sheet/tariff_sheet.dart';
|
||||
import '../state/scooter_detail_state.dart';
|
||||
import '../viewmodel/scooter_detail_bloc.dart';
|
||||
|
||||
class ScooterDetailScreen extends StatelessWidget {
|
||||
final String address;
|
||||
final double distance;
|
||||
const ScooterDetailScreen({super.key, required this.address, required this.distance});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = GoRouterState.of(context).pathParameters['id'];
|
||||
context.read<ScooterDetailBloc>().add(LoadScooterDetails(int.parse(id!)));
|
||||
return Scaffold(
|
||||
body: BlocBuilder<ScooterDetailBloc, ScooterDetailState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == ScooterStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
if (state.status == ScooterStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.errorMessage ?? 'Ошибка',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final scooter = state.scooter;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF1B2A4A), Color(0xFF0F1E3A)],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: -270,
|
||||
right: -200,
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
height: 500,
|
||||
child: Image.asset(
|
||||
'assets/icons/e6a5dcb6a3e2ec2362c25ea49509ab10d2312b19.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(
|
||||
title: scooter?.title != null ? 'Самокат ${scooter!.title}' : 'Самокат',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
BatteryIndicator(percent: (scooter?.batteryLevel ?? 0) / 100),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: ScooterInfoSection(distance: distance, location: address, batteryLevel: scooter?.batteryLevel ?? 0,)),
|
||||
const SizedBox(width: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
|
||||
GradientButton(
|
||||
text: "Забронировать",
|
||||
showArrows: true,
|
||||
height: 52,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
onTap: () {
|
||||
context.pop();
|
||||
context.pushReplacement('/home/tarif-sheet', extra: scooter);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,311 +1,311 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/usecase/finish_ride_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/send_photo_bloc.dart';
|
||||
import '../event/send_photo_event.dart';
|
||||
import '../state/send_photo_state.dart';
|
||||
import '../../domain/usecase/upload_scooter_photos_usecase.dart';
|
||||
import '../../di/service_locator.dart' as di;
|
||||
|
||||
class SendPhotoScreen extends StatelessWidget {
|
||||
final int orderId;
|
||||
const SendPhotoScreen({super.key, required this.orderId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => SendPhotoBloc(
|
||||
di.getIt<UploadScooterPhotosUsecase>(),
|
||||
di.getIt<FinishRideUsecase>(),
|
||||
),
|
||||
child: SendPhotoView(orderId: orderId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SendPhotoView extends StatefulWidget {
|
||||
final int orderId;
|
||||
const SendPhotoView({super.key, required this.orderId});
|
||||
|
||||
@override
|
||||
State<SendPhotoView> createState() => _SendPhotoViewState();
|
||||
}
|
||||
|
||||
class _SendPhotoViewState extends State<SendPhotoView> {
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
// Метод для выбора фото (добавляет к существующим)
|
||||
Future<void> _pickImage() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Сделать фото'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_getImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
// ListTile(
|
||||
// leading: const Icon(Icons.photo_library),
|
||||
// title: const Text('Выбрать из галереи'),
|
||||
// onTap: () {
|
||||
// Navigator.pop(context);
|
||||
// _getImage(ImageSource.gallery);
|
||||
// },
|
||||
// ),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cancel),
|
||||
title: const Text('Отмена'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Метод для получения изображения из выбранного источника
|
||||
Future<void> _getImage(ImageSource source) async {
|
||||
final XFile? pickedFile = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 80,
|
||||
);
|
||||
|
||||
if (pickedFile != null && mounted) {
|
||||
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
|
||||
context.read<SendPhotoBloc>().add(
|
||||
PhotoSelected([...currentImages, pickedFile.path]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Метод для удаления конкретного фото
|
||||
void _removeImage(String path) {
|
||||
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
|
||||
final updatedList = currentImages.where((p) => p != path).toList();
|
||||
context.read<SendPhotoBloc>().add(PhotoSelected(updatedList));
|
||||
}
|
||||
|
||||
// ✅ Динамический заголовок в зависимости от количества фото
|
||||
String _getTopText(int photoCount) {
|
||||
if (photoCount == 0) {
|
||||
return 'Для завершения аренды\nсфотографируйте самокат';
|
||||
} else if (photoCount == 1) {
|
||||
return 'Сделайте фото руля самоката';
|
||||
} else {
|
||||
return 'Отправьте фото на проверку';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Отправить фото"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100),
|
||||
child: Text(
|
||||
_getTopText(context.select((SendPhotoBloc bloc) => bloc.state.selectedImages.length)),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Основной контент центрируем
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
BlocBuilder<SendPhotoBloc, SendPhotoState>(
|
||||
builder: (context, state) {
|
||||
final photoCount = state.selectedImages.length;
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
// Список выбранных фото
|
||||
...state.selectedImages.map((path) => _buildPhotoThumbnail(path)),
|
||||
|
||||
const SizedBox(height: 35),
|
||||
|
||||
// Кнопка "Добавить", если фото меньше лимита (например, 5)
|
||||
if (photoCount < 2)
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: _buildAddButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 200),
|
||||
|
||||
// Кнопка отправки
|
||||
BlocConsumer<SendPhotoBloc, SendPhotoState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == SendPhotoStatus.success) {
|
||||
context.go('/home/checkout/${widget.orderId}', extra: state.recievedPhotoIds);
|
||||
} else if (state.status == SendPhotoStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final photoCount = state.selectedImages.length;
|
||||
final isEnabled = photoCount >= 2;
|
||||
|
||||
return GradientButton(
|
||||
text: 'Отправить',
|
||||
onTap: isEnabled
|
||||
? () => context.read<SendPhotoBloc>().add(PhotoUploadSubmitted(widget.orderId))
|
||||
: null,
|
||||
enabled: isEnabled,
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// миниатюра с крестиком
|
||||
Widget _buildPhotoThumbnail(String path) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Кнопка удаления (крестик)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removeImage(path),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF75FBF0),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Color(0xFF242F51),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Виджет кнопки добавления
|
||||
Widget _buildAddButton() {
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF242F51), // полупрозрачный белый
|
||||
Color(0xFF242F51), // ещё более прозрачный
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
boxShadow: [
|
||||
// Глубокое свечение
|
||||
BoxShadow(
|
||||
color: Color(0xFF8BFFAA).withOpacity(0.5),
|
||||
blurRadius: 50,
|
||||
spreadRadius: 6,
|
||||
offset: Offset.zero,
|
||||
),
|
||||
// Внутреннее свечение (для объёма)
|
||||
BoxShadow(
|
||||
color: Color(0xFF8BFFAA).withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
spreadRadius: -2,
|
||||
offset: Offset.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../domain/usecase/finish_ride_usecase.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../viewmodel/send_photo_bloc.dart';
|
||||
import '../event/send_photo_event.dart';
|
||||
import '../state/send_photo_state.dart';
|
||||
import '../../domain/usecase/upload_scooter_photos_usecase.dart';
|
||||
import '../../di/service_locator.dart' as di;
|
||||
|
||||
class SendPhotoScreen extends StatelessWidget {
|
||||
final int orderId;
|
||||
const SendPhotoScreen({super.key, required this.orderId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => SendPhotoBloc(
|
||||
di.getIt<UploadScooterPhotosUsecase>(),
|
||||
di.getIt<FinishRideUsecase>(),
|
||||
),
|
||||
child: SendPhotoView(orderId: orderId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SendPhotoView extends StatefulWidget {
|
||||
final int orderId;
|
||||
const SendPhotoView({super.key, required this.orderId});
|
||||
|
||||
@override
|
||||
State<SendPhotoView> createState() => _SendPhotoViewState();
|
||||
}
|
||||
|
||||
class _SendPhotoViewState extends State<SendPhotoView> {
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
// Метод для выбора фото (добавляет к существующим)
|
||||
Future<void> _pickImage() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Сделать фото'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_getImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
// ListTile(
|
||||
// leading: const Icon(Icons.photo_library),
|
||||
// title: const Text('Выбрать из галереи'),
|
||||
// onTap: () {
|
||||
// Navigator.pop(context);
|
||||
// _getImage(ImageSource.gallery);
|
||||
// },
|
||||
// ),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cancel),
|
||||
title: const Text('Отмена'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Метод для получения изображения из выбранного источника
|
||||
Future<void> _getImage(ImageSource source) async {
|
||||
final XFile? pickedFile = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 80,
|
||||
);
|
||||
|
||||
if (pickedFile != null && mounted) {
|
||||
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
|
||||
context.read<SendPhotoBloc>().add(
|
||||
PhotoSelected([...currentImages, pickedFile.path]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Метод для удаления конкретного фото
|
||||
void _removeImage(String path) {
|
||||
final currentImages = context.read<SendPhotoBloc>().state.selectedImages;
|
||||
final updatedList = currentImages.where((p) => p != path).toList();
|
||||
context.read<SendPhotoBloc>().add(PhotoSelected(updatedList));
|
||||
}
|
||||
|
||||
// ✅ Динамический заголовок в зависимости от количества фото
|
||||
String _getTopText(int photoCount) {
|
||||
if (photoCount == 0) {
|
||||
return 'Для завершения аренды\nсфотографируйте самокат';
|
||||
} else if (photoCount == 1) {
|
||||
return 'Сделайте фото руля самоката';
|
||||
} else {
|
||||
return 'Отправьте фото на проверку';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: "Отправить фото"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 100),
|
||||
child: Text(
|
||||
_getTopText(context.select((SendPhotoBloc bloc) => bloc.state.selectedImages.length)),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Основной контент центрируем
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
BlocBuilder<SendPhotoBloc, SendPhotoState>(
|
||||
builder: (context, state) {
|
||||
final photoCount = state.selectedImages.length;
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
// Список выбранных фото
|
||||
...state.selectedImages.map((path) => _buildPhotoThumbnail(path)),
|
||||
|
||||
const SizedBox(height: 35),
|
||||
|
||||
// Кнопка "Добавить", если фото меньше лимита (например, 5)
|
||||
if (photoCount < 2)
|
||||
GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: _buildAddButton(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 200),
|
||||
|
||||
// Кнопка отправки
|
||||
BlocConsumer<SendPhotoBloc, SendPhotoState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == SendPhotoStatus.success) {
|
||||
context.go('/home/checkout/${widget.orderId}', extra: state.recievedPhotoIds);
|
||||
} else if (state.status == SendPhotoStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Ошибка')),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final photoCount = state.selectedImages.length;
|
||||
final isEnabled = photoCount >= 2;
|
||||
|
||||
return GradientButton(
|
||||
text: 'Отправить',
|
||||
onTap: isEnabled
|
||||
? () => context.read<SendPhotoBloc>().add(PhotoUploadSubmitted(widget.orderId))
|
||||
: null,
|
||||
enabled: isEnabled,
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// миниатюра с крестиком
|
||||
Widget _buildPhotoThumbnail(String path) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Кнопка удаления (крестик)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removeImage(path),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF75FBF0),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Color(0xFF242F51),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Виджет кнопки добавления
|
||||
Widget _buildAddButton() {
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF242F51), // полупрозрачный белый
|
||||
Color(0xFF242F51), // ещё более прозрачный
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
boxShadow: [
|
||||
// Глубокое свечение
|
||||
BoxShadow(
|
||||
color: Color(0xFF8BFFAA).withOpacity(0.5),
|
||||
blurRadius: 50,
|
||||
spreadRadius: 6,
|
||||
offset: Offset.zero,
|
||||
),
|
||||
// Внутреннее свечение (для объёма)
|
||||
BoxShadow(
|
||||
color: Color(0xFF8BFFAA).withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
spreadRadius: -2,
|
||||
offset: Offset.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
// Фаза 1: Заполнение цветом слева направо (0.0 -> 0.5)
|
||||
late final Animation<double> _fillProgress;
|
||||
|
||||
// Фаза 2: Укатывание вправо (0.6 -> 1.0)
|
||||
late final Animation<double> _rollTranslation;
|
||||
late final Animation<double> _rollRotation;
|
||||
|
||||
// Уменьшенный размер логотипа по вашему запросу
|
||||
static const double logoSize = 130;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
);
|
||||
|
||||
_fillProgress = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
|
||||
),
|
||||
);
|
||||
|
||||
_rollTranslation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeInCubic),
|
||||
),
|
||||
);
|
||||
|
||||
_rollRotation = Tween<double>(begin: 0.0, end: 2 * math.pi).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
|
||||
),
|
||||
);
|
||||
|
||||
// Добавляем задержку перед стартом, чтобы пользователь успел увидеть экран
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Ждем 500мс после того, как первый кадр отрисовался
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
// Запускаем анимацию
|
||||
_controller.forward().then((_) {
|
||||
if (!mounted) return;
|
||||
context.read<SplashBloc>().add(AuthCheckRequested());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double endTranslation = screenWidth / 2 + logoSize;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF293A69),
|
||||
Color(0xFF202741),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Волна
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/splash_map.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final double translationX = _rollTranslation.value * endTranslation;
|
||||
|
||||
return Transform(
|
||||
transform: Matrix4.translationValues(translationX, 0, 0)
|
||||
..rotateZ(_rollRotation.value),
|
||||
alignment: Alignment.center,
|
||||
// Используем ShaderMask для эффекта заполнения/проявления
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
// Используем _fillProgress для сдвига жесткой границы градиента
|
||||
colors: const [Colors.white, Colors.transparent],
|
||||
stops: [_fillProgress.value, _fillProgress.value],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn, // Оставляет только ту часть логотипа, где градиент белый
|
||||
child: Image.asset(
|
||||
'assets/splash_logo.png',
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 5. Версия приложения
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 24,
|
||||
child: Text(
|
||||
'Версия приложения 1.0',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'dart:math' as math;
|
||||
import 'package:be_happy/presentation/event/spalsh_event.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/splash_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
// Фаза 1: Заполнение цветом слева направо (0.0 -> 0.5)
|
||||
late final Animation<double> _fillProgress;
|
||||
|
||||
// Фаза 2: Укатывание вправо (0.6 -> 1.0)
|
||||
late final Animation<double> _rollTranslation;
|
||||
late final Animation<double> _rollRotation;
|
||||
|
||||
// Уменьшенный размер логотипа по вашему запросу
|
||||
static const double logoSize = 130;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
);
|
||||
|
||||
_fillProgress = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
|
||||
),
|
||||
);
|
||||
|
||||
_rollTranslation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeInCubic),
|
||||
),
|
||||
);
|
||||
|
||||
_rollRotation = Tween<double>(begin: 0.0, end: 2 * math.pi).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
|
||||
),
|
||||
);
|
||||
|
||||
// Добавляем задержку перед стартом, чтобы пользователь успел увидеть экран
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Ждем 500мс после того, как первый кадр отрисовался
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
// Запускаем анимацию
|
||||
_controller.forward().then((_) {
|
||||
if (!mounted) return;
|
||||
context.read<SplashBloc>().add(AuthCheckRequested());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double endTranslation = screenWidth / 2 + logoSize;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF293A69),
|
||||
Color(0xFF202741),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Волна
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/wave.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Image.asset(
|
||||
'assets/splash_map.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final double translationX = _rollTranslation.value * endTranslation;
|
||||
|
||||
return Transform(
|
||||
transform: Matrix4.translationValues(translationX, 0, 0)
|
||||
..rotateZ(_rollRotation.value),
|
||||
alignment: Alignment.center,
|
||||
// Используем ShaderMask для эффекта заполнения/проявления
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
// Используем _fillProgress для сдвига жесткой границы градиента
|
||||
colors: const [Colors.white, Colors.transparent],
|
||||
stops: [_fillProgress.value, _fillProgress.value],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn, // Оставляет только ту часть логотипа, где градиент белый
|
||||
child: Image.asset(
|
||||
'assets/splash_logo.png',
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 5. Версия приложения
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 24,
|
||||
child: Text(
|
||||
'Версия приложения 1.0',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,344 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/add_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/period_selector.dart';
|
||||
import '../components/content_parser.dart'; // ✅ Парсер контента
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../event/subscription_details_event.dart';
|
||||
import '../state/susbcription_details_state.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
import '../viewmodel/susbcription_details_bloc.dart';
|
||||
|
||||
class SubscriptionDetailsScreen extends StatelessWidget {
|
||||
final int subscriptionId;
|
||||
|
||||
const SubscriptionDetailsScreen({super.key, required this.subscriptionId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocConsumer<
|
||||
SubscriptionDetailsBloc,
|
||||
SubscriptionDetailsState
|
||||
>(
|
||||
listenWhen: (previous, current) =>
|
||||
current is DetailsContentState && current.isSuccess,
|
||||
listener: (context, state) {
|
||||
if (state is DetailsContentState && state.isSuccess) {
|
||||
context.pop(true);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
String title = "Загрузка...";
|
||||
if (state is DetailsContentState) {
|
||||
title = state.subscription.title;
|
||||
}
|
||||
return CustomAppBar(title: title);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.asset('assets/wave.png'),
|
||||
),
|
||||
),
|
||||
BlocBuilder<
|
||||
SubscriptionDetailsBloc,
|
||||
SubscriptionDetailsState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
if (state is DetailsLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF80FFD1),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DetailsError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DetailsContentState) {
|
||||
return _buildContent(context, state);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, DetailsContentState state) {
|
||||
final bool isAvailableForPurchase = state.subscription.isActive;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: ContentParser.parseContent(
|
||||
textJson: state.subscription.fullDescriptionJson,
|
||||
text: state.subscription.fullDescription,
|
||||
),
|
||||
),
|
||||
|
||||
if (isAvailableForPurchase) ...[
|
||||
const SizedBox(height: 30),
|
||||
_ActionCard(state: state),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCardSelector(state, context),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
GradientButton(
|
||||
text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать',
|
||||
onTap: () {
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
ActivateSubscriptionPressed(),
|
||||
);
|
||||
},
|
||||
enabled: state.isAgreed && state.selectedCard != null,
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardSelector(DetailsContentState state, BuildContext context) {
|
||||
if (state.selectedCard != null) {
|
||||
return PaymentOption(
|
||||
title: "Карта",
|
||||
subtitle: state.selectedCard.cardLastNumber,
|
||||
isSelected: true,
|
||||
onTap: () async {
|
||||
final selectedCard = await showModalBottomSheet<PaymentCard>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: const PaymentMethodSheet(showBalance: false),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedCard != null && context.mounted) {
|
||||
context.read<SubscriptionDetailsBloc>().add(SelectCardEvent(selectedCard));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _ActionCard extends StatelessWidget {
|
||||
final DetailsContentState state;
|
||||
|
||||
const _ActionCard({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> periodTitles = state.subscription.options
|
||||
.map((e) => e.title)
|
||||
.toList();
|
||||
|
||||
final int selectedIndex = state.subscription.options.indexOf(
|
||||
state.selectedPeriod,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF131B47),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
"Выберите период действия",
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
PeriodSelector(
|
||||
periods: periodTitles,
|
||||
currentIndex: selectedIndex != -1 ? selectedIndex : 0,
|
||||
onSelect: (index) {
|
||||
final selectedOption = state.subscription.options[index];
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
SelectPeriodEvent(selectedOption),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_PriceRow(price: state.selectedPeriod.pricePrint),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: state.isAgreed,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
ToggleAgreementEvent(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'Я подтверждаю, что ознакомился со всеми условиями предоставления подписки и принимаю их безоговорочно',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _PriceRow extends StatelessWidget {
|
||||
final String price;
|
||||
|
||||
const _PriceRow({required this.price});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 72, height: 72),
|
||||
const SizedBox(width: 15),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Стоимость:", style: TextStyle(color: Color(0xFF80FFD1))),
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF80FFD1),
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/add_payment_card_usecase.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/period_selector.dart';
|
||||
import '../components/content_parser.dart'; // ✅ Парсер контента
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../event/subscription_details_event.dart';
|
||||
import '../state/susbcription_details_state.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
import '../viewmodel/susbcription_details_bloc.dart';
|
||||
|
||||
class SubscriptionDetailsScreen extends StatelessWidget {
|
||||
final int subscriptionId;
|
||||
|
||||
const SubscriptionDetailsScreen({super.key, required this.subscriptionId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocConsumer<
|
||||
SubscriptionDetailsBloc,
|
||||
SubscriptionDetailsState
|
||||
>(
|
||||
listenWhen: (previous, current) =>
|
||||
current is DetailsContentState && current.isSuccess,
|
||||
listener: (context, state) {
|
||||
if (state is DetailsContentState && state.isSuccess) {
|
||||
context.pop(true);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
String title = "Загрузка...";
|
||||
if (state is DetailsContentState) {
|
||||
title = state.subscription.title;
|
||||
}
|
||||
return CustomAppBar(title: title);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.asset('assets/wave.png'),
|
||||
),
|
||||
),
|
||||
BlocBuilder<
|
||||
SubscriptionDetailsBloc,
|
||||
SubscriptionDetailsState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
if (state is DetailsLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF80FFD1),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DetailsError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DetailsContentState) {
|
||||
return _buildContent(context, state);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, DetailsContentState state) {
|
||||
final bool isAvailableForPurchase = state.subscription.isActive;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: ContentParser.parseContent(
|
||||
textJson: state.subscription.fullDescriptionJson,
|
||||
text: state.subscription.fullDescription,
|
||||
),
|
||||
),
|
||||
|
||||
if (isAvailableForPurchase) ...[
|
||||
const SizedBox(height: 30),
|
||||
_ActionCard(state: state),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCardSelector(state, context),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
GradientButton(
|
||||
text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать',
|
||||
onTap: () {
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
ActivateSubscriptionPressed(),
|
||||
);
|
||||
},
|
||||
enabled: state.isAgreed && state.selectedCard != null,
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
fontSize: 16,
|
||||
showArrows: true,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardSelector(DetailsContentState state, BuildContext context) {
|
||||
if (state.selectedCard != null) {
|
||||
return PaymentOption(
|
||||
title: "Карта",
|
||||
subtitle: state.selectedCard.cardLastNumber,
|
||||
isSelected: true,
|
||||
onTap: () async {
|
||||
final selectedCard = await showModalBottomSheet<PaymentCard>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: const PaymentMethodSheet(showBalance: false),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedCard != null && context.mounted) {
|
||||
context.read<SubscriptionDetailsBloc>().add(SelectCardEvent(selectedCard));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _ActionCard extends StatelessWidget {
|
||||
final DetailsContentState state;
|
||||
|
||||
const _ActionCard({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> periodTitles = state.subscription.options
|
||||
.map((e) => e.title)
|
||||
.toList();
|
||||
|
||||
final int selectedIndex = state.subscription.options.indexOf(
|
||||
state.selectedPeriod,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF131B47),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
"Выберите период действия",
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
PeriodSelector(
|
||||
periods: periodTitles,
|
||||
currentIndex: selectedIndex != -1 ? selectedIndex : 0,
|
||||
onSelect: (index) {
|
||||
final selectedOption = state.subscription.options[index];
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
SelectPeriodEvent(selectedOption),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_PriceRow(price: state.selectedPeriod.pricePrint),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: state.isAgreed,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
context.read<SubscriptionDetailsBloc>().add(
|
||||
ToggleAgreementEvent(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'Я подтверждаю, что ознакомился со всеми условиями предоставления подписки и принимаю их безоговорочно',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _PriceRow extends StatelessWidget {
|
||||
final String price;
|
||||
|
||||
const _PriceRow({required this.price});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 72, height: 72),
|
||||
const SizedBox(width: 15),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Стоимость:", style: TextStyle(color: Color(0xFF80FFD1))),
|
||||
Text(
|
||||
price,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF80FFD1),
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,96 @@
|
||||
import 'package:be_happy/presentation/event/subscription_list_event.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/subscription_card.dart';
|
||||
import '../state/subscription_list_state.dart';
|
||||
import '../viewmodel/subscription_list_bloc.dart';
|
||||
|
||||
class SubscriptionsListScreen extends StatelessWidget {
|
||||
const SubscriptionsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Абонементы'),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.asset('assets/wave.png'),
|
||||
),
|
||||
),
|
||||
BlocBuilder<SubscriptionListBloc, SubscriptionState>(
|
||||
builder: (context, state) {
|
||||
if (state is SubscriptionsLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is SubscriptionsLoaded) {
|
||||
final clientSubsMap = {
|
||||
for (var sub in state.activeSubscriptions) sub.subscriptionId: sub
|
||||
};
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
itemCount: state.subscriptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final subscription = state.subscriptions[index];
|
||||
|
||||
final bool isPurchased = subscription.isCurrent;
|
||||
|
||||
final clientSub = clientSubsMap[subscription.id];
|
||||
final DateTime? expirationDate = isPurchased ? clientSub?.expiredAt : null;
|
||||
|
||||
return SizedBox(
|
||||
height: 230, //
|
||||
child: SubscriptionCard(
|
||||
subscription: subscription,
|
||||
isActive: isPurchased,
|
||||
expiredAt: expirationDate,
|
||||
onRefresh: () {
|
||||
context.read<SubscriptionListBloc>().add(LoadSubscriptionsEvent());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (state is SubscriptionsError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.message,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:be_happy/presentation/event/subscription_list_event.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/subscription_card.dart';
|
||||
import '../state/subscription_list_state.dart';
|
||||
import '../viewmodel/subscription_list_bloc.dart';
|
||||
|
||||
class SubscriptionsListScreen extends StatelessWidget {
|
||||
const SubscriptionsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.phoneScreenBg,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomAppBar(title: 'Абонементы'),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.asset('assets/wave.png'),
|
||||
),
|
||||
),
|
||||
BlocBuilder<SubscriptionListBloc, SubscriptionState>(
|
||||
builder: (context, state) {
|
||||
if (state is SubscriptionsLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is SubscriptionsLoaded) {
|
||||
final clientSubsMap = {
|
||||
for (var sub in state.activeSubscriptions) sub.subscriptionId: sub
|
||||
};
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
itemCount: state.subscriptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final subscription = state.subscriptions[index];
|
||||
|
||||
final bool isPurchased = subscription.isCurrent;
|
||||
|
||||
final clientSub = clientSubsMap[subscription.id];
|
||||
final DateTime? expirationDate = isPurchased ? clientSub?.expiredAt : null;
|
||||
|
||||
return SizedBox(
|
||||
height: 230, //
|
||||
child: SubscriptionCard(
|
||||
subscription: subscription,
|
||||
isActive: isPurchased,
|
||||
expiredAt: expirationDate,
|
||||
onRefresh: () {
|
||||
context.read<SubscriptionListBloc>().add(LoadSubscriptionsEvent());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (state is SubscriptionsError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
state.message,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/link_row.dart';
|
||||
|
||||
class SupportScreen extends StatelessWidget {
|
||||
const SupportScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// ✅ Используем общий AppBar
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Техподдержка'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Список ссылок
|
||||
LinkRow(
|
||||
icon: 'assets/icons/telegram.png',
|
||||
title: 'Telegram',
|
||||
onTap: () => openLink('https://t.me/...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/whatsapp.png',
|
||||
title: 'WhatsApp',
|
||||
onTap: () => openLink('https://wa.me/...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/viber.png',
|
||||
title: 'Viber',
|
||||
onTap: () => openLink('viber://chat?number=...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/call.png',
|
||||
title: 'Позвонить',
|
||||
onTap: () => openLink('tel:+375257110909'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Spacer(), // Отодвигаем картинку вниз
|
||||
|
||||
// Нижняя картинка
|
||||
Image.asset(
|
||||
'assets/support_bottom.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/link_row.dart';
|
||||
|
||||
class SupportScreen extends StatelessWidget {
|
||||
const SupportScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// ✅ Используем общий AppBar
|
||||
const SizedBox(height: 16),
|
||||
CustomAppBar(title: 'Техподдержка'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Список ссылок
|
||||
LinkRow(
|
||||
icon: 'assets/icons/telegram.png',
|
||||
title: 'Telegram',
|
||||
onTap: () => openLink('https://t.me/...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/whatsapp.png',
|
||||
title: 'WhatsApp',
|
||||
onTap: () => openLink('https://wa.me/...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/viber.png',
|
||||
title: 'Viber',
|
||||
onTap: () => openLink('viber://chat?number=...'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
LinkRow(
|
||||
icon: 'assets/icons/call.png',
|
||||
title: 'Позвонить',
|
||||
onTap: () => openLink('tel:+375257110909'),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Spacer(), // Отодвигаем картинку вниз
|
||||
|
||||
// Нижняя картинка
|
||||
Image.asset(
|
||||
'assets/support_bottom.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
lib/presentation/screens/tech_works_screen.dart
Normal file
105
lib/presentation/screens/tech_works_screen.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class TechWorksScreen extends StatelessWidget {
|
||||
const TechWorksScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF223252),
|
||||
Color(0xFF0D1220),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
|
||||
Image.asset(
|
||||
'assets/tech_works.png',
|
||||
width: 260,
|
||||
height: 260,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
||||
const Spacer(flex: 2),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
vertical: 28.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101528),
|
||||
borderRadius: BorderRadius.circular(28.0),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Технические работы',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'В приложении ведутся технические работы. Попробуйте зайти позже.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
fontSize: 15,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
SystemNavigator.pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(100, 44),
|
||||
foregroundColor: const Color(0xFF20E298),
|
||||
),
|
||||
child: const Text(
|
||||
'ОК',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF20E298),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,289 +1,289 @@
|
||||
import 'package:be_happy/domain/usecase/add_payment_card_usecase.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../event/top_up_event.dart';
|
||||
import '../state/top_up_state.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
import '../viewmodel/top_up_bloc.dart';
|
||||
|
||||
class TopUpScreen extends StatelessWidget {
|
||||
const TopUpScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<TopUpBloc, TopUpState>(
|
||||
listener: (context, state) {
|
||||
if (state is TopUpPaymentSuccess) {
|
||||
context.go('/home/payment-methods/top-up/result/success');
|
||||
} else if (state is TopUpPaymentFailure) {
|
||||
context.go('/home/payment-methods/top-up/result/failed');
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const CustomAppBar(title: 'Пополнение баланса'),
|
||||
const SizedBox(height: 20),
|
||||
BlocBuilder<TopUpBloc, TopUpState>(
|
||||
builder: (context, state) {
|
||||
if (state is TopUpLoading) {
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is TopUpLoaded) {
|
||||
if (state.isProcessingPayment) {
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTariffList(state, context),
|
||||
const SizedBox(height: 30),
|
||||
_buildPriceInfo(state),
|
||||
const SizedBox(height: 40),
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
_buildCardSelector(state, context),
|
||||
const SizedBox(height: 20),
|
||||
_buildAgreement(state, context),
|
||||
const Spacer(),
|
||||
_buildPayButton(state, () {
|
||||
context.read<TopUpBloc>().add(TopUpButtonPressed());
|
||||
}),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTariffList(TopUpLoaded state, BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.certificates.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final tariff = state.certificates[index];
|
||||
final isSelected = state.selectedTariff == tariff;
|
||||
return GestureDetector(
|
||||
onTap: () => context.read<TopUpBloc>().add(SelectCertificate(tariff)),
|
||||
child: Container(
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF80FFC1) : Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (tariff.discount != null)
|
||||
Text(
|
||||
'скидка: ${tariff.discount!.toInt()}%',
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.black87 : Colors.tealAccent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${tariff.value} баллов',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceInfo(TopUpLoaded state) {
|
||||
if (state.selectedTariff == null) return const SizedBox.shrink();
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Купить со скидкой ${state.selectedTariff!.discount?.toInt()}%:',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${state.selectedTariff!.price.toStringAsFixed(2)} BYN',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF80FFC1),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardSelector(TopUpLoaded state, BuildContext context) {
|
||||
return state.selectedCard != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: PaymentOption(
|
||||
title: "Карта",
|
||||
subtitle: '${state.selectedCard!.cardLastNumber}',
|
||||
isSelected: true,
|
||||
onTap: () async {
|
||||
final selectedCard = await showModalBottomSheet<PaymentCard>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: const PaymentMethodSheet(showBalance: false),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedCard != null) {
|
||||
context.read<TopUpBloc>().add(SelectCard(selectedCard));
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushReplacement('/home/payment-method-sheet');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.6)),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.4)),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreement(TopUpLoaded state, BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: state.isAgreed,
|
||||
onChanged: (v) => context.read<TopUpBloc>().add(ToggleAgreement(v ?? false)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Я принимаю условия покупки бонусного пакека, при котором денежные средства не подлежат возврату... ',
|
||||
style: TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayButton(TopUpLoaded state, VoidCallback onTap) {
|
||||
return GradientButton(
|
||||
text: 'Оплатить',
|
||||
onTap: state.isAgreed ? onTap : null,
|
||||
enabled: state.isAgreed,
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
);
|
||||
}
|
||||
import 'package:be_happy/domain/usecase/add_payment_card_usecase.dart';
|
||||
import 'package:be_happy/presentation/viewmodel/payment_methods_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../di/service_locator.dart';
|
||||
import '../../domain/entities/payment_card.dart';
|
||||
import '../../domain/usecase/get_payment_cards_usecase.dart';
|
||||
import '../../domain/usecase/get_profile_usecase.dart';
|
||||
import '../../domain/usecase/set_main_payment_card_usecase.dart';
|
||||
import '../components/app_checkbox.dart';
|
||||
import '../components/custom_app_bar.dart';
|
||||
import '../components/gradient_button.dart';
|
||||
import '../components/payment_option.dart';
|
||||
import '../components/sheet/payment_method_sheet.dart';
|
||||
import '../event/payment_method_sheet_event.dart';
|
||||
import '../event/payment_methods_event.dart';
|
||||
import '../event/top_up_event.dart';
|
||||
import '../state/top_up_state.dart';
|
||||
import '../viewmodel/payment_method_sheet_bloc.dart';
|
||||
import '../viewmodel/top_up_bloc.dart';
|
||||
|
||||
class TopUpScreen extends StatelessWidget {
|
||||
const TopUpScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: BlocListener<TopUpBloc, TopUpState>(
|
||||
listener: (context, state) {
|
||||
if (state is TopUpPaymentSuccess) {
|
||||
context.go('/home/payment-methods/top-up/result/success');
|
||||
} else if (state is TopUpPaymentFailure) {
|
||||
context.go('/home/payment-methods/top-up/result/failed');
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const CustomAppBar(title: 'Пополнение баланса'),
|
||||
const SizedBox(height: 20),
|
||||
BlocBuilder<TopUpBloc, TopUpState>(
|
||||
builder: (context, state) {
|
||||
if (state is TopUpLoading) {
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is TopUpLoaded) {
|
||||
if (state.isProcessingPayment) {
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTariffList(state, context),
|
||||
const SizedBox(height: 30),
|
||||
_buildPriceInfo(state),
|
||||
const SizedBox(height: 40),
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
_buildCardSelector(state, context),
|
||||
const SizedBox(height: 20),
|
||||
_buildAgreement(state, context),
|
||||
const Spacer(),
|
||||
_buildPayButton(state, () {
|
||||
context.read<TopUpBloc>().add(TopUpButtonPressed());
|
||||
}),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTariffList(TopUpLoaded state, BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.certificates.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final tariff = state.certificates[index];
|
||||
final isSelected = state.selectedTariff == tariff;
|
||||
return GestureDetector(
|
||||
onTap: () => context.read<TopUpBloc>().add(SelectCertificate(tariff)),
|
||||
child: Container(
|
||||
width: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF80FFC1) : Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (tariff.discount != null)
|
||||
Text(
|
||||
'скидка: ${tariff.discount!.toInt()}%',
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.black87 : Colors.tealAccent,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${tariff.value} баллов',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceInfo(TopUpLoaded state) {
|
||||
if (state.selectedTariff == null) return const SizedBox.shrink();
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Купить со скидкой ${state.selectedTariff!.discount?.toInt()}%:',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/icons/money_icon.png", width: 24, height: 24),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${state.selectedTariff!.price.toStringAsFixed(2)} BYN',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF80FFC1),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardSelector(TopUpLoaded state, BuildContext context) {
|
||||
return state.selectedCard != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: PaymentOption(
|
||||
title: "Карта",
|
||||
subtitle: '${state.selectedCard!.cardLastNumber}',
|
||||
isSelected: true,
|
||||
onTap: () async {
|
||||
final selectedCard = await showModalBottomSheet<PaymentCard>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (innerContext) => BlocProvider(
|
||||
create: (context) => PaymentMethodSheetBloc(
|
||||
getIt<GetPaymentCardsUsecase>(),
|
||||
getIt<SetMainPaymentCardUsecase>(),
|
||||
getIt<GetProfileUseCase>(),
|
||||
getIt<AddPaymentCardUsecase>(),
|
||||
)..add(PaymentMethodSheetStarted()),
|
||||
child: const PaymentMethodSheet(showBalance: false),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedCard != null) {
|
||||
context.read<TopUpBloc>().add(SelectCard(selectedCard));
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.pushReplacement('/home/payment-method-sheet');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Способ оплаты',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.6)),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.4)),
|
||||
Icon(Icons.arrow_forward_ios, size: 12, color: Colors.white.withOpacity(0.2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreement(TopUpLoaded state, BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
AppCheckbox(
|
||||
value: state.isAgreed,
|
||||
onChanged: (v) => context.read<TopUpBloc>().add(ToggleAgreement(v ?? false)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'Я принимаю условия покупки бонусного пакека, при котором денежные средства не подлежат возврату... ',
|
||||
style: TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayButton(TopUpLoaded state, VoidCallback onTap) {
|
||||
return GradientButton(
|
||||
text: 'Оплатить',
|
||||
onTap: state.isAgreed ? onTap : null,
|
||||
enabled: state.isAgreed,
|
||||
showArrows: true,
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
fontSize: 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user