대시보드 배너/타이틀 중앙정렬 + 타일 UI 편집(드래그 이동/크기조절) + 로그인 유지 기능 추가

- 이름/부제, "스마트 관리 시스템 메뉴" 제목을 중앙 정렬
- 타일을 1x1~4x4 크기로 자유 배치할 수 있는 편집 모드 추가 (6x4 캔버스, 삼성 One UI 위젯 편집 방식)
- 타일 배치는 계정별로 서버에 저장/복원 (dashboard_layout_controller.dart)
- 설정 화면(톱니바퀴 아이콘) > "UI 편집" 진입점 추가
- shared_preferences로 로그인 유지 기능 추가 (비밀번호는 저장하지 않음)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 22:18:16 +09:00
co-authored by Claude Sonnet 5
parent 83c148186d
commit 7495ece0c8
11 changed files with 967 additions and 269 deletions
+49
View File
@@ -0,0 +1,49 @@
// ⚙️ 대시보드 설정 화면 (UI 전용). 지금은 "UI 편집" 진입점 하나만 있다.
// "UI 편집"을 누르면 이 화면을 pop('edit_ui')로 닫고, 대시보드가 그 결과를 받아 편집 모드로 들어간다.
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
class DashboardSettingsPage extends StatelessWidget {
const DashboardSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text('설정'),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
elevation: 0,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
leading: const Icon(
Icons.grid_view_rounded,
color: AppPalette.ink,
),
title: const Text(
'UI 편집',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text('타일을 드래그해서 옮기고 크기를 바꿀 수 있어요.'),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.pop(context, 'edit_ui'),
),
),
],
),
);
}
}
+21 -2
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../config.dart' show schoolName;
import '../function/login_controller.dart';
import '../function/session_store.dart';
import '../theme/app_palette.dart';
import 'main_dashboard.dart';
import 'teacher_register_screen.dart';
@@ -43,6 +44,15 @@ class _LoginScreenState extends State<LoginScreen> {
_showErrorDialog(result.errorMessage!);
break;
case LoginOutcome.masterSuccess:
await SessionStore.save(
SavedSession(
userId: result.studentId!,
userName: result.name!,
role: 'student',
isDeviceMatched: true,
),
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
@@ -178,12 +188,21 @@ class _LoginScreenState extends State<LoginScreen> {
}
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
void _navigateBasedOnRole(
Future<void> _navigateBasedOnRole(
String role,
String studentId,
String name,
bool isDeviceMatched,
) {
) async {
await SessionStore.save(
SavedSession(
userId: studentId,
userName: name,
role: role,
isDeviceMatched: isDeviceMatched,
),
);
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
+481 -261
View File
@@ -1,11 +1,16 @@
// 🏠 통합 대시보드 (UI 전용). 학생/교사/관리자가 전부 이 화면 하나를 공유하고,
// 권한(role)에 따라 배너 색상/문구와 보이는 타일만 달라진다.
// 레이아웃(그리드, 카드 디자인)을 한 곳에서 고치면 모든 역할에 동시에 적용된다.
// 계정 강제 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다.
// 타일 배치(위치/크기)는 사용자가 직접 편집할 수 있고, 계정별로 서버에 저장된다
// (lib/function/dashboard_layout_controller.dart). 캔버스는 가로 6칸×세로 4칸,
// 타일 크기는 1x1~4x4.
import 'package:flutter/material.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 'dashboard_settings_page.dart';
import 'device_checkout_ledger_page.dart';
import 'device_checkout_request_screen.dart';
import 'login_screen.dart';
@@ -34,6 +39,11 @@ class MainDashboard extends StatefulWidget {
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';
@@ -46,9 +56,17 @@ class _MainDashboardState extends State<MainDashboard> {
String get _displayName =>
widget.userName ?? (widget.role == 'admin' ? '시스템 관리자' : '간편인증 선생');
@override
void initState() {
super.initState();
_layout = DashboardLayoutController(_displayId);
_layout.load(_tileSpecs().map((t) => t.id).toList());
}
@override
void dispose() {
_controller.dispose();
_layout.dispose();
super.dispose();
}
@@ -161,6 +179,25 @@ class _MainDashboardState extends State<MainDashboard> {
);
}
Future<void> _logout(BuildContext context) async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
Future<void> _openSettings(BuildContext context) async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => const DashboardSettingsPage()),
);
if (result == 'edit_ui') {
_layout.enterEditMode();
}
}
// 🎨 [역할별 테마] 배너 색상은 팔레트로 통일하고, 제목/문구/아이콘만 역할에 따라 다르게.
({String title, String subtitle, Color color, IconData icon}) _theme() {
if (_isDeveloper) {
@@ -193,12 +230,210 @@ class _MainDashboardState extends State<MainDashboard> {
);
}
// 🧩 역할에 따라 보이는 타일 목록. 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)
: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.'),
),
);
},
isActionButton: widget.isDeviceMatched,
isLoading: _controller.isLoading,
),
));
tiles.add((
id: 'school_status',
child: _buildModernCard(
icon: Icons.fastfood_rounded,
title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('실시간 학교 상황 페이지로 이동합니다.')),
);
},
),
));
tiles.add((
id: 'device_checkout_request',
child: _buildModernCard(
icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeviceCheckoutRequestScreen(
studentId: _displayId,
studentName: _displayName,
),
),
),
),
));
}
if (_isTeacherOrAbove) {
tiles.add((
id: 'attendance_check',
child: _buildModernCard(
icon: Icons.assignment_turned_in_rounded,
title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TeacherAttendancePage(),
),
),
),
));
tiles.add((
id: 'student_management',
child: _buildModernCard(
icon: Icons.manage_accounts_rounded,
title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TeacherStudentManagementPage(),
),
),
),
));
tiles.add((
id: 'device_checkout_ledger',
child: _buildModernCard(
icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeviceCheckoutLedgerPage(
teacherId: widget.userId,
teacherName: widget.userName,
),
),
),
),
));
}
if (_isAdmin) {
tiles.add((
id: 'admin_db',
child: _buildModernCard(
icon: Icons.terminal_rounded,
title: '서버 DB 제어',
subtitle: '시스템 원격 초기화',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (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: () => Navigator.push(
context,
MaterialPageRoute(builder: (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: _controller,
listenable: Listenable.merge([_controller, _layout]),
builder: (context, _) {
final theme = _theme();
final tiles = _tileSpecs();
final bool isEditing = _layout.isEditing;
return Scaffold(
backgroundColor: AppPalette.mist,
@@ -214,17 +449,46 @@ class _MainDashboardState extends State<MainDashboard> {
backgroundColor: theme.color,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
actions: isEditing
? [
TextButton.icon(
onPressed: _layout.isSaving ? null : _saveAndExitEdit,
icon: _layout.isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.check_rounded,
color: Colors.white,
),
label: const Text(
'완료',
style: TextStyle(color: Colors.white),
),
),
]
: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: '설정',
onPressed: () => _openSettings(context),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: '로그아웃',
onPressed: () => _logout(context),
),
],
),
body: SingleChildScrollView(
physics: isEditing
? const NeverScrollableScrollPhysics()
: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -241,7 +505,7 @@ class _MainDashboardState extends State<MainDashboard> {
bottomRight: Radius.circular(32),
),
),
child: Row(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(4),
@@ -255,27 +519,24 @@ class _MainDashboardState extends State<MainDashboard> {
child: Icon(theme.icon, size: 32, color: theme.color),
),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$_displayName 님',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
theme.subtitle,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
const SizedBox(height: 14),
Text(
'$_displayName 님',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
theme.subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
@@ -284,6 +545,7 @@ class _MainDashboardState extends State<MainDashboard> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 4,
@@ -305,178 +567,22 @@ class _MainDashboardState extends State<MainDashboard> {
],
),
),
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),
LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 한 줄에 5개씩, 폰(좁은 화면)은 기존 2개 그대로.
final bool isWide = constraints.maxWidth >= 800;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 1300 : 700,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: isWide ? 5 : 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.95,
children: [
// 👨‍🎓 학생 전용 -----------------------------------
if (_isStudent)
_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)
: () {
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
content: Text(
'대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
),
),
);
},
isActionButton: widget.isDeviceMatched,
isLoading: _controller.isLoading,
),
if (_isStudent)
_buildModernCard(
icon: Icons.fastfood_rounded,
title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('실시간 학교 상황 페이지로 이동합니다.'),
),
);
},
),
if (_isStudent)
_buildModernCard(
icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
DeviceCheckoutRequestScreen(
studentId: _displayId,
studentName: _displayName,
),
),
),
),
// 👨‍🏫 교사 이상 -----------------------------------
if (_isTeacherOrAbove)
_buildModernCard(
icon: Icons.assignment_turned_in_rounded,
title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherAttendancePage(),
),
),
),
if (_isTeacherOrAbove)
_buildModernCard(
icon: Icons.manage_accounts_rounded,
title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherStudentManagementPage(),
),
),
),
if (_isTeacherOrAbove)
_buildModernCard(
icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
DeviceCheckoutLedgerPage(
teacherId: widget.userId,
teacherName: widget.userName,
),
),
),
),
// 🛠️ 관리자 전용 -----------------------------------
if (_isAdmin)
_buildModernCard(
icon: Icons.terminal_rounded,
title: '서버 DB 제어',
subtitle: '시스템 원격 초기화',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const AdminDashboard(),
),
),
),
if (_isAdmin)
_buildModernCard(
icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제',
color: AppPalette.ink,
onTap: () => _showDeleteUserDialog(context),
),
if (_isAdmin)
_buildModernCard(
icon: Icons.edit_note_rounded,
title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정',
color: AppPalette.ink,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const NfcTagWriterScreen(),
),
),
),
],
),
),
),
);
},
),
_layout.isLoading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
)
: _buildGridCanvas(tiles, isEditing),
const SizedBox(height: 32),
],
),
@@ -486,7 +592,103 @@ class _MainDashboardState extends State<MainDashboard> {
);
}
// 💎 [카드 디자인 위젯] 학생/교사 대시보드에서 쓰던 것과 동일 — 이제 한 곳에만 존재.
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,
@@ -496,72 +698,90 @@ class _MainDashboardState extends State<MainDashboard> {
bool isActionButton = false,
bool isLoading = false,
}) {
return InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(24),
child: Ink(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppPalette.linen,
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 28),
return LayoutBuilder(
builder: (context, constraints) {
// 📏 타일의 실제 픽셀 크기를 기준으로 배율을 계산 (1x1 기준 ≈ 1.0).
final double scale = (constraints.maxWidth / 110).clamp(1.0, 3.4);
final double iconBoxPadding = 6 + 6 * (scale - 1);
final double iconSize = 16 + 10 * (scale - 1);
final double iconRadius = 10 + 6 * (scale - 1);
final double cardPadding = 8 + 8 * (scale - 1);
final double cardRadius = 16 + 6 * (scale - 1);
final double titleFontSize = (10 + 3 * (scale - 1)).clamp(10, 18);
final double subtitleFontSize = (8 + 2 * (scale - 1)).clamp(8, 14);
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),
),
Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
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,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (isActionButton)
Icon(
Icons.touch_app_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: AppPalette.ink.withValues(alpha: 0.55),
height: 1.2,
),
),
],
),
],
),
),
),
);
},
);
}
}