fix: issue-372, issue-227, issue-406

This commit is contained in:
misha
2026-09-02 09:32:14 +03:00
parent 10af279d87
commit c6ea1d414d
34 changed files with 1623 additions and 387 deletions

View File

@@ -122,7 +122,7 @@ class CubitRegistrations {
// Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));

View File

@@ -0,0 +1,61 @@
/// flutter_svg / vector_graphics ignore `<style>` blocks.
/// Paths then get the SVG default fill (black), so badges look like a black blob.
/// This inlines class rules from `<style>` as attributes before parsing.
String inlineSvgCss(String svg) {
final styleBlockPattern = RegExp(
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
caseSensitive: false,
);
final classProps = <String, Map<String, String>>{};
for (final match in styleBlockPattern.allMatches(svg)) {
final css = match.group(1) ?? '';
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
for (final rule in rulePattern.allMatches(css)) {
final className = rule.group(1)!;
final body = rule.group(2)!;
final props = classProps.putIfAbsent(className, () => <String, String>{});
for (final declaration in body.split(';')) {
final colon = declaration.indexOf(':');
if (colon <= 0) {
continue;
}
final key = declaration.substring(0, colon).trim();
final value = declaration.substring(colon + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) {
props[key] = value;
}
}
}
}
var result = svg.replaceAll(styleBlockPattern, '');
if (classProps.isEmpty) {
return result;
}
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
Match match,
) {
final classes = match
.group(2)!
.split(RegExp(r'\s+'))
.where((name) => name.isNotEmpty);
final attrs = <String, String>{};
for (final className in classes) {
final props = classProps[className];
if (props != null) {
attrs.addAll(props);
}
}
if (attrs.isEmpty) {
return match.group(0)!;
}
final attrString = attrs.entries
.map((entry) => '${entry.key}="${entry.value}"')
.join(' ');
return ' $attrString';
});
return result;
}

View File

@@ -0,0 +1,57 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
final Map<String, String> _inlinedSvgCache = <String, String>{};
class NetworkSvgPicture extends StatefulWidget {
const NetworkSvgPicture({
super.key,
required this.url,
this.width,
this.height,
});
final String url;
final double? width;
final double? height;
@override
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
}
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
late final Future<String> _svgFuture = _load(widget.url);
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _svgFuture,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (!snapshot.hasData) {
return SizedBox(width: widget.width, height: widget.height);
}
return SvgPicture.string(
snapshot.data!,
width: widget.width,
height: widget.height,
);
},
);
}
static Future<String> _load(String url) async {
final cached = _inlinedSvgCache[url];
if (cached != null) {
return cached;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Failed to load SVG from $url');
}
final processed = inlineSvgCss(response.body);
_inlinedSvgCache[url] = processed;
return processed;
}
}

View File

@@ -763,7 +763,6 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(

View File

@@ -26,16 +26,47 @@ class PostFeedMapper {
totalViews: item.postViewsCount,
totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser,
images: item.images,
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb,
);
}
static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return '${sl<AppConfig>().baseUrl}$url';
}
return '${sl<AppConfig>().baseUrl}/storage/$url';
}
static bool isPlaceholderMediaUrl(String url) {
return url.toLowerCase().contains('element-image-placeholder');
}
static bool hasRenderableImage(String? url) {
if (url == null || url.trim().isEmpty) {
return false;
}
return !isPlaceholderMediaUrl(url);
}
static bool hasVisibleMedia({
required String image,
required List<FeedGalleryImage> images,
String? videoEmbedUrl,
}) {
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
return true;
}
if (hasRenderableImage(image)) {
return true;
}
return images.any((image) => hasRenderableImage(image.url));
}
}

View File

@@ -330,7 +330,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
}
if (widget.imageUrl.isNotEmpty) {
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
final image = _photo(widget.imageUrl, width: width);
return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),

View File

