대시보드 레이아웃을 목업처럼 재구성 (작은 프로필 카드 + 매이슨리 타일 그리드)

- 화면 전체를 차지하던 색상 배너를 작은 둥근 프로필 카드로 교체하고 가운데에 배치
- 앱바는 ">" 메뉴 버튼만 올라가는 투명한 얇은 바로 변경
- 균일한 그리드 대신 flutter_staggered_grid_view로 타일마다 크기가 다른
  매이슨리 레이아웃 적용 (NFC 태그는 세로로 길게, 실시간 출석/서버 DB 제어는
  가로로 넓게) - 화면 폭에 따라 열 개수가 자동으로 바뀌어 폰에서도 안 깨짐
- 가로로 넓지만 낮은 타일에서 내용이 넘치지 않도록 카드 배율 계산을
  가로/세로 중 작은 쪽 기준으로 변경

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:45:38 +09:00
co-authored by Claude Sonnet 5
parent f885719674
commit 79938898a0
3 changed files with 133 additions and 83 deletions
+124 -83
View File
@@ -4,6 +4,7 @@
// (lib/function/dashboard_layout_controller.dart). 캔버스는 가로 6칸×세로 4칸,
// 타일 크기는 1x1~4x4.
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../function/dashboard_layout_controller.dart';
import '../function/session_store.dart';
import '../function/student_dashboard_controller.dart';
@@ -449,17 +450,11 @@ class _MainDashboardState extends State<MainDashboard> {
return Scaffold(
backgroundColor: AppPalette.mist,
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
appBar: AppBar(
title: Text(
theme.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
centerTitle: true,
backgroundColor: theme.color,
foregroundColor: Colors.white,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
actions: isEditing
? [
@@ -469,19 +464,10 @@ class _MainDashboardState extends State<MainDashboard> {
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(
Icons.check_rounded,
color: Colors.white,
),
label: const Text(
'완료',
style: TextStyle(color: Colors.white),
),
: const Icon(Icons.check_rounded),
label: const Text('완료'),
),
]
: [
@@ -499,56 +485,79 @@ class _MainDashboardState extends State<MainDashboard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
decoration: BoxDecoration(
color: theme.color,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
const SizedBox(height: 8),
// 🪪 작은 프로필 카드로 배치 — 화면 전체를 차지하던 배너 대신
// 가운데에 떠 있는 카드 하나만 둔다.
Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 18,
),
decoration: BoxDecoration(
color: theme.color,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: theme.color.withValues(alpha: 0.25),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
theme.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.white,
fontSize: 13,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 22,
backgroundColor: AppPalette.linen,
child: Icon(
theme.icon,
size: 22,
color: theme.color,
),
),
),
const SizedBox(height: 10),
Text(
'$_displayName 님',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 3),
Text(
theme.subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 12,
),
),
],
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 30,
backgroundColor: AppPalette.linen,
child: Icon(theme.icon, size: 32, color: theme.color),
),
),
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,
),
),
],
),
),
const SizedBox(height: 32),
const SizedBox(height: 28),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
@@ -603,22 +612,47 @@ class _MainDashboardState extends State<MainDashboard> {
);
}
// 📱 [기능 보류] kTileEditingEnabled가 꺼져 있을 때 쓰는 기본 그리드.
// 화면 폭에 맞춰 열 개수가 자동으로 줄어들기 때문에 폰에서도 안 깨진다.
// 🧱 타일마다 크기(가로/세로 칸 수)가 다른 매이슨리(벽돌쌓기) 그리드.
// 화면 폭에 맞춰 열 개수가 자동으로 바뀌기 때문에 폰에서도 안 깨진다.
// id는 서버 레이아웃 저장용 키와 같지만, 여기서는 그냥 "이 타일이 몇 칸짜리인지" 찾는 데만 쓴다.
static const Map<String, (int w, int h)> _tileSpans = {
'nfc_tag': (1, 2),
'attendance_check': (2, 1),
'admin_db': (2, 1),
};
(int, int) _spanFor(String id) => _tileSpans[id] ?? (1, 1);
Widget _buildSimpleGrid(List<({String id, Widget child})> tiles) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 160,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: tiles.length,
itemBuilder: (context, index) => tiles[index].child,
child: LayoutBuilder(
builder: (context, constraints) {
final double width = constraints.maxWidth;
final int crossAxisCount = width < 420
? 2
: width < 700
? 3
: width < 1000
? 4
: 6;
return StaggeredGrid.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: [
for (final tile in tiles)
StaggeredGridTile.count(
crossAxisCellCount: _spanFor(
tile.id,
).$1.clamp(1, crossAxisCount),
mainAxisCellCount: _spanFor(tile.id).$2,
child: tile.child,
),
],
);
},
),
);
}
@@ -732,7 +766,14 @@ class _MainDashboardState extends State<MainDashboard> {
return LayoutBuilder(
builder: (context, constraints) {
// 📏 타일의 실제 픽셀 크기를 기준으로 배율을 계산 (1x1 기준 ≈ 1.0).
final double scale = (constraints.maxWidth / 110).clamp(1.0, 3.4);
// 가로/세로 중 더 작은 쪽을 기준으로 삼아야, 가로로 넓지만 낮은 타일에서
// 배율이 과하게 커져 내용이 넘치는 걸 막을 수 있다.
final double referenceSize = constraints.hasBoundedHeight
? constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight
: constraints.maxWidth;
final double scale = (referenceSize / 110).clamp(1.0, 3.4);
final double iconBoxPadding = 6 + 6 * (scale - 1);
final double iconSize = 16 + 10 * (scale - 1);
final double iconRadius = 10 + 6 * (scale - 1);