// ๐Ÿ  ํ†ตํ•ฉ ๋Œ€์‹œ๋ณด๋“œ (UI ์ „์šฉ). ํ•™์ƒ/๊ต์‚ฌ/๊ด€๋ฆฌ์ž๊ฐ€ ์ „๋ถ€ ์ด ํ™”๋ฉด ํ•˜๋‚˜๋ฅผ ๊ณต์œ ํ•˜๊ณ , // ๊ถŒํ•œ(role)์— ๋”ฐ๋ผ ๋ฐฐ๋„ˆ ์ƒ‰์ƒ/๋ฌธ๊ตฌ์™€ ๋ณด์ด๋Š” ํƒ€์ผ๋งŒ ๋‹ฌ๋ผ์ง„๋‹ค. // ํƒ€์ผ ๋ฐฐ์น˜(์œ„์น˜/ํฌ๊ธฐ)๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ๊ณ , ๊ณ„์ •๋ณ„๋กœ ์„œ๋ฒ„์— ์ €์žฅ๋œ๋‹ค // (lib/function/dashboard_layout_controller.dart). ์บ”๋ฒ„์Šค๋Š” ๊ฐ€๋กœ 6์นธร—์„ธ๋กœ 4์นธ, // ํƒ€์ผ ํฌ๊ธฐ๋Š” 1x1~4x4. import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../function/dashboard_layout_controller.dart'; import '../function/session_store.dart'; 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 'board_home_screen.dart'; import 'board_report_screen.dart'; import 'dashboard_settings_page.dart'; import 'device_checkout_ledger_page.dart'; import 'device_checkout_request_screen.dart'; import 'friends_screen.dart'; import 'group_list_screen.dart'; import 'launchpad_transition.dart'; import 'login_screen.dart'; import 'nfc_poccket_checkin_screen.dart'; import 'nfc_tag_writer_screen.dart'; import 'study_calendar_screen.dart'; import 'study_timer_screen.dart'; import 'teacher_attendance_page.dart'; import 'teacher_call_screen.dart'; import 'teacher_location_screen.dart'; import 'teacher_student_management_page.dart'; class MainDashboard extends StatefulWidget { final String? userId; final String? userName; final String role; // 'student' | 'teacher' | 'admin' final bool isDeviceMatched; final int? grade; // ๐ŸŽ“ ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ•™๋…„๋ณ„ ๋žญํ‚น์—์„œ ๋ณธ์ธ ํ•™๋…„์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์“ฐ๊ธฐ ์œ„ํ•จ. const MainDashboard({ super.key, this.userId, this.userName, required this.role, this.isDeviceMatched = true, this.grade, }); @override State createState() => _MainDashboardState(); } class _MainDashboardState extends State { final StudentDashboardController _controller = StudentDashboardController(); late final DashboardLayoutController _layout; // ๐ŸŽฏ ํƒ€์ผ ์ด๋™/ํฌ๊ธฐ์กฐ์ ˆ ๋“œ๋ž˜๊ทธ ์ค‘ ๊ธฐ์ค€๊ฐ’(๋“œ๋ž˜๊ทธ ์‹œ์ž‘ ์‹œ์ ์˜ ์ขŒํ‘œ)์„ ๋‹ด์•„๋‘”๋‹ค. Offset? _dragStartGlobal; TileRect? _dragStartRect; // ๐Ÿ”‘ [๊ถŒํ•œ ํŒ์ •] 2061์€ role์ด 'student'๋กœ ์ €์žฅ๋ผ ์žˆ์ง€๋งŒ ์‹ค์งˆ์ ์œผ๋กœ ์ตœ๊ณ  ๊ถŒํ•œ์„ ๊ฐ€์ง„๋‹ค. bool get _isDeveloper => widget.userId == '2061'; bool get _isAdmin => widget.role == 'admin' || _isDeveloper; bool get _isTeacherOrAbove => widget.role == 'teacher' || _isAdmin; bool get _isStudent => widget.role == 'student'; String get _displayId => widget.userId ?? (widget.role == 'admin' ? '๊ด€๋ฆฌ์ž' : '๊ฐ„ํŽธ์ธ์ฆ'); String get _displayName => widget.userName ?? (widget.role == 'admin' ? '์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž' : '๊ฐ„ํŽธ์ธ์ฆ ์„ ์ƒ'); @override void initState() { super.initState(); _layout = DashboardLayoutController(_displayId); // ๐Ÿ“ฑ [๊ธฐ๋Šฅ ๋ณด๋ฅ˜] kTileEditingEnabled๊ฐ€ false์ธ ๋™์•ˆ์€ ์„œ๋ฒ„์—์„œ ๋ฐฐ์น˜๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ํ•„์š”๊ฐ€ ์—†๋‹ค. if (kTileEditingEnabled) { _layout.load(_tileSpecs().map((t) => t.id).toList()); } } @override void dispose() { _controller.dispose(); _layout.dispose(); super.dispose(); } void _openPocketCheckIn(BuildContext context) { pushLaunchpad( context, (context) => NfcPocketCheckInScreen( studentId: _displayId, studentName: _displayName, ), ); } Future _deleteUser(String userId) async { final (_, message) = await _controller.deleteUser(userId); if (!mounted) return; AppNotice.show(context, message); } void _showDeleteUserDialog(BuildContext context) { final TextEditingController idController = TextEditingController(); showDialog( context: context, builder: (context) { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), title: const Row( children: [ Icon(Icons.warning_amber_rounded, color: Colors.redAccent), SizedBox(width: 10), Text( '๊ณ„์ • ๊ฐ•์ œ ์‚ญ์ œ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'ํ•™์ƒ์˜ ํ•™๋ฒˆ ๋˜๋Š” ๊ต์‚ฌ์˜ ๊ต์ง์› ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.\nDB์—์„œ ํ•ด๋‹น ๊ณ„์ •๊ณผ ํ† ํฐ์ด ์ฆ‰์‹œ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.', style: TextStyle( color: Colors.black54, fontSize: 13, height: 1.4, ), ), const SizedBox(height: 16), TextField( controller: idController, decoration: InputDecoration( labelText: 'ํ•™๋ฒˆ ๋˜๋Š” ๊ต์ง์› ๋ฒˆํ˜ธ', hintText: '์˜ˆ: 201101 ๋˜๋Š” T1001', labelStyle: TextStyle(color: Colors.red[400]), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: Colors.red[400]!, width: 2), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), ), prefixIcon: const Icon(Icons.person_remove_alt_1_rounded), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text( '์ทจ์†Œ', style: TextStyle( color: Colors.grey, fontWeight: FontWeight.bold, ), ), ), ElevatedButton( onPressed: () { final inputId = idController.text.trim(); if (inputId.isNotEmpty) { Navigator.pop(context); _deleteUser(inputId); } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 0, ), child: const Text( '์‚ญ์ œ ์‹คํ–‰', style: TextStyle(fontWeight: FontWeight.bold), ), ), ], ); }, ); } Future _logout(BuildContext context) async { await SessionStore.clear(); if (!context.mounted) return; Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const LoginScreen()), ); } // ๐Ÿš€ ์˜ค๋ฅธ์ชฝ ์œ„ ">" ๋ฒ„ํŠผ โ€” ๋กœ๊ทธ์•„์›ƒ/์„ค์ •์„ ๋Ÿฐ์น˜ํŒจ๋“œ ์Šคํƒ€์ผ ๋ฉ”๋‰ด๋กœ ๋„์šด๋‹ค. 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 pushLaunchpad( context, (context) => const DashboardSettingsPage(), ); if (result == 'edit_ui' && kTileEditingEnabled) { _layout.enterEditMode(); } } // ๐ŸŽจ [์—ญํ• ๋ณ„ ํ…Œ๋งˆ] ๋ฐฐ๋„ˆ ์ƒ‰์ƒ์€ ํŒ”๋ ˆํŠธ๋กœ ํ†ต์ผํ•˜๊ณ , ์ œ๋ชฉ/๋ฌธ๊ตฌ/์•„์ด์ฝ˜๋งŒ ์—ญํ• ์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒŒ. ({String title, String subtitle, Color color, IconData icon}) _theme() { if (_isDeveloper) { return ( title: 'MASTER CONTROL', subtitle: '์ตœ๊ณ  ๊ด€๋ฆฌ ๊ถŒํ•œ ํ™œ์„ฑํ™”๋จ', color: AppPalette.ink, icon: Icons.admin_panel_settings_rounded, ); } else if (widget.role == 'admin') { return ( title: 'ADMIN CONTROL', subtitle: '์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™œ์„ฑํ™”๋จ', color: AppPalette.ink, icon: Icons.admin_panel_settings_rounded, ); } else if (widget.role == 'teacher') { return ( title: 'TEACHER PORTAL', subtitle: '๊ต์ง์› ๋ฒˆํ˜ธ: $_displayId | ๊ต์‚ฌ ๊ถŒํ•œ ํ™œ์„ฑํ™”๋จ', color: AppPalette.ink, icon: Icons.admin_panel_settings_rounded, ); } return ( title: 'STUDENT PORTAL', subtitle: 'ํ•™๋ฒˆ: $_displayId | ์ธ์ฆ ์™„๋ฃŒ', color: AppPalette.ink, icon: Icons.school_rounded, ); } // ๐Ÿงฉ ์—ญํ• ์— ๋”ฐ๋ผ ๋ณด์ด๋Š” ํƒ€์ผ ๋ชฉ๋ก. id๋Š” ์„œ๋ฒ„ ๋ ˆ์ด์•„์›ƒ ์ €์žฅ์˜ ํ‚ค๋กœ ์“ฐ์ด๋ฏ€๋กœ ์ ˆ๋Œ€ ๋ฐ”๋€Œ๋ฉด ์•ˆ ๋œ๋‹ค. List<({String id, Widget child})> _tileSpecs() { final List<({String id, Widget child})> tiles = []; if (_isStudent) { tiles.add(( id: 'nfc_tag', child: _buildModernCard( icon: widget.isDeviceMatched ? Icons.contactless_rounded : Icons.lock_rounded, title: 'NFC ํƒœ๊ทธ', subtitle: widget.isDeviceMatched ? '์ถœ์„ ๋ฐ ํฐ ์ˆ˜๊ฑฐ ์™„๋ฃŒ' : '๋ณธ์ธ ์ธ์ฆ ๊ธฐ๊ธฐ ์ „์šฉ', color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage, onTap: widget.isDeviceMatched ? () => _openPocketCheckIn(context) : () => AppNotice.show( context, '๋Œ€๋ฆฌ ์ถœ์„ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ๋“ฑ๋ก๋œ ๋ณธ์ธ ์Šค๋งˆํŠธํฐ์—์„œ๋งŒ ์ถœ์„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.', icon: Icons.lock_rounded, ), isActionButton: widget.isDeviceMatched, isLoading: _controller.isLoading, ), )); tiles.add(( id: 'school_status', child: _buildModernCard( icon: Icons.fastfood_rounded, title: '์‹ค์‹œ๊ฐ„ ํ•™๊ต ์ƒํ™ฉ', subtitle: '๊ธ‰์‹์‹ค ์ค„ & ๋งค์  ์žฌ๊ณ  ํ™•์ธ', color: AppPalette.ink, onTap: () => AppNotice.show( context, '์‹ค์‹œ๊ฐ„ ํ•™๊ต ์ƒํ™ฉ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.', icon: Icons.fastfood_rounded, ), ), )); tiles.add(( id: 'device_checkout_request', child: _buildModernCard( icon: Icons.tablet_mac_rounded, title: '์Šค๋งˆํŠธ๊ธฐ๊ธฐ ๋ฐ˜์ถœ', subtitle: 'ํŒจ๋“œ ์‚ฌ์šฉ ์‹ ์ฒญ (์‹œ๊ฐ„/๋ชฉ์ )', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => DeviceCheckoutRequestScreen( studentId: _displayId, studentName: _displayName, ), ), ), )); tiles.add(( id: 'study_timer', child: _buildModernCard( icon: Icons.timer_rounded, title: '๋ฐฑํ’ˆํƒ€', subtitle: '๊ณต๋ถ€ ํƒ€์ด๋จธ | ํ•™๋…„๋ณ„ ๊ณต๋ถ€ ๋žญํ‚น', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => StudyTimerScreen( studentId: _displayId, studentName: _displayName, grade: widget.grade, ), ), ), )); tiles.add(( id: 'community_board', child: _buildModernCard( icon: Icons.forum_rounded, title: '๋ฐฑํŒ', subtitle: '์ต๋ช… ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ฒŒ์‹œํŒ', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => BoardHomeScreen(studentId: _displayId), ), ), )); tiles.add(( id: 'teacher_call', child: _buildModernCard( icon: Icons.campaign_rounded, title: '์„ ์ƒ๋‹˜ ํ˜ธ์ถœ', subtitle: '๊ต๋ฌด์‹ค ์œ„์น˜ ํ™•์ธ ๋ฐ ํ˜ธ์ถœ', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => TeacherCallScreen( studentId: _displayId, studentName: _displayName, ), ), ), )); tiles.add(( id: 'friends', child: _buildModernCard( icon: Icons.people_alt_rounded, title: '์นœ๊ตฌ', subtitle: 'ํ•™๋ฒˆ์œผ๋กœ ์นœ๊ตฌ ์ถ”๊ฐ€', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => FriendsScreen(studentId: _displayId, studentName: _displayName), ), ), )); tiles.add(( id: 'group_study', child: _buildModernCard( icon: Icons.groups_rounded, title: '๊ทธ๋ฃน ์Šคํ„ฐ๋””', subtitle: '์นœ๊ตฌ๋ž‘ ๊ทธ๋ฃน ๋งŒ๋“ค์–ด ๊ฐ™์ด ๊ณต๋ถ€', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => GroupListScreen( studentId: _displayId, studentName: _displayName, grade: widget.grade, ), ), ), )); tiles.add(( id: 'study_calendar', child: _buildModernCard( icon: Icons.calendar_month_rounded, title: '์ถœ์„ ๋‹ฌ๋ ฅ', subtitle: '์—ฐ์† ์ถœ์„ ๊ธฐ๋ก ํ™•์ธ', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => StudyCalendarScreen(studentId: _displayId), ), ), )); } if (_isTeacherOrAbove) { tiles.add(( id: 'attendance_check', child: _buildModernCard( icon: Icons.assignment_turned_in_rounded, title: '์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ™•์ธ', subtitle: 'ํ•™์ƒ ์ œ์ถœ ๋กœ๊ทธ ๋ชจ๋‹ˆํ„ฐ๋ง', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => const TeacherAttendancePage(), ), ), )); tiles.add(( id: 'student_management', child: _buildModernCard( icon: Icons.manage_accounts_rounded, title: 'ํ•™์ƒ ๊ณ„์ • ๊ด€๋ฆฌ', subtitle: '๊ณ„์ • ์ถ”๊ฐ€ ๋ฐ ๊ฐ•์ œ ๋ฆฌ์…‹', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => const TeacherStudentManagementPage(), ), ), )); tiles.add(( id: 'device_checkout_ledger', child: _buildModernCard( icon: Icons.tablet_mac_rounded, title: '์Šค๋งˆํŠธ๊ธฐ๊ธฐ ๋ฐ˜์ถœ ๋Œ€์žฅ', subtitle: 'ํŒจ๋“œ ๋ฐ˜์ถœ ์‹ ์ฒญ ์Šน์ธ/๊ฑฐ์ ˆ', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => DeviceCheckoutLedgerPage( teacherId: widget.userId, teacherName: widget.userName, ), ), ), )); tiles.add(( id: 'board_moderation', child: _buildModernCard( icon: Icons.shield_outlined, title: '๋ฐฑํŒ ์‹ ๊ณ  ๊ฒ€ํ† ', subtitle: 'ํ•™์ƒ ๊ฒŒ์‹œํŒ ์‹ ๊ณ  ์ฒ˜๋ฆฌ', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => BoardReportScreen(reviewerId: _displayId), ), ), )); tiles.add(( id: 'study_calendar_admin', child: _buildModernCard( icon: Icons.event_available_rounded, title: '์ถœ์„ ๋‹ฌ๋ ฅ ๊ด€๋ฆฌ', subtitle: '๋‚ ์งœ๋ณ„ ์„ ๋ฌผ/์ด๋ฒคํŠธ ๋“ฑ๋ก', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => StudyCalendarScreen( studentId: _displayId, canManageEvents: true, ), ), ), )); tiles.add(( id: 'teacher_location', child: _buildModernCard( icon: Icons.my_location_rounded, title: '๋‚ด ์œ„์น˜ ์•Œ๋ฆฌ๊ธฐ', subtitle: 'ํ•™์ƒ ํ˜ธ์ถœ ํ™•์ธ ๋ฐ ์œ„์น˜ ๋“ฑ๋ก', color: AppPalette.ink, onTap: () => pushLaunchpad( context, (context) => TeacherLocationScreen(teacherId: _displayId), ), ), )); } if (_isAdmin) { tiles.add(( id: 'admin_db', child: _buildModernCard( icon: Icons.terminal_rounded, title: '์„œ๋ฒ„ DB ์ œ์–ด', subtitle: '์‹œ์Šคํ…œ ์›๊ฒฉ ์ดˆ๊ธฐํ™”', color: AppPalette.ink, onTap: () => pushLaunchpad(context, (context) => const AdminDashboard()), ), )); tiles.add(( id: 'delete_account', child: _buildModernCard( icon: Icons.delete_sweep_rounded, title: '๊ณ„์ • ๊ฐ•์ œ ์‚ญ์ œ', subtitle: 'ํ•™์ƒ ๋ฐ ๊ต์‚ฌ DB ์‚ญ์ œ', color: AppPalette.ink, onTap: () => _showDeleteUserDialog(context), ), )); tiles.add(( id: 'nfc_tag_writer', child: _buildModernCard( icon: Icons.edit_note_rounded, title: 'NFC ํƒœ๊ทธ ์“ฐ๊ธฐ', subtitle: '์ฃผ๋จธ๋‹ˆ ์Šคํ‹ฐ์ปค ์ดˆ๊ธฐ ์„ค์ •', color: AppPalette.ink, onTap: () => pushLaunchpad(context, (context) => const NfcTagWriterScreen()), ), )); } return tiles; } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๐Ÿ–๏ธ ํŽธ์ง‘ ๋ชจ๋“œ: ๋“œ๋ž˜๊ทธ๋กœ ์ด๋™, ๋ชจ์„œ๋ฆฌ ์†์žก์ด๋กœ ํฌ๊ธฐ ์กฐ์ ˆ (1x1~4x4). // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ void _onMovePanStart(String id, DragStartDetails details) { _dragStartGlobal = details.globalPosition; _dragStartRect = _layout.positions[id]; } void _onMovePanUpdate(String id, double cellSize, DragUpdateDetails details) { if (_dragStartGlobal == null || _dragStartRect == null) return; final delta = details.globalPosition - _dragStartGlobal!; final dx = (delta.dx / cellSize).round(); final dy = (delta.dy / cellSize).round(); _layout.tryMove(id, _dragStartRect!.x + dx, _dragStartRect!.y + dy); } void _onMovePanEnd(DragEndDetails details) { _dragStartGlobal = null; _dragStartRect = null; } void _onResizePanStart(String id, DragStartDetails details) { _dragStartGlobal = details.globalPosition; _dragStartRect = _layout.positions[id]; } void _onResizePanUpdate( String id, double cellSize, DragUpdateDetails details, ) { if (_dragStartGlobal == null || _dragStartRect == null) return; final delta = details.globalPosition - _dragStartGlobal!; final dw = (delta.dx / cellSize).round(); final dh = (delta.dy / cellSize).round(); _layout.tryResize(id, _dragStartRect!.w + dw, _dragStartRect!.h + dh); } Future _saveAndExitEdit() async { await _layout.save(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: Listenable.merge([_controller, _layout]), builder: (context, _) { final theme = _theme(); final tiles = _tileSpecs(); final bool isEditing = _layout.isEditing; return Scaffold( backgroundColor: AppPalette.mist, // ๐Ÿชถ ํฐ ์ƒ‰์ƒ ๋ฐฐ๋„ˆ ๋Œ€์‹  ์ž‘์€ ํ”„๋กœํ•„ ์นด๋“œ๋ฅผ ๋ณธ๋ฌธ์— ๋‘๋ฏ€๋กœ, ์•ฑ๋ฐ”๋Š” ">" ๋ฉ”๋‰ด // ๋ฒ„ํŠผ(๋˜๋Š” ํŽธ์ง‘ ์ค‘ "์™„๋ฃŒ" ๋ฒ„ํŠผ)๋งŒ ์˜ฌ๋ ค๋‘๋Š” ํˆฌ๋ช…ํ•œ ์–‡์€ ๋ฐ”๋กœ ๋‘”๋‹ค. appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, actions: isEditing ? [ TextButton.icon( onPressed: _layout.isSaving ? null : _saveAndExitEdit, icon: _layout.isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check_rounded), label: const Text('์™„๋ฃŒ'), ), ] : [ IconButton( icon: const Icon(Icons.chevron_right_rounded), tooltip: '๋ฉ”๋‰ด', onPressed: () => _openMenu(context), ), ], ), body: SingleChildScrollView( physics: isEditing ? const NeverScrollableScrollPhysics() : const AlwaysScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), // ๐Ÿชช ์ž‘์€ ํ”„๋กœํ•„ ์นด๋“œ๋กœ ๋ฐฐ์น˜ โ€” ํ™”๋ฉด ์ „์ฒด๋ฅผ ์ฐจ์ง€ํ•˜๋˜ ๋ฐฐ๋„ˆ ๋Œ€์‹  // ๊ฐ€์šด๋ฐ์— ๋–  ์žˆ๋Š” ์นด๋“œ ํ•˜๋‚˜๋งŒ ๋‘”๋‹ค. Center( child: Container( padding: const EdgeInsets.symmetric( horizontal: 28, vertical: 18, ), decoration: BoxDecoration( color: theme.color, borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: theme.color.withValues(alpha: 0.25), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( theme.title, style: const TextStyle( fontWeight: FontWeight.bold, letterSpacing: 1.2, color: Colors.white, fontSize: 13, ), ), const SizedBox(height: 10), Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: CircleAvatar( radius: 22, backgroundColor: AppPalette.linen, child: Icon( theme.icon, size: 22, color: theme.color, ), ), ), const SizedBox(height: 10), Text( '$_displayName ๋‹˜', textAlign: TextAlign.center, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(height: 3), Text( theme.subtitle, textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.8), fontSize: 12, ), ), ], ), ), ), const SizedBox(height: 28), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 4, height: 18, decoration: BoxDecoration( color: theme.color, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 10), const Text( '์Šค๋งˆํŠธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๋ฉ”๋‰ด', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), ], ), ), if (isEditing) const Padding( padding: EdgeInsets.only(top: 8, left: 24, right: 24), child: Text( 'ํƒ€์ผ์„ ๋“œ๋ž˜๊ทธํ•ด์„œ ์˜ฎ๊ธฐ๊ณ , ์˜ค๋ฅธ์ชฝ ์•„๋ž˜ ์†์žก์ด๋กœ ํฌ๊ธฐ๋ฅผ ๋ฐ”๊ฟ”๋ณด์„ธ์š”.', textAlign: TextAlign.center, style: TextStyle(color: AppPalette.sage, fontSize: 12), ), ), const SizedBox(height: 16), // ๐Ÿ“ฑ [๊ธฐ๋Šฅ ๋ณด๋ฅ˜] kTileEditingEnabled๊ฐ€ ๊บผ์ ธ ์žˆ๋Š” ๋™์•ˆ์€ ํ™”๋ฉด ํฌ๊ธฐ์— ๋งž์ถฐ // ์•Œ์•„์„œ ์ค„์„œ๋Š” ๊ธฐ๋ณธ ๊ทธ๋ฆฌ๋“œ๋ฅผ ์“ฐ๊ณ , ๋“œ๋ž˜๊ทธ ํŽธ์ง‘์šฉ ์บ”๋ฒ„์Šค๋Š” ์“ฐ์ง€ ์•Š๋Š”๋‹ค. kTileEditingEnabled ? (_layout.isLoading ? const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center(child: CircularProgressIndicator()), ) : _buildGridCanvas(tiles, isEditing)) : _buildSimpleGrid(tiles), const SizedBox(height: 32), ], ), ), ); }, ); } // ๐Ÿงฑ ํƒ€์ผ๋งˆ๋‹ค ํฌ๊ธฐ(๊ฐ€๋กœ/์„ธ๋กœ ์นธ ์ˆ˜)๊ฐ€ ๋‹ค๋ฅธ ๋งค์ด์Šจ๋ฆฌ(๋ฒฝ๋Œ์Œ“๊ธฐ) ๊ทธ๋ฆฌ๋“œ. // ํ™”๋ฉด ํญ์— ๋งž์ถฐ ์—ด ๊ฐœ์ˆ˜๊ฐ€ ์ž๋™์œผ๋กœ ๋ฐ”๋€Œ๊ธฐ ๋•Œ๋ฌธ์— ํฐ์—์„œ๋„ ์•ˆ ๊นจ์ง„๋‹ค. // id๋Š” ์„œ๋ฒ„ ๋ ˆ์ด์•„์›ƒ ์ €์žฅ์šฉ ํ‚ค์™€ ๊ฐ™์ง€๋งŒ, ์—ฌ๊ธฐ์„œ๋Š” ๊ทธ๋ƒฅ "์ด ํƒ€์ผ์ด ๋ช‡ ์นธ์งœ๋ฆฌ์ธ์ง€" ์ฐพ๋Š” ๋ฐ๋งŒ ์“ด๋‹ค. static const Map _tileSpans = { 'nfc_tag': (1, 2), 'attendance_check': (2, 1), 'admin_db': (2, 1), }; (int, int) _spanFor(String id) => _tileSpans[id] ?? (1, 1); Widget _buildSimpleGrid(List<({String id, Widget child})> tiles) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: LayoutBuilder( builder: (context, constraints) { final double width = constraints.maxWidth; // ๐Ÿ”ฒ ์ •์‚ฌ๊ฐํ˜• 1์นธ ๊ธฐ์ค€ 4ร—3 ๋ฐฐ์น˜ โ€” ํ™”๋ฉด์ด ๋„“์–ด๋„ 6์นธ๊นŒ์ง€ ๋Š˜๋ฆฌ์ง€ ์•Š๊ณ  4์นธ์—์„œ ๋ฉˆ์ถ˜๋‹ค. final int crossAxisCount = width < 420 ? 2 : width < 700 ? 3 : 4; // ๐Ÿ“ 1์นธ(1x1) ํฌ๊ธฐ ๋ชฉํ‘œ. ํ™”๋ฉด์ด ๊ทธ๋ณด๋‹ค ๋„“์œผ๋ฉด ๊ฐ€์šด๋ฐ๋กœ ๋ชจ์•„์„œ ์—ฌ๋ฐฑ์„ // ์ฃผ๊ณ , ์ข์œผ๋ฉด(ํฐ) ํ™”๋ฉด ํญ์— ๋งž์ถ˜๋‹ค. const double targetCellSize = 226; const double gap = 12; final double idealGridWidth = crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap; final double gridWidth = width < idealGridWidth ? width : idealGridWidth; return Center( child: SizedBox( width: gridWidth, child: StaggeredGrid.count( crossAxisCount: crossAxisCount, mainAxisSpacing: gap, crossAxisSpacing: gap, children: [ for (final tile in tiles) StaggeredGridTile.count( crossAxisCellCount: _spanFor( tile.id, ).$1.clamp(1, crossAxisCount), mainAxisCellCount: _spanFor(tile.id).$2, child: tile.child, ), ], ), ), ); }, ), ); } Widget _buildGridCanvas( List<({String id, Widget child})> tiles, bool isEditing, ) { const double gap = 10; const double maxCanvasWidth = 720; return LayoutBuilder( builder: (context, constraints) { final double canvasWidth = constraints.maxWidth < maxCanvasWidth ? constraints.maxWidth - 24 : maxCanvasWidth; final double cellSize = canvasWidth / kGridCols; final double canvasHeight = cellSize * kGridRows; final List positioned = []; for (final tile in tiles) { final rect = _layout.positions[tile.id]; if (rect == null) continue; // ์บ”๋ฒ„์Šค๊ฐ€ ๊ฝ‰ ์ฐจ์„œ ์ž๋ฆฌ๋ฅผ ๋ชป ์ฐพ์€ ํƒ€์ผ final double left = rect.x * cellSize; final double top = rect.y * cellSize; final double w = rect.w * cellSize - gap; final double h = rect.h * cellSize - gap; positioned.add( Positioned( key: ValueKey(tile.id), left: left, top: top, width: w, height: h, child: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: IgnorePointer( ignoring: isEditing, child: tile.child, ), ), if (isEditing) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onPanStart: (d) => _onMovePanStart(tile.id, d), onPanUpdate: (d) => _onMovePanUpdate(tile.id, cellSize, d), onPanEnd: _onMovePanEnd, ), ), if (isEditing) Positioned( right: 2, bottom: 2, child: GestureDetector( onPanStart: (d) => _onResizePanStart(tile.id, d), onPanUpdate: (d) => _onResizePanUpdate(tile.id, cellSize, d), onPanEnd: _onMovePanEnd, child: Container( width: 26, height: 26, decoration: BoxDecoration( color: AppPalette.ink, borderRadius: BorderRadius.circular(8), border: Border.all( color: AppPalette.paper, width: 2, ), ), child: const Icon( Icons.open_in_full_rounded, size: 14, color: AppPalette.paper, ), ), ), ), ], ), ), ); } return Center( child: SizedBox( width: canvasWidth, height: canvasHeight, child: Stack(children: positioned), ), ); }, ); } // ๐Ÿ’Ž [์นด๋“œ ๋””์ž์ธ ์œ„์ ฏ] ํƒ€์ผ ํฌ๊ธฐ๊ฐ€ ์ปค์ง€๋ฉด ์•„์ด์ฝ˜/๊ธ€์ž๋„ ๋น„๋ก€ํ•ด์„œ ์ปค์ง„๋‹ค. Widget _buildModernCard({ required IconData icon, required String title, required String subtitle, required Color color, required VoidCallback onTap, bool isActionButton = false, bool isLoading = false, }) { return LayoutBuilder( builder: (context, constraints) { // ๐Ÿ“ ํƒ€์ผ์˜ ์‹ค์ œ ํ”ฝ์…€ ํฌ๊ธฐ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋ฐฐ์œจ์„ ๊ณ„์‚ฐ (1x1 ๊ธฐ์ค€ โ‰ˆ 1.0). // ๊ฐ€๋กœ/์„ธ๋กœ ์ค‘ ๋” ์ž‘์€ ์ชฝ์„ ๊ธฐ์ค€์œผ๋กœ ์‚ผ์•„์•ผ, ๊ฐ€๋กœ๋กœ ๋„“์ง€๋งŒ ๋‚ฎ์€ ํƒ€์ผ์—์„œ // ๋ฐฐ์œจ์ด ๊ณผํ•˜๊ฒŒ ์ปค์ ธ ๋‚ด์šฉ์ด ๋„˜์น˜๋Š” ๊ฑธ ๋ง‰์„ ์ˆ˜ ์žˆ๋‹ค. final double referenceSize = constraints.hasBoundedHeight ? constraints.maxWidth < constraints.maxHeight ? constraints.maxWidth : constraints.maxHeight : constraints.maxWidth; final double scale = (referenceSize / 110).clamp(1.0, 3.4); final double iconBoxPadding = 8 + 6 * (scale - 1); final double iconSize = 22 + 10 * (scale - 1); final double iconRadius = 12 + 6 * (scale - 1); final double cardPadding = 10 + 8 * (scale - 1); final double cardRadius = 16 + 6 * (scale - 1); final double titleFontSize = (12 + 3 * (scale - 1)).clamp(12, 20); final double subtitleFontSize = (10 + 2 * (scale - 1)).clamp(10, 16); return InkWell( onTap: isLoading ? null : onTap, borderRadius: BorderRadius.circular(cardRadius), child: Ink( padding: EdgeInsets.all(cardPadding), decoration: BoxDecoration( color: AppPalette.paper, borderRadius: BorderRadius.circular(cardRadius), border: Border.all(color: AppPalette.sage, width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( padding: EdgeInsets.all(iconBoxPadding), decoration: BoxDecoration( color: AppPalette.linen, borderRadius: BorderRadius.circular(iconRadius), ), child: Icon(icon, color: color, size: iconSize), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( title, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: titleFontSize, fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), ), if (isLoading) const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) else if (isActionButton) Icon( Icons.touch_app_rounded, size: 14, color: color.withValues(alpha: 0.5), ), ], ), SizedBox(height: 1 + 2 * (scale - 1)), Text( subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: subtitleFontSize, color: AppPalette.ink.withValues(alpha: 0.55), height: 1.15, ), ), ], ), ], ), ), ); }, ); } } // ๐Ÿš€ ์˜ค๋ฅธ์ชฝ ์œ„ ">" ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ๋œจ๋Š” ๋Ÿฐ์น˜ํŒจ๋“œ ์Šคํƒ€์ผ ๋ฉ”๋‰ด ์นด๋“œ. 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, ), ), ], ), ), ); } }