Files
school-attendance/lib/ui/main_dashboard.dart
T
sihooandClaude Sonnet 5 f5aa832ad9 그룹 상세: 멤버를 타일 그리드로, 공부 시간순 정렬, 백품타 바로가기 추가
가로로 길게 늘어지던 멤버 목록을 학생 계정관리 화면 같은 타일
그리드로 바꾸고, 오늘 공부 시간이 많은 순으로 정렬(백엔드).
화면 폭도 다른 화면들처럼 최대 560으로 제한. "백품타 바로가기"
버튼으로 그룹 화면에서 바로 공부 타이머로 이동 가능.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:58:21 +00:00

1091 lines
38 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 '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<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),
),
),
));
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<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,
),
),
],
),
),
);
}
}