221 lines
8.0 KiB
Dart
221 lines
8.0 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|