Files
fanuzu/app/lib/features/home/coach_mark.dart

162 lines
5.3 KiB
Dart

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<void> armCoachMark() async {
try {
await (await SharedPreferences.getInstance()).setBool(_pendingKey, true);
} catch (_) {}
}
Future<void> 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<void>(
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;
}