From 0b6757e26f81a6db6ed9c01472a8d0881447089b Mon Sep 17 00:00:00 2001 From: Polyanka1 Date: Mon, 1 Jun 2026 14:35:46 +0300 Subject: [PATCH] create promocode page --- android/app/build.gradle.kts | 2 +- lib/data/models/promo_code_response_dto.dart | 25 ++ lib/data/network/api_service.dart | 27 ++ .../promo_code_repository_impl.dart | 23 ++ lib/di/service_locator.dart | 20 ++ lib/domain/entities/promo_code_result.dart | 9 + .../repositories/promo_code_repository.dart | 5 + .../usecase/apply_promo_code_usecase.dart | 12 + lib/presentation/event/promo_code_event.dart | 8 + .../screens/promo_code_screen.dart | 260 ++++++++++-------- lib/presentation/state/promo_code_state.dart | 25 ++ .../viewmodel/promo_code_bloc.dart | 37 +++ pubspec.yaml | 2 +- 13 files changed, 343 insertions(+), 112 deletions(-) create mode 100644 lib/data/models/promo_code_response_dto.dart create mode 100644 lib/data/repositories/promo_code_repository_impl.dart create mode 100644 lib/domain/entities/promo_code_result.dart create mode 100644 lib/domain/repositories/promo_code_repository.dart create mode 100644 lib/domain/usecase/apply_promo_code_usecase.dart create mode 100644 lib/presentation/event/promo_code_event.dart create mode 100644 lib/presentation/state/promo_code_state.dart create mode 100644 lib/presentation/viewmodel/promo_code_bloc.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e337cda..ae88da7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/lib/data/models/promo_code_response_dto.dart b/lib/data/models/promo_code_response_dto.dart new file mode 100644 index 0000000..24ba6b1 --- /dev/null +++ b/lib/data/models/promo_code_response_dto.dart @@ -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 json) { + return PromoCodeResponseDto( + success: json['success'] as bool, + balance: (json['balance'] as num).toDouble(), + ); + } + + PromoCodeResult toEntity() { + return PromoCodeResult( + success: success, + balance: balance, + ); + } +} \ No newline at end of file diff --git a/lib/data/network/api_service.dart b/lib/data/network/api_service.dart index 295b07a..970ed04 100644 --- a/lib/data/network/api_service.dart +++ b/lib/data/network/api_service.dart @@ -1150,4 +1150,31 @@ class ApiService { } return null; } + + Future> 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 {} diff --git a/lib/data/repositories/promo_code_repository_impl.dart b/lib/data/repositories/promo_code_repository_impl.dart new file mode 100644 index 0000000..8f6a0c5 --- /dev/null +++ b/lib/data/repositories/promo_code_repository_impl.dart @@ -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 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'); + } + } +} \ No newline at end of file diff --git a/lib/di/service_locator.dart b/lib/di/service_locator.dart index 48c9864..1198bff 100644 --- a/lib/di/service_locator.dart +++ b/lib/di/service_locator.dart @@ -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 setupDependencies() async { getIt.registerFactory( () => ScooterCodeBloc(getScooterByTitleUsecase: getIt()), ); + + + // Repository + getIt.registerSingleton( + PromoCodeRepositoryImpl(getIt()), + ); + +// UseCase + getIt.registerSingleton( + ApplyPromoCodeUsecase(getIt()), + ); + +// Bloc (factory, т.к. экран создаёт новый экземпляр) + getIt.registerFactory( + () => PromoCodeBloc(getIt()), + ); } diff --git a/lib/domain/entities/promo_code_result.dart b/lib/domain/entities/promo_code_result.dart new file mode 100644 index 0000000..7256168 --- /dev/null +++ b/lib/domain/entities/promo_code_result.dart @@ -0,0 +1,9 @@ +class PromoCodeResult { + final bool success; + final double balance; + + PromoCodeResult({ + required this.success, + required this.balance, + }); +} \ No newline at end of file diff --git a/lib/domain/repositories/promo_code_repository.dart b/lib/domain/repositories/promo_code_repository.dart new file mode 100644 index 0000000..467ed16 --- /dev/null +++ b/lib/domain/repositories/promo_code_repository.dart @@ -0,0 +1,5 @@ +import '../entities/promo_code_result.dart'; + +abstract class PromoCodeRepository { + Future applyPromoCode(String code); +} \ No newline at end of file diff --git a/lib/domain/usecase/apply_promo_code_usecase.dart b/lib/domain/usecase/apply_promo_code_usecase.dart new file mode 100644 index 0000000..683f075 --- /dev/null +++ b/lib/domain/usecase/apply_promo_code_usecase.dart @@ -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 call(String code) { + return repository.applyPromoCode(code); + } +} \ No newline at end of file diff --git a/lib/presentation/event/promo_code_event.dart b/lib/presentation/event/promo_code_event.dart new file mode 100644 index 0000000..65c8852 --- /dev/null +++ b/lib/presentation/event/promo_code_event.dart @@ -0,0 +1,8 @@ +sealed class PromoCodeEvent {} + +class PromoCodeApplyRequested extends PromoCodeEvent { + final String code; + PromoCodeApplyRequested(this.code); +} + +class PromoCodeReset extends PromoCodeEvent {} \ No newline at end of file diff --git a/lib/presentation/screens/promo_code_screen.dart b/lib/presentation/screens/promo_code_screen.dart index 46836e7..e13bbff 100644 --- a/lib/presentation/screens/promo_code_screen.dart +++ b/lib/presentation/screens/promo_code_screen.dart @@ -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 createState() => _PromoCodeScreenState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => PromoCodeBloc(getIt()), + child: const _PromoCodeScreenContent(), + ); + } } -class _PromoCodeScreenState extends State { +class _PromoCodeScreenContent extends StatefulWidget { + const _PromoCodeScreenContent(); + + @override + State<_PromoCodeScreenContent> createState() => _PromoCodeScreenContentState(); +} + +class _PromoCodeScreenContentState extends State<_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() { - setState(() { - isError = false; - promoController.clear(); - }); + @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,106 +54,135 @@ class _PromoCodeScreenState extends State { CustomAppBar(title: 'Промокоды'), const SizedBox(height: 32), - Container( - padding: const EdgeInsets.all(25), - decoration: BoxDecoration( - color: 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, - style: TextStyle( - color: isError ? 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: AppColors.smsDigit, width: 1.5), + // 🔹 LISTENER: Только сайд-эффекты (SnackBar, навигация) + BlocListener( + listener: (context, state) { + if (state.status == PromoCodeStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Промокод активирован! Баланс: ${state.newBalance?.toStringAsFixed(2)} BYN', ), + backgroundColor: Colors.green, ), - ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => 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), - ), + ); + // Очищаем поле визуально + 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( + 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(width: 22), - Expanded( - child: ElevatedButton( - onPressed: _activatePromo, - style: ElevatedButton.styleFrom( - 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), - ), + 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', + Image.asset( + 'assets/promo_bottom.png', width: double.infinity, fit: BoxFit.contain, alignment: Alignment.center, @@ -160,4 +194,10 @@ class _PromoCodeScreenState extends State { ), ); } + + void _submit(BuildContext context) { + final code = promoController.text.trim(); + if (code.isEmpty) return; + context.read().add(PromoCodeApplyRequested(code)); + } } \ No newline at end of file diff --git a/lib/presentation/state/promo_code_state.dart b/lib/presentation/state/promo_code_state.dart new file mode 100644 index 0000000..f8418f6 --- /dev/null +++ b/lib/presentation/state/promo_code_state.dart @@ -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, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/viewmodel/promo_code_bloc.dart b/lib/presentation/viewmodel/promo_code_bloc.dart new file mode 100644 index 0000000..50e7e47 --- /dev/null +++ b/lib/presentation/viewmodel/promo_code_bloc.dart @@ -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 { + final ApplyPromoCodeUsecase _applyPromoCodeUsecase; + + PromoCodeBloc(this._applyPromoCodeUsecase) : super(const PromoCodeState()) { + on(_onApplyRequested); + on(_onReset); + } + + Future _onApplyRequested( + PromoCodeApplyRequested event, + Emitter 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 emit) { + emit(const PromoCodeState()); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index b687ce5..0128c1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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