대시보드 배너/타이틀 중앙정렬 + 타일 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:
@@ -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 (_) {
|
||||
// 무시 - 어차피 로그아웃 화면으로는 이동한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user