INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user