update content handling and improve UI for infographics and banners

This commit is contained in:
Mohamad Mahdi Jebeli 2026-06-23 11:44:58 +03:30
parent c60c081f99
commit 61718baa54
10 changed files with 176 additions and 114 deletions

25
build.ps1 Normal file
View File

@ -0,0 +1,25 @@
# Build pipeline for the didvan app — clean, fetch deps, then release APK.
# Usage: .\build.ps1
# Stops at the first failure.
$ErrorActionPreference = 'Stop'
Write-Host '==> flutter clean' -ForegroundColor Cyan
flutter clean
if ($LASTEXITCODE -ne 0) { throw "flutter clean failed" }
Write-Host '==> flutter pub get' -ForegroundColor Cyan
flutter pub get
if ($LASTEXITCODE -ne 0) { throw "flutter pub get failed" }
Write-Host '==> flutter build apk --release' -ForegroundColor Cyan
flutter build apk --release
if ($LASTEXITCODE -ne 0) { throw "flutter build failed" }
$apk = 'build\app\outputs\flutter-apk\app-release.apk'
if (Test-Path $apk) {
$sizeMb = [math]::Round((Get-Item $apk).Length / 1MB, 1)
Write-Host "==> Done. APK at $apk ($sizeMb MB)" -ForegroundColor Green
} else {
Write-Host '==> Done, but expected APK not found.' -ForegroundColor Yellow
}

View File

