INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'my_data.dart';
import 'widgets/my_cards.dart';
/// 푸시 알림 설정 — 화면 대조 원장 F6 · Figma `357:223444` · 정책 원장 B-77.
///
/// **토글은 5 개다.** 별첨 B 가 「서비스 필수 / 팬덤 이벤트 / AI Briefing /
/// Community / Marketing」 5 개 구분으로 확정했고, Figma `554:24062` 의 6 종
/// (이클립스·좋아요·댓글·퀘스트·캠페인·마케팅)은 그 안으로 들어간다. 공통 기준
/// 2항 — Figma 의 과거 표기가 이후 확정된 정책보다 우선하지 않는다.
///
/// 두 상태를 나눈 Figma 명세는 그대로 지킨다.
/// - `554:24049` — *디바이스 앱 설정의 알림이 **off** 되어 있을 경우 표시*
/// - `554:24062` — *알림이 **ON** 되어 있을 경우 표시*
///
/// **권한 조회는 화면이 하지 않는다.** [deviceAllowed] 로 주입받아 두 갈래를 모두
/// 그린다. 권한을 여기서 읽으면 위젯 테스트가 플러그인을 요구하고, 실제 판정
/// 주체는 `PushMessaging` 하나여야 한다. 거부 상태의 재요청은
/// [onRequestPermission] 이 맡고, 없으면 기기 설정 안내만 띄운다.
class PushSettingsScreen extends StatefulWidget {
const PushSettingsScreen({
super.key,
required this.repository,
this.deviceAllowed = true,
this.onRequestPermission,
this.onSettingsChanged,
});
final MyRepository repository;
/// 기기에서 FANUZU 알림이 켜져 있는가.
final bool deviceAllowed;
/// 권한 재요청. 허용되면 true 를 돌려준다.
final Future<bool> Function()? onRequestPermission;
/// 설정이 바뀐 직후 토픽 구독을 다시 맞추는 콜백.
///
/// **저장만으로는 발송이 멈추지 않는다.** 서버는 토픽으로만 보내므로, 문서에
/// `false` 를 적어도 그 토픽을 구독한 채면 알림이 계속 온다(B-77 검증 기준
/// 「OFF 카테고리 발송 0건」). 그래서 저장과 구독 갱신을 같은 자리에서 한다.
final Future<void> Function(Map<PushTopic, bool> prefs)? onSettingsChanged;
@override
State<PushSettingsScreen> createState() => _PushSettingsScreenState();
}
class _PushSettingsScreenState extends State<PushSettingsScreen> {
Map<PushTopic, bool>? _settings;
bool _allowed = true;
bool _requesting = false;
@override
void initState() {
super.initState();
_allowed = widget.deviceAllowed;
_load();
}
Future<void> _load() async {
final settings = await widget.repository.pushSettings();
if (mounted) setState(() => _settings = settings);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.settingsPush)),
body: !_allowed
? _blocked()
: _settings == null
? const Center(child: CircularProgressIndicator())
: _toggles(),
);
/// 알림이 꺼져 있을 때 — 시안 `357:223444`. 토글을 그리지 않는다. 앱 안에서 켜도 소용이 없다.
///
/// 「설정하러 가기」는 **앱 안에서 한 번 더 물을 수 있으면** 시스템 다이얼로그를
/// 다시 띄우고([onRequestPermission]), 영구 거부면 기기 설정 안내를 띄운다.
Widget _blocked() => ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
context.l10n.pushDeviceOff,
textAlign: TextAlign.center,
style: myText(20, FontWeight.w400, height: 1.2).copyWith(
fontFamily: AppFonts.display,
fontFamilyFallback: const [AppFonts.body],
letterSpacing: 0,
),
),
const SizedBox(height: 16),
Text(
context.l10n.pushDeviceOffHint,
textAlign: TextAlign.center,
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1.2,
).copyWith(letterSpacing: 0),
),
const SizedBox(height: 24),
MyGroup(
children: [
MyLinkRow(
label: _requesting
? context.l10n.pushRequesting
: context.l10n.pushGoToSettings,
onTap: _requesting
? () {}
: widget.onRequestPermission == null
? () => _toast(context.l10n.pushOpeningDeviceSettings)
: _retryPermission,
),
],
),
],
);
Future<void> _retryPermission() async {
setState(() => _requesting = true);
final granted = await widget.onRequestPermission!.call();
if (!mounted) return;
setState(() {
_requesting = false;
_allowed = granted;
});
if (!granted) {
_toast(context.l10n.pushEnableInDevice);
}
}
/// 알림이 켜져 있을 때 — 시안 `357:223611` 의 「기기 알림 설정 · 서비스 알림 설정」 두 구획.
/// 줄은 B-77 확정 5 구분이다.
Widget _toggles() {
final settings = _settings!;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: [
MyGroup(
title: context.l10n.pushDeviceSection,
children: [
MyLinkRow(
label: context.l10n.pushSoundVibration,
onTap: () => _toast(context.l10n.pushOpeningDeviceSettings),
),
],
),
MyGroup(
title: context.l10n.pushServiceSection,
children: [
for (final topic in PushTopic.values)
SizedBox(
height: 32,
child: Row(
children: [
Expanded(
child: Text(
_topicLabel(context.l10n, topic),
style: myText(
16,
FontWeight.w600,
height: 1,
color: topic.locked
? MyColors.muted
: AppColors.onCosmic,
),
),
),
Switch(
value: topic.locked
? true
: (settings[topic] ?? topic.defaultOn),
activeThumbColor: AppColors.onCosmic,
activeTrackColor: AppColors.cosmicAccent,
inactiveThumbColor: const Color(0xFF3A3A3A),
inactiveTrackColor: const Color(0xFF2C2C2C),
trackOutlineColor: const WidgetStatePropertyAll(
Colors.transparent,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
// 서비스 필수는 끌 수 없다(B-77 「서비스 필수 외 4종 개별 OFF」).
onChanged: topic.locked
? null
: (on) async {
setState(() => settings[topic] = on);
await widget.repository.setPush(topic, on);
// 저장 뒤에 부른다 — 구독을 먼저 바꾸면 저장이 실패했을 때
// 문서와 실제 구독이 갈린다.
await widget.onSettingsChanged?.call(settings);
},
),
],
),
),
],
),
],
);
}
static String _topicLabel(AppLocalizations l10n, PushTopic t) => switch (t) {
PushTopic.essential => l10n.pushTopicEssential,
PushTopic.fandomEvent => l10n.pushTopicFandomEvent,
PushTopic.briefing => l10n.pushTopicBriefing,
PushTopic.community => l10n.pushTopicCommunity,
PushTopic.marketing => l10n.pushTopicMarketing,
};
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
}