import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; /// 1회성 로컬 Coach Mark — 9/21 회신서 ④-2 A · 10/3 통합회신 1항 · 해석 확인 Q-OB-02 A. /// /// 온보딩 6단계를 마치면 [armCoachMark] 가 이 기기에 「대기」를 남기고, 그 뒤 처음 ZAP 이 보이는 /// HOME 에서 [maybeShowCoachMark] 가 2단계(① Zap 버튼 ② 하단 탭 MY)를 띄운 뒤 지운다. /// 서버 데이터는 없다(단말당 1회 · 재설치하면 다시 나올 수 있다). const _pendingKey = 'coach.pending'; /// 하단 탭 MY 아이콘 위치를 찾는 열쇠 — `AppShell` 이 단다. final coachMyTabKey = GlobalKey(debugLabel: 'coach-my-tab'); Future armCoachMark() async { try { await (await SharedPreferences.getInstance()).setBool(_pendingKey, true); } catch (_) {} } Future maybeShowCoachMark(BuildContext context, GlobalKey zapKey) async { SharedPreferences prefs; try { prefs = await SharedPreferences.getInstance(); } catch (_) { return; } if (prefs.getBool(_pendingKey) != true || !context.mounted) return; final zap = _rectOf(zapKey); if (zap == null) return; // ZAP 이 없는 HOME(무소속) — 가입한 뒤 다시 본다. await prefs.setBool(_pendingKey, false); if (!context.mounted) return; // 하단 탭 MY — 셸이 MY 목적지 아이콘에 [coachMyTabKey] 를 단다. 못 찾으면 ①만 보인다. final my = _rectOf(coachMyTabKey)?.inflate(12); final l10n = context.l10n; await showGeneralDialog( context: context, barrierColor: Colors.transparent, pageBuilder: (_, _, _) => _CoachOverlay( steps: [ (zap.inflate(8), l10n.coachZap), if (my != null) (my, l10n.coachMy), ], ), ); } Rect? _rectOf(GlobalKey key) { final box = key.currentContext?.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return null; return box.localToGlobal(Offset.zero) & box.size; } class _CoachOverlay extends StatefulWidget { const _CoachOverlay({required this.steps}); final List<(Rect, String)> steps; @override State<_CoachOverlay> createState() => _CoachOverlayState(); } class _CoachOverlayState extends State<_CoachOverlay> { int _i = 0; void _next() => _i + 1 < widget.steps.length ? setState(() => _i++) : Navigator.of(context).pop(); @override Widget build(BuildContext context) { final (hole, text) = widget.steps[_i]; final above = hole.center.dy > MediaQuery.sizeOf(context).height / 2; return Stack( children: [ Positioned.fill( child: GestureDetector( onTap: _next, child: CustomPaint(painter: _Dim(hole)), ), ), Positioned( left: 24, right: 24, top: above ? null : hole.bottom + 16, bottom: above ? MediaQuery.sizeOf(context).height - hole.top + 16 : null, child: Material( color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(AppRadius.md), child: Padding( padding: const EdgeInsets.all(AppSpace.s4), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( text, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.onCosmic, ), ), const SizedBox(height: AppSpace.s3), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.onbSkip), ), FilledButton( onPressed: _next, style: FilledButton.styleFrom( backgroundColor: AppColors.cosmicAccent, foregroundColor: AppColors.onCosmicAccent, ), child: Text( _i + 1 < widget.steps.length ? context.l10n.commonNext : context.l10n.commonConfirm, ), ), ], ), ], ), ), ), ), ], ); } } /// 나머지 화면은 어둡게, 가리킨 곳만 둥글게 뚫는다. class _Dim extends CustomPainter { const _Dim(this.hole); final Rect hole; @override void paint(Canvas canvas, Size size) { final path = Path() ..fillType = PathFillType.evenOdd ..addRect(Offset.zero & size) ..addRRect(RRect.fromRectAndRadius(hole, const Radius.circular(16))); canvas.drawPath( path, Paint()..color = Colors.black.withValues(alpha: 0.72), ); } @override bool shouldRepaint(_Dim old) => old.hole != hole; }