가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
|
||||
export '../../core/format.dart' show formatPop;
|
||||
|
||||
import 'coach_mark.dart';
|
||||
import '../planet/planet_history_screen.dart';
|
||||
import '../galaxy/widgets/galaxy_web_view.dart';
|
||||
import '../galaxy/galaxy_search_screen.dart';
|
||||
@@ -121,6 +122,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_snapshot = widget.repository.load();
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
_galaxy = repository.load();
|
||||
// 온보딩을 마친 뒤 첫 HOME — ZAP 이 그려진 다음 Coach Mark(1회)를 띄운다.
|
||||
Future.wait<Object?>([_snapshot, _galaxy!]).then((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) maybeShowCoachMark(context, _zapKey);
|
||||
});
|
||||
}, onError: (_) {});
|
||||
_zap = ZapController(
|
||||
repository,
|
||||
onFailure: (message) {
|
||||
|
||||
Reference in New Issue
Block a user