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

- 좌우 슬라이드 대신 맥북 런치패드처럼 확대+페이드로 나타나고 전환 중 뒤 화면이
  블러 처리되는 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
+139 -59
View File
@@ -10,9 +10,11 @@ import '../function/student_dashboard_controller.dart';
import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart';
import 'admin_dashboard.dart';
import 'app_notice.dart';
import 'dashboard_settings_page.dart';
import 'device_checkout_ledger_page.dart';
import 'device_checkout_request_screen.dart';
import 'launchpad_transition.dart';
import 'login_screen.dart';
import 'nfc_poccket_checkin_screen.dart';
import 'nfc_tag_writer_screen.dart';
@@ -74,13 +76,11 @@ class _MainDashboardState extends State<MainDashboard> {
}
void _openPocketCheckIn(BuildContext context) {
Navigator.push(
pushLaunchpad(
context,
MaterialPageRoute(
builder: (context) => NfcPocketCheckInScreen(
studentId: _displayId,
studentName: _displayName,
),
(context) => NfcPocketCheckInScreen(
studentId: _displayId,
studentName: _displayName,
),
);
}
@@ -88,9 +88,7 @@ class _MainDashboardState extends State<MainDashboard> {
Future<void> _deleteUser(String userId) async {
final (_, message) = await _controller.deleteUser(userId);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
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 {
final result = await Navigator.push<String>(
final result = await pushLaunchpad<String>(
context,
MaterialPageRoute(builder: (context) => const DashboardSettingsPage()),
(context) => const DashboardSettingsPage(),
);
if (result == 'edit_ui' && kTileEditingEnabled) {
_layout.enterEditMode();
@@ -249,13 +272,11 @@ class _MainDashboardState extends State<MainDashboard> {
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage,
onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context)
: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.'),
),
);
},
: () => AppNotice.show(
context,
'대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
icon: Icons.lock_rounded,
),
isActionButton: widget.isDeviceMatched,
isLoading: _controller.isLoading,
),
@@ -267,11 +288,11 @@ class _MainDashboardState extends State<MainDashboard> {
title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('실시간 학교 상황 페이지로 이동합니다.')),
);
},
onTap: () => AppNotice.show(
context,
'실시간 학교 상황 페이지로 이동합니다.',
icon: Icons.fastfood_rounded,
),
),
));
tiles.add((
@@ -281,13 +302,11 @@ class _MainDashboardState extends State<MainDashboard> {
title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink,
onTap: () => Navigator.push(
onTap: () => pushLaunchpad(
context,
MaterialPageRoute(
builder: (context) => DeviceCheckoutRequestScreen(
studentId: _displayId,
studentName: _displayName,
),
(context) => DeviceCheckoutRequestScreen(
studentId: _displayId,
studentName: _displayName,
),
),
),
@@ -302,11 +321,9 @@ class _MainDashboardState extends State<MainDashboard> {
title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink,
onTap: () => Navigator.push(
onTap: () => pushLaunchpad(
context,
MaterialPageRoute(
builder: (context) => const TeacherAttendancePage(),
),
(context) => const TeacherAttendancePage(),
),
),
));
@@ -317,11 +334,9 @@ class _MainDashboardState extends State<MainDashboard> {
title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink,
onTap: () => Navigator.push(
onTap: () => pushLaunchpad(
context,
MaterialPageRoute(
builder: (context) => const TeacherStudentManagementPage(),
),
(context) => const TeacherStudentManagementPage(),
),
),
));
@@ -332,13 +347,11 @@ class _MainDashboardState extends State<MainDashboard> {
title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink,
onTap: () => Navigator.push(
onTap: () => pushLaunchpad(
context,
MaterialPageRoute(
builder: (context) => DeviceCheckoutLedgerPage(
teacherId: widget.userId,
teacherName: widget.userName,
),
(context) => DeviceCheckoutLedgerPage(
teacherId: widget.userId,
teacherName: widget.userName,
),
),
),
@@ -353,10 +366,8 @@ class _MainDashboardState extends State<MainDashboard> {
title: '서버 DB 제어',
subtitle: '시스템 원격 초기화',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AdminDashboard()),
),
onTap: () =>
pushLaunchpad(context, (context) => const AdminDashboard()),
),
));
tiles.add((
@@ -376,10 +387,8 @@ class _MainDashboardState extends State<MainDashboard> {
title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const NfcTagWriterScreen()),
),
onTap: () =>
pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
),
));
}
@@ -477,14 +486,9 @@ class _MainDashboardState extends State<MainDashboard> {
]
: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: '설정',
onPressed: () => _openSettings(context),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: '로그아웃',
onPressed: () => _logout(context),
icon: const Icon(Icons.chevron_right_rounded),
tooltip: '메뉴',
onPressed: () => _openMenu(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,
),
),
],
),
),
);
}
}