런치패드 스타일 화면 전환 + 설정/로그아웃 메뉴 오버레이 + 알약형 알림 추가
- 좌우 슬라이드 대신 맥북 런치패드처럼 확대+페이드로 나타나고 전환 중 뒤 화면이 블러 처리되는 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 '../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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user