From f88571967473a100d20a4cb073d7cc43c23a123b Mon Sep 17 00:00:00 2001 From: sihoo Date: Mon, 7 Sep 2026 23:25:23 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9F=B0=EC=B9=98=ED=8C=A8=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20=EC=84=A4=EC=A0=95/=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=A9=94=EB=89=B4=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20+=20=EC=95=8C=EC=95=BD=ED=98=95=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌우 슬라이드 대신 맥북 런치패드처럼 확대+페이드로 나타나고 전환 중 뒤 화면이 블러 처리되는 LaunchpadPageRoute 추가, 대시보드의 모든 기능 타일 이동에 적용 - 오른쪽 위 설정 아이콘 + 로그아웃 아이콘 2개를 ">" 버튼 1개로 통합하고, 누르면 로그아웃/설정 메뉴가 런치패드 스타일 오버레이(배경 블러 유지)로 뜨게 변경 - 기능 타일을 누를 때 뜨는 안내 메시지를 스낵바 대신 알약(pill) 모양 알림으로 교체 (AppNotice) - 웹에서는 왼쪽 위, 앱에서는 기존처럼 아래쪽에 표시. 푸시 알림과는 무관. Co-Authored-By: Claude Sonnet 5 --- lib/ui/app_notice.dart | 162 +++++++++++++++++++++++++ lib/ui/launchpad_transition.dart | 115 ++++++++++++++++++ lib/ui/main_dashboard.dart | 198 ++++++++++++++++++++++--------- 3 files changed, 416 insertions(+), 59 deletions(-) create mode 100644 lib/ui/app_notice.dart create mode 100644 lib/ui/launchpad_transition.dart diff --git a/lib/ui/app_notice.dart b/lib/ui/app_notice.dart new file mode 100644 index 0000000..cb5c138 --- /dev/null +++ b/lib/ui/app_notice.dart @@ -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( + 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)), + ); + } +} diff --git a/lib/ui/launchpad_transition.dart b/lib/ui/launchpad_transition.dart new file mode 100644 index 0000000..d841468 --- /dev/null +++ b/lib/ui/launchpad_transition.dart @@ -0,0 +1,115 @@ +// 🚀 맥북 런치패드처럼 "앞에서 확대되며 나타나고, 뒤는 블러 처리되는" 전환 효과. +// 기존의 좌우 슬라이드 전환을 대체한다. 두 가지 쓰임새가 있다: +// 1) launchpadPageRoute — 전체 화면 이동(다른 기능 타일 진입)용. 전환 중에만 블러가 보이고, +// 화면이 다 뜨면 원래 화면(대시보드)은 완전히 가려진다. +// 2) showLaunchpadMenu — 설정/로그아웃 같은 작은 메뉴 오버레이용. 메뉴가 떠 있는 동안 +// 뒤에 있는 대시보드가 계속 뿌옇게 보인다. 바깥을 탭하면 닫힌다. +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class LaunchpadPageRoute extends PageRouteBuilder { + 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 pushLaunchpad(BuildContext context, WidgetBuilder builder) { + return Navigator.push(context, LaunchpadPageRoute(builder: builder)); +} + +/// 설정/로그아웃 같은 작은 메뉴를 런치패드 스타일(확대+페이드, 배경은 계속 블러 유지)로 띄운다. +/// 바깥을 탭하면 닫힌다. builder는 화면 전체를 채우는 Align 등으로 메뉴 위치를 직접 잡아야 한다. +Future showLaunchpadMenu({ + required BuildContext context, + required WidgetBuilder builder, +}) { + return showGeneralDialog( + 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, + ), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index b552c55..967b729 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -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 { } 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 { Future _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 { ); } + // 🚀 오른쪽 위 ">" 버튼 — 로그아웃/설정을 런치패드 스타일 메뉴로 띄운다. + Future _openMenu(BuildContext context) async { + final action = await showLaunchpadMenu( + 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 _openSettings(BuildContext context) async { - final result = await Navigator.push( + final result = await pushLaunchpad( context, - MaterialPageRoute(builder: (context) => const DashboardSettingsPage()), + (context) => const DashboardSettingsPage(), ); if (result == 'edit_ui' && kTileEditingEnabled) { _layout.enterEditMode(); @@ -249,13 +272,11 @@ class _MainDashboardState extends State { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { ] : [ 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 { ); } } + +// 🚀 오른쪽 위 ">" 버튼을 누르면 뜨는 런치패드 스타일 메뉴 카드. +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, + ), + ), + ], + ), + ), + ); + } +}