228 lines
7.6 KiB
Dart
228 lines
7.6 KiB
Dart
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 대로 번호 + 제목만 띄운다.** 정책 원장 B-75(확정) 「각 항목에 출처명 ·
|
|
/// 게시/발행시각 · 원본 URL · 원문보기 · AI 요약 표시」는 Figma 에 배치가 없어, 줄을 누르면
|
|
/// 여는 상세 시트([showBriefingDetail])에 모두 싣는다. 시트의 「원문 보기」가 원문으로 나간다.
|
|
class BriefingList extends StatelessWidget {
|
|
const BriefingList({super.key, required this.items, this.onOpen});
|
|
|
|
final List<BriefingItem> items;
|
|
|
|
/// 원문보기 동작. 기본값은 기본 브라우저로 넘기는 것이고, 테스트가 대신 받는다 —
|
|
/// 위젯 테스트에서 실제 브라우저를 띄울 수는 없다.
|
|
final Future<void> 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<void> _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: 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와
|
|
/// 공식/팬덤 출처 구분을 한 시트에 싣는다.
|
|
Future<void> showBriefingDetail(
|
|
BuildContext context,
|
|
BriefingItem item, {
|
|
required Future<void> 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,
|
|
};
|
|
String two(int v) => v.toString().padLeft(2, '0');
|
|
final at = item.sourceAt.toLocal();
|
|
final stamp =
|
|
'${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
|
|
return showModalBottomSheet<void>(
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|