Files
fanuzu/app/lib/features/home/widgets/briefing_list.dart
T

148 lines
5.0 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 블록을 숨김*. 구획이 없으면 「섹션만
/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다.
///
/// **한 줄은 제목만 띄운다.** 출처명·게시시각·AI 요약을 어디에 배치할지는 발주사가
/// HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후 정한다.
/// 데이터는 [BriefingItem] 에 5 요소가 전부 들어와 있으므로 배치만 남는다.
///
/// 줄을 누르면 원문보기로 나간다(B-75 5 요소 중 「원문보기」).
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: () => _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,
),
),
),
],
),
);
}
}