디시인사이드식 고정 카테고리 게시판 + 에타 스타일 익명(글 안에서만 유효한 익명 번호) 댓글. 신고는 자동 삭제 없이 운영자(교사/관리자) 검토 후 처리하도록 별도 신고 검토 화면을 대시보드에 추가. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
997 lines
35 KiB
Dart
997 lines
35 KiB
Dart
// 🏠 통합 대시보드 (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 'launchpad_transition.dart';
|
|
import 'login_screen.dart';
|
|
import 'nfc_poccket_checkin_screen.dart';
|
|
import 'nfc_tag_writer_screen.dart';
|
|
import 'study_timer_screen.dart';
|
|
import 'teacher_attendance_page.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<MainDashboard> createState() => _MainDashboardState();
|
|
}
|
|
|
|
class _MainDashboardState extends State<MainDashboard> {
|
|
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<void> _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<void> _logout(BuildContext context) async {
|
|
await SessionStore.clear();
|
|
if (!context.mounted) return;
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
|
);
|
|
}
|
|
|
|
// 🚀 오른쪽 위 ">" 버튼 — 로그아웃/설정을 런치패드 스타일 메뉴로 띄운다.
|
|
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 pushLaunchpad<String>(
|
|
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),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
|
|
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<void> _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<String, (int w, int h)> _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<Widget> 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|