didvan-app/lib/views/home/infography/infography_details/infography_details.dart

598 lines
24 KiB
Dart

// ignore_for_file: deprecated_member_use
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/models/content/content_item.dart';
import 'package:didvan/models/infography/infography_content.dart';
import 'package:didvan/routes/routes.dart';
import 'package:didvan/services/content/content_service.dart';
import 'package:didvan/utils/action_sheet.dart';
import 'package:didvan/utils/category_labels.dart';
import 'package:didvan/views/widgets/didvan/divider.dart';
import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:didvan/views/widgets/floating_navigation_bar.dart';
import 'package:didvan/views/widgets/item_title.dart';
import 'package:didvan/views/widgets/skeleton_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:persian_number_utility/persian_number_utility.dart';
import 'package:url_launcher/url_launcher_string.dart';
/// شناسه‌ی دسته‌ی «بنر» (= اینفوگرافی) در content-service.
const String _bannerCategoryId = '3e53627d-9a18-4bad-b4ae-1f0c55438147';
/// آیتم لیست «مطالب مرتبط» در صفحه‌ی detail.
class _RelatedItem {
final String uuid;
final String title;
final String image;
final String metaCategory;
final String createdAt;
_RelatedItem({
required this.uuid,
required this.title,
required this.image,
required this.metaCategory,
required this.createdAt,
});
}
/// صفحه‌ی detail اینفوگرافی — استایل دقیقا مشابه news single page (DidvanPageView)
/// با سه تفاوت درخواست‌شده:
/// 1. لبه‌ی بالای کارت سفید (که روی تصویر می‌نشیند) **صاف** است (نه گرد).
/// 2. FloatingNavigationBar شامل کامنت/منشن/بوکمارک (مشابه news).
/// 3. بخش «مطالب مرتبط» با بنرهای هم‌دسته‌ی موضوعی.
class InfographyDetails extends StatefulWidget {
final Map<String, dynamic> pageData;
const InfographyDetails({super.key, required this.pageData});
@override
State<InfographyDetails> createState() => _InfographyDetailsState();
}
class _InfographyDetailsState extends State<InfographyDetails> {
final ScrollController _scrollController = ScrollController();
Content? _item;
String? _description;
bool _loading = false;
bool _failed = false;
// related contents
List<_RelatedItem> _related = [];
bool _relatedLoading = false;
@override
void initState() {
super.initState();
_item = widget.pageData['item'] as Content?;
final uuid = widget.pageData['contentUuid'] as String?;
if (uuid != null && uuid.isNotEmpty) {
_fetchDetail(uuid);
} else if (_item != null) {
_loadRelated(_item!.metaCategory, _item!.keycloakId);
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _fetchDetail(String uuid) async {
setState(() => _loading = true);
try {
final res = await ContentService.instance.getContent(uuid);
if (res.isSuccess && res.data != null) {
final c = res.data!;
_item = Content.fromContentItem(c);
_description = c.summary ?? '';
} else {
_failed = true;
}
} catch (_) {
_failed = true;
} finally {
if (mounted) setState(() => _loading = false);
}
if (_item != null) {
_loadRelated(_item!.metaCategory, _item!.keycloakId);
}
}
Future<void> _loadRelated(String metaCategory, String? currentUuid) async {
if (_relatedLoading) return;
setState(() => _relatedLoading = true);
try {
// همه‌ی بنرها رو می‌گیریم بعد client-side با metaCategory فیلتر می‌کنیم.
final res = await ContentService.instance.getContents(
page: 1,
limit: metaCategory.isEmpty ? 10 : 50,
filterStatus: '\$in:accepted,published',
categoryId: _bannerCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (res.isSuccess && res.data != null) {
final all = res.data!.data;
final filtered = <ContentItem>[];
for (final c in all) {
if (c.id == currentUuid) continue;
if (metaCategory.isNotEmpty &&
(c.metaCategory ?? '') != metaCategory) {
continue;
}
filtered.add(c);
if (filtered.length >= 8) break;
}
_related = filtered
.map((c) => _RelatedItem(
uuid: c.id,
title: c.title,
image: c.coverImage ?? '',
metaCategory: c.metaCategory ?? '',
createdAt: c.publishedAt?.toIso8601String() ?? '',
))
.toList();
}
} catch (_) {
// silent
} finally {
if (mounted) setState(() => _relatedLoading = false);
}
}
String get _categoryLabel {
final m = _item?.metaCategory;
if (m != null && m.isNotEmpty) {
final t = CategoryLabels.label(m);
if (t.isNotEmpty) return t;
}
return _item?.category ?? '';
}
String _persianDate(String raw) {
if (raw.isEmpty) return '';
try {
return DateTime.parse(raw).toPersianDateStr();
} catch (_) {
return '';
}
}
String _getCategoryIcon(String label) {
if (label.contains('سیاسی')) return 'lib/assets/icons/SiasiNoBG.svg';
if (label.contains('کسب')) return 'lib/assets/icons/KasbokarNoBG.svg';
if (label.contains('اقتصادی')) return 'lib/assets/icons/Economic_NoBG.svg';
if (label.contains('اجتماعی')) return 'lib/assets/icons/EjtemaeiNoBg.svg';
if (label.contains('محیطی') || label.contains('زیست')) {
return 'lib/assets/icons/ZistMohitNoBG.svg';
}
if (label.contains('فناوری')) return 'lib/assets/icons/FanavariNoBG.svg';
return 'lib/assets/icons/element-2.svg';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_item == null && _loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_failed || _item == null) {
return Scaffold(
appBar: _appBar(context),
body: const Center(child: Text('خطا در دریافت محتوا')),
);
}
final item = _item!;
final dateStr = _persianDate(item.createdAt).toPersianDigit();
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: _appBar(context),
body: Stack(
children: [
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 92),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// === تصویر اصلی — کل تصویر پیدا، ارتفاع طبیعی ===
// به‌جای container با ارتفاع 16:9 fixed (که portrait
// را crop می‌کرد یا با contain کناره خالی می‌گذاشت)،
// اینجا از Image.network مستقیم استفاده می‌کنیم تا
// ارتفاع تصویر متناسب با aspect ratio طبیعی‌اش باشد.
GestureDetector(
onTap: () => ActionSheetUtils(context)
.openInteractiveViewer(context, item.image, false),
child: Container(
color: theme.colorScheme.background,
width: double.infinity,
child: Image.network(
item.image,
width: double.infinity,
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => SizedBox(
height: constraints.maxWidth * 9 / 16,
child: const Icon(
Icons.image_not_supported_outlined),
),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return SizedBox(
height: constraints.maxWidth * 9 / 16,
child: const Center(
child: CircularProgressIndicator()),
);
},
),
),
),
// === کارت سفید زیر تصویر (overlap حذف شد) ===
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.background,
// لبه صاف به‌جای BorderRadius.vertical(top: 24)
borderRadius: BorderRadius.zero,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// عنوان
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: DidvanText(
item.title,
style: theme.textTheme.titleMedium,
),
),
const SizedBox(height: 8),
// تاریخ
if (dateStr.isNotEmpty)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/calendar.svg',
color: theme.colorScheme.caption,
height: 16,
),
const SizedBox(width: 6),
DidvanText(
dateStr,
style: theme.textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.caption),
),
],
),
),
// === badge دسته‌بندی (زیر تاریخ، به‌جای روی عکس) ===
if (_categoryLabel.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16, right: 16, top: 8),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color.fromARGB(
255, 230, 242, 246),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
_getCategoryIcon(_categoryLabel),
height: 16,
width: 16,
color: const Color.fromARGB(
255, 25, 93, 128),
),
const SizedBox(width: 6),
DidvanText(
_categoryLabel,
style: theme.textTheme.bodySmall
?.copyWith(
color: const Color.fromARGB(
255, 25, 93, 128),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
),
),
// خلاصه/توضیحات
if ((_description ?? '').isNotEmpty) ...[
const SizedBox(height: 16),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
DidvanText(
'توضیحات:',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
DidvanText(
_description!,
style: theme.textTheme.bodyMedium
?.copyWith(
color: const Color.fromARGB(
255, 102, 102, 102),
),
),
const SizedBox(height: 2),
const DidvanDivider(),
],
),
),
],
// دکمه‌ی منبع
if ((item.link ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16, right: 16, top: 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () =>
launchUrlString(item.link!),
icon: const Icon(Icons.open_in_new,
size: 18),
label:
const DidvanText('مشاهده منبع'),
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 14),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(12),
),
),
),
),
),
// ====== مطالب مرتبط ======
const Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
),
child: DidvanDivider(verticalPadding: 0),
),
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.0, vertical: 16.0),
child: ItemTitle(title: 'مطالب مرتبط:'),
),
if (_relatedLoading && _related.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator()),
),
if (!_relatedLoading && _related.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: DidvanText(
'مطلب مرتبطی یافت نشد.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.caption),
),
),
for (final r in _related)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
child: _RelatedCard(item: r),
),
],
),
),
),
],
),
);
},
),
// === FloatingNavigationBar (مثل news single page) ===
Positioned(
bottom: 0,
left: 0,
right: 0,
child: FloatingNavigationBar(
scrollController: _scrollController,
item: _NavItem(item: item),
openComments: widget.pageData['goToComment'] == true,
hasUnmarkConfirmation:
widget.pageData['hasUnmarkConfirmation'] ?? false,
isRadar: false,
onCommentsChanged: (count) {
setState(() => item.comments = count);
},
onMarkChanged: (value) {
setState(() => _item = item..marked = value);
final cb = widget.pageData['onMarkChanged'];
if (cb is Function) {
cb(item.id, value);
}
},
),
),
],
),
);
}
PreferredSizeWidget _appBar(BuildContext context) {
return PreferredSize(
preferredSize: const Size.fromHeight(90.0),
child: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
automaticallyImplyLeading: false,
flexibleSpace: SafeArea(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Center(
child: SvgPicture.asset(
'lib/assets/images/logos/logo-horizontal-light.svg',
height: 55,
),
),
IconButton(
icon: SvgPicture.asset(
'lib/assets/icons/arrow-left.svg',
color: Theme.of(context).colorScheme.caption,
height: 24,
),
onPressed: () => Navigator.pop(context),
),
],
),
),
),
),
);
}
}
/// Adapter که فیلدهای موردنیاز FloatingNavigationBar را از Content فراهم می‌کند.
/// FloatingNavigationBar از dynamic استفاده می‌کند و فقط به فیلدهای زیر دسترسی
/// می‌خواهد: `id`, `title`, `marked`, `comments`, `keycloakId`.
class _NavItem {
final Content item;
_NavItem({required this.item});
int get id => item.id;
String get title => item.title;
bool get marked => item.marked;
int get comments => item.comments;
String? get keycloakId => item.keycloakId;
}
/// کارت کوچک «مطلب مرتبط». با تپ به همان صفحه‌ی detail با uuid جدید می‌رود.
class _RelatedCard extends StatelessWidget {
final _RelatedItem item;
const _RelatedCard({required this.item});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
Routes.infographyDetails,
arguments: {
'contentUuid': item.uuid,
},
);
},
child: Container(
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
clipBehavior: Clip.hardEdge,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 120,
child: SkeletonImage(
imageUrl: item.image,
borderRadius: BorderRadius.zero,
width: 120,
height: 100,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DidvanText(
item.title,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Row(
children: [
if (item.metaCategory.isNotEmpty) ...[
SvgPicture.asset(
'lib/assets/icons/element-2.svg',
color: theme.colorScheme.caption,
height: 14,
width: 14,
),
const SizedBox(width: 4),
DidvanText(
CategoryLabels.label(item.metaCategory),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.caption),
),
],
],
),
],
),
),
),
],
),
),
);
}
}