@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
Widget build(BuildContext context) {
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
final hasMedia = PostFeedMapper.hasVisibleMedia(
image: imageUrl,
images: post.images,
videoEmbedUrl: embedUrl,
);
return InkWell(
borderRadius: BorderRadius.circular(12),
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
children: [
const SizedBox(height: 12),
_BlogPostHeader(post: post),
_BlogPostTextContent(post: post),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'feed-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'feed-blog-post-${post.id}-cover'
: null,
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
if (hasMedia)
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'feed-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
SizedBox(height: hasMedia ? 12 : 8),
_BlogPostActionsRow(
post: post,
isLiked: isLiked,
@@ -213,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
}
class _BlogPostTextContent extends StatelessWidget {
const _BlogPostTextContent({required this.post});
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
final FeedBlogPostModel post;
final bool includeBottomSpacing;
@override
Widget build(BuildContext context) {
@@ -259,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty)
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
const SizedBox(height: 8),
],
);

View File

@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/profile/models/badge_model.dart';
import 'package:flutter/material.dart';
@@ -92,7 +93,7 @@ class ConsultantWidget extends StatelessWidget {
children: [
for (int i = 0; i < badges.length; i++) ...[
badges[i].image.contains('svg')
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
? NetworkSvgPicture(url: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
],

View File

@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
onPressed: () {
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
},
),
],

View File

@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
child: InfoByOrderScreenContent(
successfulPayment: successfulPayment,
planId: planId,
customerSlug: customerSlug,
isConsultantView: isConsultantView,
),
);
}
}
class InfoByOrderScreenContent extends StatefulWidget {
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
const InfoByOrderScreenContent({
super.key,
required this.planId,
required this.successfulPayment,
this.customerSlug,
this.isConsultantView = false,
});
final int planId;
final bool? successfulPayment;
final String? customerSlug;
final bool isConsultantView;
@override
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
}
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
body: SingleChildScrollView(
child: Column(
children: [
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
],
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
const SizedBox(height: 8),
AuthorCard(

View File

@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class AboutPlanCubit extends Cubit<AboutPlanState> {
final PlanRepository planRepository;
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
final ProfileRepository profileRepository;
final DietRepository dietRepository;
AboutPlanCubit({
required this.planRepository,
required this.profileRepository,
required this.dietRepository,
}) : super(AboutPlanState.initial());
Future<void> getPlan({required String slug, int? index}) async {
try {
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
return (null, '');
}
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
Future<ChosenDietModel?> getActivePublicDiet() async {
try {
final chosenDiets = await profileRepository.getChosenDiets();
final lastChosenDiet = chosenDiets.firstOrNull;
if (lastChosenDiet == null) {
return null;
}
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
final isPublic = lastChosenDiet.chosenPlanId == 0;
if (isActive && isPublic && lastChosenDiet.dietId != null) {
return lastChosenDiet;
}
return null;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return null;
}
}
Future<bool> cancelPublicDiet(int dietId) async {
try {
await dietRepository.cancelDiet(dietId);
return true;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
}
String _getOptionNameFromIndex(int index) => switch (index) {
1 => 'options2',
2 => 'options3',

View File

@@ -17,8 +17,10 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:eatvio_app/core/locator_service.dart';
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
Future<void> _onOrderPressed(BuildContext context) async {
final cubit = context.read<AboutPlanCubit>();
final publicDiet = await cubit.getActivePublicDiet();
if (!context.mounted) {
return;
}
if (publicDiet != null) {
showModalBottomSheet(
context: context,
builder: (_) => ConfirmStartPlanBottomSheet(
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
),
);
return;
}
await _submitOrder(context, cubit);
}
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
final s = S.of(context);
if (publicDietId != null) {
final cancelled = await cubit.cancelPublicDiet(publicDietId);
if (!cancelled) {
showErrorToast('Не удалось отменить диету');
return;
}
}
final (chosenPlan, error) = await cubit.chosePlan();
if (!context.mounted) {
return;
}
if (error == 'hasPlan') {
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
return;
}
if (chosenPlan == null) {
showErrorDialog(context, title: 'Не удалось заказать план');
return;
}
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -183,20 +230,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: appTheme.greenA700,
onPressed: () async {
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
if (error == 'hasPlan') {
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
return;
}
if (chosenPlan == null) {
if (!context.mounted) return;
showErrorDialog(context, title: 'Не удалось заказать план');
return;
}
if (!context.mounted) return;
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
},
onPressed: () => _onOrderPressed(context),
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
),
),

View File

@@ -0,0 +1,50 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ConfirmStartPlanBottomSheet extends StatelessWidget {
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
final Future<void> Function() onConfirm;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
const BottomSheetHandlerBar(),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25.0),
child: Text(
'Вы уверены, что хотите начать прохождение плана?',
textAlign: TextAlign.center,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
child: CustomOutlinedButton(
onPressed: () async {
await Navigator.of(context).maybePop();
await onConfirm();
},
height: 60,
text: s.confirm,
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
),
),
),
],
);
}
}

