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, + ), + ), + ], + ), + ), + ); + } +}