From 86a8f7b9d159dd23c5922f359f71cf339bf171dc Mon Sep 17 00:00:00 2001 From: sihoo Date: Fri, 18 Sep 2026 03:57:10 +0000 Subject: [PATCH] =?UTF-8?q?=EC=84=A0=EC=83=9D=EB=8B=98=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B6=9C=EC=84=9D=20=ED=98=84=ED=99=A9=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=8C=A8=EB=B0=80=EB=A6=AC=EB=A3=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목을 AppBar 대신 본문 상단 알약(TitlePill)으로 옮기고, 필 모양 통계/선택 버튼과 그림자 있는 둥근 타일(원형 아이콘 배지 + 코너 배지) 스타일을 출석 화면에서 그대로 가져왔다. Co-Authored-By: Claude Sonnet 5 --- lib/ui/teacher_call_screen.dart | 313 ++++++++++++++++++++-------- lib/ui/teacher_location_screen.dart | 130 +++++++++--- 2 files changed, 319 insertions(+), 124 deletions(-) diff --git a/lib/ui/teacher_call_screen.dart b/lib/ui/teacher_call_screen.dart index 429db5c..aee8ecd 100644 --- a/lib/ui/teacher_call_screen.dart +++ b/lib/ui/teacher_call_screen.dart @@ -1,4 +1,5 @@ -// 📍 선생님 호출 화면(학생용) (UI 전용). 선생님을 골라 방문 목적과 함께 호출한다. +// 📍 선생님 호출 화면(학생용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩 +// (제목 알약 + 필 모양 통계/필터 + 그림자 있는 둥근 타일)을 따른다. // 서버 통신/상태는 lib/function/teacher_call_controller.dart가 담당한다. import 'package:flutter/material.dart'; import '../data/teacher_call_schedule.dart'; @@ -75,32 +76,44 @@ class _TeacherCallScreenState extends State { return ListenableBuilder( listenable: _controller, builder: (context, _) { + final teachers = _controller.teachers; + final inClassCount = teachers + .where((t) => _controller.isInClassNow(t['name'] ?? '')) + .length; + return Scaffold( backgroundColor: AppPalette.mist, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, - centerTitle: true, - title: const TitlePill('선생님 호출'), ), body: _controller.isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: _controller.refresh, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), children: [ - if (_controller.ranking.isNotEmpty) _buildRanking(), + const Center(child: TitlePill('선생님 호출')), const SizedBox(height: 16), - const Text( - '방문 목적', - style: TextStyle(fontWeight: FontWeight.bold), + Row( + children: [ + Expanded(child: _statPill('전체 선생님', teachers.length)), + const SizedBox(width: 8), + Expanded(child: _statPill('지금 수업 중', inClassCount)), + ], ), - const SizedBox(height: 8), + if (_controller.ranking.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildRanking(), + ], + const SizedBox(height: 20), + _sectionHeader('방문 목적'), + const SizedBox(height: 10), _buildPurposeChips(), if (_useCustomPurpose) ...[ - const SizedBox(height: 8), + const SizedBox(height: 10), TextField( controller: _customPurposeController, maxLength: 40, @@ -109,18 +122,15 @@ class _TeacherCallScreenState extends State { filled: true, fillColor: AppPalette.paper, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: AppPalette.sage), ), ), ), ], - const SizedBox(height: 16), - const Text( - '선생님 선택', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), + const SizedBox(height: 20), + _sectionHeader('선생님 목록', count: teachers.length), + const SizedBox(height: 10), _buildTeacherGrid(), const SizedBox(height: 20), SizedBox( @@ -133,7 +143,7 @@ class _TeacherCallScreenState extends State { foregroundColor: AppPalette.paper, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(28), ), ), onPressed: _controller.isCalling ? null : _call, @@ -157,11 +167,11 @@ class _TeacherCallScreenState extends State { ), ), const SizedBox(height: 24), - const Text( + _sectionHeader( '내 호출 기록', - style: TextStyle(fontWeight: FontWeight.bold), + count: _controller.myCalls.length, ), - const SizedBox(height: 8), + const SizedBox(height: 10), _buildMyCalls(), ], ), @@ -171,21 +181,89 @@ class _TeacherCallScreenState extends State { ); } + /// 🪶 "실시간 출석 현황"의 _sidebarStatPill과 같은 필 모양 통계 표시(선택 불가, 숫자 강조용). + Widget _statPill(String label, int count) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + children: [ + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Text( + '$count명', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + ); + } + + /// 🏷️ "학년 그룹" 헤더처럼 작은 강조 바 + 굵은 제목 + (선택) 개수를 붙인 섹션 제목. + Widget _sectionHeader(String title, {int? count}) { + return Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Text( + '$count명', + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ], + ], + ); + } + Widget _buildRanking() { final medals = ['🥇', '🥈', '🥉']; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppPalette.paper, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), border: Border.all(color: AppPalette.sage), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( '오늘 인기 선생님', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), ), const SizedBox(height: 8), Row( @@ -201,7 +279,7 @@ class _TeacherCallScreenState extends State { ), Text( '${_controller.ranking[i]['count']}회', - style: const TextStyle(color: Colors.grey, fontSize: 12), + style: TextStyle(color: Colors.grey[500], fontSize: 12), ), ], ), @@ -212,111 +290,161 @@ class _TeacherCallScreenState extends State { ); } + /// 🍬 필 모양 선택 버튼(스탯 필과 같은 톤: 선택=ink 배경, 미선택=paper+sage 테두리). + Widget _pillChoice(String label, bool selected, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(28), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: selected ? AppPalette.ink : AppPalette.paper, + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: selected ? AppPalette.ink : AppPalette.sage, + ), + ), + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: selected ? Colors.white : AppPalette.ink, + ), + ), + ), + ); + } + Widget _buildPurposeChips() { return Wrap( spacing: 8, runSpacing: 8, children: [ for (final p in kCallPurposes) - ChoiceChip( - label: Text(p), - selected: !_useCustomPurpose && _selectedPurpose == p, - onSelected: (_) => setState(() { + _pillChoice( + p, + !_useCustomPurpose && _selectedPurpose == p, + () => setState(() { _useCustomPurpose = false; _selectedPurpose = p; }), - selectedColor: AppPalette.ink, - labelStyle: TextStyle( - color: !_useCustomPurpose && _selectedPurpose == p - ? Colors.white - : AppPalette.ink, - ), - ), - ChoiceChip( - label: const Text('직접 입력'), - selected: _useCustomPurpose, - onSelected: (_) => setState(() => _useCustomPurpose = true), - selectedColor: AppPalette.ink, - labelStyle: TextStyle( - color: _useCustomPurpose ? Colors.white : AppPalette.ink, ), + _pillChoice( + '직접 입력', + _useCustomPurpose, + () => setState(() => _useCustomPurpose = true), ), ], ); } Widget _buildTeacherGrid() { + final teachers = _controller.teachers; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 1, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 160, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 110, ), - itemCount: _controller.teachers.length, + itemCount: teachers.length, itemBuilder: (context, index) { - final t = _controller.teachers[index]; + final t = teachers[index]; final name = t['name'] ?? ''; final location = t['location'] ?? '교무실'; final isSelected = _selectedTeacherId == t['teacherId']; final inClass = _controller.isInClassNow(name); + final statusColor = inClass ? Colors.orange : AppPalette.ink; + return GestureDetector( onTap: () => setState(() { _selectedTeacherId = t['teacherId']; _selectedTeacherName = name; }), child: Container( + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isSelected ? const Color(0xFFEFF6FF) : AppPalette.paper, - borderRadius: BorderRadius.circular(12), + color: isSelected ? AppPalette.ink : AppPalette.paper, + borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? AppPalette.ink : AppPalette.sage, - width: isSelected ? 2 : 1, ), - ), - child: Stack( - children: [ - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - '${kLocationIcons[location] ?? '📍'} $location', - style: const TextStyle( - fontSize: 11, - color: Colors.grey, - ), - ), - ], - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 12, + offset: const Offset(0, 4), ), - if (inClass) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.orange[100], - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange), + color: isSelected + ? Colors.white.withValues(alpha: 0.15) + : statusColor.withValues(alpha: 0.1), + shape: BoxShape.circle, ), - child: const Text( - '수업중', - style: TextStyle(fontSize: 9, color: Colors.orange), + child: Icon( + inClass ? Icons.school_rounded : Icons.person_rounded, + color: isSelected ? Colors.white : statusColor, + size: 16, ), ), + const Spacer(), + if (inClass) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withValues(alpha: 0.15) + : Colors.orange[50], + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? Colors.white54 : Colors.orange, + ), + ), + child: Text( + '수업중', + style: TextStyle( + fontSize: 9, + color: isSelected ? Colors.white : Colors.orange, + ), + ), + ), + ], + ), + const Spacer(), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: isSelected ? Colors.white : AppPalette.ink, ), + ), + const SizedBox(height: 2), + Text( + '${kLocationIcons[location] ?? '📍'} $location', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: isSelected ? Colors.white70 : Colors.grey[500], + ), + ), ], ), ), @@ -337,10 +465,11 @@ class _TeacherCallScreenState extends State { for (final c in _controller.myCalls) Container( margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppPalette.paper, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), ), child: Row( children: [ @@ -349,7 +478,7 @@ class _TeacherCallScreenState extends State { ), Text( '${c['createdAt'] ?? ''}'.split(' ').last, - style: const TextStyle(color: Colors.grey, fontSize: 12), + style: TextStyle(color: Colors.grey[500], fontSize: 12), ), ], ), diff --git a/lib/ui/teacher_location_screen.dart b/lib/ui/teacher_location_screen.dart index 62489a3..eec89a9 100644 --- a/lib/ui/teacher_location_screen.dart +++ b/lib/ui/teacher_location_screen.dart @@ -1,4 +1,5 @@ -// 📍 선생님 위치 등록 화면(교사용) (UI 전용). 본인 위치를 바꾸고, 받은 호출을 확인한다. +// 📍 선생님 위치 등록 화면(교사용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩 +// (제목 알약 + 필 모양 통계 + 그림자 있는 둥근 타일)을 따른다. // 서버 통신/상태는 lib/function/teacher_location_controller.dart가 담당한다. import 'package:flutter/material.dart'; import '../data/teacher_call_schedule.dart'; @@ -42,6 +43,37 @@ class _TeacherLocationScreenState extends State { ); } + Widget _sectionHeader(String title, {int? count}) { + return Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Text( + '$count건', + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ], + ], + ); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -53,54 +85,56 @@ class _TeacherLocationScreenState extends State { backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, - centerTitle: true, - title: const TitlePill('내 위치 알리기'), ), body: _controller.isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: _controller.fetchReceivedCalls, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), children: [ + const Center(child: TitlePill('내 위치 알리기')), + const SizedBox(height: 16), Container( - padding: const EdgeInsets.all(14), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 18), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(14), + color: AppPalette.ink, + borderRadius: BorderRadius.circular(24), ), - child: Row( + child: Column( children: [ const Text( '현재 위치', - style: TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.white70, + fontSize: 12, + ), ), - const Spacer(), + const SizedBox(height: 6), Text( '${kLocationIcons[_controller.currentLocation] ?? '📍'} ${_controller.currentLocation}', style: const TextStyle( fontWeight: FontWeight.bold, - color: AppPalette.ink, + fontSize: 20, + color: Colors.white, ), ), ], ), ), const SizedBox(height: 20), - const Text( - '위치 선택', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), + _sectionHeader('위치 선택'), + const SizedBox(height: 10), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 1.3, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 160, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 90, ), itemCount: kLocationOptions.length, itemBuilder: (context, index) { @@ -115,22 +149,40 @@ class _TeacherLocationScreenState extends State { color: isSelected ? AppPalette.ink : AppPalette.paper, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? AppPalette.ink : AppPalette.sage, ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - kLocationIcons[loc] ?? '📍', - style: const TextStyle(fontSize: 20), + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withValues( + alpha: 0.15, + ) + : AppPalette.linen, + shape: BoxShape.circle, + ), + child: Text( + kLocationIcons[loc] ?? '📍', + style: const TextStyle(fontSize: 16), + ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( loc, style: TextStyle( @@ -149,11 +201,11 @@ class _TeacherLocationScreenState extends State { }, ), const SizedBox(height: 24), - const Text( + _sectionHeader( '나를 찾은 학생 기록', - style: TextStyle(fontWeight: FontWeight.bold), + count: _controller.receivedCalls.length, ), - const SizedBox(height: 8), + const SizedBox(height: 10), _buildReceivedCalls(), ], ), @@ -175,19 +227,33 @@ class _TeacherLocationScreenState extends State { for (final c in _controller.receivedCalls) Container( margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppPalette.paper, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), ), child: Row( children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppPalette.ink.withValues(alpha: 0.06), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.notifications_active_rounded, + size: 16, + color: AppPalette.ink, + ), + ), + const SizedBox(width: 10), Expanded( child: Text('${c['studentName']} 학생 · ${c['purpose']}'), ), Text( '${c['createdAt'] ?? ''}', - style: const TextStyle(color: Colors.grey, fontSize: 11), + style: TextStyle(color: Colors.grey[500], fontSize: 11), ), ], ),