598 lines
24 KiB
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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|