런치패드 스타일 화면 전환 + 설정/로그아웃 메뉴 오버레이 + 알약형 알림 추가

- 좌우 슬라이드 대신 맥북 런치패드처럼 확대+페이드로 나타나고 전환 중 뒤 화면이
  블러 처리되는 LaunchpadPageRoute 추가, 대시보드의 모든 기능 타일 이동에 적용
- 오른쪽 위 설정 아이콘 + 로그아웃 아이콘 2개를 ">" 버튼 1개로 통합하고,
  누르면 로그아웃/설정 메뉴가 런치패드 스타일 오버레이(배경 블러 유지)로 뜨게 변경
- 기능 타일을 누를 때 뜨는 안내 메시지를 스낵바 대신 알약(pill) 모양 알림으로 교체
  (AppNotice) - 웹에서는 왼쪽 위, 앱에서는 기존처럼 아래쪽에 표시. 푸시 알림과는 무관.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:25:23 +09:00
co-authored by Claude Sonnet 5
parent c94f51a033
commit f885719674
3 changed files with 416 additions and 59 deletions
+162
View File
@@ -0,0 +1,162 @@
// 🔔 스낵바 대신 쓰는 알약(pill) 모양 알림. 기능 타일을 누를 때 뜨는
// "OO로 이동합니다" 같은 짧은 안내용. 푸시 알림(FCM)과는 무관 — 그건 그대로 동작한다.
// 웹에서는 왼쪽 위(알림창 자리)에, 폰 앱에서는 기존 스낵바처럼 아래쪽에 뜬다.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
class AppNotice {
static final List<_QueuedNotice> _queue = [];
static bool _showing = false;
static void show(BuildContext context, String message, {IconData? icon}) {
_queue.add(_QueuedNotice(context, message, icon));
_tryShowNext();
}
static void _tryShowNext() {
if (_showing || _queue.isEmpty) return;
final next = _queue.removeAt(0);
if (!next.context.mounted) {
_tryShowNext();
return;
}
_showing = true;
final overlay = Overlay.of(next.context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _NoticeBanner(
message: next.message,
icon: next.icon,
onDone: () {
entry.remove();
_showing = false;
_tryShowNext();
},
),
);
overlay.insert(entry);
}
}
class _QueuedNotice {
final BuildContext context;
final String message;
final IconData? icon;
_QueuedNotice(this.context, this.message, this.icon);
}
class _NoticeBanner extends StatefulWidget {
final String message;
final IconData? icon;
final VoidCallback onDone;
const _NoticeBanner({
required this.message,
required this.icon,
required this.onDone,
});
@override
State<_NoticeBanner> createState() => _NoticeBannerState();
}
class _NoticeBannerState extends State<_NoticeBanner>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 180),
);
_controller.forward();
Future.delayed(const Duration(milliseconds: 2600), () async {
if (!mounted) return;
await _controller.reverse();
widget.onDone();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final media = MediaQuery.of(context);
final pill = Material(
color: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(widget.icon, color: AppPalette.paper, size: 18),
const SizedBox(width: 8),
],
Flexible(
child: Text(
widget.message,
style: const TextStyle(
color: AppPalette.paper,
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
final animatedPill = FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: kIsWeb ? const Offset(0, -0.3) : const Offset(0, 0.3),
end: Offset.zero,
).animate(curved),
child: pill,
),
);
if (kIsWeb) {
return Positioned(
top: media.padding.top + 16,
left: 16,
child: IgnorePointer(child: animatedPill),
);
}
return Positioned(
left: 16,
right: 16,
bottom: media.padding.bottom + 24,
child: IgnorePointer(child: Center(child: animatedPill)),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
// 🚀 맥북 런치패드처럼 "앞에서 확대되며 나타나고, 뒤는 블러 처리되는" 전환 효과.
// 기존의 좌우 슬라이드 전환을 대체한다. 두 가지 쓰임새가 있다:
// 1) launchpadPageRoute — 전체 화면 이동(다른 기능 타일 진입)용. 전환 중에만 블러가 보이고,
// 화면이 다 뜨면 원래 화면(대시보드)은 완전히 가려진다.
// 2) showLaunchpadMenu — 설정/로그아웃 같은 작은 메뉴 오버레이용. 메뉴가 떠 있는 동안
// 뒤에 있는 대시보드가 계속 뿌옇게 보인다. 바깥을 탭하면 닫힌다.
import 'dart:ui';
import 'package:flutter/material.dart';
class LaunchpadPageRoute<T> extends PageRouteBuilder<T> {
LaunchpadPageRoute({required WidgetBuilder builder})
: super(
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 240),
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return AnimatedBuilder(
animation: curved,
child: child,
builder: (context, child) {
final t = curved.value;
return Stack(
children: [
// 뒤에 있던 화면(대시보드)을 전환하는 동안만 뿌옇게 보여준다.
Positioned.fill(
child: IgnorePointer(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 22 * t,
sigmaY: 22 * t,
),
child: Container(
color: Colors.black.withValues(alpha: 0.12 * t),
),
),
),
),
Opacity(
opacity: t,
child: Transform.scale(
scale: 0.94 + 0.06 * t,
child: child,
),
),
],
);
},
);
},
);
}
/// 화면 전체 이동에 런치패드 전환 효과를 적용한다. MaterialPageRoute 대신 이걸 쓰면 됨.
Future<T?> pushLaunchpad<T>(BuildContext context, WidgetBuilder builder) {
return Navigator.push<T>(context, LaunchpadPageRoute<T>(builder: builder));
}
/// 설정/로그아웃 같은 작은 메뉴를 런치패드 스타일(확대+페이드, 배경은 계속 블러 유지)로 띄운다.
/// 바깥을 탭하면 닫힌다. builder는 화면 전체를 채우는 Align 등으로 메뉴 위치를 직접 잡아야 한다.
Future<T?> showLaunchpadMenu<T>({
required BuildContext context,
required WidgetBuilder builder,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'menu',
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 260),
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return AnimatedBuilder(
animation: curved,
child: child,
builder: (context, child) {
final t = curved.value;
return Stack(
children: [
// 메뉴가 떠 있는 동안 계속 뿌옇게 — 기능 화면이랑 헷갈리지 않게 진하게.
Positioned.fill(
child: IgnorePointer(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 32 * t, sigmaY: 32 * t),
child: Container(
color: Colors.black.withValues(alpha: 0.28 * t),
),
),
),
),
Opacity(
opacity: t,
child: Transform.scale(
alignment: Alignment.topRight,
scale: 0.85 + 0.15 * t,
child: child,
),
),
],
);
},
);
},
);
}
+132 -52
View File
@@ -10,9 +10,11 @@ import '../function/student_dashboard_controller.dart';
import '../models/dashboard_tile_layout.dart'; import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'admin_dashboard.dart'; import 'admin_dashboard.dart';
import 'app_notice.dart';
import 'dashboard_settings_page.dart'; import 'dashboard_settings_page.dart';
import 'device_checkout_ledger_page.dart'; import 'device_checkout_ledger_page.dart';
import 'device_checkout_request_screen.dart'; import 'device_checkout_request_screen.dart';
import 'launchpad_transition.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'nfc_poccket_checkin_screen.dart'; import 'nfc_poccket_checkin_screen.dart';
import 'nfc_tag_writer_screen.dart'; import 'nfc_tag_writer_screen.dart';
@@ -74,23 +76,19 @@ class _MainDashboardState extends State<MainDashboard> {
} }
void _openPocketCheckIn(BuildContext context) { void _openPocketCheckIn(BuildContext context) {
Navigator.push( pushLaunchpad(
context, context,
MaterialPageRoute( (context) => NfcPocketCheckInScreen(
builder: (context) => NfcPocketCheckInScreen(
studentId: _displayId, studentId: _displayId,
studentName: _displayName, studentName: _displayName,
), ),
),
); );
} }
Future<void> _deleteUser(String userId) async { Future<void> _deleteUser(String userId) async {
final (_, message) = await _controller.deleteUser(userId); final (_, message) = await _controller.deleteUser(userId);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
void _showDeleteUserDialog(BuildContext context) { void _showDeleteUserDialog(BuildContext context) {
@@ -191,10 +189,35 @@ class _MainDashboardState extends State<MainDashboard> {
); );
} }
// 🚀 오른쪽 위 ">" 버튼 — 로그아웃/설정을 런치패드 스타일 메뉴로 띄운다.
Future<void> _openMenu(BuildContext context) async {
final action = await showLaunchpadMenu<String>(
context: context,
builder: (context) => Align(
alignment: Alignment.topRight,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 8, right: 12),
child: _DashboardMenuCard(
onLogout: () => Navigator.pop(context, 'logout'),
onSettings: () => Navigator.pop(context, 'settings'),
),
),
),
),
);
if (!context.mounted) return;
if (action == 'logout') {
_logout(context);
} else if (action == 'settings') {
_openSettings(context);
}
}
Future<void> _openSettings(BuildContext context) async { Future<void> _openSettings(BuildContext context) async {
final result = await Navigator.push<String>( final result = await pushLaunchpad<String>(
context, context,
MaterialPageRoute(builder: (context) => const DashboardSettingsPage()), (context) => const DashboardSettingsPage(),
); );
if (result == 'edit_ui' && kTileEditingEnabled) { if (result == 'edit_ui' && kTileEditingEnabled) {
_layout.enterEditMode(); _layout.enterEditMode();
@@ -249,13 +272,11 @@ class _MainDashboardState extends State<MainDashboard> {
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage, color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage,
onTap: widget.isDeviceMatched onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context) ? () => _openPocketCheckIn(context)
: () { : () => AppNotice.show(
ScaffoldMessenger.of(context).showSnackBar( context,
const SnackBar( '대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
content: Text('대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.'), icon: Icons.lock_rounded,
), ),
);
},
isActionButton: widget.isDeviceMatched, isActionButton: widget.isDeviceMatched,
isLoading: _controller.isLoading, isLoading: _controller.isLoading,
), ),
@@ -267,11 +288,11 @@ class _MainDashboardState extends State<MainDashboard> {
title: '실시간 학교 상황', title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인', subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () { onTap: () => AppNotice.show(
ScaffoldMessenger.of(context).showSnackBar( context,
const SnackBar(content: Text('실시간 학교 상황 페이지로 이동합니다.')), '실시간 학교 상황 페이지로 이동합니다.',
); icon: Icons.fastfood_rounded,
}, ),
), ),
)); ));
tiles.add(( tiles.add((
@@ -281,16 +302,14 @@ class _MainDashboardState extends State<MainDashboard> {
title: '스마트기기 반출', title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)', subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () => pushLaunchpad(
context, context,
MaterialPageRoute( (context) => DeviceCheckoutRequestScreen(
builder: (context) => DeviceCheckoutRequestScreen(
studentId: _displayId, studentId: _displayId,
studentName: _displayName, studentName: _displayName,
), ),
), ),
), ),
),
)); ));
} }
@@ -302,11 +321,9 @@ class _MainDashboardState extends State<MainDashboard> {
title: '실시간 출석 확인', title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링', subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () => pushLaunchpad(
context, context,
MaterialPageRoute( (context) => const TeacherAttendancePage(),
builder: (context) => const TeacherAttendancePage(),
),
), ),
), ),
)); ));
@@ -317,11 +334,9 @@ class _MainDashboardState extends State<MainDashboard> {
title: '학생 계정 관리', title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋', subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () => pushLaunchpad(
context, context,
MaterialPageRoute( (context) => const TeacherStudentManagementPage(),
builder: (context) => const TeacherStudentManagementPage(),
),
), ),
), ),
)); ));
@@ -332,16 +347,14 @@ class _MainDashboardState extends State<MainDashboard> {
title: '스마트기기 반출 대장', title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절', subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () => pushLaunchpad(
context, context,
MaterialPageRoute( (context) => DeviceCheckoutLedgerPage(
builder: (context) => DeviceCheckoutLedgerPage(
teacherId: widget.userId, teacherId: widget.userId,
teacherName: widget.userName, teacherName: widget.userName,
), ),
), ),
), ),
),
)); ));
} }
@@ -353,10 +366,8 @@ class _MainDashboardState extends State<MainDashboard> {
title: '서버 DB 제어', title: '서버 DB 제어',
subtitle: '시스템 원격 초기화', subtitle: '시스템 원격 초기화',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () =>
context, pushLaunchpad(context, (context) => const AdminDashboard()),
MaterialPageRoute(builder: (context) => const AdminDashboard()),
),
), ),
)); ));
tiles.add(( tiles.add((
@@ -376,10 +387,8 @@ class _MainDashboardState extends State<MainDashboard> {
title: 'NFC 태그 쓰기', title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정', subtitle: '주머니 스티커 초기 설정',
color: AppPalette.ink, color: AppPalette.ink,
onTap: () => Navigator.push( onTap: () =>
context, pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
MaterialPageRoute(builder: (context) => const NfcTagWriterScreen()),
),
), ),
)); ));
} }
@@ -477,14 +486,9 @@ class _MainDashboardState extends State<MainDashboard> {
] ]
: [ : [
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined), icon: const Icon(Icons.chevron_right_rounded),
tooltip: '설정', tooltip: '메뉴',
onPressed: () => _openSettings(context), onPressed: () => _openMenu(context),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: '로그아웃',
onPressed: () => _logout(context),
), ),
], ],
), ),
@@ -812,3 +816,79 @@ class _MainDashboardState extends State<MainDashboard> {
); );
} }
} }
// 🚀 오른쪽 위 ">" 버튼을 누르면 뜨는 런치패드 스타일 메뉴 카드.
class _DashboardMenuCard extends StatelessWidget {
final VoidCallback onLogout;
final VoidCallback onSettings;
const _DashboardMenuCard({required this.onLogout, required this.onSettings});
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.paper,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 180),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MenuRow(
icon: Icons.logout_rounded,
label: '로그아웃',
onTap: onLogout,
),
const Divider(height: 1, color: AppPalette.sage),
_MenuRow(
icon: Icons.settings_outlined,
label: '설정',
onTap: onSettings,
),
],
),
),
),
);
}
}
class _MenuRow extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _MenuRow({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: AppPalette.ink),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppPalette.ink,
),
),
],
),
),
);
}
}