대시보드 배너/타이틀 중앙정렬 + 타일 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:
@@ -13,10 +13,7 @@ class DebugPocketApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: NfcPocketCheckInScreen(
|
||||
studentId: "2061",
|
||||
studentName: "디버그테스트",
|
||||
),
|
||||
home: NfcPocketCheckInScreen(studentId: "2061", studentName: "디버그테스트"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 🧩 대시보드 타일 배치(위치/크기)의 서버 통신/상태 담당 컨트롤러.
|
||||
// 캔버스는 6열×4행 고정, 타일 크기는 1x1~4x4. 계정별로 서버(계정=userId)에 저장한다.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../models/dashboard_tile_layout.dart';
|
||||
|
||||
class DashboardLayoutController extends ChangeNotifier {
|
||||
final String userId;
|
||||
DashboardLayoutController(this.userId);
|
||||
|
||||
Map<String, TileRect> _positions = {};
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
bool _isEditing = false;
|
||||
|
||||
Map<String, TileRect> get positions => _positions;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isSaving => _isSaving;
|
||||
bool get isEditing => _isEditing;
|
||||
|
||||
void enterEditMode() {
|
||||
_isEditing = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 편집 중이던 변경을 서버에 저장하지 않고 그대로 유지한 채 편집 모드만 끈다.
|
||||
/// (저장은 [save]를 명시적으로 불러야 함 — 완료 버튼이 save+exit를 같이 호출한다.)
|
||||
void exitEditMode() {
|
||||
_isEditing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 서버에 저장된 배치를 불러오고, 현재 화면에 보여야 할 타일 id 목록([visibleIds]) 중
|
||||
/// 저장된 값이 없는 새 타일은 빈 칸을 찾아 1x1로 자동 배치한다.
|
||||
Future<void> load(List<String> visibleIds) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
Map<String, TileRect> loaded = {};
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/dashboard/layout?userId=$userId'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final rawLayout = data['layout'];
|
||||
if (rawLayout is List) {
|
||||
for (final item in rawLayout) {
|
||||
final id = item['id']?.toString();
|
||||
if (id == null) continue;
|
||||
final rect = TileRect.fromJson(item);
|
||||
if (rect.isWithinCanvas) loaded[id] = rect;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 새로고침 실패는 조용히 무시하고 기본 배치로 대체한다.
|
||||
}
|
||||
|
||||
_positions = _fillDefaults(loaded, visibleIds);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Map<String, TileRect> _fillDefaults(
|
||||
Map<String, TileRect> existing,
|
||||
List<String> ids,
|
||||
) {
|
||||
final Map<String, TileRect> result = {};
|
||||
for (final id in ids) {
|
||||
final saved = existing[id];
|
||||
if (saved != null && !_collidesWithAny(result, saved, null)) {
|
||||
result[id] = saved;
|
||||
}
|
||||
}
|
||||
for (final id in ids) {
|
||||
if (result.containsKey(id)) continue;
|
||||
final spot = _findFirstFit(result, 1, 1);
|
||||
if (spot != null) {
|
||||
result[id] = TileRect(x: spot.$1, y: spot.$2, w: 1, h: 1);
|
||||
}
|
||||
// 캔버스(6x4=24칸)가 꽉 찼으면 자리를 못 찾을 수도 있음 — 그 타일은 화면에 안 보이게 됨.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _collidesWithAny(
|
||||
Map<String, TileRect> placed,
|
||||
TileRect candidate,
|
||||
String? ignoreId,
|
||||
) {
|
||||
for (final entry in placed.entries) {
|
||||
if (entry.key == ignoreId) continue;
|
||||
if (entry.value.overlaps(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
(int, int)? _findFirstFit(Map<String, TileRect> placed, int w, int h) {
|
||||
for (int y = 0; y <= kGridRows - h; y++) {
|
||||
for (int x = 0; x <= kGridCols - w; x++) {
|
||||
final candidate = TileRect(x: x, y: y, w: w, h: h);
|
||||
if (!_collidesWithAny(placed, candidate, null)) return (x, y);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _fits(String movingId, TileRect candidate) {
|
||||
if (!candidate.isWithinCanvas) return false;
|
||||
return !_collidesWithAny(_positions, candidate, movingId);
|
||||
}
|
||||
|
||||
/// 타일을 (newX, newY)로 옮긴다. 다른 타일과 겹치거나 캔버스를 벗어나면 무시하고 false 반환.
|
||||
bool tryMove(String id, int newX, int newY) {
|
||||
final cur = _positions[id];
|
||||
if (cur == null) return false;
|
||||
final candidate = cur.copyWith(x: newX, y: newY);
|
||||
if (!_fits(id, candidate)) return false;
|
||||
_positions = {..._positions, id: candidate};
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 타일 크기를 (newW, newH)로 바꾼다. 다른 타일과 겹치거나 4x4/캔버스를 벗어나면 무시.
|
||||
bool tryResize(String id, int newW, int newH) {
|
||||
final cur = _positions[id];
|
||||
if (cur == null) return false;
|
||||
final candidate = cur.copyWith(w: newW, h: newH);
|
||||
if (!_fits(id, candidate)) return false;
|
||||
_positions = {..._positions, id: candidate};
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
_isSaving = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final layout = _positions.entries
|
||||
.map((e) => e.value.toJson(e.key))
|
||||
.toList();
|
||||
await http.post(
|
||||
Uri.parse('$baseUrl/api/dashboard/layout'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"userId": userId, "layout": layout}),
|
||||
);
|
||||
} catch (_) {
|
||||
// 저장 실패해도 로컬 배치는 유지 — 다음에 다시 시도 가능.
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
_isEditing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 🔐 "로그인 유지" 기능. 로그인 성공 시 계정 정보를 기기에 저장해두고,
|
||||
// 다음 실행 때 로그인 화면을 건너뛰고 바로 대시보드로 들어가게 한다.
|
||||
// (서버에 별도 세션/토큰 개념이 없어서, 비밀번호는 저장하지 않고 신원 정보만 저장한다.)
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SavedSession {
|
||||
final String userId;
|
||||
final String userName;
|
||||
final String role;
|
||||
final bool isDeviceMatched;
|
||||
|
||||
const SavedSession({
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.role,
|
||||
required this.isDeviceMatched,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'role': role,
|
||||
'isDeviceMatched': isDeviceMatched,
|
||||
};
|
||||
|
||||
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
|
||||
userId: json['userId'] as String,
|
||||
userName: json['userName'] as String,
|
||||
role: json['role'] as String,
|
||||
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class SessionStore {
|
||||
static const _key = 'saved_session_v1';
|
||||
|
||||
static Future<void> save(SavedSession session) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key, jsonEncode(session.toJson()));
|
||||
} catch (_) {
|
||||
// 저장 실패해도 로그인 자체는 계속 진행 (로그인 유지만 안 될 뿐).
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SavedSession?> load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null) return null;
|
||||
return SavedSession.fromJson(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key);
|
||||
} catch (_) {
|
||||
// 무시 - 어차피 로그아웃 화면으로는 이동한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'config.dart';
|
||||
import 'function/session_store.dart';
|
||||
import 'pocket_watch_service.dart';
|
||||
import 'theme/app_palette.dart';
|
||||
import 'ui/login_screen.dart';
|
||||
import 'ui/main_dashboard.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -44,7 +46,56 @@ class SchoolAttendanceApp extends StatelessWidget {
|
||||
onSurface: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
|
||||
home: const _StartupGate(), // 🚪 저장된 로그인이 있으면 대시보드로, 없으면 로그인 화면으로.
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔐 "로그인 유지" 진입점. 기기에 저장된 세션이 있는지 확인하는 동안 잠깐 로딩을 보여주고,
|
||||
/// 있으면 로그인 화면 없이 바로 대시보드로, 없으면 로그인 화면으로 보낸다.
|
||||
class _StartupGate extends StatefulWidget {
|
||||
const _StartupGate();
|
||||
|
||||
@override
|
||||
State<_StartupGate> createState() => _StartupGateState();
|
||||
}
|
||||
|
||||
class _StartupGateState extends State<_StartupGate> {
|
||||
SavedSession? _session;
|
||||
bool _checked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkSession();
|
||||
}
|
||||
|
||||
Future<void> _checkSession() async {
|
||||
final session = await SessionStore.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_session = session;
|
||||
_checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_checked) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final session = _session;
|
||||
if (session != null) {
|
||||
return MainDashboard(
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
role: session.role,
|
||||
isDeviceMatched: session.isDeviceMatched,
|
||||
);
|
||||
}
|
||||
return const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 🧩 대시보드 타일의 격자 위치/크기(1x1 ~ 4x4)를 표현하는 값 객체.
|
||||
// 전체 배치 캔버스는 가로 6칸 × 세로 4칸으로 고정한다.
|
||||
const int kGridCols = 6;
|
||||
const int kGridRows = 4;
|
||||
const int kMinTileSize = 1;
|
||||
const int kMaxTileSize = 4;
|
||||
|
||||
class TileRect {
|
||||
final int x;
|
||||
final int y;
|
||||
final int w;
|
||||
final int h;
|
||||
|
||||
const TileRect({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.w,
|
||||
required this.h,
|
||||
});
|
||||
|
||||
factory TileRect.fromJson(Map<String, dynamic> json) => TileRect(
|
||||
x: (json['x'] as num).toInt(),
|
||||
y: (json['y'] as num).toInt(),
|
||||
w: (json['w'] as num).toInt(),
|
||||
h: (json['h'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson(String id) => {
|
||||
'id': id,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'w': w,
|
||||
'h': h,
|
||||
};
|
||||
|
||||
TileRect copyWith({int? x, int? y, int? w, int? h}) =>
|
||||
TileRect(x: x ?? this.x, y: y ?? this.y, w: w ?? this.w, h: h ?? this.h);
|
||||
|
||||
bool overlaps(TileRect other) {
|
||||
return x < other.x + other.w &&
|
||||
x + w > other.x &&
|
||||
y < other.y + other.h &&
|
||||
y + h > other.y;
|
||||
}
|
||||
|
||||
bool get isWithinCanvas =>
|
||||
x >= 0 &&
|
||||
y >= 0 &&
|
||||
x + w <= kGridCols &&
|
||||
y + h <= kGridRows &&
|
||||
w >= kMinTileSize &&
|
||||
h >= kMinTileSize &&
|
||||
w <= kMaxTileSize &&
|
||||
h <= kMaxTileSize;
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user