update content handling and improve UI for infographics and banners
This commit is contained in:
parent
c60c081f99
commit
61718baa54
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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> {
|
|||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue