런치패드 스타일 화면 전환 + 설정/로그아웃 메뉴 오버레이 + 알약형 알림 추가
- 좌우 슬라이드 대신 맥북 런치패드처럼 확대+페이드로 나타나고 전환 중 뒤 화면이 블러 처리되는 LaunchpadPageRoute 추가, 대시보드의 모든 기능 타일 이동에 적용 - 오른쪽 위 설정 아이콘 + 로그아웃 아이콘 2개를 ">" 버튼 1개로 통합하고, 누르면 로그아웃/설정 메뉴가 런치패드 스타일 오버레이(배경 블러 유지)로 뜨게 변경 - 기능 타일을 누를 때 뜨는 안내 메시지를 스낵바 대신 알약(pill) 모양 알림으로 교체 (AppNotice) - 웹에서는 왼쪽 위, 앱에서는 기존처럼 아래쪽에 표시. 푸시 알림과는 무관. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,23 +76,19 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
}
|
||||
|
||||
void _openPocketCheckIn(BuildContext context) {
|
||||
Navigator.push(
|
||||
pushLaunchpad(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NfcPocketCheckInScreen(
|
||||
(context) => NfcPocketCheckInScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,16 +302,14 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
title: '스마트기기 반출',
|
||||
subtitle: '패드 사용 신청 (시간/목적)',
|
||||
color: AppPalette.ink,
|
||||
onTap: () => Navigator.push(
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DeviceCheckoutRequestScreen(
|
||||
(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,16 +347,14 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
title: '스마트기기 반출 대장',
|
||||
subtitle: '패드 반출 신청 승인/거절',
|
||||
color: AppPalette.ink,
|
||||
onTap: () => Navigator.push(
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DeviceCheckoutLedgerPage(
|
||||
(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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user