View File

@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
abstract class ChosenDietModel with _$ChosenDietModel {
factory ChosenDietModel({
@JsonKey(name: 'id') required int chosenDietID,
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
@JsonKey() required String state,
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
if (json == null) return null;
return json['id'] as int?;
}
int _parseChosenPlanId(dynamic json) {
if (json == null) return 0;
if (json is num) return json.toInt();
return int.tryParse(json.toString()) ?? 0;
}

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
@@ -382,19 +384,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
),
const SizedBox(height: 8),
SizedBox(
height: 781,
child: PageView.builder(
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemCount: state.plansList.length,
),
ExpandablePageView(
itemCount: state.plansList.length,
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
),
const SizedBox(height: 8),
_bullets(_currentBullet, state.plansList.length),
@@ -779,7 +778,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)],
),
child: SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40),
child: NetworkSvgPicture(
url: '${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}',
width: 40,
height: 40,
),
)
: Tooltip(
preferBelow: true,

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
class ExpandablePageView extends StatefulWidget {
const ExpandablePageView({
super.key,
required this.itemCount,
required this.itemBuilder,
this.onPageChanged,
});
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final ValueChanged<int>? onPageChanged;
@override
State<ExpandablePageView> createState() => _ExpandablePageViewState();
}
class _ExpandablePageViewState extends State<ExpandablePageView> {
late List<double> _heights;
int _currentPage = 0;
double get _currentHeight {
if (_heights.isEmpty) {
return 0;
}
final page = _currentPage.clamp(0, _heights.length - 1);
return _heights[page];
}
@override
void initState() {
super.initState();
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
}
@override
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.itemCount != oldWidget.itemCount) {
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
if (widget.itemCount == 0) {
_currentPage = 0;
} else {
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
}
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: _currentHeight,
child: PageView.builder(
itemCount: widget.itemCount,
onPageChanged: (int index) {
setState(() {
_currentPage = index;
});
widget.onPageChanged?.call(index);
},
itemBuilder: (BuildContext context, int index) {
return OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: double.infinity,
child: _SizeReportingWidget(
onSizeChange: (Size size) {
if (index >= _heights.length ||
_heights[index] == size.height) {
return;
}
setState(() {
_heights[index] = size.height;
});
},
child: widget.itemBuilder(context, index),
),
);
},
),
);
}
}
class _SizeReportingWidget extends StatefulWidget {
const _SizeReportingWidget({required this.child, required this.onSizeChange});
final Widget child;
final ValueChanged<Size> onSizeChange;
@override
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
}
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
Size? _oldSize;
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
return widget.child;
}
void _notifySize() {
if (!mounted) {
return;
}
final size = context.size;
if (size != null && _oldSize != size) {
_oldSize = size;
widget.onSizeChange(size);
}
}
}

View File

@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
totalLikes = post.totalLikes;
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
final hasMedia = PostFeedMapper.hasVisibleMedia(
image: imageUrl,
images: post.images,
videoEmbedUrl: embedUrl,
);
return InkWell(
borderRadius: BorderRadius.circular(12),
@@ -79,40 +84,12 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
post.author.authorProfileIcon.contains('https')
? post.author.authorProfileIcon
: sl<AppConfig>().baseUrl +
post.author.authorProfileIcon,
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
height: 1,
color: Colors.black45,
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author.authorName,
style: const TextStyle(
height: 1,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
height: 1,
color: Colors.black45,
),
),
],
),
const Spacer(),
CustomPopup(
@@ -167,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty)
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
const SizedBox(height: 8),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
: null,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'profile-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'profile-blog-post-${post.id}-cover'
if (hasMedia)
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
: null,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'profile-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'profile-blog-post-${post.id}-cover'
: null,
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
SizedBox(height: hasMedia ? 12 : 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(