From b9da1d306476222691caa1ad5f9debaaf04b1f88 Mon Sep 17 00:00:00 2001 From: Polyanka1 Date: Mon, 1 Jun 2026 17:15:08 +0300 Subject: [PATCH] fix styles on the add card page, move the parser to a separate file, change the styles of the page of one news item, add a parser for the subscription page, add the input field for the qr code --- lib/domain/entities/subscription.dart | 3 + .../components/content_parser.dart | 244 +++++++++++++++++ .../components/subscription_card.dart | 1 + lib/presentation/screens/add_card_screen.dart | 1 + .../screens/news_detail_screen.dart | 257 +----------------- .../screens/promo_code_screen.dart | 19 +- .../screens/scooter_code_input_screen.dart | 2 +- .../screens/subscription_details_screen.dart | 65 +++-- .../screens/subscription_list_screen.dart | 15 +- 9 files changed, 300 insertions(+), 307 deletions(-) create mode 100644 lib/presentation/components/content_parser.dart diff --git a/lib/domain/entities/subscription.dart b/lib/domain/entities/subscription.dart index 51f1d69..9f7ec7f 100644 --- a/lib/domain/entities/subscription.dart +++ b/lib/domain/entities/subscription.dart @@ -6,6 +6,7 @@ class Subscription { final String title; final String shortDescription; final String fullDescription; + final String fullDescriptionJson; final int planId; final bool isActive; final bool isCurrent; @@ -22,6 +23,7 @@ class Subscription { required this.title, required this.shortDescription, required this.fullDescription, + required this.fullDescriptionJson, required this.planId, required this.isActive, required this.currency, @@ -43,6 +45,7 @@ class Subscription { title: json['title'] ?? '', shortDescription: json['shortDescription'] ?? '', fullDescription: json['fullDescription'] ?? '', + fullDescriptionJson: json['fullDescriptionJson'] ?? '', planId: json['planId'] ?? 0, isActive: json['isActive'] ?? false, currency: currencyData['currency'] ?? 'BYN', diff --git a/lib/presentation/components/content_parser.dart b/lib/presentation/components/content_parser.dart new file mode 100644 index 0000000..71e4e21 --- /dev/null +++ b/lib/presentation/components/content_parser.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:html/dom.dart' as dom; +import 'dart:convert'; + +/// Парсер контента новостей (поддерживает HTML и JSON-формат) +class ContentParser { + + /// Парсит текст новости: сначала проверяет textJson, потом text + static List parseContent({String? textJson, String? text}) { + if (textJson != null && textJson.isNotEmpty) { + return _parseJsonText(textJson); + } else if (text != null && text.isNotEmpty) { + return _parseHtmlText(text); + } + return const []; + } + + // 🔹 HTML-парсинг + static List _parseHtmlText(String htmlText) { + final parsedHtml = html_parser.parse(htmlText); + final List elements = parsedHtml.body?.nodes ?? []; + return _parseHtmlElements(elements); + } + + static List _parseHtmlElements(List nodes) { + List widgets = []; + + for (final node in nodes) { + if (node is dom.Element) { + final text = node.text.trim(); + if (text.isEmpty) continue; + + switch (node.localName) { + case 'h1': + case 'h2': + case 'h3': + widgets.add(_buildHeader(text)); + break; + case 'p': + widgets.add(_buildParagraph(text)); + break; + case 'ul': + widgets.add(_buildUnorderedList(node.nodes)); + break; + default: + widgets.add(_buildDefaultText(text)); + } + } else if (node is dom.Text) { + final text = node.text.trim(); + if (text.isNotEmpty) { + widgets.add(_buildDefaultText(text)); + } + } + } + + return widgets; + } + + // 🔹 JSON-парсинг + static List _parseJsonText(String jsonString) { + try { + final dynamic data = jsonDecode(jsonString); + return _parseJsonNode(data); + } catch (e) { + return [ + const Text( + 'Ошибка отображения текста новости', + style: TextStyle(color: Colors.red, fontSize: 14), + ), + ]; + } + } + + static List _parseJsonNode(dynamic node) { + List widgets = []; + + if (node is List) { + for (final item in node) { + widgets.addAll(_parseJsonNode(item)); + } + } else if (node is Map) { + final type = node['type'] as String?; + final content = node['content']; + + switch (type) { + case 'div': + if (content is List) { + widgets.addAll(_parseJsonNode(content)); + } + break; + case 'h1': + case 'h2': + case 'h3': + final text = _extractJsonText(content); + if (text != null && text.isNotEmpty) { + widgets.add(_buildHeader(text)); + } + break; + case 'p': + final text = _extractJsonText(content); + if (text != null && text.isNotEmpty) { + widgets.add(_buildParagraph(text)); + } + break; + case 'ul': + if (content is List) { + widgets.add(_buildJsonUnorderedList(content)); + } + break; + case 'li': + final text = _extractJsonText(content); + if (text != null && text.isNotEmpty) { + widgets.add(_buildListItem(text)); + } + break; + default: + final text = _extractJsonText(content); + if (text != null && text.isNotEmpty) { + widgets.add(_buildDefaultText(text)); + } + } + } + + return widgets; + } + + // 🔹 Вспомогательные методы извлечения текста из JSON + static String? _extractJsonText(dynamic content) { + if (content == null) return null; + if (content is List) { + return content.map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).join(' '); + } + return content.toString(); + } + + // 🔹 Виджеты для отображения (принимают НЕ nullable String) + static Widget _buildHeader(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + text, // ✅ text — не nullable + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + static Widget _buildParagraph(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ); + } + + static Widget _buildDefaultText(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ); + } + + static Widget _buildUnorderedList(List children) { + final items = []; + for (final child in children) { + if (child is dom.Element && child.localName == 'li') { + final text = child.text.trim(); + if (text.isNotEmpty) { + items.add(_buildListItem(text)); + } + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: items, + ), + ); + } + + static Widget _buildJsonUnorderedList(List content) { + final items = []; + for (final item in content) { + if (item is Map && item['type'] == 'li') { + final text = _extractJsonText(item['content']); + if (text != null && text.isNotEmpty) { + items.add(_buildListItem(text)); + } + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: items, + ), + ); + } + + static Widget _buildListItem(String text) { + return Padding( + padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + )), + Expanded( + child: Text( + text, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/components/subscription_card.dart b/lib/presentation/components/subscription_card.dart index 3d55322..a7a26ec 100644 --- a/lib/presentation/components/subscription_card.dart +++ b/lib/presentation/components/subscription_card.dart @@ -69,6 +69,7 @@ class SubscriptionCard extends StatelessWidget { subscription.shortDescription, style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 14), ), + Spacer(), if (isActive && expiredAt != null) ...[ const SizedBox(height: 16), Builder( diff --git a/lib/presentation/screens/add_card_screen.dart b/lib/presentation/screens/add_card_screen.dart index d3cd555..0b4044e 100644 --- a/lib/presentation/screens/add_card_screen.dart +++ b/lib/presentation/screens/add_card_screen.dart @@ -18,6 +18,7 @@ class AddCardScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + resizeToAvoidBottomInset: false, body: BlocListener( listenWhen: (previous, current) { print( diff --git a/lib/presentation/screens/news_detail_screen.dart b/lib/presentation/screens/news_detail_screen.dart index 1a4a8a7..c118259 100644 --- a/lib/presentation/screens/news_detail_screen.dart +++ b/lib/presentation/screens/news_detail_screen.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:html/parser.dart' as html_parser; -import 'package:html/dom.dart' as dom; -import 'dart:convert'; 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 { @@ -126,258 +124,17 @@ class _NewsDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - - // Текст новости: сначала проверяем textJson, потом text - if (news.textJson != null) - ..._parseJsonText(news.textJson) - else if (news.text != null) - ..._parseHtmlText(news.text), + // ✅ Используем вынесенный парсер + ...ContentParser.parseContent( + textJson: news.textJson, + text: news.text, + ), ], ), ); } - List _parseHtmlText(String htmlText) { - final parsedHtml = html_parser.parse(htmlText); - final List elements = parsedHtml.body?.nodes ?? []; - - return _parseHtmlElements(elements); - } - - List _parseHtmlElements(List nodes) { - List widgets = []; - - for (final node in nodes) { - if (node is dom.Element) { - switch (node.localName) { - case 'h1': - case 'h2': - case 'h3': - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - node.text, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - break; - case 'p': - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - node.text, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - height: 1.5, - ), - ), - ), - ); - break; - case 'ul': - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .where((element) => element is dom.Element && element.localName == 'li') - .map((dom.Element li) => Padding( - padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('• ', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - )), - Expanded( - child: Text( - li.text, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - height: 1.5, - ), - ), - ), - ], - ), - )) - .toList(), - ), - ), - ); - break; - default: - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - node.text, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - ), - ); - } - } else if (node is dom.Text) { - widgets.add( - Text( - node.text, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - ); - } - } - - return widgets; - } - - // 🔹 Парсинг JSON-текста (новый метод) - List _parseJsonText(String jsonString) { - try { - final dynamic data = jsonDecode(jsonString); - return _parseJsonNode(data); - } catch (e) { - return [ - const Text( - 'Ошибка отображения текста новости', - style: TextStyle(color: Colors.red, fontSize: 14), - ), - ]; - } - } - - List _parseJsonNode(dynamic node) { - List widgets = []; - - if (node is List) { - // Если корень — массив, парсим каждый элемент - for (final item in node) { - widgets.addAll(_parseJsonNode(item)); - } - } else if (node is Map) { - final type = node['type'] as String?; - final content = node['content']; - - switch (type) { - case 'div': - if (content is List) { - widgets.addAll(_parseJsonNode(content)); - } - break; - case 'h2': - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - (content is List) - ? content.join(' ') - : content?.toString() ?? '', - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - break; - case 'p': - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - (content is List) - ? content.join(' ') - : content?.toString() ?? '', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - height: 1.5, - ), - ), - ), - ); - break; - case 'ul': - if (content is List) { - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: content - .where((item) => item is Map) - .map((item) => item as Map) - .where((item) => item['type'] == 'li') - .map((li) => Padding( - padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('• ', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - )), - Expanded( - child: Text( - (li['content'] is List) - ? li['content'].join(' ') - : li['content']?.toString() ?? '', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - height: 1.5, - ), - ), - ), - ], - ), - )) - .toList(), - ), - ), - ); - } - break; - default: - // Если тип неизвестен, просто выводим текст - widgets.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - (content is List) - ? content.join(' ') - : content?.toString() ?? type ?? 'unknown', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - ), - ); - } - } - - return widgets; - } + // 🔹 Удалены старые методы: _parseHtmlText, _parseHtmlElements, _parseJsonText, _parseJsonNode String _formatDate(DateTime? date) { if (date == null) return ''; diff --git a/lib/presentation/screens/promo_code_screen.dart b/lib/presentation/screens/promo_code_screen.dart index e13bbff..7cccae9 100644 --- a/lib/presentation/screens/promo_code_screen.dart +++ b/lib/presentation/screens/promo_code_screen.dart @@ -58,27 +58,10 @@ class _PromoCodeScreenContentState extends State<_PromoCodeScreenContent> { BlocListener( 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), () { + Future.delayed(const Duration(milliseconds: 800), () { 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( diff --git a/lib/presentation/screens/scooter_code_input_screen.dart b/lib/presentation/screens/scooter_code_input_screen.dart index 774fd1b..083b059 100644 --- a/lib/presentation/screens/scooter_code_input_screen.dart +++ b/lib/presentation/screens/scooter_code_input_screen.dart @@ -87,7 +87,7 @@ class _ScooterCodeInputScreenState extends State { TextField( controller: controller, - keyboardType: TextInputType.number, + keyboardType: TextInputType.text, maxLength: 7, textAlign: TextAlign.left, style: const TextStyle(color: Colors.white, fontSize: 18), diff --git a/lib/presentation/screens/subscription_details_screen.dart b/lib/presentation/screens/subscription_details_screen.dart index 91f4694..4481c3b 100644 --- a/lib/presentation/screens/subscription_details_screen.dart +++ b/lib/presentation/screens/subscription_details_screen.dart @@ -4,9 +4,10 @@ import 'package:go_router/go_router.dart'; import '../../core/app_colors.dart'; import '../components/app_checkbox.dart'; -import '../components/custom_app_bar.dart'; // ✅ Добавь импорт +import '../components/custom_app_bar.dart'; import '../components/gradient_button.dart'; import '../components/period_selector.dart'; +import '../components/content_parser.dart'; // ✅ Парсер контента import '../event/subscription_details_event.dart'; import '../state/susbcription_details_state.dart'; import '../viewmodel/susbcription_details_bloc.dart'; @@ -27,26 +28,25 @@ class SubscriptionDetailsScreen extends StatelessWidget { 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); - }, - ), + 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), @@ -65,8 +65,8 @@ class SubscriptionDetailsScreen extends StatelessWidget { ), ), BlocBuilder< - SubscriptionDetailsBloc, - SubscriptionDetailsState + SubscriptionDetailsBloc, + SubscriptionDetailsState >( builder: (context, state) { if (state is DetailsLoading) { @@ -102,27 +102,26 @@ class SubscriptionDetailsScreen extends StatelessWidget { 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: [ - Text( - state.subscription.fullDescription, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - height: 1.5, + // 🔹 Обёртка для растягивания контента на всю ширину + 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: 30), - GradientButton( text: state.isAlreadyPurchased ? 'Продлить' : 'Активировать', onTap: () { @@ -255,4 +254,4 @@ class _PriceRow extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/presentation/screens/subscription_list_screen.dart b/lib/presentation/screens/subscription_list_screen.dart index c6b1b89..d290a96 100644 --- a/lib/presentation/screens/subscription_list_screen.dart +++ b/lib/presentation/screens/subscription_list_screen.dart @@ -60,11 +60,16 @@ class SubscriptionsListScreen extends StatelessWidget { final clientSub = clientSubsMap[subscription.id]; final DateTime? expirationDate = isPurchased ? clientSub?.expiredAt : null; - return SubscriptionCard( - subscription: subscription, - isActive: isPurchased, - expiredAt: expirationDate, - onRefresh: () { context.read().add(LoadSubscriptionsEvent());}, + return SizedBox( + height: 230, // + child: SubscriptionCard( + subscription: subscription, + isActive: isPurchased, + expiredAt: expirationDate, + onRefresh: () { + context.read().add(LoadSubscriptionsEvent()); + }, + ), ); }, );