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 Function()? onRequestPermission; /// 설정이 바뀐 직후 토픽 구독을 다시 맞추는 콜백. /// /// **저장만으로는 발송이 멈추지 않는다.** 서버는 토픽으로만 보내므로, 문서에 /// `false` 를 적어도 그 토픽을 구독한 채면 알림이 계속 온다(B-77 검증 기준 /// 「OFF 카테고리 발송 0건」). 그래서 저장과 구독 갱신을 같은 자리에서 한다. final Future Function(Map prefs)? onSettingsChanged; @override State createState() => _PushSettingsScreenState(); } class _PushSettingsScreenState extends State { Map? _settings; bool _allowed = true; bool _requesting = false; @override void initState() { super.initState(); _allowed = widget.deviceAllowed; _load(); } Future _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 _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), ); } }