@ -168,6 +168,16 @@ class ContentItem {
String? audioIdLocal;
String? videoIdLocal;
int? mediaDuration;
// اگر content-service خودش coverImage signed-url را در پاسخ گذاشته
// (resolveFileUrl برای metadata.cover_image_id)، آن را بهعنوان منبع
// اصلی بپذیر این مسیر برای محتواهایی مثل «بنر فولادینفو» که body
// خالی است و تصویر فقط در metadata.cover_image_id ذخیره میشود لازم
// است. اسکن body در ادامه فقط در صورت خالیماندنِ cover کار میکند.
final rootCover = json['coverImage'];
if (rootCover is String && rootCover.isNotEmpty) {
cover = rootCover;
}
// helper: تشخیصِ شناسهی UUID از یک URL واقعی.
bool isHttpUrl(String s) => s.startsWith('http://') || s.startsWith('https://');
bool looksLikeUuid(String s) => RegExp(

View File

@ -6,7 +6,20 @@ import 'package:flutter/cupertino.dart';
class CoreProvier with ChangeNotifier {
AppState _appState = AppState.busy;
/// وقتی provider dispose میشود این flag true میشود تا callback های
/// async (مثل fetch network) که بعد از dispose برمیگردند و `update()` را
/// صدا میزنند به جای throw، silently no-op شوند.
bool _disposed = false;
bool get isDisposed => _disposed;
@override
void dispose() {
_disposed = true;
super.dispose();
}
set appState(AppState newState) {
if (_disposed) return;
if (newState == AppState.isolatedBusy) {
ActionSheetUtils(navigatorKey.currentContext!).showLogoLoadingIndicator();
}
@ -17,7 +30,10 @@ class CoreProvier with ChangeNotifier {
notifyListeners();
}
void update() => notifyListeners();
void update() {
if (_disposed) return;
notifyListeners();
}
AppState get appState => _appState;
}

View File

@ -193,35 +193,48 @@ class _InfographyDetailsState extends State<InfographyDetails> {
children: [
LayoutBuilder(
builder: (context, constraints) {
final double imageHeight = constraints.maxWidth / (16 / 9);
const double overlap = 24.0;
return SingleChildScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 92),
child: Stack(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// === تصویر اصلی ===
Positioned(
top: 0,
left: 0,
right: 0,
height: imageHeight,
child: GestureDetector(
// === تصویر اصلی کل تصویر پیدا، ارتفاع طبیعی ===
// بهجای container با ارتفاع 16:9 fixed (که portrait
// را crop میکرد یا با contain کناره خالی میگذاشت)،
// اینجا از Image.network مستقیم استفاده میکنیم تا
// ارتفاع تصویر متناسب با aspect ratio طبیعیاش باشد.
GestureDetector(
onTap: () => ActionSheetUtils(context)
.openInteractiveViewer(context, item.image, false),
child: SkeletonImage(
imageUrl: item.image,
borderRadius: BorderRadius.zero,
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(
margin: EdgeInsets.only(top: imageHeight - overlap),
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.background,
// لبه صاف بهجای BorderRadius.vertical(top: 24)

View File

@ -195,38 +195,6 @@ class _InfographyScreenState extends State<InfographyScreen> {
value: context.watch<InfographyScreenState>().lastSearch,
isFiltered: context.watch<InfographyScreenState>().filtering),
),
// پوستر بالای صفحه از دستهی «بنر فولادینفو» کشیده میشود.
// اگر هیچ آیتم منتشرشدهای در آن دسته نباشد، چیزی نمایش داده نمیشود.
Builder(builder: (context) {
final bannerUrl =
context.watch<InfographyScreenState>().topBannerImageUrl;
if (bannerUrl == null || bannerUrl.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 7,
child: Image.network(
bannerUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Container(
color: Theme.of(context)
.colorScheme
.surface
.withOpacity(0.4),
);
},
),
),
),
);
}),
StateHandler<InfographyScreenState>(
placeholder: SingleChildScrollView(
child: Column(

View File

@ -72,15 +72,6 @@ class InfographyScreenState extends CoreProvier {
static const String _infographyCategoryId =
'3e53627d-9a18-4bad-b4ae-1f0c55438147';
/// شناسهی دستهی «بنر فولادینفو» تصویر کاور آخرین content این دسته
/// بهعنوان پوستر بالای صفحهی فولادینفو در اپ نمایش داده میشود.
static const String _foladinfoBannerCategoryId =
'd1a5e9b3-6f47-4c82-9e1d-3b8f2c5a4d76';
/// URL تصویر بنر فولادینفو که در بالای صفحه نمایش داده میشود؛
/// اگر null یا خالی بود، بنر اصلاً رندر نمیشود.
String? topBannerImageUrl;
/// فقط محتوای منتشرشده/تأییدشده. status فقط IN دارد.
static const String _visibleStatusFilter = '\$in:accepted,published';
@ -176,39 +167,6 @@ class InfographyScreenState extends CoreProvier {
appState = AppState.idle;
}
/// آخرین content منتشرشده در دستهی «بنر فولادینفو» را میگیریم و
/// `coverImage` آن را در [topBannerImageUrl] میگذاریم. در صورت خطا
/// یا خالیبودن، بنر نمایش داده نمیشود (مقدار null میماند).
Future<void> _fetchTopBanner() async {
try {
final res = await ContentService.instance.getContents(
page: 1,
limit: 1,
filterStatus: _visibleStatusFilter,
categoryId: _foladinfoBannerCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
if (!res.isSuccess || res.data == null || res.data!.data.isEmpty) {
topBannerImageUrl = null;
update();
return;
}
final first = res.data!.data.first;
// The list endpoint may already include coverImage; if not, fetch detail.
String? cover = first.coverImage;
if (cover == null || cover.isEmpty) {
final detail = await ContentService.instance.getContent(first.id);
if (detail.isSuccess && detail.data != null) {
cover = detail.data!.coverImage;
}
}
topBannerImageUrl = (cover != null && cover.isNotEmpty) ? cover : null;
update();
} catch (_) {
topBannerImageUrl = null;
}
}
void resetFilters(bool isInit) {
selectedCats.clear();
selectedTags.clear();
@ -226,7 +184,6 @@ class InfographyScreenState extends CoreProvier {
resetFilters(true);
Future.delayed(Duration.zero, () {
getInfographyContent(page: 1);
_fetchTopBanner();
});
categories = [
CategoryData(

View File

@ -109,11 +109,13 @@ class MainPageState extends CoreProvier {
_getSwotItems(),
_getDidvanPlus(),
_getDidvanVoice(),
// Top banner از category «بنر فولادینفو» در content-service خوانده
// میشود (مستقل از legacy api) قبلاً پشت useLegacyApi پنهان بود.
_getTopBanner(),
];
if (AuthConfig.useLegacyApi) {
tasks.addAll([
_getMainPageContent(),
_getTopBanner(),
]);
} else {
debugPrint(
@ -220,9 +222,11 @@ class MainPageState extends CoreProvier {
Future<void> _loadFooladInfoBanners() async {
try {
// فقط آخرین (تازهترین) بنر فولادینفو نشان داده میشود یعنی هیچ
// carousel چرخشی نباشد. limit=1 + DESC = جدیدترین.
final res = await ContentService.instance.getContents(
page: 1,
limit: 15,
limit: 1,
filterStatus: _visibleStatusFilter,
categoryId: _fooladInfoCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
@ -685,22 +689,50 @@ class MainPageState extends CoreProvier {
}
}
// TODO(backend-migration): `api.didvan.app/topBanner` فعلا در دسترس نیست.
// پس از پیادهسازی endpoint معادل در api2.didvan.com، این تابع را به آن
// متصل کنید.
/// شناسهی دستهی «بنر فولادینفو» در content-service. آخرین آیتم منتشرشده
/// در این دسته بهعنوان پوستر بالای صفحهی فولادینفو استفاده میشود.
static const String _foladinfoBannerCategoryId =
'd1a5e9b3-6f47-4c82-9e1d-3b8f2c5a4d76';
/// تاپبنر فولادینفو از content-service خوانده میشود (آخرین content
/// منتشرشده در دستهی «بنر فولادینفو»). `coverImage` این آیتم سرور-ساید
/// از `metadata.cover_image_id` به signed S3 URL تبدیل میشود.
Future<void> _getTopBanner() async {
print('[FOLADINFO_TOPBANNER] start');
try {
final service = RequestService(RequestHelper.topBanner);
await service.httpGet().timeout(_legacyTimeout);
if (service.statusCode == 200) {
final data = service.result['result'] ?? service.result;
topBanner = TopBannerModel.fromJson(data);
final res = await ContentService.instance.getContents(
page: 1,
limit: 1,
filterStatus: r'$in:accepted,published',
categoryId: _foladinfoBannerCategoryId,
sortBy: const [MapEntry('createdAt', 'DESC')],
);
print(
'[FOLADINFO_TOPBANNER] success=${res.isSuccess} len=${res.data?.data.length}');
if (!res.isSuccess || res.data == null || res.data!.data.isEmpty) {
topBanner = null;
notifyListeners();
return;
}
} on TimeoutException {
debugPrint('⏱️ topBanner legacy timeout');
} catch (e) {
debugPrint('❌ topBanner legacy failed: $e');
final first = res.data!.data.first;
String? cover = first.coverImage;
if (cover == null || cover.isEmpty) {
// fallback: backend ممکن است coverImage را در list نگذارد؛ detail
// را میگیریم چون findOne بهطور حتم signed URL را پر میکند.
final detail = await ContentService.instance.getContent(first.id);
if (detail.isSuccess && detail.data != null) {
cover = detail.data!.coverImage;
}
}
print('[FOLADINFO_TOPBANNER] cover=$cover');
if (cover != null && cover.isNotEmpty) {
topBanner = TopBannerModel(id: 0, image: cover);
} else {
topBanner = null;
}
notifyListeners();
} catch (e, st) {
print('[FOLADINFO_TOPBANNER] ERROR: $e\n$st');
}
}

View File

@ -138,11 +138,16 @@ class InfographyItem extends StatelessWidget {
aspectRatio: 1.42,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
// برای اینفوگرافی کل تصویر باید پیدا باشد (هیچ
// بخشی crop نشود). `BoxFit.contain` ضمن حفظ نسبت،
// کل تصویر را در container مینشاند؛ شاید کنارههای
// خالی بماند ولی هیچ بخشی از تصویر گم نمیشود.
child: SkeletonImage(
imageUrl: image,
width: double.infinity,
height: double.infinity,
borderRadius: BorderRadius.circular(16),
fit: BoxFit.contain,
),
),
),

View File

@ -27,26 +27,55 @@ class _MediaBannerWithThumbnailsState extends State<MediaBannerWithThumbnails> {
final state = context.watch<MainPageState>();
// بنرِ فولادینفو از content-service (دستهی «تصویر») میآید.
final banners = state.fooladInfoBanners;
final hasTopBanner = widget.isFirst && state.topBanner != null;
if (banners.isEmpty) {
if (banners.isEmpty && !hasTopBanner) {
return const SizedBox.shrink();
}
return Column(
children: [
if (widget.isFirst && state.topBanner != null) ...[
if (hasTopBanner) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: SkeletonImage(
imageUrl: state.topBanner!.image,
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(height: 12),
],
// وقتی فقط ۱ بنر داریم، dots و thumbnails navigation معنا ندارد و
// پنهان میشوند؛ فقط همان یک تصویر نمایش داده میشود.
if (banners.length == 1) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: GestureDetector(
onTap: () => banners.first.link == null ||
banners.first.link!.isEmpty
? ActionSheetUtils(context).openInteractiveViewer(
context, banners.first.image, false)
: launchUrlString(banners.first.link!,
mode: LaunchMode.inAppWebView),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: SkeletonImage(
imageUrl: banners.first.image,
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
] else if (banners.length > 1) ...[
CarouselSlider.builder(
carouselController: _carouselController,
itemCount: banners.length,
@ -263,6 +292,7 @@ class _MediaBannerWithThumbnailsState extends State<MediaBannerWithThumbnails> {
],
),
],
],
);
}
}

View File

@ -17,6 +17,11 @@ class SkeletonImage extends StatelessWidget {
final double? pHeight;
final BorderRadius? borderRadius;
final double? aspectRatio;
/// نحوهی fit کردن تصویر در container. مقدار پیشفرض `BoxFit.cover` است
/// (برای کارتهای سبک پادکست/خبر). برای صفحههایی مثل اینفوگرافی که
/// میخواهیم کل تصویر پیدا باشد، `BoxFit.contain` پاس داده میشود.
final BoxFit? fit;
const SkeletonImage({
Key? key,
required this.imageUrl,
@ -26,6 +31,7 @@ class SkeletonImage extends StatelessWidget {
this.height,
this.pWidth,
this.pHeight,
this.fit,
}) : super(key: key);
@override
@ -60,7 +66,7 @@ class SkeletonImage extends StatelessWidget {
child: const Icon(Icons.image_not_supported_outlined));
},
errorListener: (value) {},
fit: kIsWeb ? BoxFit.fill : BoxFit.cover,
fit: fit ?? (kIsWeb ? BoxFit.fill : BoxFit.cover),
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
httpHeaders: headers,
width: width,