import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../core/theme/tokens.dart'; import '../home_data.dart'; import '../../../core/i18n/app_locale.dart'; /// 아티스트 소식(AI Daily Briefing) — Figma 「홈」 `Frame 1984081094`: `#1A1A1C` · r12 · /// 패딩 20/16 · 줄 간격 20. 줄은 번호(16/500 흰색) · 28 · 제목(16/500 · 행간 150% · #A3A2A0). /// /// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당* /// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만 /// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다. /// /// **한 줄은 Figma 의 번호 + 제목 아래에 출처명 · 게시시각을 12/500 으로 덧붙인다** — UX·FX v1.5 /// 「AI Briefing 출처 표시」(B-3 8-4). 정책 원장 B-75(확정)의 나머지(공식/팬덤 구분 · 원본 URL · /// 원문보기 · AI 요약 표시)는 Figma 에 배치가 없어, 줄을 누르면 여는 상세 시트([showBriefingDetail])에 /// 싣는다. 시트의 「원문 보기」가 원문으로 나간다. class BriefingList extends StatelessWidget { const BriefingList({super.key, required this.items, this.onOpen}); final List items; /// 원문보기 동작. 기본값은 기본 브라우저로 넘기는 것이고, 테스트가 대신 받는다 — /// 위젯 테스트에서 실제 브라우저를 띄울 수는 없다. final Future Function(BriefingItem item)? onOpen; @override Widget build(BuildContext context) { // 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다. final sections = [ for (final section in BriefingSection.values) ( section: section, rows: items.where((i) => i.section == section).toList() ..sort((a, b) => a.rank.compareTo(b.rank)), ), ].where((g) => g.rows.isNotEmpty).toList(); // 구획 이름(News · SNS)은 사본에 없지만 B-47 의 구획 단위 숨김을 화면에서 가르는 표지라 // 남긴다 — 「더 보기」와 같은 12/600 #A3A2A0 으로 작게. return Container( padding: const EdgeInsets.fromLTRB( AppSpace.s4, AppSpace.s5, AppSpace.s4, AppSpace.s5, ), decoration: BoxDecoration( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.md), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (groupIndex, group) in sections.indexed) ...[ if (groupIndex > 0) const SizedBox(height: AppSpace.s5), Text( group.section.label, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w600, height: 1, color: AppColors.onCosmicVariant, ), ), for (final item in group.rows) ...[ const SizedBox(height: AppSpace.s5), _Row( item: item, onOpen: () => showBriefingDetail( context, item, onOriginal: () => _open(context, item), ), ), ], ], ], ), ); } Future _open(BuildContext context, BriefingItem item) async { final handler = onOpen; if (handler != null) { await handler(item); return; } final opened = await launchUrl( Uri.parse(item.articleUrl), mode: LaunchMode.externalApplication, ); // 열리지 않으면 알린다. 조용히 아무 일도 없으면 눌린 것인지조차 알 수 없다. if (!opened && context.mounted) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text(context.l10n.briefingOpenFailed), behavior: SnackBarBehavior.floating, ), ); } } } class _Row extends StatelessWidget { const _Row({required this.item, required this.onOpen}); final BriefingItem item; final VoidCallback onOpen; @override Widget build(BuildContext context) { return InkWell( onTap: onOpen, child: Row( children: [ SizedBox( width: 10, child: Text( '${item.rank}', style: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w500, height: 1.4, color: AppColors.onCosmic, ), ), ), const SizedBox(width: 28), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.headline, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w500, height: 1.5, color: AppColors.onCosmicVariant, ), ), const SizedBox(height: AppSpace.s1), Text( '${item.sourceName} · ${briefingStamp(item.sourceAt)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, height: 1.4, color: AppColors.onCosmicMuted, ), ), ], ), ), ], ), ); } } /// 게시시각 표기 `yyyy.MM.dd HH:mm`(기기 시간대) — 줄과 상세 시트가 같이 쓴다. String briefingStamp(DateTime sourceAt) { String two(int v) => v.toString().padLeft(2, '0'); final at = sourceAt.toLocal(); return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}'; } /// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와 /// 공식/팬덤 출처 구분을 한 시트에 싣는다. Future showBriefingDetail( BuildContext context, BriefingItem item, { required Future Function() onOriginal, }) { const muted = TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, height: 1.4, color: AppColors.onCosmicMuted, ); final l10n = context.l10n; final kind = switch (item.sourceKind) { 'official' => l10n.briefingSourceOfficial, 'fandom' => l10n.briefingSourceFandom, _ => null, }; final stamp = briefingStamp(item.sourceAt); return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppColors.cosmicSurfaceContainer, builder: (sheet) => SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpace.s5), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( item.headline, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 18, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ), ), const SizedBox(height: AppSpace.s2), Text([?kind, item.sourceName, stamp].join(' · '), style: muted), const SizedBox(height: AppSpace.s4), Text(l10n.briefingAiSummary, style: muted), const SizedBox(height: AppSpace.s1), Text( item.aiSummary, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 15, height: 1.5, color: AppColors.onCosmicVariant, ), ), const SizedBox(height: AppSpace.s3), Text(item.sourceUrl, style: muted), const SizedBox(height: AppSpace.s4), FilledButton( onPressed: () async { Navigator.of(sheet).pop(); await onOriginal(); }, child: Text(l10n.briefingViewOriginal), ), ], ), ), ), ); }