From 7495ece0c8d763d738ad603e21206ad8e755a538 Mon Sep 17 00:00:00 2001 From: sihoo Date: Mon, 7 Sep 2026 22:18:16 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88/=ED=83=80=EC=9D=B4=ED=8B=80=20=EC=A4=91?= =?UTF-8?q?=EC=95=99=EC=A0=95=EB=A0=AC=20+=20=ED=83=80=EC=9D=BC=20UI=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91(=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99/=ED=81=AC=EA=B8=B0=EC=A1=B0=EC=A0=88)=20+=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름/부제, "스마트 관리 시스템 메뉴" 제목을 중앙 정렬 - 타일을 1x1~4x4 크기로 자유 배치할 수 있는 편집 모드 추가 (6x4 캔버스, 삼성 One UI 위젯 편집 방식) - 타일 배치는 계정별로 서버에 저장/복원 (dashboard_layout_controller.dart) - 설정 화면(톱니바퀴 아이콘) > "UI 편집" 진입점 추가 - shared_preferences로 로그인 유지 기능 추가 (비밀번호는 저장하지 않음) Co-Authored-By: Claude Sonnet 5 --- lib/debug_pocket_main.dart | 5 +- lib/function/dashboard_layout_controller.dart | 158 ++++ lib/function/session_store.dart | 66 ++ lib/main_client_app.dart | 53 +- lib/models/dashboard_tile_layout.dart | 55 ++ lib/ui/dashboard_settings_page.dart | 49 ++ lib/ui/login_screen.dart | 23 +- lib/ui/main_dashboard.dart | 742 ++++++++++++------ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 82 +- pubspec.yaml | 1 + 11 files changed, 967 insertions(+), 269 deletions(-) create mode 100644 lib/function/dashboard_layout_controller.dart create mode 100644 lib/function/session_store.dart create mode 100644 lib/models/dashboard_tile_layout.dart create mode 100644 lib/ui/dashboard_settings_page.dart diff --git a/lib/debug_pocket_main.dart b/lib/debug_pocket_main.dart index 19b6374..ed96750 100644 --- a/lib/debug_pocket_main.dart +++ b/lib/debug_pocket_main.dart @@ -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: "디버그테스트"), ); } } diff --git a/lib/function/dashboard_layout_controller.dart b/lib/function/dashboard_layout_controller.dart new file mode 100644 index 0000000..da91f27 --- /dev/null +++ b/lib/function/dashboard_layout_controller.dart @@ -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 _positions = {}; + bool _isLoading = true; + bool _isSaving = false; + bool _isEditing = false; + + Map 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 load(List visibleIds) async { + _isLoading = true; + notifyListeners(); + + Map 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 _fillDefaults( + Map existing, + List ids, + ) { + final Map 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 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 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 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(); + } + } +} diff --git a/lib/function/session_store.dart b/lib/function/session_store.dart new file mode 100644 index 0000000..42b61a0 --- /dev/null +++ b/lib/function/session_store.dart @@ -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 toJson() => { + 'userId': userId, + 'userName': userName, + 'role': role, + 'isDeviceMatched': isDeviceMatched, + }; + + factory SavedSession.fromJson(Map 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 save(SavedSession session) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, jsonEncode(session.toJson())); + } catch (_) { + // 저장 실패해도 로그인 자체는 계속 진행 (로그인 유지만 안 될 뿐). + } + } + + static Future 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 clear() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } catch (_) { + // 무시 - 어차피 로그아웃 화면으로는 이동한다. + } + } +} diff --git a/lib/main_client_app.dart b/lib/main_client_app.dart index f4091a7..40df03c 100644 --- a/lib/main_client_app.dart +++ b/lib/main_client_app.dart @@ -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 _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(); + } +} diff --git a/lib/models/dashboard_tile_layout.dart b/lib/models/dashboard_tile_layout.dart new file mode 100644 index 0000000..484e643 --- /dev/null +++ b/lib/models/dashboard_tile_layout.dart @@ -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 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 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; +} diff --git a/lib/ui/dashboard_settings_page.dart b/lib/ui/dashboard_settings_page.dart new file mode 100644 index 0000000..c8f05da --- /dev/null +++ b/lib/ui/dashboard_settings_page.dart @@ -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'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index bc39ae7..7c79629 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -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 { _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 { } // 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달 - void _navigateBasedOnRole( + Future _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( diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index 621b0cd..dd2708b 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -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 { 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 { 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 { ); } + Future _logout(BuildContext context) async { + await SessionStore.clear(); + if (!context.mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const LoginScreen()), + ); + } + + Future _openSettings(BuildContext context) async { + final result = await Navigator.push( + 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 { ); } + // 🧩 역할에 따라 보이는 타일 목록. 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 _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 { 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 { bottomRight: Radius.circular(32), ), ), - child: Row( + child: Column( children: [ Container( padding: const EdgeInsets.all(4), @@ -255,27 +519,24 @@ class _MainDashboardState extends State { 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 { 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 { ], ), ), + 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 { ); } - // 💎 [카드 디자인 위젯] 학생/교사 대시보드에서 쓰던 것과 동일 — 이제 한 곳에만 존재. + Widget _buildGridCanvas( + List<({String id, Widget child})> tiles, + bool isEditing, + ) { + const double gap = 10; + const double maxCanvasWidth = 720; + + return LayoutBuilder( + builder: (context, constraints) { + final double canvasWidth = constraints.maxWidth < maxCanvasWidth + ? constraints.maxWidth - 24 + : maxCanvasWidth; + final double cellSize = canvasWidth / kGridCols; + final double canvasHeight = cellSize * kGridRows; + + final List positioned = []; + for (final tile in tiles) { + final rect = _layout.positions[tile.id]; + if (rect == null) continue; // 캔버스가 꽉 차서 자리를 못 찾은 타일 + + final double left = rect.x * cellSize; + final double top = rect.y * cellSize; + final double w = rect.w * cellSize - gap; + final double h = rect.h * cellSize - gap; + + positioned.add( + Positioned( + key: ValueKey(tile.id), + left: left, + top: top, + width: w, + height: h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: isEditing, + child: tile.child, + ), + ), + if (isEditing) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (d) => _onMovePanStart(tile.id, d), + onPanUpdate: (d) => + _onMovePanUpdate(tile.id, cellSize, d), + onPanEnd: _onMovePanEnd, + ), + ), + if (isEditing) + Positioned( + right: 2, + bottom: 2, + child: GestureDetector( + onPanStart: (d) => _onResizePanStart(tile.id, d), + onPanUpdate: (d) => + _onResizePanUpdate(tile.id, cellSize, d), + onPanEnd: _onMovePanEnd, + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppPalette.paper, + width: 2, + ), + ), + child: const Icon( + Icons.open_in_full_rounded, + size: 14, + color: AppPalette.paper, + ), + ), + ), + ), + ], + ), + ), + ); + } + + return Center( + child: SizedBox( + width: canvasWidth, + height: canvasHeight, + child: Stack(children: positioned), + ), + ); + }, + ); + } + + // 💎 [카드 디자인 위젯] 타일 크기가 커지면 아이콘/글자도 비례해서 커진다. Widget _buildModernCard({ required IconData icon, required String title, @@ -496,72 +698,90 @@ class _MainDashboardState extends State { 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, - ), - ), ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 99d9634..44fd51b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_picker_darwin import firebase_core import firebase_messaging import flutter_local_notifications +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) @@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index cd5ebe6..9cd88bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,6 +472,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" permission_handler: dependency: "direct main" description: @@ -544,6 +568,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -703,4 +783,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.41.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index f654dfc..b9ccebe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: nfc_manager: ^4.2.1 nfc_manager_ndef: ^1.1.0 http: ^1.1.0 + shared_preferences: ^2.3.3 # 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요) light: ^5.0.0