create promocode page

This commit is contained in:
2026-06-01 14:35:46 +03:00
parent 134ffdde60
commit 0b6757e26f
13 changed files with 343 additions and 112 deletions

View File

@@ -44,7 +44,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // 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. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26 minSdk = 26

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

View File

@@ -1150,4 +1150,31 @@ class ApiService {
} }
return null; 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 {}

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

View File

@@ -85,11 +85,14 @@ import '../data/repositories/auth_repository_impl.dart';
import '../data/repositories/news_repository_impl.dart'; import '../data/repositories/news_repository_impl.dart';
import '../data/repositories/pin_repository_impl.dart'; import '../data/repositories/pin_repository_impl.dart';
import '../data/repositories/profile_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 '../data/service/news_api_service.dart';
import '../domain/repositories/auth_repository.dart'; import '../domain/repositories/auth_repository.dart';
import '../domain/repositories/news_repository.dart'; import '../domain/repositories/news_repository.dart';
import '../domain/repositories/promo_code_repository.dart';
import '../domain/service/device_info_service.dart'; import '../domain/service/device_info_service.dart';
import '../domain/usecase/activate_subscription_usecase.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_client_subscriptions_usecase.dart';
import '../domain/usecase/get_news_by_id_usecase.dart'; import '../domain/usecase/get_news_by_id_usecase.dart';
import '../domain/usecase/get_notifications_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/news_bloc.dart';
import '../presentation/viewmodel/notifications_bloc.dart'; import '../presentation/viewmodel/notifications_bloc.dart';
import '../presentation/viewmodel/order_history_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/scooter_detail_modal_bloc.dart';
import '../presentation/viewmodel/subscription_list_bloc.dart'; import '../presentation/viewmodel/subscription_list_bloc.dart';
import '../presentation/viewmodel/verify_code_bloc.dart'; import '../presentation/viewmodel/verify_code_bloc.dart';
@@ -383,4 +387,20 @@ Future<void> setupDependencies() async {
getIt.registerFactory<ScooterCodeBloc>( getIt.registerFactory<ScooterCodeBloc>(
() => ScooterCodeBloc(getScooterByTitleUsecase: getIt<GetScooterByTitleUsecase>()), () => 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>()),
);
} }

View File

@@ -0,0 +1,9 @@
class PromoCodeResult {
final bool success;
final double balance;
PromoCodeResult({
required this.success,
required this.balance,
});
}

View File

@@ -0,0 +1,5 @@
import '../entities/promo_code_result.dart';
abstract class PromoCodeRepository {
Future<PromoCodeResult> applyPromoCode(String code);
}

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

View File

@@ -0,0 +1,8 @@
sealed class PromoCodeEvent {}
class PromoCodeApplyRequested extends PromoCodeEvent {
final String code;
PromoCodeApplyRequested(this.code);
}
class PromoCodeReset extends PromoCodeEvent {}

View File

@@ -1,40 +1,45 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/app_colors.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 '../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}); const PromoCodeScreen({super.key});
@override @override
State<PromoCodeScreen> createState() => _PromoCodeScreenState(); Widget build(BuildContext context) {
} return BlocProvider(
create: (context) => PromoCodeBloc(getIt<ApplyPromoCodeUsecase>()),
class _PromoCodeScreenState extends State<PromoCodeScreen> { child: const _PromoCodeScreenContent(),
final TextEditingController promoController = TextEditingController();
bool isError = false;
void _activatePromo() {
if (promoController.text == 'G17N160') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Промокод активирован!')),
); );
} else {
setState(() {
isError = true;
});
} }
} }
void _retry() { class _PromoCodeScreenContent extends StatefulWidget {
setState(() { const _PromoCodeScreenContent();
isError = false;
promoController.clear(); @override
}); State<_PromoCodeScreenContent> createState() => _PromoCodeScreenContentState();
}
class _PromoCodeScreenContentState extends State<_PromoCodeScreenContent> {
final TextEditingController promoController = TextEditingController();
@override
void dispose() {
promoController.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false,
body: Container( body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg), decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea( child: SafeArea(
@@ -49,10 +54,42 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
CustomAppBar(title: 'Промокоды'), CustomAppBar(title: 'Промокоды'),
const SizedBox(height: 32), 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), padding: const EdgeInsets.all(25),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Color(0xFF141530), color: const Color(0xFF141530),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: Column(
@@ -60,26 +97,19 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
children: [ children: [
const Text( const Text(
'У вас есть промокод?', 'У вас есть промокод?',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
'Введите промокод и получите скидку на поездку', 'Введите промокод и получите скидку на поездку',
style: TextStyle( style: TextStyle(color: AppColors.white70, fontSize: 16),
color: AppColors.white70,
fontSize: 16,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( TextField(
controller: promoController, controller: promoController,
style: TextStyle( enabled: !isLoading,
color: isError ? Colors.red : Colors.white, style: TextStyle(color: hasError ? Colors.red : Colors.white),
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Введите промокод', hintText: 'Введите промокод',
hintStyle: const TextStyle(color: AppColors.white70), hintStyle: const TextStyle(color: AppColors.white70),
@@ -95,60 +125,64 @@ class _PromoCodeScreenState extends State<PromoCodeScreen> {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), 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), const SizedBox(height: 20),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => Navigator.pop(context), onPressed: isLoading ? null : () => Navigator.pop(context),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
borderRadius: BorderRadius.circular(24),
),
side: BorderSide(color: AppColors.white70.withOpacity(0.4)), side: BorderSide(color: AppColors.white70.withOpacity(0.4)),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
child: const Text( child: const Text('Отмена', style: TextStyle(color: AppColors.white70)),
'Отмена',
style: TextStyle(color: AppColors.white70),
),
), ),
), ),
const SizedBox(width: 22), const SizedBox(width: 22),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: _activatePromo, onPressed: isLoading ? null : () => _submit(context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
borderRadius: BorderRadius.circular(24),
),
backgroundColor: AppColors.activeButtonGradient.colors[0], backgroundColor: AppColors.activeButtonGradient.colors[0],
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
child: const Text( child: isLoading
'Активировать', ? const SizedBox(
style: TextStyle(color: AppColors.activeButtonText), width: 20,
), height: 20,
child: CircularProgressIndicator(color: AppColors.activeButtonText, strokeWidth: 2),
)
: const Text('Активировать', style: TextStyle(color: AppColors.activeButtonText)),
), ),
), ),
], ],
), ),
], ],
), ),
);
},
),
), ),
const Spacer(), const Spacer(),
], ],
), ),
), ),
), ),
Image.asset(
'assets/promo_bottom.png',
Image.asset('assets/promo_bottom.png',
width: double.infinity, width: double.infinity,
fit: BoxFit.contain, fit: BoxFit.contain,
alignment: Alignment.center, 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));
}
} }

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

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

View File

@@ -2,7 +2,7 @@ name: be_happy
description: "" description: ""
publish_to: 'none' publish_to: 'none'
version: 1.0.0+11 version: 1.0.0+12
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1