162 lines
5.3 KiB
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;
|
|
}
|