From 61718baa54cb3506702d53ec6abdec6e94620260 Mon Sep 17 00:00:00 2001 From: "Mr.Jebelli" Date: Tue, 23 Jun 2026 11:44:58 +0330 Subject: [PATCH] update content handling and improve UI for infographics and banners --- build.ps1 | 25 ++++++++ lib/models/content/content_item.dart | 10 ++++ lib/providers/core.dart | 18 +++++- .../infography_details.dart | 49 +++++++++------ .../home/infography/infography_screen.dart | 32 ---------- .../infography/infography_screen_state.dart | 43 ------------- lib/views/home/main/main_page_state.dart | 60 ++++++++++++++----- .../home/main/widgets/infography_item.dart | 5 ++ .../widgets/media_banner_with_thumbnails.dart | 40 +++++++++++-- lib/views/widgets/skeleton_image.dart | 8 ++- 10 files changed, 176 insertions(+), 114 deletions(-) create mode 100644 build.ps1 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..284dac6 --- /dev/null +++ b/build.ps1 @@ -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 +} diff --git a/lib/models/content/content_item.dart b/lib/models/content/content_item.dart index 17470ca..26431b4 100644 --- a/lib/models/content/content_item.dart +++ b/lib/models/content/content_item.dart @@ -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( diff --git a/lib/providers/core.dart b/lib/providers/core.dart index 06b7cb6..316ef55 100644 --- a/lib/providers/core.dart +++ b/lib/providers/core.dart @@ -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; } diff --git a/lib/views/home/infography/infography_details/infography_details.dart b/lib/views/home/infography/infography_details/infography_details.dart index df5bfe3..d2b43ae 100644 --- a/lib/views/home/infography/infography_details/infography_details.dart +++ b/lib/views/home/infography/infography_details/infography_details.dart @@ -193,35 +193,48 @@ class _InfographyDetailsState extends State { 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( - onTap: () => ActionSheetUtils(context) - .openInteractiveViewer(context, item.image, false), - child: SkeletonImage( - imageUrl: item.image, - borderRadius: BorderRadius.zero, + // === تصویر اصلی — کل تصویر پیدا، ارتفاع طبیعی === + // به‌جای 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( - margin: EdgeInsets.only(top: imageHeight - overlap), + width: double.infinity, decoration: BoxDecoration( color: theme.colorScheme.background, // لبه صاف به‌جای BorderRadius.vertical(top: 24) diff --git a/lib/views/home/infography/infography_screen.dart b/lib/views/home/infography/infography_screen.dart index 5e567bf..f1acb33 100644 --- a/lib/views/home/infography/infography_screen.dart +++ b/lib/views/home/infography/infography_screen.dart @@ -195,38 +195,6 @@ class _InfographyScreenState extends State { value: context.watch().lastSearch, isFiltered: context.watch().filtering), ), - // پوستر بالای صفحه — از دسته‌ی «بنر فولادینفو» کشیده می‌شود. - // اگر هیچ آیتم منتشرشده‌ای در آن دسته نباشد، چیزی نمایش داده نمی‌شود. - Builder(builder: (context) { - final bannerUrl = - context.watch().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( placeholder: SingleChildScrollView( child: Column( diff --git a/lib/views/home/infography/infography_screen_state.dart b/lib/views/home/infography/infography_screen_state.dart index 58d75ad..e4318c5 100644 --- a/lib/views/home/infography/infography_screen_state.dart +++ b/lib/views/home/infography/infography_screen_state.dart @@ -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 _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( diff --git a/lib/views/home/main/main_page_state.dart b/lib/views/home/main/main_page_state.dart index 62769dc..ba4e3e6 100644 --- a/lib/views/home/main/main_page_state.dart +++ b/lib/views/home/main/main_page_state.dart @@ -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 _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 _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'); } } diff --git a/lib/views/home/main/widgets/infography_item.dart b/lib/views/home/main/widgets/infography_item.dart index bef368f..8a55408 100644 --- a/lib/views/home/main/widgets/infography_item.dart +++ b/lib/views/home/main/widgets/infography_item.dart @@ -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, ), ), ), diff --git a/lib/views/home/media/widgets/media_banner_with_thumbnails.dart b/lib/views/home/media/widgets/media_banner_with_thumbnails.dart index f20da3d..a89e9bb 100644 --- a/lib/views/home/media/widgets/media_banner_with_thumbnails.dart +++ b/lib/views/home/media/widgets/media_banner_with_thumbnails.dart @@ -27,26 +27,55 @@ class _MediaBannerWithThumbnailsState extends State { final state = context.watch(); // بنرِ فولادینفو از 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: SkeletonImage( - imageUrl: state.topBanner!.image, - 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, @@ -262,6 +291,7 @@ class _MediaBannerWithThumbnailsState extends State { ), ], ), + ], ], ); } diff --git a/lib/views/widgets/skeleton_image.dart b/lib/views/widgets/skeleton_image.dart index 63faf81..5139750 100644 --- a/lib/views/widgets/skeleton_image.dart +++ b/lib/views/widgets/skeleton_image.dart @@ -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,