create promocode page
This commit is contained in:
@@ -44,7 +44,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.sparkit.be_happy"
|
||||
applicationId = "com.sparkit.behappy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 26
|
||||
|
||||
25
lib/data/models/promo_code_response_dto.dart
Normal file
25
lib/data/models/promo_code_response_dto.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import '../../domain/entities/promo_code_result.dart';
|
||||
|
||||
class PromoCodeResponseDto {
|
||||
final bool success;
|
||||
final double balance;
|
||||
|
||||
PromoCodeResponseDto({
|
||||
required this.success,
|
||||
required this.balance,
|
||||
});
|
||||
|
||||
factory PromoCodeResponseDto.fromJson(Map<String, dynamic> json) {
|
||||
return PromoCodeResponseDto(
|
||||
success: json['success'] as bool,
|
||||
balance: (json['balance'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
PromoCodeResult toEntity() {
|
||||
return PromoCodeResult(
|
||||
success: success,
|
||||
balance: balance,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1150,4 +1150,31 @@ class ApiService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> applyPromoCode(String code) async {
|
||||
final token = await _securityService.getAccessToken();
|
||||
if (token == null) throw Exception('No access token');
|
||||
|
||||
final response = await _dio.post(
|
||||
'$baseUrl/promocode/apply',
|
||||
data: {'code': code},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
return response.data;
|
||||
} else if (response.statusCode == 404) {
|
||||
throw PromoCodeNotFoundException();
|
||||
} else {
|
||||
throw Exception('API Error: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Добавь исключение (можно в отдельный файл)
|
||||
class PromoCodeNotFoundException implements Exception {}
|
||||
|
||||
23
lib/data/repositories/promo_code_repository_impl.dart
Normal file
23
lib/data/repositories/promo_code_repository_impl.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import '../../domain/entities/promo_code_result.dart';
|
||||
import '../../domain/repositories/promo_code_repository.dart';
|
||||
import '../models/promo_code_response_dto.dart';
|
||||
import '../network/api_service.dart';
|
||||
|
||||
class PromoCodeRepositoryImpl implements PromoCodeRepository {
|
||||
final ApiService apiService;
|
||||
|
||||
PromoCodeRepositoryImpl(this.apiService);
|
||||
|
||||
@override
|
||||
Future<PromoCodeResult> applyPromoCode(String code) async {
|
||||
try {
|
||||
final data = await apiService.applyPromoCode(code);
|
||||
final dto = PromoCodeResponseDto.fromJson(data);
|
||||
return dto.toEntity();
|
||||
} on PromoCodeNotFoundException {
|
||||
throw Exception('Промокод не найден');
|
||||
} catch (e) {
|
||||
throw Exception('Ошибка активации промокода: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,11 +85,14 @@ import '../data/repositories/auth_repository_impl.dart';
|
||||
import '../data/repositories/news_repository_impl.dart';
|
||||
import '../data/repositories/pin_repository_impl.dart';
|
||||
import '../data/repositories/profile_repository_impl.dart';
|
||||
import '../data/repositories/promo_code_repository_impl.dart';
|
||||
import '../data/service/news_api_service.dart';
|
||||
import '../domain/repositories/auth_repository.dart';
|
||||
import '../domain/repositories/news_repository.dart';
|
||||
import '../domain/repositories/promo_code_repository.dart';
|
||||
import '../domain/service/device_info_service.dart';
|
||||
import '../domain/usecase/activate_subscription_usecase.dart';
|
||||
import '../domain/usecase/apply_promo_code_usecase.dart';
|
||||
import '../domain/usecase/get_client_subscriptions_usecase.dart';
|
||||
import '../domain/usecase/get_news_by_id_usecase.dart';
|
||||
import '../domain/usecase/get_notifications_usecase.dart';
|
||||
@@ -103,6 +106,7 @@ import '../presentation/viewmodel/map_bloc.dart';
|
||||
import '../presentation/viewmodel/news_bloc.dart';
|
||||
import '../presentation/viewmodel/notifications_bloc.dart';
|
||||
import '../presentation/viewmodel/order_history_bloc.dart';
|
||||
import '../presentation/viewmodel/promo_code_bloc.dart';
|
||||
import '../presentation/viewmodel/scooter_detail_modal_bloc.dart';
|
||||
import '../presentation/viewmodel/subscription_list_bloc.dart';
|
||||
import '../presentation/viewmodel/verify_code_bloc.dart';
|
||||
@@ -383,4 +387,20 @@ Future<void> setupDependencies() async {
|
||||
getIt.registerFactory<ScooterCodeBloc>(
|
||||
() => ScooterCodeBloc(getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>()),
|
||||
);
|
||||
|
||||
|
||||
// Repository
|
||||
getIt.registerSingleton<PromoCodeRepository>(
|
||||
PromoCodeRepositoryImpl(getIt<ApiService>()),
|
||||
);
|
||||
|
||||
// UseCase
|
||||
getIt.registerSingleton<ApplyPromoCodeUsecase>(
|
||||
ApplyPromoCodeUsecase(getIt<PromoCodeRepository>()),
|
||||
);
|
||||
|
||||
// Bloc (factory, т.к. экран создаёт новый экземпляр)
|
||||
getIt.registerFactory<PromoCodeBloc>(
|
||||
() => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
|
||||
);
|
||||
}
|
||||
|
||||
9
lib/domain/entities/promo_code_result.dart
Normal file
9
lib/domain/entities/promo_code_result.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class PromoCodeResult {
|
||||
final bool success;
|
||||
final double balance;
|
||||
|
||||
PromoCodeResult({
|
||||
required this.success,
|
||||
required this.balance,
|
||||
});
|
||||
}
|
||||
5
lib/domain/repositories/promo_code_repository.dart
Normal file
5
lib/domain/repositories/promo_code_repository.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../entities/promo_code_result.dart';
|
||||
|
||||
abstract class PromoCodeRepository {
|
||||
Future<PromoCodeResult> applyPromoCode(String code);
|
||||
}
|
||||
12
lib/domain/usecase/apply_promo_code_usecase.dart
Normal file
12
lib/domain/usecase/apply_promo_code_usecase.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import '../entities/promo_code_result.dart';
|
||||
import '../repositories/promo_code_repository.dart';
|
||||
|
||||
class ApplyPromoCodeUsecase {
|
||||
final PromoCodeRepository repository;
|
||||
|
||||
ApplyPromoCodeUsecase(this.repository);
|
||||
|
||||
Future<PromoCodeResult> call(String code) {
|
||||
return repository.applyPromoCode(code);
|
||||
}
|
||||
}
|
||||
8
lib/presentation/event/promo_code_event.dart
Normal file
8
lib/presentation/event/promo_code_event.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
sealed class PromoCodeEvent {}
|
||||
|
||||
class PromoCodeApplyRequested extends PromoCodeEvent {
|
||||
final String code;
|
||||
PromoCodeApplyRequested(this.code);
|
||||
}
|
||||
|
||||
class PromoCodeReset extends PromoCodeEvent {}
|
||||
@@ -1,40 +1,45 @@
|
||||
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 StatefulWidget {
|
||||
class PromoCodeScreen extends StatelessWidget {
|
||||
const PromoCodeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PromoCodeScreen> createState() => _PromoCodeScreenState();
|
||||
}
|
||||
|
||||
class _PromoCodeScreenState extends State<PromoCodeScreen> {
|
||||
final TextEditingController promoController = TextEditingController();
|
||||
bool isError = false;
|
||||
|
||||
void _activatePromo() {
|
||||
if (promoController.text == 'G17N160') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Промокод активирован!')),
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
|
||||
child: const _PromoCodeScreenContent(),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
isError = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
isError = false;
|
||||
promoController.clear();
|
||||
});
|
||||
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(
|
||||
@@ -49,10 +54,42 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
|
||||
CustomAppBar(title: 'Промокоды'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Container(
|
||||
// 🔹 LISTENER: Только сайд-эффекты (SnackBar, навигация)
|
||||
BlocListener<PromoCodeBloc, PromoCodeState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == PromoCodeStatus.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Промокод активирован! Баланс: ${state.newBalance?.toStringAsFixed(2)} BYN',
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
// Очищаем поле визуально
|
||||
promoController.clear();
|
||||
// Ждем чуть меньше, чтобы пользователь увидел успех
|
||||
Future.delayed(const Duration(milliseconds: 1200), () {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
} else if (state.status == PromoCodeStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Ошибка активации'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
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: Color(0xFF141530),
|
||||
color: const Color(0xFF141530),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
@@ -60,26 +97,19 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'У вас есть промокод?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Введите промокод и получите скидку на поездку',
|
||||
style: TextStyle(
|
||||
color: AppColors.white70,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: TextStyle(color: AppColors.white70, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
TextField(
|
||||
controller: promoController,
|
||||
style: TextStyle(
|
||||
color: isError ? Colors.red : Colors.white,
|
||||
),
|
||||
enabled: !isLoading,
|
||||
style: TextStyle(color: hasError ? Colors.red : Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите промокод',
|
||||
hintStyle: const TextStyle(color: AppColors.white70),
|
||||
@@ -95,60 +125,64 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: AppColors.smsDigit, width: 1.5),
|
||||
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: () => Navigator.pop(context),
|
||||
onPressed: isLoading ? null : () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
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),
|
||||
),
|
||||
child: const Text('Отмена', style: TextStyle(color: AppColors.white70)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 22),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _activatePromo,
|
||||
onPressed: isLoading ? null : () => _submit(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
backgroundColor: AppColors.activeButtonGradient.colors[0],
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Активировать',
|
||||
style: TextStyle(color: AppColors.activeButtonText),
|
||||
),
|
||||
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',
|
||||
Image.asset(
|
||||
'assets/promo_bottom.png',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
@@ -160,4 +194,10 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) {
|
||||
final code = promoController.text.trim();
|
||||
if (code.isEmpty) return;
|
||||
context.read<PromoCodeBloc>().add(PromoCodeApplyRequested(code));
|
||||
}
|
||||
}
|
||||
25
lib/presentation/state/promo_code_state.dart
Normal file
25
lib/presentation/state/promo_code_state.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
enum PromoCodeStatus { initial, loading, success, failure }
|
||||
|
||||
class PromoCodeState {
|
||||
final PromoCodeStatus status;
|
||||
final double? newBalance;
|
||||
final String? errorMessage;
|
||||
|
||||
const PromoCodeState({
|
||||
this.status = PromoCodeStatus.initial,
|
||||
this.newBalance,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
PromoCodeState copyWith({
|
||||
PromoCodeStatus? status,
|
||||
double? newBalance,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return PromoCodeState(
|
||||
status: status ?? this.status,
|
||||
newBalance: newBalance ?? this.newBalance,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/presentation/viewmodel/promo_code_bloc.dart
Normal file
37
lib/presentation/viewmodel/promo_code_bloc.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecase/apply_promo_code_usecase.dart';
|
||||
import '../event/promo_code_event.dart';
|
||||
import '../state/promo_code_state.dart';
|
||||
|
||||
class PromoCodeBloc extends Bloc<PromoCodeEvent, PromoCodeState> {
|
||||
final ApplyPromoCodeUsecase _applyPromoCodeUsecase;
|
||||
|
||||
PromoCodeBloc(this._applyPromoCodeUsecase) : super(const PromoCodeState()) {
|
||||
on<PromoCodeApplyRequested>(_onApplyRequested);
|
||||
on<PromoCodeReset>(_onReset);
|
||||
}
|
||||
|
||||
Future<void> _onApplyRequested(
|
||||
PromoCodeApplyRequested event,
|
||||
Emitter<PromoCodeState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: PromoCodeStatus.loading));
|
||||
|
||||
try {
|
||||
final result = await _applyPromoCodeUsecase(event.code);
|
||||
emit(state.copyWith(
|
||||
status: PromoCodeStatus.success,
|
||||
newBalance: result.balance,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: PromoCodeStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onReset(PromoCodeReset event, Emitter<PromoCodeState> emit) {
|
||||
emit(const PromoCodeState());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: be_happy
|
||||
description: ""
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+11
|
||||
version: 1.0.0+12
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user