didvan-app/lib/views/home/explore/explore.dart

1621 lines
64 KiB
Dart

// ignore_for_file: deprecated_member_use
import 'package:didvan/config/design_config.dart';
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/main.dart';
import 'package:didvan/models/home_page_content/home_page_list.dart';
import 'package:didvan/models/home_page_content/swot.dart';
import 'package:didvan/models/requests/news.dart';
import 'package:didvan/models/requests/radar.dart';
import 'package:didvan/routes/routes.dart';
import 'package:didvan/services/content/content_service.dart';
import 'package:didvan/services/app_initalizer.dart';
import 'package:didvan/views/home/main/main_page_state.dart';
import 'package:didvan/views/home/main/widgets/podcast_item.dart';
import 'package:didvan/views/widgets/didvan/slider.dart';
import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:didvan/views/widgets/state_handlers/state_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import 'package:didvan/services/network/request.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:didvan/views/home/main/widgets/swot_item_card.dart';
import 'package:flutter/foundation.dart'
show kIsWeb, defaultTargetPlatform, kDebugMode;
import 'package:universal_html/html.dart' as html;
import 'package:didvan/views/widgets/home_app_bar.dart';
import 'package:didvan/services/auth/jwt_utils.dart';
import 'package:didvan/services/auth/token_storage.dart';
import 'package:didvan/views/widgets/skeleton_image.dart';
import 'package:persian_number_utility/persian_number_utility.dart';
import 'package:didvan/views/widgets/text_divider.dart';
import 'package:didvan/views/widgets/bookmark_button.dart';
import 'package:didvan/models/home_page_content/content.dart';
/// Multi-tenant: helper برای خواندن access token از storage.
String _readAccessToken() => TokenStorage.accessToken ?? '';
bool isAnyMobile() {
if (kIsWeb) {
final userAgent = html.window.navigator.userAgent.toLowerCase();
return userAgent.contains('mobile') ||
userAgent.contains('android') ||
userAgent.contains('ios');
}
return defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
}
class ExplorePage extends StatelessWidget {
const ExplorePage({super.key});
@override
Widget build(BuildContext context) {
return Consumer<MainPageState>(
builder: (context, state, child) {
return StateHandler<MainPageState>(
onRetry: () => context.read<MainPageState>().init(),
state: state,
builder: (context, state) {
final List<Widget> pageContent = [];
if (state.exploreLists.isNotEmpty) {
List<MainPageList> lists = List.from(state.exploreLists);
// Multi-tenant: بخش‌هایی که کاربر دسترسی ندارد را حذف کن.
// deny:view:* حتی برای super tenant هم اولویت دارد.
final token = _readAccessToken();
if (token.isNotEmpty) {
lists = lists
.where((l) => JwtUtils.canView(token, l.type))
.toList();
}
// ترتیب ثابتِ صفحه‌ی کاوش (مثل UI قدیمی). هیچ آیتمی حذف نمی‌شود؛
// فقط بر اساس این ترتیب مرتب می‌شود تا اگر بعضی دسته‌ها خالی
// بودند، بقیه همچنان نمایش داده شوند.
const order = <String, int>{
'monthly': 0,
'news': 1,
'radar': 2,
'trend': 3,
'risk': 4,
'startup': 5,
'technology': 6,
'survey': 7,
};
lists.sort((a, b) =>
(order[a.type] ?? 99).compareTo(order[b.type] ?? 99));
final MainPageList? techItem =
lists.where((e) => e.type == 'technology').isNotEmpty
? lists.firstWhere((e) => e.type == 'technology')
: null;
for (int i = 0; i < lists.length; i++) {
final currentList = lists[i];
if (currentList.type == 'video' ||
currentList.type == 'podcast') {
continue;
}
pageContent.add(StaggeredEntry(
index: pageContent.length,
child: MainPageSection(
list: currentList,
isLast: i == lists.length - 1,
),
));
if (currentList.type == 'radar') {
pageContent.add(StaggeredEntry(
index: pageContent.length,
child: const Column(
children: [
SizedBox(
height: 35,
),
TextDivider(text: 'رادارهای استراتژیک'),
],
),
));
}
if (currentList.type == 'technology') {
pageContent.add(StaggeredEntry(
index: pageContent.length,
child:
SwotSection(headerSize: 13, swotItems: state.swotItems),
));
if (lists.any((e) => e.type == 'survey')) {
pageContent.add(StaggeredEntry(
index: pageContent.length,
child: const Column(
children: [
SizedBox(height: 10),
TextDivider(text: 'سامانه هم‌اندیشی آنلاین'),
],
),
));
}
} else if (currentList.type == 'startup' && techItem == null) {
pageContent.add(StaggeredEntry(
index: pageContent.length,
child: SwotSection(
headerSize: 13,
moreSize: 13,
swotItems: state.swotItems),
));
if (lists.any((e) => e.type == 'survey')) {
pageContent.add(StaggeredEntry(
index: pageContent.length,
child: const Column(
children: [
SizedBox(height: 10),
TextDivider(text: 'سامانه هم‌اندیشی آنلاین'),
],
),
));
}
}
}
}
return Column(
children: [
const HomeAppBar(
showSearchField: true,
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 30),
children: pageContent,
),
),
],
);
},
);
},
);
}
}
class StaggeredEntry extends StatefulWidget {
final Widget child;
final int index;
final Duration duration;
final double verticalOffset;
const StaggeredEntry({
super.key,
required this.child,
required this.index,
this.duration = const Duration(milliseconds: 250),
this.verticalOffset = 50.0,
});
@override
State<StaggeredEntry> createState() => _StaggeredEntryState();
}
class _StaggeredEntryState extends State<StaggeredEntry>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
);
_slideAnimation = Tween<Offset>(
begin: Offset(0, widget.verticalOffset / 100),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
));
final delay = (widget.index < 5) ? (widget.index * 100) : 50;
Future.delayed(Duration(milliseconds: delay), () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: widget.child,
),
);
}
}
class SwotSection extends StatelessWidget {
final List<SwotItem> swotItems;
final double? headerSize;
final double? moreSize;
const SwotSection(
{super.key, required this.swotItems, this.headerSize, this.moreSize});
@override
Widget build(BuildContext context) {
if (swotItems.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.all(0.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 20, top: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
SvgPicture.asset(
"lib/assets/icons/Swot_New.svg",
color: const Color.fromARGB(255, 0, 126, 167),
),
const SizedBox(width: 5),
DidvanText(
"ماژول بایدها و نبایدها",
style: headerSize == null
? Theme.of(context).textTheme.titleSmall
: TextStyle(
fontSize: headerSize,
fontWeight: FontWeight.bold),
color: DesignConfig.isDark
? const Color.fromARGB(255, 0, 125, 166)
: const Color.fromARGB(255, 0, 89, 119),
),
],
),
GestureDetector(
onTap: () {
AppInitializer.openWebLink(
navigatorKey.currentContext!,
'https://app.didvan.com/swot?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
},
child: Padding(
padding: const EdgeInsets.only(left: 20),
child: Row(
children: [
DidvanText(
"مشاهده همه",
style: moreSize == null
? Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontSize: 13)
: TextStyle(fontSize: moreSize),
color: Theme.of(context).colorScheme.primary,
fontWeight: moreSize == null
? FontWeight.bold
: FontWeight.normal,
),
],
),
),
),
],
),
),
const SizedBox(height: 16),
/// Swot Items Slider
DidvanSlider(
height: 200,
itemCount: swotItems.length,
viewportFraction: isAnyMobile() ? 0.80 : 0.75,
itemBuilder: (context, index, realIndex) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: SwotItemCard(item: swotItems[index]),
),
),
),
const SizedBox(height: 16),
],
),
);
}
}
class MainPageSection extends StatelessWidget {
final MainPageList list;
final bool isLast;
const MainPageSection({super.key, required this.list, required this.isLast});
void _moreHandler(BuildContext context) {
// لینک «مشاهده همه» برای دسته‌های جدید هنوز ست نشده؛ no-op تا خطای route ندهد.
if (list.link.isEmpty && list.type != 'monthly') {
return;
}
if (list.type == 'monthly') {
Navigator.of(context).pushNamed(Routes.monthlyList);
return;
}
if (list.link.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${list.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
return;
}
Navigator.of(context).pushNamed(list.link);
}
String? _generateIcon() {
switch (list.type) {
case 'monthly':
return 'lib/assets/icons/Monthly.svg';
case 'news':
return 'lib/assets/icons/Donye_Foolad.svg';
case 'radar':
return 'lib/assets/icons/Pouyesh_Ofogh_New.svg';
case 'video':
return 'lib/assets/icons/video-play.svg';
case 'podcast':
return 'lib/assets/icons/play-circle.svg';
case 'trend':
return 'lib/assets/icons/Ravand.svg';
case 'technology':
return 'lib/assets/icons/Technology.svg';
case 'risk':
return 'lib/assets/icons/RiskRadar.svg';
case 'startup':
return 'lib/assets/icons/Startup.svg';
case 'survey':
return 'lib/assets/icons/Saha.svg';
default:
return null;
}
}
double _calculateSliderHeight() {
switch (list.type) {
case 'monthly':
return 150;
case 'news':
return 225;
case 'radar':
return 225;
case 'trend':
return 150;
case 'technology':
return 230;
case 'risk':
return 225;
case 'startup':
return 145;
case 'delphi':
return 220;
default:
return 220;
}
}
double _calculateFixedViewportFraction(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
double targetWidth;
switch (list.type) {
case 'monthly':
targetWidth = 320.0;
break;
case 'news':
targetWidth = 265.0;
break;
case 'radar':
targetWidth = 310.0;
break;
case 'trend':
targetWidth = 320.0;
break;
case 'technology':
targetWidth = 250.0;
break;
case 'risk':
targetWidth = 310.0;
break;
case 'startup':
targetWidth = 330.0;
break;
case 'delphi':
targetWidth = 250.0;
break;
default:
targetWidth = 250.0;
}
return (targetWidth / screenWidth).clamp(0.1, 1.0);
}
@override
Widget build(BuildContext context) {
final iconPath = _generateIcon();
if (list.contents.isEmpty) {
return const SizedBox();
}
return Column(
children: [
_buildSectionHeader(context, iconPath),
_buildSectionSlider(context),
],
);
}
Padding _buildSectionHeader(BuildContext context, String? iconPath) {
String headerText = list.header;
if (list.type == 'news') {
headerText = 'دنیای فولاد';
}
if (list.type == 'radar') {
headerText = 'پویش افق';
}
if (list.type == 'survey') {
headerText = 'سها';
}
return Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
top: 28,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (iconPath != null)
SvgPicture.asset(
iconPath,
width: 24,
height: 24,
color: const Color.fromRGBO(0, 126, 167, 1),
),
const SizedBox(width: 4),
DidvanText(
headerText,
style:
const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
color: DesignConfig.isDark
? const Color.fromARGB(255, 0, 125, 166)
: const Color.fromARGB(255, 0, 89, 119),
),
],
),
GestureDetector(
onTap: () => _moreHandler(context),
child: Row(
children: [
DidvanText(
"مشاهده همه",
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 13,
),
],
),
)
],
),
);
}
Widget _buildSectionSlider(BuildContext context) {
if (list.type == 'podcast') {
return Padding(
padding: const EdgeInsets.only(top: 28),
child: Column(
children: list.contents
.map(
(e) => Padding(
padding: const EdgeInsets.only(
bottom: 12,
left: 28,
right: 28,
),
child: MainPagePodcastItem(
content: e,
),
),
)
.toList(),
),
);
}
return DidvanSlider(
key: ValueKey('${list.type}_slider'),
height: _calculateSliderHeight(),
viewportFraction: _calculateFixedViewportFraction(context),
itemCount: list.contents.length,
itemBuilder: (context, index, realIndex) {
Widget cardContent;
final item = list.contents[index];
switch (list.type) {
case 'monthly':
cardContent = GestureDetector(
key: ValueKey('monthly_$index'),
onTap: () {
if (kDebugMode) {
print('=== Monthly Item Debug ===');
print('ID: ${item.id}');
print('Title: ${item.title}');
print('Link: ${item.link}');
print('File: ${item.file}');
}
if (item.link == null || item.link!.isEmpty) {
if (item.file != null && item.file!.isNotEmpty) {
if (kDebugMode) {
print('Opening PDF file in viewer: ${item.file}');
print('=======================');
}
Navigator.of(context).pushNamed(
Routes.pdfViewer,
arguments: {
'pdfUrl': item.file,
'title': item.title,
},
);
} else {
if (kDebugMode) {
print(
'⚠️ Monthly item has no link and no file, opening monthly list');
print('=======================');
}
Navigator.of(context).pushNamed(Routes.monthlyList);
}
return;
}
if (kDebugMode) {
print('Opening link in WebView: ${item.link}');
print('=======================');
}
Navigator.of(context).pushNamed(
Routes.web,
arguments: item.link,
);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin:
const EdgeInsets.symmetric(vertical: 3, horizontal: 5),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SkeletonImage(
imageUrl: item.image,
aspectRatio: 1 / 1.3,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(12)),
height: 120,
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
item.title,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SvgPicture.asset(
'lib/assets/icons/calendar.svg'),
const SizedBox(
width: 5,
),
Expanded(
child: Text(
DateTime.parse(item.subtitles.last)
.toPersianDateStr(),
style: const TextStyle(
color: Color.fromARGB(
255, 102, 102, 102),
fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
Row(
children: [
SvgPicture.asset(
'lib/assets/icons/PDF_Page.svg'),
const SizedBox(width: 6),
Expanded(
child: Text(
'${item.subtitles.first.toPersianDigit()} صفحه',
style: const TextStyle(
color: Color.fromARGB(
255, 102, 102, 102),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
],
),
),
),
),
);
break;
case 'news':
cardContent = GestureDetector(
key: ValueKey('news_$index'),
onTap: () {
// اگر uuid محتوای جدید موجود است، صفحه‌ی جزئیات بومی را با
// contentUuid باز می‌کنیم تا از content-service بخواند.
if (item.contentId != null && item.contentId!.isNotEmpty) {
final uuid = item.contentId!;
Navigator.of(context).pushNamed(
Routes.newsDetails,
arguments: {
'id': item.id,
'contentUuid': uuid,
'args': const NewsRequestArgs(page: 0),
'description': item.description,
// toggleBookmark سمتِ سرور با uuid انجام می‌شود تا
// در صفحه‌ی نشان‌شده‌ها هم درست بیاید.
'onMarkChanged': (int id, bool value) {
ContentService.instance.toggleBookmark(uuid);
},
'hasUnmarkConfirmation': false,
},
);
return;
}
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
description: item.description,
);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SkeletonImage(
imageUrl: item.image,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0)),
height: 120,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 20, 12, 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 15,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SvgPicture.asset(
'lib/assets/icons/calendar.svg'),
const SizedBox(
width: 5,
),
Text(
DateTime.parse(item.subtitles.first)
.toPersianDateStr(),
style: TextStyle(
color:
Theme.of(context).colorScheme.caption,
fontSize: 12),
),
],
)
],
),
),
],
),
),
),
);
break;
case 'radar':
cardContent = GestureDetector(
key: ValueKey('radar_$index'),
onTap: () {
// پویش افق با محتوای جدید → صفحه‌ی جزئیات بومیِ رادار با uuid.
if (item.contentId != null && item.contentId!.isNotEmpty) {
final uuid = item.contentId!;
Navigator.of(context).pushNamed(
Routes.radarDetails,
arguments: {
'id': item.id,
'contentUuid': uuid,
'args': const RadarRequestArgs(page: 0),
'description': item.description,
'onMarkChanged': (int id, bool value) {
ContentService.instance.toggleBookmark(uuid);
},
'hasUnmarkConfirmation': false,
},
);
return;
}
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
description: item.description,
);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184))),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(9.5),
child: Column(
children: [
Row(
children: [
SvgPicture.asset(
'lib/assets/icons/Pouyesh Ofogh.svg'),
const SizedBox(
width: 5,
),
Expanded(
child: Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(
height: 12,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SvgPicture.asset(
'lib/assets/icons/calendar.svg'),
const SizedBox(
width: 5,
),
Text(
DateTime.parse(item.subtitles.first)
.toPersianDateStr(),
style: TextStyle(
color:
Theme.of(context).colorScheme.caption,
fontSize: 12),
),
],
),
],
),
),
const SizedBox(
height: 5,
),
SkeletonImage(
imageUrl: item.image,
height: 130,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
topRight: Radius.circular(0),
topLeft: Radius.circular(0)),
),
],
),
),
),
);
break;
case 'trend':
cardContent = GestureDetector(
key: ValueKey('trend_$index'),
onTap: () {
if (item.link!.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${item.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
} else {
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
);
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(15.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 10,
),
Row(
children: [
SvgPicture.asset(
'lib/assets/icons/ion_extension-puzzle-outline.svg'),
const SizedBox(
width: 5,
),
Text(
item.subtitles.last,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.caption),
),
],
)
],
),
),
),
SizedBox(
width: 150,
child: SkeletonImage(
imageUrl: item.image,
height: 10,
width: 150,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
topRight: Radius.circular(0),
bottomRight: Radius.circular(0)),
),
),
],
),
),
),
);
break;
case 'technology':
cardContent = GestureDetector(
key: ValueKey('technology_$index'),
onTap: () {
if (item.link!.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${item.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
} else {
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
);
}
},
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SkeletonImage(
imageUrl: item.image,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0)),
height: 110,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 20, 12, 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 10,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SvgPicture.asset(
'lib/assets/icons/ion_extension-puzzle-outline.svg'),
const SizedBox(
width: 5,
),
Expanded(
child: Text(
item.subtitles.first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
)
],
),
),
],
),
),
),
);
break;
case 'risk':
cardContent = GestureDetector(
key: ValueKey('risk_$index'),
onTap: () {
if (item.link!.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${item.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
} else {
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
);
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184))),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(9.5),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(
height: 9,
),
Builder(
builder: (context) {
final riskValue =
double.tryParse(item.riskScore ?? '0') ??
0;
final isNegative = riskValue < 0;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 3),
decoration: BoxDecoration(
color: (isNegative
? Colors.red
: Colors.green)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
isNegative
? "lib/assets/images/features/Badge.svg"
: "lib/assets/images/features/Badge-Green.svg",
height: 9,
),
const SizedBox(width: 4),
Text(
"عدد ریسک : ${riskValue.abs().toInt().toString().toPersianDigit()}",
style: TextStyle(
color: isNegative
? Colors.red
: Colors.green,
fontWeight: FontWeight.normal,
fontSize: 12,
),
)
],
),
);
},
),
],
),
),
const SizedBox(
height: 5,
),
SkeletonImage(
imageUrl: item.image,
height: 130,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
topRight: Radius.circular(0),
topLeft: Radius.circular(0)),
),
],
),
),
));
break;
case 'startup':
cardContent = GestureDetector(
key: ValueKey('startup_$index'),
onTap: () {
if (item.link != null && item.link!.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${item.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
} else {
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
);
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(255, 36, 36, 36)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 150,
child: SkeletonImage(
imageUrl: item.image,
height: 10,
width: 150,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(0),
bottomLeft: Radius.circular(0),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16)),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(15.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(
height: 10,
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SvgPicture.asset(
'lib/assets/icons/location.svg'),
const SizedBox(
width: 5,
),
Text(
item.subtitles.first,
style: const TextStyle(
color: Color.fromARGB(
255, 102, 102, 102)),
),
],
),
const SizedBox(
height: 10,
),
Container(
decoration: BoxDecoration(
color: DesignConfig.isDark
? const Color.fromARGB(
255, 188, 188, 188)
: const Color.fromARGB(
255, 235, 235, 235),
borderRadius: const BorderRadius.all(
Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
item.subtitles.last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color.fromARGB(
255, 102, 102, 102),
fontSize: 12),
),
),
)
],
)
],
),
),
),
],
),
),
),
);
break;
case 'delphi':
cardContent = GestureDetector(
key: ValueKey('delphi_$index'),
onTap: () {
if (item.link != null && item.link!.startsWith('http')) {
AppInitializer.openWebLink(
context,
'${item.link}?accessToken=${RequestService.token}',
mode: LaunchMode.inAppWebView,
);
} else {
context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
);
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark ? Colors.black : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SkeletonImage(
imageUrl: item.image,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0)),
height: 135,
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 15, 12, 15),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
break;
default:
cardContent = GestureDetector(
key: ValueKey('survey_$index'),
onTap: () => context.read<MainPageState>().navigationHandler(
list.type,
item.id,
item.link,
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: DesignConfig.isDark ? Colors.black : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color.fromARGB(255, 184, 184, 184),
width: 1),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (item.image.isEmpty)
Container(
height: 135,
color: DesignConfig.isDark
? const Color.fromARGB(255, 30, 30, 30)
: const Color.fromARGB(255, 240, 244, 248),
child: Center(
child: SvgPicture.asset(
'lib/assets/icons/Saha.svg',
height: 48,
colorFilter: ColorFilter.mode(
DesignConfig.isDark
? Colors.white70
: const Color.fromARGB(255, 0, 126, 167),
BlendMode.srcIn),
),
),
)
else
SkeletonImage(
imageUrl: item.image,
height: 135,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0)),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 15, 12, 15),
child: Text(
item.title,
textAlign: TextAlign.start,
style: TextStyle(
color: DesignConfig.isDark
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
// ماهنامه/دنیای فولاد/پویش افق در صفحات دیگر آیکن bookmark
// دارند، اینجا تکرار نمی‌کنیم. survey/delphi هم bookmarkable
// نیستند.
_BookmarkPos? pos;
switch (list.type) {
case 'trend':
// عکس کنار + متن — آیکن پایین چپ روی بدنهٔ متن.
pos = _BookmarkPos.bottomEnd;
break;
case 'technology':
// عکس بالا + متن پایین — آیکن پایین چپ روی متن.
pos = _BookmarkPos.bottomEnd;
break;
case 'risk':
// header بالا + عکس پایین — آیکن بالا چپ روی header.
pos = _BookmarkPos.topEnd;
break;
case 'startup':
// عکس سمت start + متن سمت end — آیکن پایین راست روی متن.
pos = _BookmarkPos.bottomStart;
break;
default:
pos = null;
}
if (pos == null) return cardContent;
return _withBookmarkOverlay(context, cardContent, item, list.type, pos);
},
);
}
/// آیکن BookmarkButton را روی گوشهٔ مناسب کارت overlay می‌کند.
/// از PositionedDirectional استفاده می‌کنیم تا با RTL/LTR هماهنگ باشد.
/// IconButton خودش tap را consume می‌کند → tap روی آیکن باعث
/// navigation به detail نمی‌شود.
Widget _withBookmarkOverlay(BuildContext context, Widget card,
MainPageContentType item, String type, _BookmarkPos pos) {
double? top, bottom, start, end;
switch (pos) {
case _BookmarkPos.topStart:
top = 6;
start = 6;
break;
case _BookmarkPos.topEnd:
top = 6;
end = 6;
break;
case _BookmarkPos.bottomStart:
bottom = 6;
start = 6;
break;
case _BookmarkPos.bottomEnd:
bottom = 6;
end = 6;
break;
}
// trend (رادار روند) و startup (رادار استارتاپ) از آیکن قبلی
// (bookmark_on/off پیش‌فرض BookmarkButton) استفاده می‌کنند که
// ذاتاً دایره سیاه نیمه‌شفاف + bookmark سفید هستند → باید
// `color`/`unbookmarkedColor` را null بگذاریم تا رنگ ذاتی SVG
// حفظ شود (وگرنه color override کل آیکن را خاکستری/محو می‌کند).
// اندازه هم 30 تا واضح‌تر دیده شود.
//
// risk و technology همان archive-tick/bookmark_fill استاندارد
// را با اندازهٔ کوچک‌تر (22) و رنگ آبی برند نگه می‌دارند.
final useStandardIcons = type == 'risk' || type == 'technology';
final size = useStandardIcons ? 22.0 : 30.0;
return Stack(
children: [
card,
PositionedDirectional(
top: top,
bottom: bottom,
start: start,
end: end,
child: BookmarkButton(
value: item.marked,
type: type,
itemId: item.id,
keycloakId: item.contentId,
gestureSize: size,
svgIconOn:
useStandardIcons ? 'lib/assets/icons/bookmark_fill.svg' : null,
svgIconOff:
useStandardIcons ? 'lib/assets/icons/archive-tick.svg' : null,
// برای آیکن‌های استاندارد رنگ‌دهی می‌کنیم. برای آیکن default
// (دایره سیاه با bookmark سفید) رنگ ذاتی را حفظ می‌کنیم.
color: useStandardIcons
? const Color.fromARGB(255, 0, 126, 167)
: null,
unbookmarkedColor:
useStandardIcons ? Theme.of(context).colorScheme.caption : null,
onMarkChanged: (v) {
item.marked = v;
},
),
),
],
);
}
}
enum _BookmarkPos { topStart, topEnd, bottomStart, bottomEnd }