48 Commits
Author SHA1 Message Date
sihoo 9fe3e61c1c 전체 UI 색상 통일 - 제각각이던 빨강/주황/파랑 계열을 의미별 토큰 4종으로 정리
danger/warning/success/approved(+각 Bg 버전)를 AppPalette에 추가하고,
화면마다 다르게 쓰이던 Colors.red[400/600/700]/redAccent, orange/amber
계열, blue 계열 리터럴을 전부 해당 토큰으로 교체. NFC 체크인 화면의
홀로 튀던 Colors.blueAccent도 함께 정리.
2026-09-30 06:29:37 +00:00
sihooandClaude Sonnet 5 153c341efc 친구/그룹 스터디/백품타 랭킹 화면 폭 제한 (양옆으로 길게 늘어지던 것 수정)
백품타 랭킹, 그룹 스터디 목록은 최대 560px로(기존 대비 절반 느낌),
친구 목록은 최대 400px로(더 좁게, 1/3 느낌) 가운데 정렬되게 제한.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 06:10:30 +00:00
sihooandClaude Sonnet 5 ae3a3f18ea 프로필 타일에 편집 버튼 추가 - 프로필 커스텀 화면으로 이동
메인 대시보드 프로필 카드 오른쪽 위에 작은 편집(연필) 버튼을 달아서
누르면 새로 만든 빈 커스텀 화면(ProfileCustomizeScreen)으로 이동한다.
아직은 자리만 잡아둔 화면이고, 실제 커스텀 기능들은 이후에 하나씩 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 05:53:01 +00:00
sihooandClaude Sonnet 5 0055b3d40b 학번 검색창 폭을 계정 추가 타일과 같게 조정
가로로 길게 늘어지던 검색창을 위쪽 "신규 학생 계정 추가" 타일과
같은 폭(formSquareSize)으로 맞추고, 좁은 화면에서 필터 칩들이
잘리지 않게 Row 대신 Wrap으로 변경.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 05:40:30 +00:00
sihooandClaude Sonnet 5 6b90ae3925 전체 학생 목록에 학번 검색 + 학년 필터, 1학년부터 정렬 추가
목록을 학년 오름차순(1→2→3, 미배정은 맨 뒤)으로 정렬하고, 학번으로
검색하는 입력창과 전체/1학년/2학년/3학년 필터 칩을 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 05:36:33 +00:00
sihooandClaude Sonnet 5 206eec03bc 그룹 스터디 멤버 프로필에 30분 단위 색상 테두리 연출 추가
오늘 공부 시간이 30분씩 늘어날 때마다 프로필 원형 테두리가
흰→노랑→주황→빨강→보라 순으로 진해지도록(2시간 이상은 보라 고정)
꾸며서 누가 얼마나 공부하고 있는지 한눈에 보이게 함. 범례도 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 05:12:55 +00:00
sihooandClaude Sonnet 5 51a106faf4 교무실 Stream Deck 선생님 위치 표시기 스크립트 추가
교사가 앱에 직접 접속해서 위치를 바꾸는 대신, 교무실 출입구의
Stream Deck(15키)에서 이름 버튼 하나로 "수업 중"을 표시하고,
설정 버튼으로 과학실/출장 등 다른 위치도 고를 수 있게 하는 라즈베리파이용
독립 스크립트. 기존 POST /api/teacher-call/location API를 그대로
호출하므로 서버/앱 변경 없이 별도 기기에서 실행하면 된다. 선생님
목록은 /api/teacher-call/teachers에서 1분마다 자동 새로고침해서
새로 가입한 교사도 재시작 없이 반영된다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 04:06:00 +00:00
sihooandClaude Sonnet 5 521355626d 타이머 화면을 나갈 때(앱 안에서 이동) 계속 돌아가는 중이라고 알림 표시
브라우저 탭 자체는 그대로 두고 앱 안에서 다른 화면으로 이동하면
AppLifecycleState가 안 바뀌어서 기존 "탭 이탈" 감지가 못 잡는 사각
지대였음. 화면 dispose 시점에 타이머가 running이면 직접 알림을 띄움.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:30:53 +00:00
sihooandClaude Sonnet 5 6b66edcd40 교사 간편인증 비밀번호를 4885로 변경
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 07:05:55 +00:00
sihooandClaude Sonnet 5 3f156b8833 PC 웹에서 대시보드 타일을 4칸이 아닌 6칸부터 줄바꿈하게 변경
넓은 화면(웹 + 900px 이상)에서는 타일이 한 줄에 더 많이 들어가서
관리자 계정이 아니면 스크롤 자체가 거의 필요 없어지게 함(스크롤
바운스로 생기던 파란 띠 회피). 모바일/앱에서는 기존 4칸 유지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 07:00:18 +00:00
sihooandClaude Sonnet 5 10051e9896 루트 경로(/) 요청도 index.html처럼 캐시 안 되게 수정
firebase.json의 no-cache 규칙이 "/index.html" 경로에만 걸려있었는데,
실제로 브라우저는 "/"를 요청하고 SPA rewrite로 index.html을 받다보니
그 규칙이 안 먹혀서 1시간 동안 캐시됐다. 이 때문에 배포 직후에도
최대 1시간까지 이전 버전(스크롤 바운스 파란색 버그 등)이 그대로
보이는 문제가 있었음. "/" 경로에도 같은 no-cache 규칙을 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:54:51 +00:00
sihooandClaude Sonnet 5 bebbcb1d54 공부 타이머 통계 박스 오버플로우 수정
초단위까지 표시하면서 "50시간 57분 0초"처럼 길어진 값이 줄바꿈되며
박스 밖으로 넘치던 문제를, FittedBox로 한 줄에 맞게 자동 축소되도록 수정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:48:47 +00:00
sihooandClaude Sonnet 5 37f67540dd 공부시간에 초단위 표시 + 백품타 출석 인정 기준(1시간) 안내 문구 추가
랭킹, 공부 타이머 통계, 그룹 멤버 카드에 초단위까지 표시하고,
출석 달력/그룹 화면에 "1시간 이상 공부해야 출석 인정" 안내를 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:40:32 +00:00
sihooandClaude Sonnet 5 b6e77ad1fa 엑셀 일괄등록: 일부 엑셀 파일 읽기 실패 버그 수정
"custom numFmtId starts at 164 but found a value of 0" 에러 -
구글시트/WPS 등에서 내보낸 일부 xlsx 파일은 styles.xml에 커스텀
숫자서식 id를 164 미만으로 적어두는데, excel 패키지가 이를 엄격하게
검증하며 예외를 던져 파일 전체를 못 읽었다. 파싱 전에 zip 안의
styles.xml만 열어 그 규격 위반 서식 선언을 제거하고 다시 압축해서
넘기도록 전처리 단계를 추가. 합성 xlsx로 버그 재현 + 수정 후 정상
파싱까지 직접 검증함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:06:25 +09:00
sihooandClaude Sonnet 5 80667d20f1 백품타 랭킹에 전체랭킹/학년랭킹 필터 추가
grade 쿼리파라미터를 생략하면 전체 학년 통합 랭킹을 주도록 백엔드를
바꾸고, 랭킹 화면에 "N학년"/"전체랭킹" 필 필터를 추가해 시간대
필터(오늘/이번주/전체)와 별도로 고를 수 있게 했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:19:05 +00:00
sihooandClaude Sonnet 5 f5aa832ad9 그룹 상세: 멤버를 타일 그리드로, 공부 시간순 정렬, 백품타 바로가기 추가
가로로 길게 늘어지던 멤버 목록을 학생 계정관리 화면 같은 타일
그리드로 바꾸고, 오늘 공부 시간이 많은 순으로 정렬(백엔드).
화면 폭도 다른 화면들처럼 최대 560으로 제한. "백품타 바로가기"
버튼으로 그룹 화면에서 바로 공부 타이머로 이동 가능.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:58:21 +00:00
sihooandClaude Sonnet 5 4f64315197 그룹 스터디 공개/비공개 기능 추가
그룹을 공개로 만들면 "공개 그룹" 목록에 뜨고, 학생이 참여 신청을
보내면 그룹장이 허용/거절할 수 있다. 그룹 상세 화면에 그룹장 전용
공개/비공개 전환 버튼과 대기 중인 참여 신청 목록을 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:49:35 +00:00
sihooandClaude Sonnet 5 1140747af2 선생님 위치 알리기 화면도 호출 화면처럼 가운데 최대 560 폭으로 제한
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:40:42 +00:00
sihooandClaude Sonnet 5 cb0984ed6f 선생님 호출 화면 폭 제한 + 출석 달력 크기 축소
넓은 화면에서 양옆으로 늘어나던 선생님 호출 내용을 가운데 최대 560 폭으로,
출석 달력은 최대 440 폭 + 칸 비율/간격을 줄여서 한눈에 보이게 했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:32:10 +00:00
sihooandClaude Sonnet 5 e8b375a18c 백품타 타이머 켜놓고 탭 이탈 시 브라우저 알림 추가
공부 타이머가 진행 중일 때 브라우저 탭이 백그라운드로 가면(다른
탭/창/앱으로 이동) 20초 뒤에도 계속 그 상태면 "다시 돌아와서
집중해볼까요?" 브라우저 알림을 띄운다. dart:html 대신 package:web +
js_interop로 구현하고, 웹이 아닌 빌드에서는 조건부 임포트로 아무
동작 안 하는 스텁을 쓴다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 04:17:48 +00:00
sihooandClaude Sonnet 5 8a9b414535 백품타에 친구/그룹 스터디/출석 달력 기능 추가
친구 신청·수락, 그룹 생성·초대(친구 목록에서 선택)·오늘 출석 현황
공유, 개인 연속 출석(스트릭) 달력, 관리자가 날짜별로 선물/이벤트를
등록하는 달력 관리 화면을 추가. 대시보드에 학생용 3개(친구/그룹
스터디/출석 달력) + 교사·관리자용 1개(출석 달력 관리) 타일을 배치.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 04:09:35 +00:00
sihooandClaude Sonnet 5 bdc79b94c3 선생님 호출 화면 이모티콘을 검정 아이콘으로 통일
위치 이모티콘(🏢🍴🧪 등)을 Material 아이콘으로, 랭킹 메달 이모티콘을
백품타 랭킹 화면과 같은 금/은/동 색 emoji_events 아이콘으로 교체.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 06:28:50 +00:00
sihooandClaude Sonnet 5 86a8f7b9d1 선생님 호출 화면을 실시간 출석 현황과 같은 패밀리룩으로 재디자인
제목을 AppBar 대신 본문 상단 알약(TitlePill)으로 옮기고, 필 모양
통계/선택 버튼과 그림자 있는 둥근 타일(원형 아이콘 배지 + 코너 배지)
스타일을 출석 화면에서 그대로 가져왔다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 03:57:10 +00:00
sihooandClaude Sonnet 5 4cf759de62 선생님 호출 시스템 추가 (Firebase 버전을 앱 계정 체계로 이전)
별도로 돌아가던 Firebase RTDB 기반 호출 페이지(index.html/teacher.html)에서
발견한 XSS, 익명 접근(아무나 아무 선생님 위치 변경 가능), 이름 위조 문제를
우리 앱 로그인 체계로 재구현하면서 구조적으로 없앴다. 학생은 본인 계정으로만
호출하고 본인 호출 기록만 보이며, 교사는 본인 위치만 수정하고 본인이 받은
호출만 본다. TTS 대신 FCM 푸시로 대체. 시간표 데이터의 "김지"→"조신현" 오타도
"조신"으로 수정해서 옮겼다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:34:08 +00:00
sihooandClaude Sonnet 5 89487a7dfc 뽀모도로 웹 탭 딜레이로 공부/휴식 시간 안맞는 버그 수정
브라우저가 백그라운드 탭의 타이머를 느리게 돌려서(setInterval
throttling) 카운트다운을 매 tick마다 1초씩 빼는 방식으로는 다른
창에 갔다오면 시간이 밀렸다. 남은 시간을 절대 종료 시각
(_phaseEndsAt) 기준으로 계산하도록 바꾸고, 탭이 다시 활성화될 때
(AppLifecycleState.resumed) 서버 상태를 재동기화하고 밀린 전환을
바로 따라잡도록 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 00:24:35 +09:00
sihooandClaude Sonnet 5 d183370cca 뽀모도로 공부/휴식 시간 커스텀 설정을 친구들과 공유하는 기능 추가
내가 쓰는 공부/휴식 시간 조합에 이름을 붙여 공유하고, 다른 학생이
올린 루틴을 목록에서 골라 그대로 적용할 수 있다. 인기순/최신순 정렬,
많이 쓰인 루틴일수록 사용 횟수가 올라가서 눈에 띄게 했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 00:15:28 +09:00
sihooandClaude Sonnet 5 7277eb5962 백품타에 뽀모도로 커스텀 모드 추가
공부/휴식 시간을 5분 단위로 직접 정해두면(기본 50/10), 공부 시간이
끝났을 때 자동으로 일시정지되고 휴식이 끝나면 자동으로 재개된다.
서버의 기존 일시정지/재개 API를 그대로 재사용하는 클라이언트 전용
스케줄러라 백엔드 변경은 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:01:50 +09:00
sihooandClaude Sonnet 5 094ce2292c 백품타에 일시정지/재개 기능 추가
진행 중일 때 일시정지하면 그 구간은 누적 시간에서 빠지고, 재개하면
다시 이어서 잰다. 상태를 idle/running/paused 3가지로 나눠서 관리.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:49:48 +09:00
sihooandClaude Sonnet 5 93104362be 익명 커뮤니티 게시판(백판) 기능 추가
디시인사이드식 고정 카테고리 게시판 + 에타 스타일 익명(글 안에서만
유효한 익명 번호) 댓글. 신고는 자동 삭제 없이 운영자(교사/관리자)
검토 후 처리하도록 별도 신고 검토 화면을 대시보드에 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 00:59:04 +09:00
sihooandClaude Sonnet 5 73772de78a 공부 타이머 이름을 "백품타"로 변경 (백산고 + 열품타)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 15:12:43 +09:00
sihooandClaude Sonnet 5 a59a3054fb 학교 전용 공부 타이머(열품타) + 학년별 랭킹 기능 추가
학생 참여 유도를 위해 공부 시작/종료 타이머와 학년별(오늘/이번주/전체)
랭킹 화면을 대시보드에 추가. 로그인 응답에 학년 정보를 포함시켜
세션 저장/복원 전체 경로에 전달되도록 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:58:55 +09:00
sihooandClaude Sonnet 5 26e54a1798 전체 학생 허용 시 장시간(1시간 59분 이상) 요청은 자동 제외
기존엔 전체 승인이 장시간 요청도 구분 없이 다 승인해버렸음. 이제
1시간 59분 넘는 요청은 자동으로 빼고 대기중 상태로 남겨서, "많은
시간요청 학생" 목록에서 따로 검토 후 개별 승인/거절하게 함. 확인
다이얼로그에도 몇 건이 승인되고 몇 건이 제외되는지 미리 보여줌.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:03:27 +09:00
sihooandClaude Sonnet 5 04892d5e65 스마트기기 반출 대장: 1시간 59분 이상 요청은 목록에서 빨간 테두리로 표시
기존엔 "많은 시간요청 학생" 팝업에서만 2시간 기준으로 걸러냈는데,
메인 목록 카드 자체에도 (대기중/승인/거절 등 상태 무관하게) 빨간
테두리를 씌워서 바로 눈에 띄게 함. 기준을 2시간 → 1시간 59분(119분)으로
낮춰서 두 기능이 같은 기준을 쓰도록 통일.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:31:10 +09:00
sihooandClaude Sonnet 5 a3894cc878 스마트기기 반출 대장: 전체 학생 허용 + 많은 시간요청 학생 검토 기능 추가
- "전체 학생 허용" 아이콘을 새로고침 버튼 옆에 추가 - 대기 중인 요청을
  한 명씩 승인하는 대신 확인 팝업 후 한 번에 전부 승인
- "많은 시간요청 학생" 아이콘 추가 - 학생이 시간을 비정상적으로 길게
  적어낼 수 있으니, 2시간 이상 신청한 대기 중인 요청만 따로 모아 보여줌
  (해당 건수를 빨간 배지로 표시). 목록에서 바로 승인/거절 가능
- 컨트롤러에 durationHours/longPendingRequests/approveAllPending 추가.
  일괄 승인은 서버에 batch API가 없어서 대기 요청마다 순서대로
  승인 요청을 보내고 마지막에 한 번만 새로고침

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 19:58:34 +09:00
sihooandClaude Sonnet 5 ce77c32026 페이지를 화면에 position: fixed로 고정해서 스크롤/바운스 자체를 없앰
배경색을 맞추는 대신, html/body를 position: fixed + overflow: hidden +
overscroll-behavior: none으로 화면에 못박아 브라우저가 바운스할 대상
자체가 없게 만듦 (Safari/iOS에서 rubber-band 스크롤을 막는 표준 기법).
Flutter 내부 스크롤(대시보드, 하위 화면 목록 등)은 별도로 동작하므로
영향 없음 - 실제로 정상 스크롤되는 것 확인함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 19:29:20 +09:00
sihooandClaude Sonnet 5 a0403e40f2 스크롤 바운스 시 파란색 대신 앱 배경색이 보이게 수정
다른 사이트/브라우저에서도 똑같이 나타난다는 걸 확인 - macOS 트랙패드의
정상적인 "탄력 바운스" 동작이었음. 다만 지난번 overflow: hidden으로
막아버린 탓에, 브라우저가 우리 페이지를 진짜 스크롤 가능한 문서로
인식하지 못해서 바운스 시 body 배경색 대신 브라우저 기본색(파란색)이
드러났던 것으로 보임. overflow: hidden을 제거해서 다른 사이트들처럼
자연스럽게 바운스되면서 body의 실제 배경색(#EEF0F2)이 드러나게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:27:48 +09:00
sihooandClaude Sonnet 5 7e83884d33 스크롤 시 파랗게 보이는 진짜 원인 발견 - PWA manifest 기본 파란색 제거
manifest.json의 theme_color/background_color가 Flutter 프로젝트
템플릿의 기본값(#0175C2, Flutter 브랜드 파란색)으로 그대로 남아있었음.
Safari가 스크롤 시 툴바 주변을 이 색으로 물들여서 파란 줄이 보인 것.

- manifest.json: theme_color를 앱의 ink색(#1C1C1C)으로, background_color를
  앱 배경색(#EEF0F2)으로 교체
- index.html에도 <meta name="theme-color">를 명시적으로 추가해 이중으로 보장

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:17:03 +09:00
sihooandClaude Sonnet 5 0467f31996 스크롤 시 나오는 파란 영역 - 브라우저 페이지 바운스 원천 차단
Flutter 자체 오버스크롤 글로우를 껐는데도 계속 보인다는 피드백 -
Flutter 레이어가 아니라 브라우저가 HTML 문서 자체를 스크롤 가능한
것으로 인식해서 트랙패드 제스처로 페이지 전체가 바운스되며 배경이
드러나는 것으로 보임. html/body에 overflow: hidden +
overscroll-behavior: none을 줘서 브라우저 차원의 바운스를 막고,
혹시 드러나더라도 흰색/파란색이 아니라 앱 배경색(#EEF0F2)이
보이도록 배경색도 지정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:10:33 +09:00
sihooandClaude Sonnet 5 2d87a681ca 스크롤 시 나오는 파란 오버스크롤 글로우 효과 제거
Flutter 기본 GlowingOverscrollIndicator가 스크롤 경계에서 파란색으로
번쩍이던 걸 커스텀 ScrollBehavior로 전체 앱에서 비활성화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:05:18 +09:00
sihooandClaude Sonnet 5 c40cbed0bb 메인 대시보드 타일 크기 50px 축소 (276 -> 226)
직전 커밋에서 타일을 너무 키웠다는 피드백을 반영.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 17:50:18 +09:00
sihooandClaude Sonnet 5 5d37d3e3b1 제목 알약 폰트 통일 + 위치 조정, 좁은 폼 너비, 메인 타일 확대
- TitlePill을 String 기반으로 바꿔서 모든 화면(설정/교사 회원가입/관리자
  시스템/NFC 태그 쓰기·체크인/학생 관리/스마트기기 반출 신청·대장/실시간
  출석 현황)이 완전히 같은 폰트(굵게, 15px, 흰색)를 쓰게 통일
- 알약이 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 8px 여백 추가
- "스마트기기 반출 신청" 폼과 "관리자 시스템"의 "모든 출석 데이터 초기화"
  버튼이 넓은 화면에서 양옆으로 과하게 늘어지던 문제 수정 - 최대 폭
  420px로 제한하고 가운데 정렬 (기존 대비 약 1/3 크기)
- 메인 대시보드 타일 목표 크기를 184 → 276(50% 확대)로 키우고, 아이콘/
  패딩/폰트 기준값도 함께 올려서 제목 10→12pt, 부제 8→10pt로 확대

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:26:29 +09:00
sihooandClaude Sonnet 5 527661b6e5 나머지 기능 화면들도 검정 AppBar를 둥근 제목 알약으로 통일
실시간 출석 현황과 같은 톤으로, 설정/교사 회원가입/관리자 시스템/
NFC 태그 쓰기·체크인/학생 통합 관리 센터/스마트기기 반출 신청·대장
화면의 검정 배경 AppBar를 투명 배경 + 가운데 둥근 제목 알약으로 교체.

출석 현황 화면과 달리 폰트 크기는 줄이지 않고 각 화면이 원래 쓰던
스타일(굵기 등)을 그대로 유지 - 공용 TitlePill 위젯이 스타일링된
Text를 그대로 감싸주기만 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:17:05 +09:00
sihooandClaude Sonnet 5 d9ca4913d4 실시간 출석 현황 모바일 화면: 검정 요약바 제거 + 중복 필터 정리
작은 화면(폰) 레이아웃만 데스크탑과 톤을 맞춤 - 큰 화면(사이드바/좌석
그리드)은 그대로 둠.

- 검정 배경 요약바(_buildSummaryCards)를 없애고, 데스크탑 사이드바와
  같은 흰색 캡슐(_sidebarStatPill)을 가로로 3개 배치. 탭하면 바로
  전체/출석/미출석 필터가 적용됨
- 전체 학생 수 캡슐 바로 아래 있던 필터 칩 줄(전체/출석자/미출석자 +
  학년 칩)은 위 캡슐과 기능이 겹쳐서 제거하고, 데스크탑과 동일한
  "학생 나눠보기"(런치패드 학년 필터 메뉴) 버튼으로 대체
- 더 이상 안 쓰는 _buildSummaryCards/_summaryCard/_buildFilterChips 삭제

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:23:36 +09:00
sihooandClaude Sonnet 5 3946e2ef04 앱 전체 알림을 알약형(AppNotice)으로 통일
나머지 화면들(교사 회원가입, 학생 계정 관리, 관리자 대시보드, 스마트기기
반출 신청/대장, NFC 태그 쓰기/체크인, 비밀번호 변경)에 남아있던 예전
ScaffoldMessenger.showSnackBar를 전부 AppNotice로 교체.

- AppNotice.show에 선택적 color 파라미터 추가 - NFC 태그 쓰기/체크인
  화면처럼 성공(초록)/실패(빨강) 등 색으로 구분하던 알림은 그 색을
  그대로 유지하면서 모양/위치만 통일된 알약 스타일로 바뀜
- 알림 직후 화면을 전환(로그인 성공 후 대시보드 이동, 회원가입 성공 후
  로그인 화면 복귀 등)하던 곳들도, AppNotice가 화면(Scaffold)이 아니라
  전역 Overlay를 쓰기 때문에 전환 중에 알림이 잘리지 않고 새 화면 위에
  계속 보임 (기존 스낵바 방식보다 개선됨)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:11:55 +09:00
sihooandClaude Sonnet 5 f80f1f7837 실시간 출석 현황: 하교 처리 등 결과 알림을 알약형(AppNotice)으로 통일
_runAction이 아직 예전 ScaffoldMessenger.showSnackBar(하단 스낵바)를
쓰고 있어서, 하교 처리/반출 허용/출석시간 설정/테스트 초기화 결과가
다른 화면과 다르게 아래에서 떴던 문제 수정. AppNotice.show로 교체해서
웹은 왼쪽 위, 앱은 하단(기존 위치)에 나머지 화면들과 같은 스타일로 뜨게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:55:35 +09:00
sihooandClaude Sonnet 5 32203baffc 실시간 출석 현황: 제목 알약을 앱바에서 사이드바 상단으로 이동
앱바 가운데 제목 대신, "전체 학생 수" 버튼 바로 위(사이드바 최상단)에
제목 알약을 두도록 변경. 모바일도 요약 카드 위로 동일하게 이동.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:38:56 +09:00
sihooandClaude Sonnet 5 20f751b65e 실시간 출석 현황: 검정 배너 제거 + 기능들을 런치패드 메뉴로 통합
- 상단 검정 AppBar 대신 투명 배경 + 가운데 둥근 알약 제목으로 변경
- 새로고침/자습실 출석시간 설정/반출 허용 시간 설정/하교 처리/테스트용
  초기화를 "자습실 시간 설정 메뉴" 하나로 모아서, 메인 대시보드의
  로그아웃/설정 메뉴와 같은 런치패드 스타일(배경 블러) 오버레이로 표시
- 데스크탑은 사이드바에 전용 버튼을 추가하고, 폰에서는 앱바의 "..."
  아이콘으로 같은 메뉴에 접근 (모바일 기능 접근성 유지)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:31:17 +09:00
sihooandClaude Sonnet 5 2d0768da73 실시간 출석 현황 데스크탑 화면을 좌석표 스타일로 재구성
80명 이상인 자습실 인원을 스크롤 없이 최대한 한눈에 보기 위해,
데스크탑(교실 컴퓨터) 레이아웃을 왼쪽 요약/필터 사이드바 + 오른쪽
촘촘한 좌석 그리드 구조로 바꿈. 학생 한 명당 칸을 92x62 크기로 작게
줄여 한 화면에 훨씬 많은 인원이 들어가게 했고, 사이드바의 인원 수
캡슐(전체/출석/미출석)을 눌러 바로 필터링할 수 있게 함. 학년 필터는
런치패드 스타일 오버레이 메뉴로 이동. 모바일 카드 리스트 레이아웃은
그대로 유지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:01:51 +09:00
59 changed files with 8685 additions and 637 deletions
+73 -1
View File
@@ -1 +1,73 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
{
"flutter": {
"platforms": {
"android": {
"default": {
"projectId": "school-display-ff28f",
"appId": "1:137322214849:android:2595999513206bdeb16b40",
"fileOutput": "android/app/google-services.json"
}
},
"dart": {
"lib/firebase_options.dart": {
"projectId": "school-display-ff28f",
"configurations": {
"android": "1:137322214849:android:2595999513206bdeb16b40",
"web": "1:137322214849:web:0423e5c788d95761b16b40"
}
}
}
}
},
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/index.html",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
},
{
"source": "/",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
},
{
"source": "/flutter_service_worker.js",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
},
{
"source": "/version.json",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
]
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
// 📅 선생님 호출 화면에서 "지금 수업 중" 배지를 보여주기 위한 시간표 데이터.
// 원래 별도 Firebase 페이지(index.html)에 있던 데이터를 그대로 옮기면서,
// "김지" 약어가 실제로는 조신현 선생님을 가리키던 오타를 "조신"으로 바로잡았다.
// (다른 모든 약어는 이름 앞 두 글자 규칙을 따르는데 이것만 깨져 있었음)
class PeriodTime {
final int period;
final String start;
final String end;
const PeriodTime(this.period, this.start, this.end);
}
const List<PeriodTime> kPeriodSettings = [
PeriodTime(1, "08:40", "09:30"),
PeriodTime(2, "09:40", "10:30"),
PeriodTime(3, "10:40", "11:30"),
PeriodTime(4, "11:40", "12:30"),
PeriodTime(5, "13:30", "14:20"),
PeriodTime(6, "14:30", "15:20"),
PeriodTime(7, "15:30", "16:20"),
];
// 이름 앞 두 글자 약어 → 전체 이름. ("조신":"조신현" 으로 수정됨 - 원래 "김지"였던 오타)
const Map<String, String> kTeacherAbbrevMap = {
"손계": "손계강",
"조신": "조신현",
"진경": "진경아",
"김만": "김만중",
"이길": "이길석",
"김효": "김효정",
"이부": "이부원",
"고경": "고경철",
"김경": "김경원",
"양성": "양성욱",
"김명": "김명선",
"김현": "김현진",
"박은": "박은호",
"심윤": "심윤주",
"조영": "조영희",
"이정": "이정상",
"조용": "조용우",
"이민": "이민혁",
};
// day: 1=월 ~ 5=금, period: 1~7. 값은 그 시간에 수업 중인 선생님 약어 목록.
const Map<int, Map<int, List<String>>> kFullTimetable = {
1: {
1: ["손계", "조신", "진경", "김만", "이길", "김효", "이부"],
2: ["김경", "손계", "이길", "조영", "김효", "양성", "김명", "김현"],
3: ["박은", "심윤", "조영", "고경", "이정", "이부", "김경"],
4: ["심윤", "진경", "김효", "이길", "이정", "김만", "김명", "김현", "양성"],
5: ["박은", "손계", "이정", "고경", "조영", "김현", "양성", "김명"],
6: ["이민", "심윤", "고경", "김만", "이길", "이부", "김경"],
7: ["이민", "김경", "박은", "김만", "이길", "조영", "양성", "조신", "손계"],
},
2: {
1: ["손계", "이정", "박은", "김만", "심윤", "이길", "조영", "김현", "조신", "양성"],
2: ["김효", "김경", "이정", "고경", "조영", "김만", "양성", "김현", "김명"],
3: ["진경", "심윤", "손계", "이민", "이부", "김명", "조용", "조신"],
4: ["이정", "진경", "김효", "이민", "이부", "조용", "양성", "김현"],
5: ["박은", "김효", "심윤", "이민", "이부", "김명", "김현", "손계"],
6: ["김경", "박은", "진경", "이민", "이부", "양성", "김명", "조용"],
7: ["진경", "손계", "이부", "김만", "이길", "김효", "박은", "김경", "김현"],
},
3: {
1: ["김효", "김경", "이민", "이부", "김명", "김현", "이정"],
2: ["조용", "진경", "김효", "조영", "김만", "이길", "이부", "고경"],
3: ["심윤", "손계", "조용", "이민", "박은", "조신", "양성", "이정", "김명"],
4: ["진경", "김효", "심윤", "이민", "박은", "조신", "이정", "양성", "김현"],
},
4: {
1: ["심윤", "김경", "조신", "조영", "이길", "김만", "이정", "양성", "김명"],
2: ["박은", "진경", "김만", "심윤", "이길", "조영", "이부", "고경"],
3: ["진경", "김효", "이민", "이부", "김현", "김명", "이정"],
4: ["손계", "김효", "김경", "이민", "박은", "조신", "양성", "이정", "김현"],
5: ["조신", "진경", "심윤", "김만", "고경", "조영", "이부", "이민"],
6: ["김경", "심윤", "손계", "이길", "조영", "김효", "박은", "진경", "조신"],
7: ["김효", "손계", "박은", "고경", "김만", "이길", "김현", "김명", "양성"],
},
5: {
1: ["손계", "박은", "이정", "김만", "조영", "이길", "김현", "김명", "양성"],
2: ["이정", "손계", "김경", "이길", "고경", "조영", "김명", "조신", "김현"],
3: ["진경", "심윤", "조용", "고경", "김만", "김효", "이부", "김경"],
4: ["조용", "이정", "진경", "김만", "심윤", "이길", "조영", "양성", "김현", "김명"],
5: ["진경", "손계", "이부", "조용", "조신", "고경", "박은", "김경", "김현"],
7: ["김효", "조용", "손계", "조영", "이길", "김만", "이부", "고경"],
},
};
/// 지금 이 순간(day: 1~5, HH:mm) 수업 중인 선생님 전체 이름 목록. 주말/쉬는시간이면 빈 목록.
List<String> teachersInClassNow(int weekday, String hhmm) {
if (weekday < 1 || weekday > 5) return [];
PeriodTime? current;
for (final p in kPeriodSettings) {
if (hhmm.compareTo(p.start) >= 0 && hhmm.compareTo(p.end) <= 0) {
current = p;
break;
}
}
if (current == null) return [];
final abbrevs = kFullTimetable[weekday]?[current.period] ?? [];
return abbrevs.map((a) => kTeacherAbbrevMap[a]).whereType<String>().toList();
}
const Map<String, IconData> kLocationIcons = {
"교무실": Icons.business_rounded,
"급식실": Icons.restaurant_rounded,
"과학실": Icons.science_rounded,
"도서관": Icons.menu_book_rounded,
"출장": Icons.directions_car_rounded,
"조퇴/퇴근": Icons.home_rounded,
"상담실": Icons.forum_rounded,
"기숙사": Icons.hotel_rounded,
};
const List<String> kLocationOptions = [
"교무실",
"급식실",
"과학실",
"도서관",
"출장",
"조퇴/퇴근",
"상담실",
"기숙사",
];
const List<String> kCallPurposes = [
"간단한 용무",
"질문 있어요",
"상담 신청",
"과제 제출",
"물건 전달",
];
+76
View File
@@ -0,0 +1,76 @@
// 🗣️ 커뮤니티 게시판 - 카테고리별 게시글 목록 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class BoardListController extends ChangeNotifier {
final String category;
final String studentId;
BoardListController({required this.category, required this.studentId});
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMore = true;
int _page = 1;
List<dynamic> _posts = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
List<dynamic> get posts => _posts;
static const int _pageSize = 20;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => refresh();
Future<void> refresh() async {
_isLoading = true;
_page = 1;
_hasMore = true;
_safeNotify();
await _fetchPage(replace: true);
_isLoading = false;
_safeNotify();
}
Future<void> loadMore() async {
if (_isLoadingMore || !_hasMore || _isLoading) return;
_isLoadingMore = true;
_safeNotify();
_page += 1;
await _fetchPage(replace: false);
_isLoadingMore = false;
_safeNotify();
}
Future<void> _fetchPage({required bool replace}) async {
try {
final response = await http.get(
Uri.parse(
'$baseUrl/api/board/posts?category=$category&studentId=$studentId&page=$_page',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final fetched = (data['posts'] as List<dynamic>?) ?? [];
_posts = replace ? fetched : [..._posts, ...fetched];
_hasMore = fetched.length >= _pageSize;
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
}
}
}
+143
View File
@@ -0,0 +1,143 @@
// 💬 커뮤니티 게시판 - 게시글 상세(+댓글) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class BoardPostController extends ChangeNotifier {
final int postId;
final String studentId;
BoardPostController({required this.postId, required this.studentId});
bool _isLoading = true;
bool _isBusy = false;
bool _postDeleted = false;
Map<String, dynamic>? _post;
List<dynamic> _comments = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
bool get postDeleted => _postDeleted;
Map<String, dynamic>? get post => _post;
List<dynamic> get comments => _comments;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => fetchDetail();
Future<void> fetchDetail() async {
_isLoading = true;
_safeNotify();
try {
final response = await http.get(
Uri.parse('$baseUrl/api/board/posts/$postId?studentId=$studentId'),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
_post = Map<String, dynamic>.from(result['post']);
_comments = result['comments'] ?? [];
} else {
_postDeleted = true;
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 상태를 유지한다.
} finally {
_isLoading = false;
_safeNotify();
}
}
Future<(bool success, String message)> addComment(String content) async {
if (content.trim().isEmpty) return (false, '댓글 내용을 입력해주세요.');
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/board/posts/$postId/comments'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "content": content.trim()}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchDetail();
return (true, '댓글이 등록되었습니다.');
}
return (false, '${result['message'] ?? '댓글 등록 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> deletePost() async {
return _delete('$baseUrl/api/board/posts/$postId');
}
Future<(bool success, String message)> deleteComment(int commentId) async {
final (success, message) = await _delete(
'$baseUrl/api/board/comments/$commentId',
);
if (success) await fetchDetail();
return (success, message);
}
Future<(bool success, String message)> _delete(String url) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.delete(
Uri.parse(url),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '삭제되었습니다.'}');
}
return (false, '${result['message'] ?? '삭제 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> report({
required String targetType,
required int targetId,
String? reason,
}) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/board/reports'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"targetType": targetType,
"targetId": targetId,
"reporterStudentId": studentId,
"reason": reason,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '신고가 접수되었습니다.'}');
}
return (false, '${result['message'] ?? '신고 접수 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
}
}
}
+77
View File
@@ -0,0 +1,77 @@
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class BoardReportController extends ChangeNotifier {
final String reviewerId;
BoardReportController({required this.reviewerId});
bool _isLoading = true;
bool _isBusy = false;
List<dynamic> _reports = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
List<dynamic> get reports => _reports;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => fetchPending();
Future<void> fetchPending() async {
_isLoading = true;
_safeNotify();
try {
final response = await http.get(
Uri.parse('$baseUrl/api/board/reports?status=pending'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_reports = data['reports'] ?? [];
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
} finally {
_isLoading = false;
_safeNotify();
}
}
Future<(bool success, String message)> resolve(
int reportId,
String action,
) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/board/reports/$reportId/resolve'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"reviewerId": reviewerId, "action": action}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchPending();
return (true, '${result['message'] ?? '처리되었습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+48
View File
@@ -0,0 +1,48 @@
// ✍️ 커뮤니티 게시판 - 글쓰기 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class BoardWriteController extends ChangeNotifier {
final String studentId;
BoardWriteController({required this.studentId});
bool _isSubmitting = false;
bool get isSubmitting => _isSubmitting;
Future<(bool success, String message)> submit({
required String category,
required String title,
required String content,
}) async {
if (title.trim().isEmpty || content.trim().isEmpty) {
return (false, '제목과 내용을 입력해주세요.');
}
_isSubmitting = true;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/board/posts'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"category": category,
"title": title.trim(),
"content": content.trim(),
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '게시글이 등록되었습니다.');
}
return (false, '${result['message'] ?? '게시글 등록 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isSubmitting = false;
notifyListeners();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
// 🌐 플랫폼에 따라 웹 전용 구현/무동작 구현 중 하나로 연결되는 진입점.
export 'browser_notification_stub.dart'
if (dart.library.html) 'browser_notification_web.dart';
@@ -0,0 +1,4 @@
// 🌐 웹이 아닌 빌드(Android 등)에서는 브라우저 알림 API 자체가 없으니 아무 동작도 안 한다.
Future<bool> requestNotificationPermission() async => false;
void showBrowserNotification(String title, String body) {}
@@ -0,0 +1,24 @@
// 🌐 웹 전용 - 브라우저 알림(Notification) API로 "타이머 켜놓고 탭을 벗어났을 때" 같은
// 즉각적인 로컬 알림을 띄운다. FCM 푸시와 달리 서버 왕복 없이 바로 뜬다.
// 알림 API를 지원하지 않는 브라우저에서는 조용히 무시한다.
import 'dart:js_interop';
import 'package:web/web.dart' as web;
Future<bool> requestNotificationPermission() async {
try {
if (web.Notification.permission == 'granted') return true;
final permission = await web.Notification.requestPermission().toDart;
return permission.toDart == 'granted';
} catch (_) {
return false;
}
}
void showBrowserNotification(String title, String body) {
try {
if (web.Notification.permission != 'granted') return;
web.Notification(title, web.NotificationOptions(body: body));
} catch (_) {
// 조용히 무시
}
}
@@ -16,6 +16,39 @@ class DeviceCheckoutLedgerController extends ChangeNotifier {
bool get isLoading => _isLoading;
bool get isWorking => _isWorking;
// 🚨 [장시간 요청 기준] 1시간 59분(119분) 이상이면 "많은 시간요청"으로 본다.
static const int longRequestMinutes = 119;
int? _durationMinutes(dynamic request) {
try {
final start = DateTime.parse(request['requestedStart']);
final end = DateTime.parse(request['requestedEnd']);
final minutes = end.difference(start).inMinutes;
return minutes > 0 ? minutes : null;
} catch (_) {
return null;
}
}
/// ⏰ 요청 시간이 얼마나 되는지(시간 단위). 형식이 이상하면 null.
double? durationHours(dynamic request) {
final minutes = _durationMinutes(request);
return minutes == null ? null : minutes / 60.0;
}
/// 🚨 [장시간 요청 감지] 1시간 59분 넘게 신청했는지. 상태(대기/승인 등)와 무관하게 판단한다 -
/// 목록 카드에 빨간 테두리를 씌우는 데 쓴다.
bool isLongRequest(dynamic request) {
final minutes = _durationMinutes(request);
return minutes != null && minutes >= longRequestMinutes;
}
/// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 아직 대기 중인 장시간 요청만
/// 따로 모아서 전체 승인 전에 훑어볼 수 있게 한다.
List<dynamic> get longPendingRequests => _requests
.where((r) => r['status'] == 'PENDING' && isLongRequest(r))
.toList();
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@@ -63,6 +96,69 @@ class DeviceCheckoutLedgerController extends ChangeNotifier {
});
}
/// 🙌 [전체 학생 허용] 대상이 될 요청 id 목록 (대기 중이면서 장시간 요청은 아닌 것만).
/// 확인 다이얼로그에서 몇 건이 승인되고 몇 건이 제외되는지 미리 보여주는 데 쓴다.
List<int> get autoApprovableIds => _requests
.where((r) => r['status'] == 'PENDING' && !isLongRequest(r))
.map<int>((r) => r['id'] as int)
.toList();
// 🙌 [전체 학생 허용] 대기 중인 요청을 한 명씩 누르기 귀찮을 때, 한 번에 전부 승인한다.
// 단, 1시간 59분 넘는 장시간 요청은 자동으로 제외하고 대기중 상태로 남겨서,
// "많은 시간요청 학생" 목록에서 따로 검토/승인하게 한다.
// 서버에 일괄 승인 API가 따로 없어서, 대상 요청마다 승인 요청을 순서대로 보낸다.
Future<(bool success, String message)> approveAllPending({
required String teacherId,
required String teacherName,
}) async {
final targetIds = autoApprovableIds;
final excludedCount = longPendingRequests.length;
if (targetIds.isEmpty) {
return (
true,
excludedCount > 0
? '장시간 요청 $excludedCount건을 제외하면 승인할 대기 요청이 없습니다.'
: '승인 대기 중인 요청이 없습니다.',
);
}
_isWorking = true;
_safeNotify();
int successCount = 0;
for (final id in targetIds) {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/device-checkout/approve'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"requestId": id,
"teacherId": teacherId,
"teacherName": teacherName,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
successCount++;
}
} catch (_) {
// 개별 요청 실패는 건너뛰고 나머지는 계속 진행한다.
}
}
await fetchAll();
_isWorking = false;
_safeNotify();
final allOk = successCount == targetIds.length;
final excludedNote = excludedCount > 0
? ' (장시간 요청 $excludedCount건은 제외 - 대기중으로 남음)'
: '';
return (
allOk,
'${targetIds.length}건 중 $successCount건 승인 완료${allOk ? '' : ' (일부 실패)'}$excludedNote',
);
}
Future<(bool success, String message)> reject(int requestId) async {
return _postAction('/api/device-checkout/reject', {"requestId": requestId});
}
+152
View File
@@ -0,0 +1,152 @@
// 🧑‍🤝‍🧑 백품타 친구 목록/신청 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class FriendController extends ChangeNotifier {
final String studentId;
final String studentName;
FriendController({required this.studentId, required this.studentName});
bool _isLoading = true;
bool _isBusy = false;
List<dynamic> _friends = [];
List<dynamic> _requests = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
List<dynamic> get friends => _friends;
List<dynamic> get requests => _requests;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() async {
await refresh();
}
Future<void> refresh() async {
_isLoading = true;
_safeNotify();
await Future.wait([_fetchFriends(), _fetchRequests()]);
_isLoading = false;
_safeNotify();
}
Future<void> _fetchFriends() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/friends/list?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_friends = data['friends'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<void> _fetchRequests() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/friends/requests?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_requests = data['requests'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> sendRequest(String friendId) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/friends/request'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"friendId": friendId.trim(),
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '친구 신청을 보냈습니다.'}');
}
return (false, '${result['message'] ?? '친구 신청 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> respondRequest(
int requestId,
bool accept,
) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/friends/respond'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"requestId": requestId,
"responderId": studentId,
"accept": accept,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await refresh();
return (true, '${result['message'] ?? '처리했습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> removeFriend(String friendId) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.delete(
Uri.parse('$baseUrl/api/friends'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "friendId": friendId}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await _fetchFriends();
return (true, '${result['message'] ?? '삭제했습니다.'}');
}
return (false, '${result['message'] ?? '삭제 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+167
View File
@@ -0,0 +1,167 @@
// 👥 백품타 그룹 스터디(목록/생성/초대) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class GroupController extends ChangeNotifier {
final String studentId;
final String studentName;
GroupController({required this.studentId, required this.studentName});
bool _isLoading = true;
bool _isBusy = false;
List<dynamic> _groups = [];
List<dynamic> _invites = [];
List<dynamic> _publicGroups = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
List<dynamic> get groups => _groups;
List<dynamic> get invites => _invites;
List<dynamic> get publicGroups => _publicGroups;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => refresh();
Future<void> refresh() async {
_isLoading = true;
_safeNotify();
await Future.wait([_fetchGroups(), _fetchInvites(), _fetchPublicGroups()]);
_isLoading = false;
_safeNotify();
}
Future<void> _fetchGroups() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/groups/mine?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_groups = data['groups'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<void> _fetchInvites() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/groups/invites?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_invites = data['invites'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<void> _fetchPublicGroups() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/groups/public?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_publicGroups = data['groups'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> createGroup(
String name, {
bool isPublic = false,
}) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"ownerId": studentId,
"ownerName": studentName,
"name": name.trim(),
"isPublic": isPublic,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await _fetchGroups();
return (true, '${result['message'] ?? '그룹을 만들었습니다.'}');
}
return (false, '${result['message'] ?? '그룹 생성 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> respondInvite(
int inviteId,
bool accept,
) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/invites/$inviteId/respond'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"responderId": studentId, "accept": accept}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await refresh();
return (true, '${result['message'] ?? '처리했습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> requestJoin(int groupId) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/$groupId/join-request'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "studentName": studentName}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await _fetchPublicGroups();
return (true, '${result['message'] ?? '참여 신청을 보냈습니다.'}');
}
return (false, '${result['message'] ?? '참여 신청 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+175
View File
@@ -0,0 +1,175 @@
// 👥 백품타 그룹 상세(멤버/오늘 출석/초대/나가기) 화면의 기능 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class GroupDetailController extends ChangeNotifier {
final int groupId;
final String studentId;
GroupDetailController({required this.groupId, required this.studentId});
bool _isLoading = true;
bool _isBusy = false;
List<dynamic> _members = [];
List<dynamic> _joinRequests = [];
bool _isPublic = false;
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
List<dynamic> get members => _members;
List<dynamic> get joinRequests => _joinRequests;
bool get isPublic => _isPublic;
bool get isOwner =>
_members.any((m) => m['studentId'] == studentId && m['role'] == 'owner');
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => refresh();
Future<void> refresh() async {
_isLoading = true;
_safeNotify();
try {
final response = await http.get(
Uri.parse('$baseUrl/api/groups/$groupId/members'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_members = data['members'] ?? [];
_isPublic = data['isPublic'] == true;
}
} catch (_) {
// 조용히 무시
}
if (isOwner) await _fetchJoinRequests();
_isLoading = false;
_safeNotify();
}
Future<void> _fetchJoinRequests() async {
try {
final response = await http.get(
Uri.parse(
'$baseUrl/api/groups/$groupId/join-requests?ownerId=$studentId',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_joinRequests = data['requests'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> respondJoinRequest(
int requestId,
bool accept,
) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/join-requests/$requestId/respond'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"responderId": studentId, "accept": accept}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await refresh();
return (true, '${result['message'] ?? '처리했습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> setVisibility(bool isPublic) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/$groupId/visibility'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"ownerId": studentId, "isPublic": isPublic}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await refresh();
return (true, '${result['message'] ?? '변경했습니다.'}');
}
return (false, '${result['message'] ?? '변경 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> invite(
String inviteeId,
String inviteeName,
) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/$groupId/invite'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"inviterId": studentId,
"inviteeId": inviteeId,
"inviteeName": inviteeName,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '초대를 보냈습니다.'}');
}
return (false, '${result['message'] ?? '초대 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> leave() async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/groups/$groupId/leave'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '그룹에서 나갔습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+12 -2
View File
@@ -16,18 +16,21 @@ class LoginResult {
final String? name;
final bool isDeviceMatched;
final String? errorMessage;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const LoginResult.masterSuccess({required this.studentId, required this.name})
: outcome = LoginOutcome.masterSuccess,
role = null,
isDeviceMatched = true,
errorMessage = null;
errorMessage = null,
grade = null;
const LoginResult.needsPasswordChange({
required this.studentId,
required this.name,
required this.role,
required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.needsPasswordChange,
errorMessage = null;
@@ -36,6 +39,7 @@ class LoginResult {
required this.studentId,
required this.name,
required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.success,
errorMessage = null;
@@ -44,7 +48,8 @@ class LoginResult {
role = null,
studentId = null,
name = null,
isDeviceMatched = false;
isDeviceMatched = false,
grade = null;
}
class LoginController extends ChangeNotifier {
@@ -101,6 +106,7 @@ class LoginController extends ChangeNotifier {
String name = '';
String studentId = '';
int isFirstLogin = 0;
int? grade;
var userObj = resData['user'];
if (userObj != null) {
@@ -114,6 +120,7 @@ class LoginController extends ChangeNotifier {
userObj['studentId']?.toString() ??
userObj['student_id']?.toString() ??
id;
grade = int.tryParse(userObj['grade']?.toString() ?? '');
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
isFirstLogin = (rawFirst is bool)
@@ -126,6 +133,7 @@ class LoginController extends ChangeNotifier {
resData['studentName']?.toString() ??
'';
studentId = resData['studentId']?.toString() ?? id;
grade = int.tryParse(resData['grade']?.toString() ?? '');
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
isFirstLogin = (rawFirst is bool)
@@ -153,6 +161,7 @@ class LoginController extends ChangeNotifier {
name: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
);
}
@@ -161,6 +170,7 @@ class LoginController extends ChangeNotifier {
studentId: studentId,
name: name,
isDeviceMatched: isDeviceMatched,
grade: grade,
);
} else {
String errorMsg = '로그인에 실패했습니다.';
+4
View File
@@ -9,12 +9,14 @@ class SavedSession {
final String userName;
final String role;
final bool isDeviceMatched;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const SavedSession({
required this.userId,
required this.userName,
required this.role,
required this.isDeviceMatched,
this.grade,
});
Map<String, dynamic> toJson() => {
@@ -22,6 +24,7 @@ class SavedSession {
'userName': userName,
'role': role,
'isDeviceMatched': isDeviceMatched,
'grade': grade,
};
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
@@ -29,6 +32,7 @@ class SavedSession {
userName: json['userName'] as String,
role: json['role'] as String,
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
grade: json['grade'] as int?,
);
}
+154
View File
@@ -0,0 +1,154 @@
// 📅 백품타 출석 달력(개인 스트릭 + 관리자 이벤트) 화면의 기능 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class StudyCalendarController extends ChangeNotifier {
final String studentId;
StudyCalendarController({required this.studentId});
bool _isLoading = true;
bool _isBusy = false;
int _currentStreak = 0;
int _longestStreak = 0;
int _attendanceThresholdSeconds = 3600;
Set<String> _studiedDates = {};
Map<String, dynamic> _events = {}; // date -> {title, description}
DateTime _visibleMonth = DateTime.now();
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
int get currentStreak => _currentStreak;
int get longestStreak => _longestStreak;
int get attendanceThresholdSeconds => _attendanceThresholdSeconds;
Set<String> get studiedDates => _studiedDates;
Map<String, dynamic> get events => _events;
DateTime get visibleMonth => _visibleMonth;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => refresh();
Future<void> goToPreviousMonth() async {
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month - 1);
await refresh();
}
Future<void> goToNextMonth() async {
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month + 1);
await refresh();
}
Future<void> refresh() async {
_isLoading = true;
_safeNotify();
await Future.wait([_fetchStreak(), _fetchEvents()]);
_isLoading = false;
_safeNotify();
}
Future<void> _fetchStreak() async {
try {
final response = await http.get(
Uri.parse(
'$baseUrl/api/study/streak?studentId=$studentId'
'&year=${_visibleMonth.year}&month=${_visibleMonth.month}',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_currentStreak = (data['currentStreak'] as num?)?.toInt() ?? 0;
_longestStreak = (data['longestStreak'] as num?)?.toInt() ?? 0;
_attendanceThresholdSeconds =
(data['attendanceThresholdSeconds'] as num?)?.toInt() ?? 3600;
_studiedDates = ((data['studiedDates'] as List<dynamic>?) ?? [])
.map((d) => d.toString())
.toSet();
}
} catch (_) {
// 조용히 무시
}
}
Future<void> _fetchEvents() async {
try {
final response = await http.get(
Uri.parse(
'$baseUrl/api/calendar/events?year=${_visibleMonth.year}&month=${_visibleMonth.month}',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final List<dynamic> list = data['events'] ?? [];
_events = {for (final e in list) e['date']: e};
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> saveEvent({
required String date,
required String title,
required String description,
required String createdBy,
}) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/calendar/events'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"date": date,
"title": title.trim(),
"description": description.trim(),
"createdBy": createdBy,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await _fetchEvents();
return (true, '${result['message'] ?? '등록했습니다.'}');
}
return (false, '${result['message'] ?? '등록 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
Future<(bool success, String message)> deleteEvent(String date) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.delete(
Uri.parse('$baseUrl/api/calendar/events/$date'),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await _fetchEvents();
return (true, '${result['message'] ?? '삭제했습니다.'}');
}
return (false, '${result['message'] ?? '삭제 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+125
View File
@@ -0,0 +1,125 @@
// 🍅 뽀모도로 루틴 공유(다른 학생과 공부/휴식 시간 조합 공유) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class StudyPresetController extends ChangeNotifier {
final String studentId;
final String studentName;
StudyPresetController({required this.studentId, required this.studentName});
bool _isLoading = true;
bool _isBusy = false;
String _sort = 'popular'; // popular / recent
List<dynamic> _presets = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
String get sort => _sort;
List<dynamic> get presets => _presets;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() => fetchPresets();
Future<void> setSort(String sort) async {
if (_sort == sort) return;
_sort = sort;
await fetchPresets();
}
Future<void> fetchPresets() async {
_isLoading = true;
_safeNotify();
try {
final response = await http.get(
Uri.parse('$baseUrl/api/study/presets?sort=$_sort'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_presets = data['presets'] ?? [];
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
} finally {
_isLoading = false;
_safeNotify();
}
}
Future<(bool success, String message)> share({
required String title,
required int studyMinutes,
required int breakMinutes,
}) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/study/presets'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"title": title.trim(),
"studyMinutes": studyMinutes,
"breakMinutes": breakMinutes,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchPresets();
return (true, '${result['message'] ?? '루틴을 공유했습니다.'}');
}
return (false, '${result['message'] ?? '루틴 공유 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
/// 다른 학생의 루틴을 내 설정으로 적용할 때 호출 - 인기도(사용 횟수)만 올려준다.
Future<void> markUsed(int presetId) async {
try {
await http.post(Uri.parse('$baseUrl/api/study/presets/$presetId/use'));
} catch (_) {
// 인기도 집계 실패는 조용히 무시 - 적용 자체는 이미 끝난 뒤라 영향 없다.
}
}
Future<(bool success, String message)> delete(int presetId) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.delete(
Uri.parse('$baseUrl/api/study/presets/$presetId'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchPresets();
return (true, '${result['message'] ?? '삭제되었습니다.'}');
}
return (false, '${result['message'] ?? '삭제 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
@@ -0,0 +1,55 @@
// 🏆 학년별 공부시간 랭킹 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class StudyRankingController extends ChangeNotifier {
final int grade;
StudyRankingController({required this.grade});
bool _isLoading = true;
String _period = 'today'; // today / week / total
String _scope = 'grade'; // grade / all
List<dynamic> _ranking = [];
bool get isLoading => _isLoading;
String get period => _period;
String get scope => _scope;
List<dynamic> get ranking => _ranking;
Future<void> init() => fetchRanking();
Future<void> setPeriod(String period) async {
if (_period == period) return;
_period = period;
await fetchRanking();
}
Future<void> setScope(String scope) async {
if (_scope == scope) return;
_scope = scope;
await fetchRanking();
}
Future<void> fetchRanking() async {
_isLoading = true;
notifyListeners();
try {
final gradeParam = _scope == 'grade' ? '&grade=$grade' : '';
final response = await http.get(
Uri.parse('$baseUrl/api/study/ranking?period=$_period$gradeParam'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_ranking = data['ranking'] ?? [];
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 랭킹을 유지한다.
} finally {
_isLoading = false;
notifyListeners();
}
}
}
+324
View File
@@ -0,0 +1,324 @@
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config.dart';
enum StudyTimerState { idle, running, paused }
// 🍅 뽀모도로 진행 단계. study/break가 아니면(=null) 뽀모도로가 꺼져있거나 아직 시작 전.
enum PomodoroPhase { study, breakTime }
const String _prefPomodoroEnabled = 'study_pomodoro_enabled';
const String _prefPomodoroStudyMinutes = 'study_pomodoro_study_minutes';
const String _prefPomodoroBreakMinutes = 'study_pomodoro_break_minutes';
class StudyTimerController extends ChangeNotifier {
final String studentId;
final String studentName;
StudyTimerController({required this.studentId, required this.studentName});
bool _isLoading = true;
bool _isBusy = false; // 시작/일시정지/재개/종료 버튼 눌러서 서버 응답 기다리는 중
StudyTimerState _state = StudyTimerState.idle;
int _elapsedSeconds = 0; // 지금 세션에서 "정지 상태"일 때 확정된 누적 초 (일시정지 구간 제외)
DateTime? _lastResumedAt; // running일 때만 의미 있음 - 여기서부터 실시간으로 흐른다
int _todaySeconds = 0;
int _weekSeconds = 0;
int _totalSeconds = 0;
Timer? _ticker;
bool _disposed = false;
bool pomodoroEnabled = false;
int studyMinutes = 50;
int breakMinutes = 10;
PomodoroPhase? _phase;
// 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당
// 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면
// 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로
// 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다.
DateTime? _phaseEndsAt;
int completedCycles = 0;
bool _phaseTransitioning = false;
Timer? _phaseTicker;
/// 🍅 뽀모도로 진행 상황이 바뀔 때(공부↔휴식 자동 전환) UI에 알림을 띄우기 위한 콜백.
/// 자동으로 일어나는 일이라 버튼 액션처럼 (bool,message)를 돌려줄 대상이 없어서 콜백으로 처리한다.
void Function(String message)? onPhaseChanged;
bool get isLoading => _isLoading;
bool get isBusy => _isBusy;
bool get isRunning => _state == StudyTimerState.running;
bool get isPaused => _state == StudyTimerState.paused;
bool get isIdle => _state == StudyTimerState.idle;
PomodoroPhase? get phase => _phase;
int get phaseSecondsLeft {
if (_phaseEndsAt == null) return 0;
final diff = _phaseEndsAt!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
/// 지금 세션 하나만의 경과 시간(타이머 화면 큰 숫자용). 일시정지 중엔 멈춰 있다.
int get liveElapsedSeconds {
if (_state != StudyTimerState.running || _lastResumedAt == null) {
return _elapsedSeconds;
}
final diff = DateTime.now().difference(_lastResumedAt!).inSeconds;
return _elapsedSeconds + (diff > 0 ? diff : 0);
}
/// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. running일 때만 실시간으로 늘어난다.
int get todaySecondsLive => _todaySeconds + _liveDelta;
int get weekSecondsLive => _weekSeconds + _liveDelta;
int get totalSecondsLive => _totalSeconds + _liveDelta;
int get _liveDelta => _state == StudyTimerState.idle ? 0 : liveElapsedSeconds;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
Future<void> init() async {
await _loadPomodoroSettings();
await _fetchStatus();
}
/// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다.
/// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이
/// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이
/// 이미 끝났어야 한다면 바로 따라잡는다.
Future<void> onAppResumed() async {
await _fetchStatus();
checkPhaseDeadlineNow();
}
@override
void dispose() {
_disposed = true;
_ticker?.cancel();
_phaseTicker?.cancel();
super.dispose();
}
Future<void> _loadPomodoroSettings() async {
try {
final prefs = await SharedPreferences.getInstance();
pomodoroEnabled = prefs.getBool(_prefPomodoroEnabled) ?? false;
studyMinutes = prefs.getInt(_prefPomodoroStudyMinutes) ?? 50;
breakMinutes = prefs.getInt(_prefPomodoroBreakMinutes) ?? 10;
} catch (_) {
// 조용히 무시 - 기본값(50/10)을 그대로 쓴다.
}
}
/// 뽀모도로 켜짐 여부와 공부/휴식 시간(분)을 저장한다. 타이머가 진행 중일 때 값이
/// 흔들리면 안 되므로, 화면에서 idle일 때만 호출하도록 막고 있다.
Future<void> setPomodoroSettings({
required bool enabled,
required int studyMinutes,
required int breakMinutes,
}) async {
pomodoroEnabled = enabled;
this.studyMinutes = studyMinutes;
this.breakMinutes = breakMinutes;
_safeNotify();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefPomodoroEnabled, enabled);
await prefs.setInt(_prefPomodoroStudyMinutes, studyMinutes);
await prefs.setInt(_prefPomodoroBreakMinutes, breakMinutes);
} catch (_) {
// 조용히 무시 - 이번 세션 동안은 메모리 값으로라도 동작한다.
}
}
Future<void> _fetchStatus() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/study/status?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_state = _parseState(data['state']);
_elapsedSeconds = (data['elapsedSeconds'] as num?)?.toInt() ?? 0;
_lastResumedAt = data['lastResumedAt'] != null
? DateTime.tryParse(data['lastResumedAt'])
: null;
_todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0;
_weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0;
_totalSeconds = (data['totalSeconds'] as num?)?.toInt() ?? 0;
_restartTickerIfNeeded();
}
} catch (_) {
// 조용히 무시 - 마지막으로 알던 상태를 유지한다.
} finally {
_isLoading = false;
_safeNotify();
}
}
StudyTimerState _parseState(dynamic raw) {
switch (raw) {
case 'running':
return StudyTimerState.running;
case 'paused':
return StudyTimerState.paused;
default:
return StudyTimerState.idle;
}
}
void _restartTickerIfNeeded() {
_ticker?.cancel();
if (_state == StudyTimerState.running) {
_ticker = Timer.periodic(
const Duration(seconds: 1),
(_) => _safeNotify(),
);
}
}
Future<(bool success, String message)> start() async {
final result = await _action(
'/api/study/start',
body: {"studentId": studentId, "studentName": studentName},
onSuccess: (_) async {
await _fetchStatus();
return '공부를 시작했습니다.';
},
);
if (result.$1 && pomodoroEnabled) {
completedCycles = 0;
_phase = PomodoroPhase.study;
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
_startPhaseTicker();
_safeNotify();
}
return result;
}
Future<(bool success, String message)> pause() async {
return _action(
'/api/study/pause',
onSuccess: (_) async {
await _fetchStatus();
return '일시정지했습니다.';
},
);
}
Future<(bool success, String message)> resume() async {
return _action(
'/api/study/resume',
onSuccess: (_) async {
await _fetchStatus();
return '다시 시작했습니다.';
},
);
}
Future<(bool success, String message)> stop() async {
final result = await _action(
'/api/study/stop',
onSuccess: (result) async {
await _fetchStatus(); // 오늘/이번주/전체 합계를 서버 값으로 다시 맞춘다.
final minutes =
((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60;
return '이번 공부 시간: $minutes분 기록 완료!';
},
);
if (result.$1) _stopPhaseTicker();
return result;
}
void _stopPhaseTicker() {
_phaseTicker?.cancel();
_phaseTicker = null;
_phase = null;
_phaseEndsAt = null;
}
/// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면
/// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, running일 때만
/// 도는 _ticker와는 별도로 관리한다.
void _startPhaseTicker() {
_phaseTicker?.cancel();
_phaseTicker = Timer.periodic(
const Duration(seconds: 1),
(_) => _checkPhaseDeadline(),
);
}
/// 탭이 백그라운드에 있는 동안 브라우저가 setInterval을 느리게 돌리면 위 1초 tick도
/// 늦게 온다. 탭이 다시 활성화될 때(앱이 resumed 될 때) 화면에서 이 메서드를 직접
/// 호출해서, 밀린 tick을 기다리지 않고 바로 지난 시각을 따라잡는다.
void checkPhaseDeadlineNow() => _checkPhaseDeadline();
void _checkPhaseDeadline() {
if (_phase == null || _phaseTransitioning || _phaseEndsAt == null) return;
if (DateTime.now().isAfter(_phaseEndsAt!)) {
_advancePhase();
} else {
_safeNotify();
}
}
Future<void> _advancePhase() async {
_phaseTransitioning = true;
if (_phase == PomodoroPhase.study) {
final (success, _) = await pause();
if (success) {
completedCycles += 1;
_phase = PomodoroPhase.breakTime;
_phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes));
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
} else {
_phaseEndsAt = DateTime.now().add(
const Duration(seconds: 1),
); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
}
} else if (_phase == PomodoroPhase.breakTime) {
final (success, _) = await resume();
if (success) {
_phase = PomodoroPhase.study;
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
} else {
_phaseEndsAt = DateTime.now().add(const Duration(seconds: 1));
}
}
_phaseTransitioning = false;
_safeNotify();
}
Future<(bool success, String message)> _action(
String path, {
Map<String, dynamic> body = const {},
required Future<String> Function(Map<String, dynamic> result) onSuccess,
}) async {
_isBusy = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl$path'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, ...body}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
final message = await onSuccess(result);
return (true, message);
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isBusy = false;
_safeNotify();
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// 📍 선생님 호출(학생용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../data/teacher_call_schedule.dart';
class TeacherCallController extends ChangeNotifier {
final String studentId;
final String studentName;
TeacherCallController({required this.studentId, required this.studentName});
bool _isLoading = true;
bool _isCalling = false;
List<dynamic> _teachers = [];
List<dynamic> _myCalls = [];
List<dynamic> _ranking = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isCalling => _isCalling;
List<dynamic> get teachers => _teachers;
List<dynamic> get myCalls => _myCalls;
List<dynamic> get ranking => _ranking;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
bool isInClassNow(String teacherName) {
final now = DateTime.now();
final hhmm =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
return teachersInClassNow(now.weekday, hhmm).contains(teacherName);
}
Future<void> init() async {
await Future.wait([_fetchTeachers(), _fetchMyCalls(), _fetchRanking()]);
_isLoading = false;
_safeNotify();
}
Future<void> refresh() => init();
Future<void> _fetchTeachers() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/teacher-call/teachers'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_teachers = data['teachers'] ?? [];
}
} catch (_) {
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
}
}
Future<void> _fetchMyCalls() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/teacher-call/my-calls?studentId=$studentId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_myCalls = data['calls'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<void> _fetchRanking() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/teacher-call/ranking'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_ranking = data['ranking'] ?? [];
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> callTeacher({
required String teacherId,
required String purpose,
}) async {
_isCalling = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/teacher-call/call'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"teacherId": teacherId,
"purpose": purpose,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await Future.wait([_fetchMyCalls(), _fetchRanking()]);
return (true, '${result['message'] ?? '호출했습니다.'}');
}
return (false, '${result['message'] ?? '호출 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isCalling = false;
_safeNotify();
}
}
}
@@ -0,0 +1,97 @@
// 📍 선생님 위치 등록(교사용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class TeacherLocationController extends ChangeNotifier {
final String teacherId;
TeacherLocationController({required this.teacherId});
bool _isLoading = true;
bool _isSaving = false;
String _currentLocation = "교무실";
List<dynamic> _receivedCalls = [];
bool _disposed = false;
bool get isLoading => _isLoading;
bool get isSaving => _isSaving;
String get currentLocation => _currentLocation;
List<dynamic> get receivedCalls => _receivedCalls;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
Future<void> init() async {
await Future.wait([_fetchMyLocation(), fetchReceivedCalls()]);
_isLoading = false;
_safeNotify();
}
Future<void> _fetchMyLocation() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/teacher-call/teachers'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final List<dynamic> list = data['teachers'] ?? [];
final mine = list.firstWhere(
(t) => t['teacherId'] == teacherId,
orElse: () => null,
);
if (mine != null) _currentLocation = mine['location'] ?? '교무실';
}
} catch (_) {
// 조용히 무시
}
}
Future<void> fetchReceivedCalls() async {
try {
final response = await http.get(
Uri.parse(
'$baseUrl/api/teacher-call/received-calls?teacherId=$teacherId',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_receivedCalls = data['calls'] ?? [];
_safeNotify();
}
} catch (_) {
// 조용히 무시
}
}
Future<(bool success, String message)> updateLocation(String location) async {
_isSaving = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/teacher-call/location'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"teacherId": teacherId, "location": location}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
_currentLocation = location;
return (true, '${result['message'] ?? '위치가 업데이트되었습니다.'}');
}
return (false, '${result['message'] ?? '업데이트 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isSaving = false;
_safeNotify();
}
}
}
@@ -1,5 +1,6 @@
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:excel/excel.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
@@ -37,8 +38,42 @@ class TeacherStudentManagementController extends ChangeNotifier {
List<dynamic> _students = [];
bool _isLoadingStudents = true;
String _searchQuery = '';
int? _gradeFilter; // null = 전체
List<dynamic> get students => _students;
bool get isLoadingStudents => _isLoadingStudents;
String get searchQuery => _searchQuery;
int? get gradeFilter => _gradeFilter;
/// 🔍 학번 검색 + 학년 필터를 적용하고, 학년 오름차순(1→2→3, 미배정은 맨 뒤)으로
/// 정렬한 목록. 화면에는 이 목록을 그린다.
List<dynamic> get filteredStudents {
final query = _searchQuery.trim();
final filtered = _students.where((s) {
if (_gradeFilter != null && s['grade'] != _gradeFilter) return false;
if (query.isEmpty) return true;
return s['id'].toString().contains(query);
}).toList();
filtered.sort((a, b) {
final ga = a['grade'] as int?;
final gb = b['grade'] as int?;
if (ga == null && gb == null) return 0;
if (ga == null) return 1;
if (gb == null) return -1;
return ga.compareTo(gb);
});
return filtered;
}
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
void setGradeFilter(int? grade) {
_gradeFilter = grade;
notifyListeners();
}
Future<void> init() => fetchStudents();
@@ -91,7 +126,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
final excel = Excel.decodeBytes(bytes);
final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes));
if (excel.tables.isEmpty) return [];
final sheet = excel.tables.values.first;
@@ -108,6 +143,70 @@ class TeacherStudentManagementController extends ChangeNotifier {
return rows;
}
/// 🩹 [엑셀 파싱 버그 우회] 일부 엑셀 파일(구글시트/WPS 등에서 내보낸 파일 포함)은
/// styles.xml에 커스텀 숫자서식 id를 164 미만(예: 0)으로 잘못 적어두는데, excel
/// 패키지가 이걸 엄격하게 걸러내며 예외를 던져서 파일 전체를 못 읽게 만든다.
/// 우리는 셀 값(학번/이름/학년)만 필요하고 서식은 필요 없으니, zip 안의 styles.xml만
/// 열어서 그 잘못된 커스텀 서식 선언을 지우고 다시 압축해서 넘긴다.
Uint8List _sanitizeXlsxBytes(Uint8List bytes) {
try {
final zip = ZipDecoder().decodeBytes(bytes);
final stylesFile = zip.files.cast<ArchiveFile?>().firstWhere(
(f) => f?.name == 'xl/styles.xml',
orElse: () => null,
);
if (stylesFile == null) return bytes;
final xml = utf8.decode(stylesFile.content as List<int>);
final fixedXml = _stripInvalidCustomNumFmts(xml);
if (fixedXml == xml) return bytes; // 고칠 게 없었으면 원본 그대로.
final newZip = Archive();
for (final file in zip.files) {
if (!file.isFile) continue;
if (file.name == 'xl/styles.xml') {
final content = utf8.encode(fixedXml);
newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content));
} else {
newZip.addFile(ArchiveFile(file.name, file.size, file.content));
}
}
final rezipped = ZipEncoder().encode(newZip);
return rezipped != null ? Uint8List.fromList(rezipped) : bytes;
} catch (_) {
return bytes; // 손대다 실패하면 원본 그대로 시도 (기존 동작 유지).
}
}
/// styles.xml 문자열에서 `<numFmt numFmtId="N" .../>` 태그 중 N이 164 미만인
/// (규격 위반인) 것만 통째로 제거한다. 그 서식을 참조하던 셀은 기본 서식으로 처리될
/// 뿐이라 값 읽기에는 영향이 없다.
String _stripInvalidCustomNumFmts(String xml) {
final buffer = StringBuffer();
int i = 0;
while (true) {
final tagStart = xml.indexOf('<numFmt ', i);
if (tagStart == -1) {
buffer.write(xml.substring(i));
break;
}
final tagEnd = xml.indexOf('/>', tagStart);
if (tagEnd == -1) {
buffer.write(xml.substring(i));
break;
}
buffer.write(xml.substring(i, tagStart));
final tag = xml.substring(tagStart, tagEnd + 2);
final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag);
final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null;
if (id == null || id >= 164) {
buffer.write(tag); // 정상 범위의 커스텀 서식은 그대로 둔다.
}
i = tagEnd + 2;
}
return buffer.toString();
}
String? _cellText(CellValue? value) {
if (value == null) return null;
String text;
+14
View File
@@ -22,6 +22,18 @@ void main() async {
runApp(const SchoolAttendanceApp());
}
// 🌊 스크롤이 끝에 닿을 때 나오는 파란색 오버스크롤 글로우 효과를 없앤다.
class _NoGlowScrollBehavior extends MaterialScrollBehavior {
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class SchoolAttendanceApp extends StatelessWidget {
const SchoolAttendanceApp({super.key});
@@ -30,6 +42,7 @@ class SchoolAttendanceApp extends StatelessWidget {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: '$schoolName 학생 도우미',
scrollBehavior: _NoGlowScrollBehavior(),
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: AppPalette.mist,
@@ -94,6 +107,7 @@ class _StartupGateState extends State<_StartupGate> {
userName: session.userName,
role: session.role,
isDeviceMatched: session.isDeviceMatched,
grade: session.grade,
);
}
return const LoginScreen();
+4 -9
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../theme/app_palette.dart';
import '../ui/app_notice.dart';
import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -22,9 +23,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
Future<void> _updatePassword() async {
String newPw = _pwController.text.trim();
if (newPw.isEmpty || newPw == "1234") {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
);
AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.');
return;
}
@@ -38,9 +37,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('비밀번호 변경 완료! 다시 로그인해 주세요.')),
);
AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.');
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
Navigator.pushReplacement(
context,
@@ -48,9 +45,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
);
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('통신 실패')));
AppNotice.show(context, '통신 실패');
} finally {
setState(() => _isLoading = false);
}
+11
View File
@@ -11,4 +11,15 @@ class AppPalette {
static const Color linen = Color(0xFFECEBE4); // 은은한 카드/배지 배경
static const Color mist = Color(0xFFEEF0F2); // 페이지 배경
static const Color paper = Color(0xFFFAFAFF); // 카드 표면(거의 흰색)
// 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼
// 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다.
static const Color danger = Color(0xFFE13B3B); // 삭제/위반/거절/신고 등 위험/경고
static const Color dangerBg = Color(0xFFFCE9E9); // danger의 연한 배경(배지 등)
static const Color warning = Color(0xFFF08A24); // 대기중/수업중/진행중 등 주의
static const Color warningBg = Color(0xFFFDF0E1); // warning의 연한 배경
static const Color success = Color(0xFF2E9E5B); // 완료/성공(NFC 스캔 성공 등)
static const Color successBg = Color(0xFFE4F5EB); // success의 연한 배경
static const Color approved = Color(0xFF3B78C2); // 승인됨/완료 상태 표시
static const Color approvedBg = Color(0xFFE7F0FA); // approved의 연한 배경
}
+62 -47
View File
@@ -3,7 +3,9 @@
import 'package:flutter/material.dart';
import '../function/admin_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'login_screen.dart';
import 'title_pill.dart';
// ==========================================
// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료)
@@ -27,9 +29,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
Future<void> _resetDatabase() async {
final message = await _controller.resetDatabase();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
void _showResetConfirmDialog() {
@@ -39,7 +39,10 @@ class _AdminDashboardState extends State<AdminDashboard> {
return AlertDialog(
title: const Text(
'DB 초기화 경고',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
style: TextStyle(
color: AppPalette.danger,
fontWeight: FontWeight.bold,
),
),
content: const Text(
'모든 학생의 출석 및 휴대폰 제출 데이터가 영구적으로 삭제됩니다.\n\n정말 초기화하시겠습니까?',
@@ -50,7 +53,9 @@ class _AdminDashboardState extends State<AdminDashboard> {
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger,
),
onPressed: () {
Navigator.pop(dialogContext);
_resetDatabase();
@@ -74,9 +79,11 @@ class _AdminDashboardState extends State<AdminDashboard> {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text('관리자 시스템'),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('관리자 시스템'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
@@ -90,50 +97,58 @@ class _AdminDashboardState extends State<AdminDashboard> {
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.admin_panel_settings,
size: 100,
color: AppPalette.ink,
),
const SizedBox(height: 20),
const Text(
'데이터베이스 관리',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.admin_panel_settings,
size: 100,
color: AppPalette.ink,
),
const SizedBox(height: 20),
const Text(
'데이터베이스 관리',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 40),
_controller.isLoading
? const CircularProgressIndicator(color: Colors.red)
: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[50],
foregroundColor: Colors.red,
side: const BorderSide(
color: Colors.red,
width: 2,
_controller.isLoading
? const CircularProgressIndicator(
color: AppPalette.danger,
)
: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.dangerBg,
foregroundColor: AppPalette.danger,
side: const BorderSide(
color: AppPalette.danger,
width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
icon: const Icon(Icons.delete_forever, size: 28),
label: const Text(
'모든 출석 데이터 초기화',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
onPressed: _showResetConfirmDialog,
),
icon: const Icon(Icons.delete_forever, size: 28),
label: const Text(
'모든 출석 데이터 초기화',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
onPressed: _showResetConfirmDialog,
),
),
],
],
),
),
),
),
+13 -4
View File
@@ -9,8 +9,13 @@ class AppNotice {
static final List<_QueuedNotice> _queue = [];
static bool _showing = false;
static void show(BuildContext context, String message, {IconData? icon}) {
_queue.add(_QueuedNotice(context, message, icon));
static void show(
BuildContext context,
String message, {
IconData? icon,
Color? color,
}) {
_queue.add(_QueuedNotice(context, message, icon, color));
_tryShowNext();
}
@@ -28,6 +33,7 @@ class AppNotice {
builder: (context) => _NoticeBanner(
message: next.message,
icon: next.icon,
color: next.color,
onDone: () {
entry.remove();
_showing = false;
@@ -43,17 +49,20 @@ class _QueuedNotice {
final BuildContext context;
final String message;
final IconData? icon;
_QueuedNotice(this.context, this.message, this.icon);
final Color? color;
_QueuedNotice(this.context, this.message, this.icon, this.color);
}
class _NoticeBanner extends StatefulWidget {
final String message;
final IconData? icon;
final Color? color;
final VoidCallback onDone;
const _NoticeBanner({
required this.message,
required this.icon,
required this.color,
required this.onDone,
});
@@ -102,7 +111,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
color: AppPalette.ink,
color: widget.color ?? AppPalette.ink,
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
+303
View File
@@ -0,0 +1,303 @@
// 🗣️ 커뮤니티 게시판(백판) 홈 화면 (UI 전용). 카테고리 탭 + 글 목록 + 글쓰기.
// 서버 통신/상태는 lib/function/board_list_controller.dart, board_write_controller.dart가 담당한다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../function/board_list_controller.dart';
import '../theme/app_palette.dart';
import 'board_post_detail_screen.dart';
import 'board_report_screen.dart';
import 'board_write_screen.dart';
import 'launchpad_transition.dart';
import 'title_pill.dart';
class BoardHomeScreen extends StatefulWidget {
final String studentId;
final bool canModerate;
const BoardHomeScreen({
super.key,
required this.studentId,
this.canModerate = false,
});
@override
State<BoardHomeScreen> createState() => _BoardHomeScreenState();
}
class _BoardHomeScreenState extends State<BoardHomeScreen>
with SingleTickerProviderStateMixin {
List<dynamic> _categories = [];
bool _isLoadingCategories = true;
TabController? _tabController;
final Map<String, BoardListController> _controllers = {};
@override
void initState() {
super.initState();
_fetchCategories();
}
@override
void dispose() {
_tabController?.dispose();
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
Future<void> _fetchCategories() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/board/categories'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_categories = data['categories'] ?? [];
}
} catch (_) {
// 조용히 무시 - 아래에서 목록이 비어있으면 에러로 처리한다.
} finally {
if (mounted) {
setState(() {
_isLoadingCategories = false;
if (_categories.isNotEmpty) {
_tabController = TabController(
length: _categories.length,
vsync: this,
);
}
});
}
}
}
BoardListController _controllerFor(String category) {
return _controllers.putIfAbsent(
category,
() =>
BoardListController(category: category, studentId: widget.studentId)
..init(),
);
}
Future<void> _openWrite() async {
final tabController = _tabController;
if (tabController == null) return;
final category = _categories[tabController.index];
final changed = await pushLaunchpad<bool>(
context,
(context) => BoardWriteScreen(
studentId: widget.studentId,
category: category['key'],
categoryName: category['name'],
),
);
if (changed == true) {
_controllerFor(category['key']).refresh();
}
}
void _openModeration() {
pushLaunchpad(
context,
(context) => BoardReportScreen(reviewerId: widget.studentId),
);
}
Future<void> _openPost(int postId, String category) async {
final changed = await pushLaunchpad<bool>(
context,
(context) =>
BoardPostDetailScreen(postId: postId, studentId: widget.studentId),
);
if (changed == true) {
_controllerFor(category).refresh();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('백판'),
actions: [
if (widget.canModerate)
IconButton(
icon: const Icon(Icons.shield_outlined),
tooltip: '신고 검토',
onPressed: _openModeration,
),
],
bottom: _tabController == null
? null
: TabBar(
controller: _tabController,
labelColor: AppPalette.ink,
unselectedLabelColor: Colors.grey,
indicatorColor: AppPalette.ink,
tabs: [for (final c in _categories) Tab(text: c['name'])],
),
),
floatingActionButton: _tabController == null
? null
: FloatingActionButton(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _openWrite,
child: const Icon(Icons.edit_rounded),
),
body: _isLoadingCategories
? const Center(child: CircularProgressIndicator())
: _tabController == null
? Center(
child: TextButton(
onPressed: () {
setState(() => _isLoadingCategories = true);
_fetchCategories();
},
child: const Text('게시판을 불러오지 못했습니다. 다시 시도'),
),
)
: TabBarView(
controller: _tabController,
children: [
for (final c in _categories)
_CategoryBoardList(
controller: _controllerFor(c['key']),
onOpenPost: (postId) => _openPost(postId, c['key']),
),
],
),
);
}
}
class _CategoryBoardList extends StatefulWidget {
final BoardListController controller;
final ValueChanged<int> onOpenPost;
const _CategoryBoardList({
required this.controller,
required this.onOpenPost,
});
@override
State<_CategoryBoardList> createState() => _CategoryBoardListState();
}
class _CategoryBoardListState extends State<_CategoryBoardList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
widget.controller.loadMore();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
if (widget.controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final posts = widget.controller.posts;
if (posts.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'아직 글이 없어요.\n첫 글을 남겨보세요.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 8),
TextButton(
onPressed: widget.controller.refresh,
child: const Text('새로고침'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: widget.controller.refresh,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: posts.length + (widget.controller.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= posts.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final post = posts[index];
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppPalette.sage),
),
child: ListTile(
title: Text(
post['title'] ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${post['createdAt'] ?? ''}',
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.mode_comment_outlined,
size: 15,
color: Colors.grey,
),
const SizedBox(width: 3),
Text('${post['commentCount'] ?? 0}'),
],
),
onTap: () => widget.onOpenPost(post['id'] as int),
),
);
},
),
);
},
);
}
}
+399
View File
@@ -0,0 +1,399 @@
// 💬 커뮤니티 게시판 - 게시글 상세 + 댓글 화면 (UI 전용).
// 서버 통신/상태는 lib/function/board_post_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/board_post_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class BoardPostDetailScreen extends StatefulWidget {
final int postId;
final String studentId;
const BoardPostDetailScreen({
super.key,
required this.postId,
required this.studentId,
});
@override
State<BoardPostDetailScreen> createState() => _BoardPostDetailScreenState();
}
class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
late final BoardPostController _controller;
final _commentController = TextEditingController();
bool _changed = false;
@override
void initState() {
super.initState();
_controller = BoardPostController(
postId: widget.postId,
studentId: widget.studentId,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
_commentController.dispose();
super.dispose();
}
Future<void> _submitComment() async {
final text = _commentController.text;
final (success, message) = await _controller.addComment(text);
if (!mounted) return;
if (success) {
_commentController.clear();
_changed = true;
}
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _showReportSheet({
required String targetType,
required int targetId,
}) async {
final reasonController = TextEditingController();
final reason = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'신고 사유 (선택)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 10),
TextField(
controller: reasonController,
maxLines: 3,
decoration: InputDecoration(
hintText: '어떤 점이 문제인지 알려주세요.',
filled: true,
fillColor: AppPalette.mist,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 14),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () =>
Navigator.of(sheetContext).pop(reasonController.text),
child: const Text('신고하기'),
),
],
),
),
);
if (reason == null || !mounted) return;
final (success, message) = await _controller.report(
targetType: targetType,
targetId: targetId,
reason: reason.trim().isEmpty ? null : reason.trim(),
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.flag_rounded : Icons.error_outline_rounded,
);
}
Future<void> _deletePost() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('게시글 삭제'),
content: const Text('이 게시글을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
),
],
),
);
if (confirmed != true) return;
final (success, message) = await _controller.deletePost();
if (!mounted) return;
AppNotice.show(context, message);
if (success) Navigator.of(context).pop(true);
}
Future<void> _deleteComment(int commentId) async {
final (success, message) = await _controller.deleteComment(commentId);
if (!mounted) return;
if (success) _changed = true;
AppNotice.show(context, message);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) Navigator.of(context).pop(_changed);
},
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final post = _controller.post;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('게시글'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18),
onPressed: () => Navigator.of(context).pop(_changed),
),
actions: [
if (post != null && post['isMine'] != true)
IconButton(
icon: const Icon(Icons.flag_outlined),
tooltip: '신고',
onPressed: () => _showReportSheet(
targetType: 'post',
targetId: widget.postId,
),
),
if (post != null && post['isMine'] == true)
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
tooltip: '삭제',
onPressed: _deletePost,
),
],
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: _controller.postDeleted || post == null
? const Center(
child: Text(
'삭제되었거나 존재하지 않는 게시글입니다.',
style: TextStyle(color: Colors.grey),
),
)
: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildPostCard(post),
const SizedBox(height: 16),
Text(
'댓글 ${_controller.comments.length}개',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
const SizedBox(height: 8),
..._controller.comments.map(_buildCommentTile),
],
),
),
_buildCommentComposer(),
],
),
);
},
),
);
}
Widget _buildPostCard(Map<String, dynamic> post) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post['title'] ?? '',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: AppPalette.ink,
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
post['isMine'] == true ? '글쓴이(나)' : '익명',
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
),
const SizedBox(width: 8),
Text(
'${post['createdAt'] ?? ''}',
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
),
const Spacer(),
Icon(Icons.visibility_outlined, size: 14, color: Colors.grey),
const SizedBox(width: 3),
Text(
'${post['viewCount'] ?? 0}',
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
),
],
),
const Divider(height: 24),
Text(
post['content'] ?? '',
style: const TextStyle(fontSize: 15, height: 1.5),
),
],
),
);
}
Widget _buildCommentTile(dynamic comment) {
final bool isMine = comment['isMine'] == true;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${comment['anonLabel'] ?? '익명'}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12.5,
color: isMine ? AppPalette.ink : Colors.grey[700],
),
),
const SizedBox(width: 8),
Text(
'${comment['createdAt'] ?? ''}',
style: const TextStyle(
color: Colors.grey,
fontSize: 11.5,
),
),
],
),
const SizedBox(height: 4),
Text(
comment['content'] ?? '',
style: const TextStyle(fontSize: 14, height: 1.4),
),
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
isMine ? Icons.delete_outline_rounded : Icons.flag_outlined,
size: 18,
color: Colors.grey,
),
onPressed: isMine
? () => _deleteComment(comment['id'] as int)
: () => _showReportSheet(
targetType: 'comment',
targetId: comment['id'] as int,
),
),
],
),
);
}
Widget _buildCommentComposer() {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
decoration: InputDecoration(
hintText: '익명으로 댓글 달기',
filled: true,
fillColor: AppPalette.paper,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(999),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 8),
_controller.isBusy
? const SizedBox(
width: 40,
height: 40,
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
icon: const Icon(Icons.send_rounded, color: AppPalette.ink),
onPressed: _submitComment,
),
],
),
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면 (UI 전용).
// 서버 통신/상태는 lib/function/board_report_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/board_report_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class BoardReportScreen extends StatefulWidget {
final String reviewerId;
const BoardReportScreen({super.key, required this.reviewerId});
@override
State<BoardReportScreen> createState() => _BoardReportScreenState();
}
class _BoardReportScreenState extends State<BoardReportScreen> {
late final BoardReportController _controller;
@override
void initState() {
super.initState();
_controller = BoardReportController(reviewerId: widget.reviewerId);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _resolve(int reportId, String action) async {
final (success, message) = await _controller.resolve(reportId, action);
if (!mounted) return;
AppNotice.show(context, message);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final reports = _controller.reports;
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())
: reports.isEmpty
? const Center(
child: Text(
'처리할 신고가 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: RefreshIndicator(
onRefresh: _controller.fetchPending,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: reports.length,
itemBuilder: (context, index) {
final report = reports[index];
final reportId = report['id'] as int;
final alreadyDeleted = report['alreadyDeleted'] == true;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppPalette.sage),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Chip(
label: Text(
report['targetType'] == 'post'
? '게시글'
: '댓글',
),
visualDensity: VisualDensity.compact,
backgroundColor: AppPalette.mist,
),
const SizedBox(width: 8),
Text(
'${report['createdAt'] ?? ''}',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
),
const SizedBox(height: 10),
Text(
'${report['targetContent'] ?? ''}',
style: const TextStyle(fontSize: 14, height: 1.4),
),
const SizedBox(height: 8),
if (report['reason'] != null &&
'${report['reason']}'.isNotEmpty)
Text(
'신고 사유: ${report['reason']}',
style: const TextStyle(
color: AppPalette.danger,
fontSize: 12.5,
),
),
Text(
'신고자 학번: ${report['reporterStudentId'] ?? ''}',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
const SizedBox(height: 12),
if (alreadyDeleted)
const Text(
'이미 삭제된 항목입니다.',
style: TextStyle(color: Colors.grey),
)
else
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _controller.isBusy
? null
: () => _resolve(reportId, 'dismiss'),
child: const Text('유지 (문제 없음)'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger,
foregroundColor: Colors.white,
),
onPressed: _controller.isBusy
? null
: () => _resolve(reportId, 'delete'),
child: const Text('삭제'),
),
),
],
),
],
),
);
},
),
),
);
},
);
}
}
+138
View File
@@ -0,0 +1,138 @@
// ✍️ 커뮤니티 게시판 - 글쓰기 화면 (UI 전용).
// 서버 통신/상태는 lib/function/board_write_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/board_write_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class BoardWriteScreen extends StatefulWidget {
final String studentId;
final String category;
final String categoryName;
const BoardWriteScreen({
super.key,
required this.studentId,
required this.category,
required this.categoryName,
});
@override
State<BoardWriteScreen> createState() => _BoardWriteScreenState();
}
class _BoardWriteScreenState extends State<BoardWriteScreen> {
late final BoardWriteController _controller;
final _titleController = TextEditingController();
final _contentController = TextEditingController();
@override
void initState() {
super.initState();
_controller = BoardWriteController(studentId: widget.studentId);
}
@override
void dispose() {
_controller.dispose();
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _submit() async {
final (success, message) = await _controller.submit(
category: widget.category,
title: _titleController.text,
content: _contentController.text,
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
if (success) Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: TitlePill('${widget.categoryName} 글쓰기'),
actions: [
TextButton(
onPressed: _controller.isSubmitting ? null : _submit,
child: _controller.isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'등록',
style: TextStyle(
color: AppPalette.ink,
fontWeight: FontWeight.bold,
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 4, left: 4),
child: Text(
'글쓴이는 익명으로 표시됩니다.',
style: TextStyle(color: Colors.grey, fontSize: 12.5),
),
),
const SizedBox(height: 8),
TextField(
controller: _titleController,
maxLength: 100,
decoration: InputDecoration(
hintText: '제목',
filled: true,
fillColor: AppPalette.paper,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: AppPalette.sage),
),
),
),
TextField(
controller: _contentController,
maxLines: 12,
minLines: 8,
decoration: InputDecoration(
hintText: '내용을 입력하세요',
filled: true,
fillColor: AppPalette.paper,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: AppPalette.sage),
),
),
),
],
),
),
);
},
);
}
}
+5 -3
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart';
import 'title_pill.dart';
class DashboardSettingsPage extends StatelessWidget {
const DashboardSettingsPage({super.key});
@@ -12,10 +13,11 @@ class DashboardSettingsPage extends StatelessWidget {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text('설정'),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('설정'),
),
body: ListView(
padding: const EdgeInsets.all(16),
+218 -27
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart';
import '../function/device_checkout_ledger_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutLedgerPage extends StatefulWidget {
final String? teacherId;
@@ -38,38 +40,199 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
teacherName: widget.teacherName ?? '간편인증 선생',
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
Future<void> _reject(int id) async {
final (_, message) = await _controller.reject(id);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
// 🙌 [전체 학생 허용] 한 명씩 누르기 귀찮을 때, 대기 중인 요청을 한 번에 전부 승인한다.
// 1시간 59분 넘는 장시간 요청은 자동으로 제외되어 대기중으로 남는다 - 따로 검토가 필요해서.
void _showApproveAllConfirmDialog() {
final pendingCount = _controller.requests
.where((r) => r['status'] == 'PENDING')
.length;
if (pendingCount == 0) {
AppNotice.show(context, '승인 대기 중인 요청이 없습니다.');
return;
}
final approvableCount = _controller.autoApprovableIds.length;
final excludedCount = pendingCount - approvableCount;
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('전체 학생 허용'),
content: Text(
excludedCount > 0
? '대기 중인 요청 $pendingCount건 중 $approvableCount건을 승인합니다.\n'
'장시간(1시간 59분 이상) 요청 $excludedCount건은 제외되어 "많은 시간요청 학생"에서 따로 확인해야 합니다.'
: '대기 중인 요청 $pendingCount건을 한 번에 전부 승인하시겠습니까?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () async {
Navigator.pop(dialogContext);
final (_, message) = await _controller.approveAllPending(
teacherId: widget.teacherId ?? '간편인증',
teacherName: widget.teacherName ?? '간편인증 선생',
);
if (!mounted) return;
AppNotice.show(context, message);
},
child: const Text('전체 승인'),
),
],
),
);
}
// 🚨 [많은 시간요청 학생] 1시간 59분 넘게 신청한 대기 중인 요청만 따로 모아 보여준다.
// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 전체 승인 전에 훑어보는 용도.
void _showLongRequestsDialog() {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppPalette.warning),
SizedBox(width: 8),
Text('많은 시간요청 학생'),
],
),
content: SizedBox(
width: 380,
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final longRequests = _controller.longPendingRequests;
if (longRequests.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text(
'1시간 59분 넘게 신청한 대기 중인 요청이 없습니다.',
style: TextStyle(color: Colors.grey),
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: true,
itemCount: longRequests.length,
separatorBuilder: (_, _) => const Divider(height: 20),
itemBuilder: (context, index) {
final r = longRequests[index];
final hours = _controller.durationHours(r);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${r['studentName']} (${r['studentId']})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'${_timeRange(r['requestedStart'], r['requestedEnd'])} '
'(약 ${hours?.toStringAsFixed(1)}시간)',
style: const TextStyle(
color: AppPalette.warning,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
r['purpose'],
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _controller.isWorking
? null
: () {
Navigator.pop(dialogContext);
_reject(r['id']);
},
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.danger,
side: const BorderSide(
color: AppPalette.danger,
),
),
child: const Text('거절'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: _controller.isWorking
? null
: () {
Navigator.pop(dialogContext);
_approve(r['id']);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
child: const Text('승인'),
),
),
],
),
],
);
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'),
),
],
),
);
}
// 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼.
Future<void> _confirmReturn(int id) async {
final (_, message) = await _controller.confirmReturn(id);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
({Color color, String label}) _statusInfo(String status) {
switch (status) {
case 'PENDING':
return (color: Colors.orange, label: '대기중');
return (color: AppPalette.warning, label: '대기중');
case 'APPROVED':
return (color: Colors.blue, label: '승인됨');
return (color: AppPalette.approved, label: '승인됨');
case 'RETURNED':
return (color: Colors.grey, label: '반납완료');
case 'REJECTED':
return (color: Colors.red, label: '거절됨');
return (color: AppPalette.danger, label: '거절됨');
default:
return (color: Colors.grey, label: status);
}
@@ -90,23 +253,37 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
final pendingCount = requests
.where((r) => r['status'] == 'PENDING')
.length;
final longCount = _controller.longPendingRequests.length;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text(
'스마트기기 반출 대장',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('스마트기기 반출 대장'),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _controller.fetchAll,
tooltip: '새로고침',
),
IconButton(
icon: const Icon(Icons.done_all_rounded),
onPressed: _showApproveAllConfirmDialog,
tooltip: '전체 학생 허용',
),
IconButton(
icon: Badge(
isLabelVisible: longCount > 0,
label: Text('$longCount'),
backgroundColor: AppPalette.danger,
child: const Icon(Icons.warning_amber_rounded),
),
onPressed: _showLongRequestsDialog,
tooltip: '많은 시간요청 학생',
),
],
),
body: _controller.isLoading
@@ -122,14 +299,14 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.orange[100],
color: AppPalette.warningBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange[400]!),
border: Border.all(color: AppPalette.warning),
),
child: Text(
'승인 대기 중인 요청이 $pendingCount건 있습니다.',
style: TextStyle(
color: Colors.orange[900],
color: AppPalette.warning,
fontWeight: FontWeight.bold,
),
),
@@ -153,6 +330,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
final String status = r['status'];
final info = _statusInfo(status);
final bool isPending = status == 'PENDING';
// 🚨 1시간 59분 넘게 신청했으면 상태와 무관하게 빨간 테두리로 눈에 띄게.
final bool isLong = _controller.isLongRequest(
r,
);
return Card(
elevation: 0,
@@ -160,9 +341,18 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: isPending
? BorderSide(color: Colors.orange[300]!)
: BorderSide(color: AppPalette.sage),
side: isLong
? const BorderSide(
color: AppPalette.danger,
width: 2,
)
: (isPending
? const BorderSide(
color: AppPalette.warning,
)
: const BorderSide(
color: AppPalette.sage,
)),
),
child: Padding(
padding: const EdgeInsets.all(16),
@@ -231,7 +421,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
Text(
'${r['approvedByTeacherName']} 선생님이 허용했습니다',
style: TextStyle(
color: Colors.blue[700],
color: AppPalette.approved,
fontSize: 12,
fontWeight: FontWeight.w600,
),
@@ -250,9 +440,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
style:
OutlinedButton.styleFrom(
foregroundColor:
Colors.red,
AppPalette.danger,
side: const BorderSide(
color: Colors.red,
color:
AppPalette.danger,
),
),
child: const Text('거절'),
+95 -92
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart';
import '../function/device_checkout_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutRequestScreen extends StatefulWidget {
final String studentId;
@@ -57,9 +59,7 @@ class _DeviceCheckoutRequestScreenState
Future<void> _submit() async {
final purpose = _purposeController.text.trim();
if (_startTime == null || _endTime == null || purpose.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('시작/종료 시각과 사용 목적을 모두 입력해주세요.')),
);
AppNotice.show(context, '시작/종료 시각과 사용 목적을 모두 입력해주세요.');
return;
}
@@ -71,9 +71,7 @@ class _DeviceCheckoutRequestScreenState
endTime: _formatTime(_endTime!),
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
if (success) Navigator.pop(context);
}
@@ -85,106 +83,111 @@ class _DeviceCheckoutRequestScreenState
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text(
'스마트기기 반출 신청',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('스마트기기 반출 신청'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Row(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickStartTime,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
_startTime == null
? '시작 시각'
: _formatTime(_startTime!),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickStartTime,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
_startTime == null
? '시작 시각'
: _formatTime(_startTime!),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickEndTime,
icon: const Icon(Icons.stop_rounded),
label: Text(
_endTime == null
? '종료 시각'
: _formatTime(_endTime!),
),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _purposeController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '사용 목적',
hintText: '예: 수행평가 자료조사',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickEndTime,
icon: const Icon(Icons.stop_rounded),
label: Text(
_endTime == null
? '종료 시각'
: _formatTime(_endTime!),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _controller.isSubmitting
? null
: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isSubmitting
? const CircularProgressIndicator(
color: Colors.white,
)
: const Text(
'반출 신청하기',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _purposeController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '사용 목적',
hintText: '예: 수행평가 자료조사',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _controller.isSubmitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isSubmitting
? const CircularProgressIndicator(
color: Colors.white,
)
: const Text(
'반출 신청하기',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
),
],
),
],
),
),
),
);
+299
View File
@@ -0,0 +1,299 @@
// 🧑‍🤝‍🧑 백품타 친구 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
// 서버 통신/상태는 lib/function/friend_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/friend_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class FriendsScreen extends StatefulWidget {
final String studentId;
final String studentName;
const FriendsScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<FriendsScreen> createState() => _FriendsScreenState();
}
class _FriendsScreenState extends State<FriendsScreen> {
late final FriendController _controller;
@override
void initState() {
super.initState();
_controller = FriendController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _showAddFriendDialog() async {
final idController = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('친구 추가'),
content: TextField(
controller: idController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '친구 학번',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('신청하기'),
),
],
),
);
if (result != true || idController.text.trim().isEmpty) return;
final (success, message) = await _controller.sendRequest(
idController.text.trim(),
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _respond(int requestId, bool accept) async {
final (_, message) = await _controller.respondRequest(requestId, accept);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _remove(String friendId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('친구 삭제'),
content: const Text('이 친구를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
),
],
),
);
if (confirmed != true) return;
final (_, message) = await _controller.removeFriend(friendId);
if (!mounted) return;
AppNotice.show(context, message);
}
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(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showAddFriendDialog,
icon: const Icon(Icons.person_add_rounded),
label: const Text('친구 추가'),
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
const Center(child: TitlePill('친구')),
const SizedBox(height: 20),
if (_controller.requests.isNotEmpty) ...[
_sectionHeader(
'받은 친구 신청',
count: _controller.requests.length,
),
const SizedBox(height: 10),
for (final r in _controller.requests)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text(
'${r['name']} (${r['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () => _respond(
r['requestId'] as int,
false,
),
child: const Text(
'거절',
style: TextStyle(color: Colors.grey),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () =>
_respond(r['requestId'] as int, true),
child: const Text('수락'),
),
],
),
),
const SizedBox(height: 20),
],
_sectionHeader(
'내 친구',
count: _controller.friends.length,
),
const SizedBox(height: 10),
if (_controller.friends.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'아직 친구가 없어요. 학번으로 친구를 추가해보세요.',
style: TextStyle(color: Colors.grey),
),
)
else
for (final f in _controller.friends)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
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.person_rounded,
size: 16,
color: AppPalette.ink,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'${f['name']} (${f['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(
Icons.person_remove_outlined,
color: Colors.grey,
),
onPressed: () =>
_remove(f['studentId'] as String),
),
],
),
),
],
),
),
),
),
);
},
);
}
}
+596
View File
@@ -0,0 +1,596 @@
// 👥 백품타 그룹 상세 화면 (UI 전용) - 멤버 오늘 출석 현황, 친구 초대, 그룹 나가기.
// 서버 통신/상태는 lib/function/group_detail_controller.dart, friend_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/friend_controller.dart';
import '../function/group_detail_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'launchpad_transition.dart';
import 'study_timer_screen.dart';
import 'title_pill.dart';
class GroupDetailScreen extends StatefulWidget {
final int groupId;
final String groupName;
final String studentId;
final String studentName;
final int? grade;
const GroupDetailScreen({
super.key,
required this.groupId,
required this.groupName,
required this.studentId,
required this.studentName,
this.grade,
});
@override
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
}
class _GroupDetailScreenState extends State<GroupDetailScreen> {
late final GroupDetailController _controller;
@override
void initState() {
super.initState();
_controller = GroupDetailController(
groupId: widget.groupId,
studentId: widget.studentId,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _showInviteSheet() async {
final friendController = FriendController(
studentId: widget.studentId,
studentName: '',
);
await friendController.init();
if (!mounted) return;
final memberIds = _controller.members
.map((m) => m['studentId'] as String)
.toSet();
final invitable = friendController.friends
.where((f) => !memberIds.contains(f['studentId']))
.toList();
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'친구 초대',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
if (invitable.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text(
'초대할 수 있는 친구가 없어요.\n(이미 그룹에 있거나 친구가 없어요)',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView(
shrinkWrap: true,
children: [
for (final f in invitable)
ListTile(
leading: const Icon(
Icons.person_rounded,
color: AppPalette.ink,
),
title: Text('${f['name']}'),
subtitle: Text('${f['studentId']}'),
trailing: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () async {
final (success, message) = await _controller
.invite(
f['studentId'] as String,
f['name'] as String,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
);
},
child: const Text('초대'),
),
),
],
),
),
],
),
);
},
);
friendController.dispose();
}
Future<void> _toggleVisibility() async {
final makePublic = !_controller.isPublic;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(makePublic ? '공개 그룹으로 전환' : '비공개 그룹으로 전환'),
content: Text(
makePublic
? '이제 누구나 이 그룹을 찾아서 참여 신청을 보낼 수 있어요. 참여는 그룹장이 허용해야 완료됩니다.'
: '더 이상 공개 목록에 뜨지 않고, 대기 중인 참여 신청은 모두 거절 처리됩니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('전환'),
),
],
),
);
if (confirmed != true) return;
final (_, message) = await _controller.setVisibility(makePublic);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _respondJoinRequest(int requestId, bool accept) async {
final (_, message) = await _controller.respondJoinRequest(
requestId,
accept,
);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _leave() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('그룹 나가기'),
content: Text('${widget.groupName} 그룹에서 나가시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text(
'나가기',
style: TextStyle(color: AppPalette.danger),
),
),
],
),
);
if (confirmed != true) return;
final (success, message) = await _controller.leave();
if (!mounted) return;
AppNotice.show(context, message);
if (success) Navigator.of(context).pop();
}
String _formatMinutes(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) return '$h시간 $m분';
if (m > 0) return '$m분 $s초';
return '$s초';
}
/// 🎨 오늘 공부 시간 30분 단위로 프로필 테두리 색이 흰→노랑→주황→빨강→보라로
/// 진해진다. 2시간(4단계) 넘으면 보라색에서 멈춘다.
static const List<Color> _studyFrameColors = [
Color(0xFFE0E0E0), // 흰(기본)
Color(0xFFFFC107), // 노랑
Color(0xFFFF9800), // 주황
Color(0xFFE53935), // 빨강
Color(0xFF8E24AA), // 보라
];
Color _studyFrameColor(int todaySeconds) {
final tier = (todaySeconds ~/ 1800).clamp(0, _studyFrameColors.length - 1);
return _studyFrameColors[tier];
}
void _openTimer() {
pushLaunchpad(
context,
(context) => StudyTimerScreen(
studentId: widget.studentId,
studentName: widget.studentName,
grade: widget.grade,
),
);
}
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
double _sidePad(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return ((width - 560) / 2).clamp(16.0, double.infinity);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final members = _controller.members;
final attendedCount = members
.where((m) => m['attendedToday'] == true)
.length;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
actions: [
if (_controller.isOwner)
IconButton(
icon: Icon(
_controller.isPublic
? Icons.public_rounded
: Icons.lock_rounded,
),
tooltip: _controller.isPublic ? '공개 그룹' : '비공개 그룹',
onPressed: _toggleVisibility,
),
IconButton(
icon: const Icon(Icons.exit_to_app_rounded),
tooltip: '그룹 나가기',
onPressed: _leave,
),
],
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showInviteSheet,
icon: const Icon(Icons.person_add_rounded),
label: const Text('친구 초대'),
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: EdgeInsets.fromLTRB(
_sidePad(context),
0,
_sidePad(context),
90,
),
children: [
Center(child: TitlePill(widget.groupName)),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: _statPill('전체 멤버', members.length)),
const SizedBox(width: 8),
Expanded(child: _statPill('오늘 공부함', attendedCount)),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openTimer,
icon: const Icon(Icons.timer_rounded),
label: const Text('백품타 바로가기'),
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.ink,
side: const BorderSide(color: AppPalette.sage),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
if (_controller.isOwner &&
_controller.joinRequests.isNotEmpty) ...[
const SizedBox(height: 20),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'대기 중인 참여 신청',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
const SizedBox(width: 6),
Text(
'${_controller.joinRequests.length}명',
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
),
],
),
const SizedBox(height: 10),
for (final r in _controller.joinRequests)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text(
'${r['name']} (${r['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () => _respondJoinRequest(
r['requestId'] as int,
false,
),
child: const Text(
'거절',
style: TextStyle(color: Colors.grey),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => _respondJoinRequest(
r['requestId'] as int,
true,
),
child: const Text('허용'),
),
],
),
),
],
const SizedBox(height: 20),
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'오늘 출석 현황',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
],
),
const SizedBox(height: 4),
Text(
'1시간 이상 공부해야 출석으로 표시돼요.',
style: TextStyle(
fontSize: 11.5,
color: Colors.grey[500],
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'프로필 테두리는 30분마다 색이 진해져요',
style: TextStyle(
fontSize: 11.5,
color: Colors.grey[500],
),
),
const SizedBox(width: 6),
for (final c in _studyFrameColors)
Padding(
padding: const EdgeInsets.only(left: 2),
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: c,
),
),
),
],
),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: 96,
),
itemCount: members.length,
itemBuilder: (context, index) {
final m = members[index];
final attended = m['attendedToday'] == true;
final todaySeconds =
(m['todaySeconds'] as num?)?.toInt() ?? 0;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: attended
? AppPalette.ink
: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: attended
? AppPalette.ink
: AppPalette.sage,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _studyFrameColor(todaySeconds),
width: 3,
),
),
child: CircleAvatar(
backgroundColor: attended
? Colors.white24
: AppPalette.linen,
child: Icon(
Icons.person_rounded,
size: 13,
color: attended
? Colors.white
: AppPalette.ink,
),
),
),
const Spacer(),
Text(
'${m['name']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: attended
? Colors.white
: AppPalette.ink,
),
),
if (m['role'] == 'owner')
Text(
'그룹장',
style: TextStyle(
fontSize: 10,
color: attended
? Colors.white70
: Colors.grey[500],
),
),
const SizedBox(height: 2),
Text(
_formatMinutes(todaySeconds),
style: TextStyle(
fontSize: 11,
color: attended
? Colors.white70
: Colors.grey[500],
),
),
],
),
);
},
),
],
),
),
);
},
);
}
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,
),
),
],
),
);
}
}
+467
View File
@@ -0,0 +1,467 @@
// 👥 백품타 그룹 스터디 목록 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
// 서버 통신/상태는 lib/function/group_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/group_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'group_detail_screen.dart';
import 'launchpad_transition.dart';
import 'title_pill.dart';
class GroupListScreen extends StatefulWidget {
final String studentId;
final String studentName;
final int? grade;
const GroupListScreen({
super.key,
required this.studentId,
required this.studentName,
this.grade,
});
@override
State<GroupListScreen> createState() => _GroupListScreenState();
}
class _GroupListScreenState extends State<GroupListScreen> {
late final GroupController _controller;
@override
void initState() {
super.initState();
_controller = GroupController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _showCreateGroupDialog() async {
final nameController = TextEditingController();
bool isPublic = false;
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: const Text('그룹 만들기'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
maxLength: 20,
decoration: const InputDecoration(
labelText: '그룹 이름',
border: OutlineInputBorder(),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('공개 그룹으로 만들기'),
subtitle: const Text(
'누구나 목록에서 보고 참여 신청할 수 있어요\n(그룹장이 허용해야 들어옵니다)',
style: TextStyle(fontSize: 12),
),
value: isPublic,
activeThumbColor: AppPalette.ink,
onChanged: (v) => setDialogState(() => isPublic = v),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('만들기'),
),
],
),
),
);
if (result != true || nameController.text.trim().isEmpty) return;
final (success, message) = await _controller.createGroup(
nameController.text.trim(),
isPublic: isPublic,
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _respondInvite(int inviteId, bool accept) async {
final (_, message) = await _controller.respondInvite(inviteId, accept);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _requestJoin(int groupId) async {
final (success, message) = await _controller.requestJoin(groupId);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _openGroup(dynamic group) async {
await pushLaunchpad(
context,
(context) => GroupDetailScreen(
groupId: group['groupId'] as int,
groupName: group['name'] as String,
studentId: widget.studentId,
studentName: widget.studentName,
grade: widget.grade,
),
);
_controller.refresh();
}
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(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showCreateGroupDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('그룹 만들기'),
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
const Center(child: TitlePill('그룹 스터디')),
const SizedBox(height: 20),
if (_controller.invites.isNotEmpty) ...[
_sectionHeader(
'받은 그룹 초대',
count: _controller.invites.length,
),
const SizedBox(height: 10),
for (final inv in _controller.invites)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text(
'${inv['groupName']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () => _respondInvite(
inv['inviteId'] as int,
false,
),
child: const Text(
'거절',
style: TextStyle(color: Colors.grey),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => _respondInvite(
inv['inviteId'] as int,
true,
),
child: const Text('참여'),
),
],
),
),
const SizedBox(height: 20),
],
_sectionHeader(
'내 그룹',
count: _controller.groups.length,
),
const SizedBox(height: 10),
if (_controller.groups.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'아직 그룹이 없어요. 새 그룹을 만들거나 초대를 기다려보세요.',
style: TextStyle(color: Colors.grey),
),
)
else
for (final g in _controller.groups)
GestureDetector(
onTap: () => _openGroup(g),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.03,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppPalette.ink.withValues(
alpha: 0.06,
),
shape: BoxShape.circle,
),
child: const Icon(
Icons.groups_rounded,
color: AppPalette.ink,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'${g['name']}',
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontWeight:
FontWeight.bold,
fontSize: 15,
),
),
),
if (g['isPublic'] == true) ...[
const SizedBox(width: 6),
const Icon(
Icons.public_rounded,
size: 14,
color: Colors.grey,
),
],
if ((g['pendingRequestCount'] ??
0) >
0) ...[
const SizedBox(width: 6),
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color:
AppPalette.warningBg,
borderRadius:
BorderRadius.circular(
20,
),
border: Border.all(
color:
AppPalette.warning,
),
),
child: Text(
'대기 ${g['pendingRequestCount']}',
style: const TextStyle(
fontSize: 10,
color:
AppPalette.warning,
),
),
),
],
],
),
Text(
'멤버 ${g['memberCount']}명'
'${g['isOwner'] == true ? ' · 그룹장' : ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.grey,
),
],
),
),
),
if (_controller.publicGroups.isNotEmpty) ...[
const SizedBox(height: 20),
_sectionHeader(
'공개 그룹',
count: _controller.publicGroups.length,
),
const SizedBox(height: 10),
for (final g in _controller.publicGroups)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
const Icon(
Icons.public_rounded,
color: AppPalette.ink,
size: 18,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'${g['name']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(
'${g['ownerName']} · 멤버 ${g['memberCount']}명',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
),
if (g['requested'] == true)
Text(
'신청됨',
style: TextStyle(
color: Colors.grey[500],
),
)
else
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () =>
_requestJoin(g['groupId'] as int),
child: const Text('참여 신청'),
),
],
),
),
],
],
),
),
),
),
);
},
);
}
}
+17 -23
View File
@@ -5,6 +5,7 @@ import '../config.dart' show schoolName;
import '../function/login_controller.dart';
import '../function/session_store.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'main_dashboard.dart';
import 'teacher_register_screen.dart';
@@ -58,9 +59,7 @@ class _LoginScreenState extends State<LoginScreen> {
await SessionStore.clear();
}
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
Navigator.pushReplacement(
context,
MaterialPageRoute(
@@ -79,17 +78,17 @@ class _LoginScreenState extends State<LoginScreen> {
result.name!,
result.role!,
result.isDeviceMatched,
result.grade,
);
break;
case LoginOutcome.success:
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${result.name}님 환영합니다!')));
AppNotice.show(context, '${result.name}님 환영합니다!');
_navigateBasedOnRole(
result.role!,
result.studentId!,
result.name!,
result.isDeviceMatched,
result.grade,
);
break;
}
@@ -101,6 +100,7 @@ class _LoginScreenState extends State<LoginScreen> {
String name,
String role,
bool isDeviceMatched,
int? grade,
) {
final TextEditingController newPwController = TextEditingController();
@@ -122,7 +122,7 @@ class _LoginScreenState extends State<LoginScreen> {
children: [
const Text(
'보안을 위해 새로운 비밀번호를 설정해 주세요.',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: AppPalette.danger),
),
const SizedBox(height: 16),
TextField(
@@ -150,9 +150,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () async {
String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('새 비밀번호를 입력해 주세요.')),
);
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
return;
}
@@ -167,19 +165,16 @@ class _LoginScreenState extends State<LoginScreen> {
if (success) {
Navigator.pop(dialogContext);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
grade,
);
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
},
child: const Text('변경하고 시작하기'),
@@ -198,6 +193,7 @@ class _LoginScreenState extends State<LoginScreen> {
String studentId,
String name,
bool isDeviceMatched,
int? grade,
) async {
if (_keepLoggedIn) {
await SessionStore.save(
@@ -206,6 +202,7 @@ class _LoginScreenState extends State<LoginScreen> {
userName: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
),
);
} else {
@@ -220,6 +217,7 @@ class _LoginScreenState extends State<LoginScreen> {
userName: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
),
),
);
@@ -269,9 +267,7 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
);
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
}
},
decoration: const InputDecoration(
@@ -293,9 +289,7 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
);
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
}
},
child: const Text('인증하기'),
@@ -438,7 +432,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
onPressed: () => _showLegacyPasswordDialog(
'선생님',
'1234',
'4885',
const MainDashboard(role: 'teacher'),
),
),
+259 -76
View File
@@ -3,6 +3,7 @@
// 타일 배치(위치/크기)는 사용자가 직접 편집할 수 있고, 계정별로 서버에 저장된다
// (lib/function/dashboard_layout_controller.dart). 캔버스는 가로 6칸×세로 4칸,
// 타일 크기는 1x1~4x4.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../function/dashboard_layout_controller.dart';
@@ -12,14 +13,23 @@ import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart';
import 'admin_dashboard.dart';
import 'app_notice.dart';
import 'board_home_screen.dart';
import 'board_report_screen.dart';
import 'dashboard_settings_page.dart';
import 'device_checkout_ledger_page.dart';
import 'device_checkout_request_screen.dart';
import 'friends_screen.dart';
import 'group_list_screen.dart';
import 'launchpad_transition.dart';
import 'login_screen.dart';
import 'nfc_poccket_checkin_screen.dart';
import 'nfc_tag_writer_screen.dart';
import 'profile_customize_screen.dart';
import 'study_calendar_screen.dart';
import 'study_timer_screen.dart';
import 'teacher_attendance_page.dart';
import 'teacher_call_screen.dart';
import 'teacher_location_screen.dart';
import 'teacher_student_management_page.dart';
class MainDashboard extends StatefulWidget {
@@ -27,6 +37,7 @@ class MainDashboard extends StatefulWidget {
final String? userName;
final String role; // 'student' | 'teacher' | 'admin'
final bool isDeviceMatched;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const MainDashboard({
super.key,
@@ -34,6 +45,7 @@ class MainDashboard extends StatefulWidget {
this.userName,
required this.role,
this.isDeviceMatched = true,
this.grade,
});
@override
@@ -104,7 +116,7 @@ class _MainDashboardState extends State<MainDashboard> {
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
Icon(Icons.warning_amber_rounded, color: AppPalette.danger),
SizedBox(width: 10),
Text(
'계정 강제 삭제',
@@ -130,10 +142,13 @@ class _MainDashboardState extends State<MainDashboard> {
decoration: InputDecoration(
labelText: '학번 또는 교직원 번호',
hintText: '예: 201101 또는 T1001',
labelStyle: TextStyle(color: Colors.red[400]),
labelStyle: const TextStyle(color: AppPalette.danger),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
borderSide: const BorderSide(
color: AppPalette.danger,
width: 2,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -163,7 +178,7 @@ class _MainDashboardState extends State<MainDashboard> {
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
backgroundColor: AppPalette.danger,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -312,6 +327,96 @@ class _MainDashboardState extends State<MainDashboard> {
),
),
));
tiles.add((
id: 'study_timer',
child: _buildModernCard(
icon: Icons.timer_rounded,
title: '백품타',
subtitle: '공부 타이머 | 학년별 공부 랭킹',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => StudyTimerScreen(
studentId: _displayId,
studentName: _displayName,
grade: widget.grade,
),
),
),
));
tiles.add((
id: 'community_board',
child: _buildModernCard(
icon: Icons.forum_rounded,
title: '백판',
subtitle: '익명 커뮤니티 게시판',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => BoardHomeScreen(studentId: _displayId),
),
),
));
tiles.add((
id: 'teacher_call',
child: _buildModernCard(
icon: Icons.campaign_rounded,
title: '선생님 호출',
subtitle: '교무실 위치 확인 및 호출',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => TeacherCallScreen(
studentId: _displayId,
studentName: _displayName,
),
),
),
));
tiles.add((
id: 'friends',
child: _buildModernCard(
icon: Icons.people_alt_rounded,
title: '친구',
subtitle: '학번으로 친구 추가',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) =>
FriendsScreen(studentId: _displayId, studentName: _displayName),
),
),
));
tiles.add((
id: 'group_study',
child: _buildModernCard(
icon: Icons.groups_rounded,
title: '그룹 스터디',
subtitle: '친구랑 그룹 만들어 같이 공부',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => GroupListScreen(
studentId: _displayId,
studentName: _displayName,
grade: widget.grade,
),
),
),
));
tiles.add((
id: 'study_calendar',
child: _buildModernCard(
icon: Icons.calendar_month_rounded,
title: '출석 달력',
subtitle: '연속 출석 기록 확인',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => StudyCalendarScreen(studentId: _displayId),
),
),
));
}
if (_isTeacherOrAbove) {
@@ -357,6 +462,48 @@ class _MainDashboardState extends State<MainDashboard> {
),
),
));
tiles.add((
id: 'board_moderation',
child: _buildModernCard(
icon: Icons.shield_outlined,
title: '백판 신고 검토',
subtitle: '학생 게시판 신고 처리',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => BoardReportScreen(reviewerId: _displayId),
),
),
));
tiles.add((
id: 'study_calendar_admin',
child: _buildModernCard(
icon: Icons.event_available_rounded,
title: '출석 달력 관리',
subtitle: '날짜별 선물/이벤트 등록',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => StudyCalendarScreen(
studentId: _displayId,
canManageEvents: true,
),
),
),
));
tiles.add((
id: 'teacher_location',
child: _buildModernCard(
icon: Icons.my_location_rounded,
title: '내 위치 알리기',
subtitle: '학생 호출 확인 및 위치 등록',
color: AppPalette.ink,
onTap: () => pushLaunchpad(
context,
(context) => TeacherLocationScreen(teacherId: _displayId),
),
),
));
}
if (_isAdmin) {
@@ -489,72 +636,104 @@ class _MainDashboardState extends State<MainDashboard> {
// 🪪 작은 프로필 카드로 배치 — 화면 전체를 차지하던 배너 대신
// 가운데에 떠 있는 카드 하나만 둔다.
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: Stack(
clipBehavior: Clip.none,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 18,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
theme.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.white,
fontSize: 13,
),
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),
),
],
),
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,
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,
),
),
],
),
),
Positioned(
top: -6,
right: -6,
child: Material(
color: AppPalette.paper,
shape: const CircleBorder(),
elevation: 2,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => pushLaunchpad(
context,
(context) => ProfileCustomizeScreen(
studentId: _displayId,
studentName: _displayName,
),
),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(
Icons.edit_rounded,
size: 16,
color: AppPalette.ink,
),
),
),
),
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,
),
),
],
),
),
],
),
),
const SizedBox(height: 28),
@@ -629,15 +808,19 @@ class _MainDashboardState extends State<MainDashboard> {
child: LayoutBuilder(
builder: (context, constraints) {
final double width = constraints.maxWidth;
// 🔲 정사각형 1칸 기준 4×3 배치 — 화면이 넓어도 6칸까지 늘리지 않고 4칸에서 멈춘다.
final int crossAxisCount = width < 420
// 🔲 정사각형 1칸 기준 배치. PC로 접속한 웹(넓은 화면)에서는 6칸까지 늘려서
// 타일이 한 화면에 다 들어오게 하고(스크롤/바운스 자체가 안 생기게), 폰 화면이나
// 앱(Android)에서는 기존처럼 4칸에서 멈춘다.
final int crossAxisCount = kIsWeb && width >= 900
? 6
: width < 420
? 2
: width < 700
? 3
: 4;
// 📏 1칸(1x1) 크기가 대략 계정 프로필 카드만 해지도록 목표 크기를 잡고,
// 화면이 그보다 넓으면 가운데로 모아서 여백을 준다(좁으면 화면 폭에 맞춤).
const double targetCellSize = 184;
// 📏 1칸(1x1) 크기 목표. 화면이 그보다 넓으면 가운데로 모아서 여백을
// 주고, 좁으면(폰) 화면 폭에 맞춘다.
const double targetCellSize = 226;
const double gap = 12;
final double idealGridWidth =
crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap;
@@ -787,13 +970,13 @@ class _MainDashboardState extends State<MainDashboard> {
: 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);
final double cardPadding = 8 + 8 * (scale - 1);
final double iconBoxPadding = 8 + 6 * (scale - 1);
final double iconSize = 22 + 10 * (scale - 1);
final double iconRadius = 12 + 6 * (scale - 1);
final double cardPadding = 10 + 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);
final double titleFontSize = (12 + 3 * (scale - 1)).clamp(12, 20);
final double subtitleFontSize = (10 + 2 * (scale - 1)).clamp(10, 16);
return InkWell(
onTap: isLoading ? null : onTap,
+23 -22
View File
@@ -5,6 +5,9 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../function/nfc_pocket_checkin_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class NfcPocketCheckInScreen extends StatefulWidget {
final String studentId; // 로그인된 학생 학번 (예: "2061")
@@ -47,9 +50,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
void _handleMessage(String text, WatchMessageLevel level) {
final Color color = switch (level) {
WatchMessageLevel.info => Colors.blueGrey,
WatchMessageLevel.success => Colors.green,
WatchMessageLevel.warning => Colors.orange,
WatchMessageLevel.error => Colors.red,
WatchMessageLevel.success => AppPalette.success,
WatchMessageLevel.warning => AppPalette.warning,
WatchMessageLevel.error => AppPalette.danger,
};
_showSnackBar(text, color);
}
@@ -70,10 +73,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.red[50],
backgroundColor: AppPalette.dangerBg,
title: const Text(
"무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
style: TextStyle(
color: AppPalette.danger,
fontWeight: FontWeight.bold,
),
),
content: Text(
"[${pocketNumber ?? _controller.activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
@@ -143,9 +149,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
void _showSnackBar(String text, Color color) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
AppNotice.show(context, text, color: color);
}
@override
@@ -159,14 +163,11 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
return Scaffold(
appBar: AppBar(
title: const Text(
"자습실 NFC 출석체크",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill("자습실 NFC 출석체크"),
),
body: Center(
child: Padding(
@@ -179,8 +180,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
_controller.isProcessing ? Icons.sync : Icons.nfc,
size: 100,
color: _controller.isProcessing
? Colors.orange
: Colors.blueAccent,
? AppPalette.warning
: AppPalette.ink,
),
const SizedBox(height: 30),
@@ -192,7 +193,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
fontSize: 18,
fontWeight: FontWeight.bold,
color: _controller.isProcessing
? Colors.orange
? AppPalette.warning
: Colors.black87,
),
),
@@ -229,13 +230,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.greenAccent.withValues(alpha: 0.15),
border: Border.all(color: Colors.greenAccent, width: 3),
color: AppPalette.success.withValues(alpha: 0.15),
border: Border.all(color: AppPalette.success, width: 3),
),
child: const Icon(
Icons.shield_rounded,
size: 72,
color: Colors.greenAccent,
color: AppPalette.success,
),
),
const SizedBox(height: 40),
+13 -9
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart';
import '../function/nfc_tag_writer_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class NfcTagWriterScreen extends StatefulWidget {
const NfcTagWriterScreen({super.key});
@@ -35,11 +37,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
void _showSnackBar(String text, bool isError) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
backgroundColor: isError ? Colors.red : Colors.green,
),
AppNotice.show(
context,
text,
color: isError ? AppPalette.danger : AppPalette.success,
icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded,
);
}
@@ -52,9 +54,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text("NFC 주머니 태그 쓰기"),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill("NFC 주머니 태그 쓰기"),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
@@ -75,7 +79,7 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
Icon(
isWriting ? Icons.nfc : Icons.edit_note_rounded,
size: 80,
color: isWriting ? Colors.orange : AppPalette.ink,
color: isWriting ? AppPalette.warning : AppPalette.ink,
),
const SizedBox(height: 16),
Text(
+56
View File
@@ -0,0 +1,56 @@
// 🎨 프로필 커스텀 화면 (UI 전용). 아직은 자리만 잡아둔 빈 화면 - 프로필 꾸미기
// 관련 기능들이 여기에 하나씩 추가될 예정이다.
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
import 'title_pill.dart';
class ProfileCustomizeScreen extends StatelessWidget {
final String studentId;
final String studentName;
const ProfileCustomizeScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('프로필 꾸미기'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.auto_awesome_rounded,
size: 48,
color: AppPalette.ink,
),
const SizedBox(height: 16),
const Text(
'곧 다양한 프로필 커스텀 기능이 추가될 예정이에요.',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 6),
Text(
'$studentName ($studentId)',
style: const TextStyle(color: Colors.grey),
),
],
),
),
),
);
}
}
+426
View File
@@ -0,0 +1,426 @@
// 📅 백품타 출석 달력 화면 (UI 전용) - 개인 연속출석(스트릭) + 관리자 이벤트 달력.
// "실시간 출석 현황"과 같은 패밀리룩(제목 알약 + 필 통계 + 둥근 카드)을 따른다.
// 서버 통신/상태는 lib/function/study_calendar_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/study_calendar_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class StudyCalendarScreen extends StatefulWidget {
final String studentId;
final bool canManageEvents;
const StudyCalendarScreen({
super.key,
required this.studentId,
this.canManageEvents = false,
});
@override
State<StudyCalendarScreen> createState() => _StudyCalendarScreenState();
}
class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
late final StudyCalendarController _controller;
static const _weekdayLabels = ['일', '월', '화', '수', '목', '금', '토'];
@override
void initState() {
super.initState();
_controller = StudyCalendarController(studentId: widget.studentId);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
String _dateKey(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Future<void> _onDayTap(DateTime day) async {
final key = _dateKey(day);
final event = _controller.events[key];
if (!widget.canManageEvents) {
if (event != null) {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(event['title'] ?? ''),
content: Text(
(event['description'] ?? '').toString().isEmpty
? '등록된 설명이 없어요.'
: event['description'],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'),
),
],
),
);
}
return;
}
final titleController = TextEditingController(text: event?['title'] ?? '');
final descController = TextEditingController(
text: event?['description'] ?? '',
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'$key 이벤트',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 12),
TextField(
controller: titleController,
maxLength: 30,
decoration: InputDecoration(
hintText: '제목 (예: 기프티콘 이벤트)',
filled: true,
fillColor: AppPalette.mist,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
TextField(
controller: descController,
maxLength: 100,
maxLines: 3,
decoration: InputDecoration(
hintText: '설명 (선택)',
filled: true,
fillColor: AppPalette.mist,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
Row(
children: [
if (event != null)
Expanded(
child: OutlinedButton(
onPressed: () async {
final (success, message) = await _controller
.deleteEvent(key);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
if (!mounted) return;
AppNotice.show(context, message);
},
child: const Text(
'삭제',
style: TextStyle(color: AppPalette.danger),
),
),
),
if (event != null) const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () async {
if (titleController.text.trim().isEmpty) return;
final (success, message) = await _controller.saveEvent(
date: key,
title: titleController.text,
description: descController.text,
createdBy: widget.studentId,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
);
},
child: const Text('저장'),
),
),
],
),
],
),
);
},
);
}
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 440 폭으로 맞춘다.
double _sidePad(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return ((width - 440) / 2).clamp(16.0, double.infinity);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final month = _controller.visibleMonth;
final firstOfMonth = DateTime(month.year, month.month, 1);
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final leadingBlanks = firstOfMonth.weekday % 7;
final today = DateTime.now();
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: EdgeInsets.fromLTRB(
_sidePad(context),
0,
_sidePad(context),
16,
),
children: [
const Center(child: TitlePill('출석 달력')),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _statPill('연속 출석', _controller.currentStreak),
),
const SizedBox(width: 8),
Expanded(
child: _statPill('최장 기록', _controller.longestStreak),
),
],
),
const SizedBox(height: 8),
Center(
child: Text(
'하루 ${_controller.attendanceThresholdSeconds ~/ 60}분 '
'이상 공부해야 그날 출석으로 인정돼요.',
style: TextStyle(
fontSize: 11.5,
color: Colors.grey[500],
),
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left_rounded),
onPressed: _controller.goToPreviousMonth,
),
Text(
'${month.year}년 ${month.month}월',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
IconButton(
icon: const Icon(Icons.chevron_right_rounded),
onPressed: _controller.goToNextMonth,
),
],
),
const SizedBox(height: 8),
Row(
children: [
for (final label in _weekdayLabels)
Expanded(
child: Center(
child: Text(
label,
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const SizedBox(height: 6),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1.0,
),
itemCount: leadingBlanks + daysInMonth,
itemBuilder: (context, index) {
if (index < leadingBlanks) {
return const SizedBox.shrink();
}
final day = index - leadingBlanks + 1;
final date = DateTime(month.year, month.month, day);
final key = _dateKey(date);
final studied = _controller.studiedDates.contains(key);
final event = _controller.events[key];
final isToday =
date.year == today.year &&
date.month == today.month &&
date.day == today.day;
return GestureDetector(
onTap: () => _onDayTap(date),
child: Container(
decoration: BoxDecoration(
color: studied
? AppPalette.ink
: AppPalette.paper,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isToday
? AppPalette.ink
: AppPalette.sage,
width: isToday ? 2 : 1,
),
),
child: Stack(
children: [
Center(
child: Text(
'$day',
style: TextStyle(
fontWeight: FontWeight.bold,
color: studied
? Colors.white
: AppPalette.ink,
),
),
),
if (event != null)
Positioned(
top: 3,
right: 3,
child: Icon(
Icons.card_giftcard_rounded,
size: 12,
color: studied
? Colors.white
: AppPalette.warning,
),
),
],
),
),
);
},
),
const SizedBox(height: 16),
Row(
children: [
_legendDot(AppPalette.ink, '공부한 날'),
const SizedBox(width: 16),
_legendIcon(
Icons.card_giftcard_rounded,
AppPalette.warning,
'이벤트',
),
],
),
],
),
);
},
);
}
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 _legendDot(Color color, String label) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
Widget _legendIcon(IconData icon, Color color, String label) {
return Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
}
+324
View File
@@ -0,0 +1,324 @@
// 🍅 뽀모도로 루틴 공유 화면 (UI 전용). 다른 학생들이 올린 공부/휴식 시간 조합을 보고
// 그대로 적용하거나, 내가 쓰는 조합을 이름 붙여 공유할 수 있다.
// 서버 통신/상태는 lib/function/study_preset_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/study_preset_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class StudyPresetScreen extends StatefulWidget {
final String studentId;
final String studentName;
final int initialStudyMinutes;
final int initialBreakMinutes;
const StudyPresetScreen({
super.key,
required this.studentId,
required this.studentName,
required this.initialStudyMinutes,
required this.initialBreakMinutes,
});
@override
State<StudyPresetScreen> createState() => _StudyPresetScreenState();
}
class _StudyPresetScreenState extends State<StudyPresetScreen> {
late final StudyPresetController _controller;
@override
void initState() {
super.initState();
_controller = StudyPresetController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _apply(dynamic preset) async {
await _controller.markUsed(preset['id'] as int);
if (!mounted) return;
Navigator.of(context).pop({
'studyMinutes': preset['studyMinutes'] as int,
'breakMinutes': preset['breakMinutes'] as int,
});
}
Future<void> _delete(int presetId) async {
final (_, message) = await _controller.delete(presetId);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _showShareDialog() async {
final titleController = TextEditingController();
int studyMinutes = widget.initialStudyMinutes;
int breakMinutes = widget.initialBreakMinutes;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'내 루틴 공유하기',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
TextField(
controller: titleController,
maxLength: 30,
decoration: InputDecoration(
hintText: '루틴 이름 (예: 불태우는 금요일)',
filled: true,
fillColor: AppPalette.mist,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
_minuteRow(
label: '공부 시간',
minutes: studyMinutes,
onChanged: (v) => setSheetState(() => studyMinutes = v),
),
_minuteRow(
label: '휴식 시간',
minutes: breakMinutes,
onChanged: (v) => setSheetState(() => breakMinutes = v),
),
const SizedBox(height: 12),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () async {
final (success, message) = await _controller.share(
title: titleController.text,
studyMinutes: studyMinutes,
breakMinutes: breakMinutes,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
);
},
child: const Text('공유하기'),
),
],
),
);
},
);
},
);
}
Widget _minuteRow({
required String label,
required int minutes,
required ValueChanged<int> onChanged,
}) {
return Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: minutes > 5 ? () => onChanged(minutes - 5) : null,
),
SizedBox(
width: 48,
child: Text(
'$minutes분',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: minutes < 120 ? () => onChanged(minutes + 5) : null,
),
],
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final presets = _controller.presets;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('뽀모도로 루틴 공유'),
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showShareDialog,
icon: const Icon(Icons.ios_share_rounded),
label: const Text('내 루틴 공유'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
_sortChip('popular', '인기순'),
const SizedBox(width: 8),
_sortChip('recent', '최신순'),
],
),
),
Expanded(
child: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: presets.isEmpty
? const Center(
child: Text(
'아직 공유된 루틴이 없어요.\n첫 루틴을 공유해보세요.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: presets.length,
itemBuilder: (context, index) {
final preset = presets[index];
final bool isMine =
preset['studentId'] == widget.studentId;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
preset['title'] ?? '',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 4),
Text(
'공부 ${preset['studyMinutes']}분 · 휴식 ${preset['breakMinutes']}분',
style: const TextStyle(
color: AppPalette.ink,
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
'${preset['studentName'] ?? ''} · 사용 ${preset['useCount'] ?? 0}회',
style: const TextStyle(
color: Colors.grey,
fontSize: 11.5,
),
),
],
),
),
if (isMine)
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.grey,
),
onPressed: () =>
_delete(preset['id'] as int),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => _apply(preset),
child: const Text('적용'),
),
],
),
);
},
),
),
],
),
);
},
);
}
Widget _sortChip(String value, String label) {
final bool selected = _controller.sort == value;
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => _controller.setSort(value),
selectedColor: AppPalette.ink,
labelStyle: TextStyle(
color: selected ? Colors.white : AppPalette.ink,
fontWeight: FontWeight.bold,
),
);
}
}
+239
View File
@@ -0,0 +1,239 @@
// 🏆 학년별 공부시간 랭킹 화면 (UI 전용).
// 서버 통신/상태는 lib/function/study_ranking_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/study_ranking_controller.dart';
import '../theme/app_palette.dart';
import 'title_pill.dart';
class StudyRankingScreen extends StatefulWidget {
final int grade;
final String myStudentId;
const StudyRankingScreen({
super.key,
required this.grade,
required this.myStudentId,
});
@override
State<StudyRankingScreen> createState() => _StudyRankingScreenState();
}
class _StudyRankingScreenState extends State<StudyRankingScreen> {
late final StudyRankingController _controller;
@override
void initState() {
super.initState();
_controller = StudyRankingController(grade: widget.grade);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
String _formatHM(int totalSeconds) {
final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60;
final s = totalSeconds % 60;
if (h > 0) return '$h시간 $m분 $s초';
if (m > 0) return '$m분 $s초';
return '$s초';
}
Color? _rankColor(int rank) {
switch (rank) {
case 1:
return const Color(0xFFC9A227); // 금
case 2:
return const Color(0xFF9AA0A6); // 은
case 3:
return const Color(0xFFB07A4B); // 동
default:
return null;
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final ranking = _controller.ranking;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: TitlePill(
_controller.scope == 'grade'
? '${widget.grade}학년 공부 랭킹'
: '전체 공부 랭킹',
),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
_scopeChip('grade', '${widget.grade}학년'),
const SizedBox(width: 8),
_scopeChip('all', '전체랭킹'),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
_periodChip('today', '오늘'),
const SizedBox(width: 8),
_periodChip('week', '이번주'),
const SizedBox(width: 8),
_periodChip('total', '전체'),
],
),
),
Expanded(
child: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: ranking.isEmpty
? const Center(
child: Text(
'아직 기록이 없어요.\n공부 타이머를 눌러 1등에 도전해보세요.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: ranking.length,
itemBuilder: (context, index) {
final r = ranking[index];
final bool isMe =
r['studentId'] == widget.myStudentId;
final int rank = (r['rank'] as num).toInt();
final medalColor = _rankColor(rank);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: isMe
? AppPalette.ink
: AppPalette.paper,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isMe
? AppPalette.ink
: AppPalette.sage,
),
),
child: Row(
children: [
SizedBox(
width: 32,
child: medalColor != null
? Icon(
Icons.emoji_events_rounded,
color: medalColor,
size: 22,
)
: Text(
'$rank',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isMe
? Colors.white
: AppPalette.ink,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
r['name'] ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isMe
? Colors.white
: AppPalette.ink,
),
),
),
Text(
_formatHM(
(r['totalSeconds'] as num).toInt(),
),
style: TextStyle(
fontWeight: FontWeight.bold,
color: isMe
? Colors.white
: AppPalette.ink,
),
),
],
),
);
},
),
),
],
),
),
),
);
},
);
}
Widget _periodChip(String value, String label) {
final bool selected = _controller.period == value;
return Expanded(
child: ChoiceChip(
label: Center(child: Text(label)),
selected: selected,
onSelected: (_) => _controller.setPeriod(value),
selectedColor: AppPalette.ink,
labelStyle: TextStyle(
color: selected ? Colors.white : AppPalette.ink,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _scopeChip(String value, String label) {
final bool selected = _controller.scope == value;
return Expanded(
child: ChoiceChip(
label: Center(child: Text(label, style: const TextStyle(fontSize: 13))),
selected: selected,
onSelected: (_) => _controller.setScope(value),
selectedColor: AppPalette.ink,
backgroundColor: AppPalette.mist,
labelStyle: TextStyle(
color: selected ? Colors.white : Colors.grey[600],
fontWeight: FontWeight.bold,
),
),
);
}
}
+546
View File
@@ -0,0 +1,546 @@
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (UI 전용).
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
import 'dart:async';
import 'package:flutter/material.dart';
import '../function/browser_notification.dart';
import '../function/study_timer_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'launchpad_transition.dart';
import 'study_preset_screen.dart';
import 'study_ranking_screen.dart';
import 'title_pill.dart';
class StudyTimerScreen extends StatefulWidget {
final String studentId;
final String studentName;
final int? grade;
const StudyTimerScreen({
super.key,
required this.studentId,
required this.studentName,
this.grade,
});
@override
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
}
class _StudyTimerScreenState extends State<StudyTimerScreen>
with WidgetsBindingObserver {
late final StudyTimerController _controller;
Timer? _awayTimer;
@override
void initState() {
super.initState();
_controller = StudyTimerController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.onPhaseChanged = _handlePhaseChanged;
_controller.init();
WidgetsBinding.instance.addObserver(this);
}
void _handlePhaseChanged(String message) {
if (!mounted) return;
AppNotice.show(context, message, icon: Icons.timer_rounded);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린
// 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문.
_awayTimer?.cancel();
_controller.onAppResumed();
return;
}
// 🔔 타이머가 켜져 있는데 탭이 백그라운드로 가면(다른 탭/창/앱으로 이동), 20초 뒤에도
// 계속 딴 데 가있으면 브라우저 알림으로 다시 공부하라고 알려준다.
final bool isAway =
state == AppLifecycleState.hidden ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused;
if (isAway && _controller.isRunning) {
_awayTimer?.cancel();
_awayTimer = Timer(const Duration(seconds: 20), () {
showBrowserNotification(
'백품타 - 공부 중이었잖아요!',
'타이머가 켜져 있어요. 다시 돌아와서 집중해볼까요?',
);
});
}
}
@override
void dispose() {
// 🔔 브라우저 탭은 그대로 두고 앱 안에서 다른 화면으로 이동하는 경우(뒤로가기 등)는
// AppLifecycleState가 안 바뀌어서 위쪽 감지 로직이 못 잡는다. 그래서 이 화면을 나갈
// 때 타이머가 돌아가는 중이었다면 여기서 직접 알림을 띄워준다.
if (_controller.isRunning) {
showBrowserNotification(
'백품타 타이머가 계속 돌아가고 있어요',
'이 화면을 나가도 공부 시간은 계속 기록돼요.',
);
}
WidgetsBinding.instance.removeObserver(this);
_awayTimer?.cancel();
_controller.dispose();
super.dispose();
}
String _formatHMS(int totalSeconds) {
final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60;
final s = totalSeconds % 60;
final hh = h.toString().padLeft(2, '0');
final mm = m.toString().padLeft(2, '0');
final ss = s.toString().padLeft(2, '0');
return '$hh:$mm:$ss';
}
String _formatMinSec(int totalSeconds) {
final s = totalSeconds < 0 ? 0 : totalSeconds;
final m = s ~/ 60;
final sec = s % 60;
return '$m:${sec.toString().padLeft(2, '0')}';
}
String _statusLabel(bool isRunning, bool isPaused) {
if (_controller.pomodoroEnabled && _controller.phase != null) {
return _controller.phase == PomodoroPhase.study
? '뽀모도로 - 공부 중이에요'
: '뽀모도로 - 쉬는 중이에요';
}
if (isRunning) return '지금 공부 중이에요';
if (isPaused) return '일시정지 중이에요';
return '공부를 시작해볼까요?';
}
String _formatHM(int totalSeconds) {
final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60;
final s = totalSeconds % 60;
if (h > 0) return '$h시간 $m분 $s초';
if (m > 0) return '$m분 $s초';
return '$s초';
}
Future<void> _runAction(
Future<(bool success, String message)> Function() action,
) async {
final (success, message) = await action();
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _showPomodoroSettings() async {
bool enabled = _controller.pomodoroEnabled;
int studyMinutes = _controller.studyMinutes;
int breakMinutes = _controller.breakMinutes;
final bool canEdit = _controller.isIdle;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Expanded(
child: Text(
'뽀모도로 모드',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Switch(
value: enabled,
activeThumbColor: AppPalette.ink,
onChanged: canEdit
? (v) => setSheetState(() => enabled = v)
: null,
),
],
),
const Text(
'공부/휴식 시간을 정해두면, 공부 시간이 끝났을 때 자동으로\n일시정지되고 휴식이 끝나면 자동으로 다시 시작해요.',
style: TextStyle(color: Colors.grey, fontSize: 12.5),
),
if (!canEdit) ...[
const SizedBox(height: 10),
const Text(
'타이머가 멈춰있을 때만 설정을 바꿀 수 있어요.',
style: TextStyle(
color: AppPalette.danger,
fontSize: 12.5,
),
),
],
const SizedBox(height: 18),
_minuteStepper(
label: '공부 시간',
minutes: studyMinutes,
enabled: canEdit,
onChanged: (v) => setSheetState(() => studyMinutes = v),
),
const SizedBox(height: 12),
_minuteStepper(
label: '휴식 시간',
minutes: breakMinutes,
enabled: canEdit,
onChanged: (v) => setSheetState(() => breakMinutes = v),
),
const SizedBox(height: 10),
OutlinedButton.icon(
icon: const Icon(Icons.groups_rounded, size: 18),
label: const Text('친구들이 공유한 루틴 보기 / 공유하기'),
onPressed: () async {
final result = await pushLaunchpad<Map<String, int>>(
sheetContext,
(context) => StudyPresetScreen(
studentId: widget.studentId,
studentName: widget.studentName,
initialStudyMinutes: studyMinutes,
initialBreakMinutes: breakMinutes,
),
);
if (result == null) return;
if (!canEdit) {
if (sheetContext.mounted) {
AppNotice.show(
sheetContext,
'타이머가 멈춰있을 때만 루틴을 적용할 수 있어요.',
);
}
return;
}
setSheetState(() {
studyMinutes = result['studyMinutes']!;
breakMinutes = result['breakMinutes']!;
enabled = true;
});
},
),
const SizedBox(height: 18),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: !canEdit
? null
: () async {
await _controller.setPomodoroSettings(
enabled: enabled,
studyMinutes: studyMinutes,
breakMinutes: breakMinutes,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
},
child: const Text('저장'),
),
],
),
);
},
);
},
);
}
Widget _minuteStepper({
required String label,
required int minutes,
required bool enabled,
required ValueChanged<int> onChanged,
}) {
return Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: enabled && minutes > 5
? () => onChanged(minutes - 5)
: null,
),
SizedBox(
width: 48,
child: Text(
'$minutes분',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: enabled && minutes < 120
? () => onChanged(minutes + 5)
: null,
),
],
);
}
void _openRanking() {
if (widget.grade == null) {
AppNotice.show(context, '학년 정보가 없어 랭킹을 볼 수 없어요.');
return;
}
pushLaunchpad(
context,
(context) => StudyRankingScreen(
grade: widget.grade!,
myStudentId: widget.studentId,
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final bool isRunning = _controller.isRunning;
final bool isPaused = _controller.isPaused;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('백품타'),
actions: [
IconButton(
icon: const Icon(Icons.tune_rounded),
tooltip: '뽀모도로 설정',
onPressed: _showPomodoroSettings,
),
IconButton(
icon: const Icon(Icons.leaderboard_rounded),
tooltip: '학년 랭킹',
onPressed: _openRanking,
),
],
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_statusLabel(isRunning, isPaused),
style: const TextStyle(
fontSize: 15,
color: Colors.grey,
),
),
if (_controller.pomodoroEnabled &&
_controller.phase != null) ...[
const SizedBox(height: 4),
Text(
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
'완료 ${_controller.completedCycles}회',
style: const TextStyle(
fontSize: 12.5,
color: Colors.grey,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
Text(
_formatHMS(_controller.liveElapsedSeconds),
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
color: isPaused ? Colors.grey : AppPalette.ink,
),
),
const SizedBox(height: 36),
_buildActionButtons(isRunning, isPaused),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statBox(
'오늘',
_formatHM(_controller.todaySecondsLive),
),
const SizedBox(width: 12),
_statBox(
'이번주',
_formatHM(_controller.weekSecondsLive),
),
const SizedBox(width: 12),
_statBox(
'전체',
_formatHM(_controller.totalSecondsLive),
),
],
),
],
),
),
),
);
},
);
}
Widget _buildActionButtons(bool isRunning, bool isPaused) {
if (!isRunning && !isPaused) {
return _circleButton(
size: 140,
icon: Icons.play_arrow_rounded,
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
color: AppPalette.ink,
onTap: () {
requestNotificationPermission();
_runAction(_controller.start);
},
);
}
if (_controller.pomodoroEnabled && _controller.phase != null) {
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
return _circleButton(
size: 140,
icon: Icons.stop_rounded,
label: '공부 종료',
color: AppPalette.danger,
onTap: () => _runAction(_controller.stop),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_circleButton(
size: 110,
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
label: isPaused ? '재개' : '일시정지',
color: isPaused ? AppPalette.ink : AppPalette.warning,
onTap: () =>
_runAction(isPaused ? _controller.resume : _controller.pause),
),
const SizedBox(width: 20),
_circleButton(
size: 110,
icon: Icons.stop_rounded,
label: '공부 종료',
color: AppPalette.danger,
onTap: () => _runAction(_controller.stop),
),
],
);
}
Widget _circleButton({
required double size,
required IconData icon,
required String label,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: _controller.isBusy ? null : onTap,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Center(
child: _controller.isBusy
? const CircularProgressIndicator(color: Colors.white)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: size > 120 ? 36 : 28),
Text(
label,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: size > 120 ? 14 : 12.5,
),
),
],
),
),
),
);
}
Widget _statBox(String label, String value) {
return Container(
width: 108,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
maxLines: 1,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
),
],
),
);
}
}
+485 -262
View File
@@ -3,6 +3,9 @@
import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'launchpad_transition.dart';
import 'title_pill.dart';
// -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
@@ -29,13 +32,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
super.dispose();
}
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
/// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다.
Future<void> _runAction(Future<String> Function() action) async {
final message = await action();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
Future<void> _showAttendanceTimeDialog() async {
@@ -204,66 +205,20 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return Scaffold(
backgroundColor: AppPalette.mist,
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
appBar: AppBar(
title: const Text(
'실시간 출석 현황',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
actions: [
IconButton(
onPressed: (_controller.isLoading || _controller.isRefreshing)
? null
: _controller.manualRefresh,
icon: _controller.isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.refresh_rounded),
tooltip: '새로고침',
icon: const Icon(Icons.more_horiz_rounded),
tooltip: '자습실 시간 설정 메뉴',
onPressed: _showTimeSettingsMenu,
),
TextButton.icon(
onPressed: _showAttendanceTimeDialog,
icon: const Icon(
Icons.access_time_rounded,
color: Colors.white,
),
label: Text(
_controller.attendanceTime != null
? '출석시간 ${_controller.attendanceTime}'
: '자습실 출석시간 설정',
style: const TextStyle(color: Colors.white),
),
),
TextButton.icon(
onPressed: _showPermissionWindowDialog,
icon: const Icon(Icons.timer_outlined, color: Colors.white),
label: const Text(
'반출 허용 시간 설정',
style: TextStyle(color: Colors.white),
),
),
TextButton.icon(
onPressed: _showDismissalConfirmDialog,
icon: const Icon(Icons.school_rounded, color: Colors.white),
label: const Text('하교', style: TextStyle(color: Colors.white)),
),
IconButton(
onPressed: _showTestResetConfirmDialog,
icon: const Icon(
Icons.bug_report_outlined,
color: Colors.white70,
),
tooltip: '테스트용: 허용시간 초기화',
),
const SizedBox(width: 8),
const SizedBox(width: 4),
],
),
body: _controller.isLoading
@@ -296,9 +251,30 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final groups = _controller.studentsByGrade;
return Column(
children: [
_buildSummaryCards(total, checkedIn, absent),
const SizedBox(height: 12),
const TitlePill('실시간 출석 현황'),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(child: _sidebarStatPill('전체 학생 수', total, 'ALL')),
const SizedBox(width: 8),
Expanded(
child: _sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
),
const SizedBox(width: 8),
Expanded(child: _sidebarStatPill('미출석 학생수', absent, 'ABSENT')),
],
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _sidebarFilterButton(),
),
_buildPermissionBanner(),
_buildFilterChips(),
const SizedBox(height: 12),
Expanded(
child: groups.isEmpty
? const Center(
@@ -384,8 +360,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final bool isPending = student['attendanceStatus'] == 'PENDING';
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
final Color statusColor = hasViolation
? Colors.red
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
? AppPalette.danger
: (isPending
? AppPalette.warning
: (isComplete ? AppPalette.approved : AppPalette.danger));
final String statusLine = hasViolation
? '무단반출 (${student['violationTime']})'
: (isPending
@@ -397,10 +375,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: hasViolation ? Colors.red[50] : AppPalette.paper,
color: hasViolation ? AppPalette.dangerBg : AppPalette.paper,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
color: hasViolation ? AppPalette.danger : AppPalette.sage,
width: hasViolation ? 1.5 : 1,
),
boxShadow: [
@@ -475,10 +453,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: hasViolation
? Colors.red[700]
? AppPalette.danger
: (isPending
? Colors.orange[800]
: (isComplete ? Colors.grey[600] : Colors.red[400])),
? AppPalette.warning
: (isComplete ? Colors.grey[600] : AppPalette.danger)),
fontSize: 11.5,
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
),
@@ -494,7 +472,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
student['studentName'],
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
backgroundColor: AppPalette.danger,
foregroundColor: Colors.white,
padding: EdgeInsets.zero,
),
@@ -509,164 +487,356 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
// -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _statTile(
"전체 학생",
"$total명",
Icons.groups_rounded,
Colors.blueGrey,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"출석 완료",
"$checkedIn명",
Icons.check_circle_rounded,
Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"미출석",
"$absent명",
Icons.error_rounded,
Colors.red,
),
),
],
),
const SizedBox(height: 20),
_buildPermissionBanner(),
_buildFilterChips(),
const SizedBox(height: 12),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20),
final students = _controller.filteredStudents;
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPermissionBanner(),
const SizedBox(height: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDesktopSidebar(total, checkedIn, absent),
const SizedBox(width: 20),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: _buildDenseSeatGrid(students),
),
),
],
),
),
],
),
);
}
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) {
return SizedBox(
width: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(child: const TitlePill('실시간 출석 현황')),
const SizedBox(height: 16),
_sidebarStatPill('전체 학생 수', total, 'ALL'),
const SizedBox(height: 10),
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
const SizedBox(height: 10),
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
const SizedBox(height: 20),
_sidebarFilterButton(),
const SizedBox(height: 10),
_sidebarTimeSettingsButton(),
],
),
);
}
Widget _sidebarTimeSettingsButton() {
return InkWell(
onTap: _showTimeSettingsMenu,
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppPalette.sage),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.access_time_rounded,
size: 18,
color: AppPalette.ink,
),
const SizedBox(width: 8),
const Flexible(
child: Text(
'자습실 시간 설정 메뉴',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
],
),
),
],
),
),
);
}
Widget _statTile(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
Widget _sidebarStatPill(String label, int count, String filterValue) {
final bool selected = _controller.filterType == filterValue;
return InkWell(
onTap: () => _controller.setFilter(filterValue),
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: selected ? AppPalette.ink : AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage,
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 26),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
child: Column(
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: selected ? Colors.white70 : Colors.grey[600],
),
const SizedBox(height: 2),
),
const SizedBox(height: 4),
Text(
'$count명',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: selected ? Colors.white : AppPalette.ink,
),
),
],
),
),
);
}
Widget _sidebarFilterButton() {
return InkWell(
onTap: _showGradeFilterMenu,
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppPalette.sage),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.filter_list_rounded,
size: 18,
color: AppPalette.ink,
),
const SizedBox(width: 8),
Flexible(
child: Text(
_gradeFilterLabel(),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
),
],
),
),
);
}
String _gradeFilterLabel() {
switch (_controller.gradeFilter) {
case '1':
return '1학년만';
case '2':
return '2학년만';
case '3':
return '3학년만';
default:
return '학생 나눠보기';
}
}
// 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다.
Future<void> _showGradeFilterMenu() async {
final grade = await showLaunchpadMenu<String>(
context: context,
builder: (context) => Center(
child: Material(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(20),
elevation: 8,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'학년 나눠보기',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_gradeChoice(context, 'ALL', '전체'),
_gradeChoice(context, '1', '1학년'),
_gradeChoice(context, '2', '2학년'),
_gradeChoice(context, '3', '3학년'),
],
),
],
),
),
),
),
);
if (grade != null) _controller.setGradeFilter(grade);
}
Widget _gradeChoice(BuildContext dialogContext, String value, String label) {
return ChoiceChip(
label: Text(label),
selected: _controller.gradeFilter == value,
onSelected: (_) => Navigator.pop(dialogContext, value),
);
}
// 🚀 "자습실 시간 설정 메뉴" — 새로고침/출석시간/반출허용/하교/테스트리셋을
// 전부 여기 하나로 모아서, 메인 대시보드의 로그아웃/설정 메뉴와 똑같은
// 런치패드 스타일(배경 블러 유지) 오버레이로 띄운다.
Future<void> _showTimeSettingsMenu() async {
final action = await showLaunchpadMenu<String>(
context: context,
builder: (context) => Center(
child: _TimeSettingsMenuCard(
isRefreshing: _controller.isRefreshing,
attendanceTimeLabel: _controller.attendanceTime != null
? '출석시간 ${_controller.attendanceTime}'
: '자습실 출석시간 설정',
onPick: (value) => Navigator.pop(context, value),
),
),
);
if (!mounted || action == null) return;
switch (action) {
case 'refresh':
_controller.manualRefresh();
break;
case 'attendance_time':
_showAttendanceTimeDialog();
break;
case 'permission_window':
_showPermissionWindowDialog();
break;
case 'dismissal':
_showDismissalConfirmDialog();
break;
case 'test_reset':
_showTestResetConfirmDialog();
break;
}
}
// 🪑 좌석표처럼 촘촘하게 학생 한 명씩을 작은 칸에 담는 그리드.
Widget _buildDenseSeatGrid(List<Map<String, dynamic>> students) {
if (students.isEmpty) {
return const Center(
child: Text('해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey)),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 92,
mainAxisExtent: 62,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
),
itemCount: students.length,
itemBuilder: (context, index) => _buildSeatCell(students[index], index),
);
}
Widget _buildSeatCell(Map<String, dynamic> student, int index) {
final bool hasViolation = student['hasActiveViolation'] == true;
final bool isPending = student['attendanceStatus'] == 'PENDING';
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
final Color background = hasViolation
? AppPalette.dangerBg
: isComplete
? AppPalette.approvedBg
: isPending
? AppPalette.warningBg
: AppPalette.paper;
final Color border = hasViolation
? AppPalette.danger
: isComplete
? AppPalette.approved
: isPending
? AppPalette.warning
: AppPalette.sage;
return InkWell(
onTap: hasViolation
? () => _showAllowDialog(student['studentId'], student['studentName'])
: null,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'[${index + 1}] ${student['studentId']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
),
Text(
'${student['studentName']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
if (hasViolation)
Text(
value,
'무단반출',
style: TextStyle(
color: color,
fontSize: 22,
fontSize: 7.5,
color: AppPalette.danger,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: AppPalette.ink,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_summaryCard("전체", "$total명", Colors.white70),
_summaryCard("출석 완료", "$checkedIn명", Colors.greenAccent),
_summaryCard("미출석", "$absent명", Colors.redAccent),
],
),
);
}
Widget _summaryCard(String title, String count, Color color) {
return Column(
children: [
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Text(
count,
style: TextStyle(
color: color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
],
),
],
),
);
}
@@ -681,19 +851,19 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.amber[100],
color: AppPalette.warningBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber[400]!),
border: Border.all(color: AppPalette.warning),
),
child: Row(
children: [
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
Icon(Icons.lock_open_rounded, color: AppPalette.warning, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
style: TextStyle(
color: Colors.amber[900],
color: AppPalette.warning,
fontWeight: FontWeight.bold,
fontSize: 12.5,
),
@@ -703,63 +873,116 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
),
);
}
}
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
Widget _buildFilterChips() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
class _TimeSettingsMenuCard extends StatelessWidget {
final bool isRefreshing;
final String attendanceTimeLabel;
final ValueChanged<String> onPick;
const _TimeSettingsMenuCard({
required this.isRefreshing,
required this.attendanceTimeLabel,
required this.onPick,
});
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.paper,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 220),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
label: const Text("전체"),
selected: _controller.filterType == "ALL",
onSelected: (_) => _controller.setFilter("ALL"),
_TimeMenuRow(
icon: isRefreshing ? null : Icons.refresh_rounded,
label: '새로고침',
onTap: () => onPick('refresh'),
),
FilterChip(
label: const Text("출석자"),
selected: _controller.filterType == "CHECKED_IN",
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.access_time_rounded,
label: attendanceTimeLabel,
onTap: () => onPick('attendance_time'),
),
FilterChip(
label: const Text("미출석자"),
selected: _controller.filterType == "ABSENT",
onSelected: (_) => _controller.setFilter("ABSENT"),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.timer_outlined,
label: '반출 허용 시간 설정',
onTap: () => onPick('permission_window'),
),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.school_rounded,
label: '하교 처리',
onTap: () => onPick('dismissal'),
),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.bug_report_outlined,
label: '테스트용: 허용시간 초기화',
dim: true,
onTap: () => onPick('test_reset'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text("학년 전체"),
selected: _controller.gradeFilter == "ALL",
onSelected: (_) => _controller.setGradeFilter("ALL"),
),
),
);
}
}
class _TimeMenuRow extends StatelessWidget {
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool dim;
const _TimeMenuRow({
required this.icon,
required this.label,
required this.onTap,
this.dim = false,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: icon == null
? const CircularProgressIndicator(strokeWidth: 2)
: Icon(
icon,
size: 20,
color: dim ? Colors.grey : AppPalette.ink,
),
),
const SizedBox(width: 12),
Flexible(
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w600,
color: dim ? Colors.grey[600] : AppPalette.ink,
),
),
ChoiceChip(
label: const Text("1학년"),
selected: _controller.gradeFilter == "1",
onSelected: (_) => _controller.setGradeFilter("1"),
),
ChoiceChip(
label: const Text("2학년"),
selected: _controller.gradeFilter == "2",
onSelected: (_) => _controller.setGradeFilter("2"),
),
ChoiceChip(
label: const Text("3학년"),
selected: _controller.gradeFilter == "3",
onSelected: (_) => _controller.setGradeFilter("3"),
),
],
),
],
),
],
),
),
);
}
+525
View File
@@ -0,0 +1,525 @@
// 📍 선생님 호출 화면(학생용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
// (제목 알약 + 필 모양 통계/필터 + 그림자 있는 둥근 타일)을 따른다.
// 서버 통신/상태는 lib/function/teacher_call_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../data/teacher_call_schedule.dart';
import '../function/teacher_call_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class TeacherCallScreen extends StatefulWidget {
final String studentId;
final String studentName;
const TeacherCallScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<TeacherCallScreen> createState() => _TeacherCallScreenState();
}
class _TeacherCallScreenState extends State<TeacherCallScreen> {
late final TeacherCallController _controller;
String? _selectedTeacherId;
String? _selectedTeacherName;
String _selectedPurpose = kCallPurposes.first;
final _customPurposeController = TextEditingController();
bool _useCustomPurpose = false;
@override
void initState() {
super.initState();
_controller = TeacherCallController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
_customPurposeController.dispose();
super.dispose();
}
Future<void> _call() async {
if (_selectedTeacherId == null) {
AppNotice.show(context, '선생님을 선택해 주세요.');
return;
}
final purpose = _useCustomPurpose
? _customPurposeController.text.trim()
: _selectedPurpose;
if (purpose.isEmpty) {
AppNotice.show(context, '방문 목적을 입력해 주세요.');
return;
}
final (success, message) = await _controller.callTeacher(
teacherId: _selectedTeacherId!,
purpose: purpose,
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
double _sidePad(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return ((width - 560) / 2).clamp(16.0, double.infinity);
}
@override
Widget build(BuildContext context) {
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,
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: EdgeInsets.fromLTRB(
_sidePad(context),
0,
_sidePad(context),
16,
),
children: [
const Center(child: TitlePill('선생님 호출')),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: _statPill('전체 선생님', teachers.length)),
const SizedBox(width: 8),
Expanded(child: _statPill('지금 수업 중', inClassCount)),
],
),
if (_controller.ranking.isNotEmpty) ...[
const SizedBox(height: 12),
_buildRanking(),
],
const SizedBox(height: 20),
_sectionHeader('방문 목적'),
const SizedBox(height: 10),
_buildPurposeChips(),
if (_useCustomPurpose) ...[
const SizedBox(height: 10),
TextField(
controller: _customPurposeController,
maxLength: 40,
decoration: InputDecoration(
hintText: '용무를 입력하세요',
filled: true,
fillColor: AppPalette.paper,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppPalette.sage),
),
),
),
],
const SizedBox(height: 20),
_sectionHeader('선생님 목록', count: teachers.length),
const SizedBox(height: 10),
_buildTeacherGrid(),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _selectedTeacherId == null
? Colors.grey
: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
onPressed: _controller.isCalling ? null : _call,
child: _controller.isCalling
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_selectedTeacherName == null
? '선생님을 선택해 주세요'
: '$_selectedTeacherName 선생님 호출하기',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 24),
_sectionHeader(
'내 호출 기록',
count: _controller.myCalls.length,
),
const SizedBox(height: 10),
_buildMyCalls(),
],
),
),
);
},
);
}
/// 🪶 "실시간 출석 현황"의 _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]),
),
],
],
);
}
static const List<Color> _medalColors = [
Color(0xFFC9A227), // 금
Color(0xFF9AA0A6), // 은
Color(0xFFB07A4B), // 동
];
Widget _buildRanking() {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppPalette.paper,
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: [
Text(
'오늘 인기 선생님',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var i = 0; i < _controller.ranking.length; i++)
Column(
children: [
Icon(
Icons.emoji_events_rounded,
color: _medalColors[i],
size: 22,
),
Text(
_controller.ranking[i]['teacherName'] ?? '',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'${_controller.ranking[i]['count']}회',
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
],
),
],
),
);
}
/// 🍬 필 모양 선택 버튼(스탯 필과 같은 톤: 선택=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)
_pillChoice(
p,
!_useCustomPurpose && _selectedPurpose == p,
() => setState(() {
_useCustomPurpose = false;
_selectedPurpose = p;
}),
),
_pillChoice(
'직접 입력',
_useCustomPurpose,
() => setState(() => _useCustomPurpose = true),
),
],
);
}
Widget _buildTeacherGrid() {
final teachers = _controller.teachers;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 160,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 110,
),
itemCount: teachers.length,
itemBuilder: (context, 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 ? AppPalette.warning : AppPalette.ink;
return GestureDetector(
onTap: () => setState(() {
_selectedTeacherId = t['teacherId'];
_selectedTeacherName = name;
}),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? AppPalette.ink : AppPalette.paper,
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isSelected
? Colors.white.withValues(alpha: 0.15)
: statusColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
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)
: AppPalette.warningBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Colors.white54
: AppPalette.warning,
),
),
child: Text(
'수업중',
style: TextStyle(
fontSize: 9,
color: isSelected
? Colors.white
: AppPalette.warning,
),
),
),
],
),
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),
Row(
children: [
Icon(
kLocationIcons[location] ?? Icons.location_on_rounded,
size: 12,
color: isSelected ? Colors.white70 : Colors.grey[500],
),
const SizedBox(width: 3),
Expanded(
child: Text(
location,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: isSelected ? Colors.white70 : Colors.grey[500],
),
),
),
],
),
],
),
),
);
},
);
}
Widget _buildMyCalls() {
if (_controller.myCalls.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
);
}
return Column(
children: [
for (final c in _controller.myCalls)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text('${c['teacherName']} 선생님 · ${c['purpose']}'),
),
Text(
'${c['createdAt'] ?? ''}'.split(' ').last,
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
),
],
);
}
}
+291
View File
@@ -0,0 +1,291 @@
// 📍 선생님 위치 등록 화면(교사용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
// (제목 알약 + 필 모양 통계 + 그림자 있는 둥근 타일)을 따른다.
// 서버 통신/상태는 lib/function/teacher_location_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../data/teacher_call_schedule.dart';
import '../function/teacher_location_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class TeacherLocationScreen extends StatefulWidget {
final String teacherId;
const TeacherLocationScreen({super.key, required this.teacherId});
@override
State<TeacherLocationScreen> createState() => _TeacherLocationScreenState();
}
class _TeacherLocationScreenState extends State<TeacherLocationScreen> {
late final TeacherLocationController _controller;
@override
void initState() {
super.initState();
_controller = TeacherLocationController(teacherId: widget.teacherId);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _selectLocation(String location) async {
final (success, message) = await _controller.updateLocation(location);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
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]),
),
],
],
);
}
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
double _sidePad(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return ((width - 560) / 2).clamp(16.0, double.infinity);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _controller.fetchReceivedCalls,
child: ListView(
padding: EdgeInsets.fromLTRB(
_sidePad(context),
0,
_sidePad(context),
16,
),
children: [
const Center(child: TitlePill('내 위치 알리기')),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(24),
),
child: Column(
children: [
const Text(
'현재 위치',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
kLocationIcons[_controller.currentLocation] ??
Icons.location_on_rounded,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
_controller.currentLocation,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
color: Colors.white,
),
),
],
),
],
),
),
const SizedBox(height: 20),
_sectionHeader('위치 선택'),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 160,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 90,
),
itemCount: kLocationOptions.length,
itemBuilder: (context, index) {
final loc = kLocationOptions[index];
final isSelected = _controller.currentLocation == loc;
return GestureDetector(
onTap: _controller.isSaving
? null
: () => _selectLocation(loc),
child: Container(
decoration: BoxDecoration(
color: isSelected
? AppPalette.ink
: AppPalette.paper,
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: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isSelected
? Colors.white.withValues(
alpha: 0.15,
)
: AppPalette.linen,
shape: BoxShape.circle,
),
child: Icon(
kLocationIcons[loc] ??
Icons.location_on_rounded,
size: 16,
color: isSelected
? Colors.white
: AppPalette.ink,
),
),
const SizedBox(height: 6),
Text(
loc,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? Colors.white
: AppPalette.ink,
),
),
],
),
),
),
);
},
),
const SizedBox(height: 24),
_sectionHeader(
'나를 찾은 학생 기록',
count: _controller.receivedCalls.length,
),
const SizedBox(height: 10),
_buildReceivedCalls(),
],
),
),
);
},
);
}
Widget _buildReceivedCalls() {
if (_controller.receivedCalls.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
);
}
return Column(
children: [
for (final c in _controller.receivedCalls)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppPalette.paper,
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: TextStyle(color: Colors.grey[500], fontSize: 11),
),
],
),
),
],
);
}
}
+9 -9
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart';
import '../function/teacher_register_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class TeacherRegisterScreen extends StatefulWidget {
const TeacherRegisterScreen({super.key});
@@ -35,9 +37,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
String secret = _secretController.text.trim();
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('모든 빈칸을 입력해 주세요.')));
AppNotice.show(context, '모든 빈칸을 입력해 주세요.');
return;
}
@@ -48,9 +48,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
secretCode: secret,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
if (success) {
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
}
@@ -64,9 +62,11 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text('교사 회원가입'),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('교사 회원가입'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
+91 -47
View File
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
const double kAttendanceTileWidth = 220;
@@ -51,9 +53,7 @@ class _TeacherStudentManagementPageState
final sName = _addNameController.text.trim();
if (sId.isEmpty || sName.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('모든 입력란을 채워주세요.')));
AppNotice.show(context, '모든 입력란을 채워주세요.');
return;
}
@@ -69,9 +69,7 @@ class _TeacherStudentManagementPageState
_addNameController.clear();
setState(() => _selectedGrade = null);
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
@@ -89,16 +87,12 @@ class _TeacherStudentManagementPageState
rows = _controller.parseExcelBytes(bytes);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('엑셀 파일을 읽는 데 실패했습니다: $e')));
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
return;
}
if (rows.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)')),
);
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)');
return;
}
_showBulkPreviewDialog(rows);
@@ -229,7 +223,10 @@ class _TeacherStudentManagementPageState
child: SingleChildScrollView(
child: Text(
result.failures.join('\n'),
style: const TextStyle(fontSize: 12, color: Colors.red),
style: const TextStyle(
fontSize: 12,
color: AppPalette.danger,
),
),
),
),
@@ -280,9 +277,7 @@ class _TeacherStudentManagementPageState
grade: selected,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
}
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
@@ -312,9 +307,7 @@ class _TeacherStudentManagementPageState
studentName,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
},
child: const Text('초기화 승인'),
),
@@ -331,7 +324,7 @@ class _TeacherStudentManagementPageState
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
Icon(Icons.warning_amber_rounded, color: AppPalette.danger),
const SizedBox(width: 10),
const Text(
'계정 완전 삭제',
@@ -349,7 +342,7 @@ class _TeacherStudentManagementPageState
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
backgroundColor: AppPalette.danger,
foregroundColor: Colors.white,
),
onPressed: () async {
@@ -358,9 +351,7 @@ class _TeacherStudentManagementPageState
studentId,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppNotice.show(context, message);
},
child: const Text('영구 삭제'),
),
@@ -370,6 +361,20 @@ class _TeacherStudentManagementPageState
}
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
Widget _gradeFilterChip(int? grade, String label) {
final bool selected = _controller.gradeFilter == grade;
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => _controller.setGradeFilter(grade),
selectedColor: AppPalette.ink,
labelStyle: TextStyle(
color: selected ? Colors.white : AppPalette.ink,
fontWeight: FontWeight.bold,
),
);
}
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
return Row(
children: [
@@ -517,10 +522,10 @@ class _TeacherStudentManagementPageState
height: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
color: _isDragging ? AppPalette.warningBg : AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _isDragging ? Colors.orange : AppPalette.sage,
color: _isDragging ? AppPalette.warning : AppPalette.sage,
width: _isDragging ? 2 : 1,
),
),
@@ -530,7 +535,7 @@ class _TeacherStudentManagementPageState
Icon(
Icons.upload_file_rounded,
size: 40,
color: _isDragging ? Colors.orange : AppPalette.sage,
color: _isDragging ? AppPalette.warning : AppPalette.sage,
),
const SizedBox(height: 12),
Text(
@@ -538,7 +543,7 @@ class _TeacherStudentManagementPageState
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
color: _isDragging ? AppPalette.warning : AppPalette.ink,
),
),
const SizedBox(height: 4),
@@ -582,15 +587,13 @@ class _TeacherStudentManagementPageState
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: needsReset
? Colors.red.withValues(alpha: 0.1)
: AppPalette.linen,
color: needsReset ? AppPalette.dangerBg : AppPalette.linen,
shape: BoxShape.circle,
),
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
size: 18,
color: needsReset ? Colors.red : AppPalette.ink,
color: needsReset ? AppPalette.danger : AppPalette.ink,
),
),
const SizedBox(height: 8),
@@ -607,7 +610,7 @@ class _TeacherStudentManagementPageState
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5,
color: needsReset ? Colors.red[700] : Colors.grey[600],
color: needsReset ? AppPalette.danger : Colors.grey[600],
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
),
),
@@ -648,7 +651,7 @@ class _TeacherStudentManagementPageState
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
color: AppPalette.danger,
size: 18,
),
tooltip: '계정 영구 삭제',
@@ -671,13 +674,11 @@ class _TeacherStudentManagementPageState
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
title: const Text(
'학생 통합 관리 센터',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('학생 통합 관리 센터'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
@@ -767,6 +768,42 @@ class _TeacherStudentManagementPageState
),
),
const SizedBox(height: 12),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 8,
children: [
SizedBox(
width: isWide ? formSquareSize : 160,
child: TextField(
onChanged: _controller.setSearchQuery,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '학번으로 검색',
prefixIcon: const Icon(
Icons.search_rounded,
),
filled: true,
fillColor: AppPalette.paper,
contentPadding: const EdgeInsets.symmetric(
vertical: 0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppPalette.sage,
),
),
),
),
),
_gradeFilterChip(null, '전체'),
_gradeFilterChip(1, '1학년'),
_gradeFilterChip(2, '2학년'),
_gradeFilterChip(3, '3학년'),
],
),
const SizedBox(height: 12),
_controller.isLoadingStudents
? const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
@@ -774,13 +811,19 @@ class _TeacherStudentManagementPageState
child: CircularProgressIndicator(),
),
)
: _controller.students.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
: _controller.filteredStudents.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: 24,
),
child: Center(
child: Text(
'가입된 학생 계정이 없습니다.',
style: TextStyle(color: Colors.grey),
_controller.students.isEmpty
? '가입된 학생 계정이 없습니다.'
: '검색/필터 조건에 맞는 학생이 없습니다.',
style: const TextStyle(
color: Colors.grey,
),
),
),
)
@@ -795,10 +838,11 @@ class _TeacherStudentManagementPageState
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _controller.students.length,
itemCount:
_controller.filteredStudents.length,
itemBuilder: (context, index) =>
_buildStudentTile(
_controller.students[index],
_controller.filteredStudents[index],
),
),
],
+32
View File
@@ -0,0 +1,32 @@
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
class TitlePill extends StatelessWidget {
final String text;
const TitlePill(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(999),
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ packages:
source: hosted
version: "1.0.3"
archive:
dependency: transitive
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
+1
View File
@@ -54,6 +54,7 @@ dependencies:
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
vibration: ^3.2.0
excel: ^4.0.6
archive: ^3.6.1
file_picker: ^12.1.2
desktop_drop: ^0.8.3
web: ^1.1.1
@@ -0,0 +1,92 @@
# 교무실 Stream Deck 선생님 위치 표시기
교무실 출입구에 놓인 Stream Deck으로 선생님이 본인 이름 버튼만 누르면
백품타 앱의 "선생님 호출" 위치가 바뀝니다. 이미 배포돼 있는 서버 API
(`POST /api/teacher-call/location`)를 그대로 호출하기 때문에, 서버나
앱은 전혀 건드릴 필요 없이 이 스크립트 하나만 라즈베리파이 같은 작은
컴퓨터에서 계속 띄워두면 됩니다.
## 준비물
- Stream Deck 오리지널/MK.2 (15키, 5x3) — USB로 라즈베리파이에 연결
- 라즈베리파이(또는 인터넷 되는 리눅스 미니PC) 1대
- 교무실 회원가입(교사 회원가입)을 마친 선생님만 목록에 뜹니다 — 아직
가입 안 한 선생님은 버튼이 안 보이니, 먼저 앱에서 "교사 회원가입"부터
해달라고 안내해주세요.
## 설치
```bash
# 1) 한글 폰트 설치 (버튼에 이름이 네모(□)로 안 뜨게)
sudo apt update
sudo apt install -y fonts-nanum
# 2) Stream Deck을 일반 사용자 권한으로 인식하게 하는 udev 규칙
# (이거 안 하면 sudo로만 인식되거나 아예 인식이 안 될 수 있어요)
sudo tee /etc/udev/rules.d/50-streamdeck.rules > /dev/null << 'UDEV'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0666"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"
UDEV
sudo udevadm control --reload-rules
sudo udevadm trigger
# 3) 파이썬 패키지 설치
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 4) 실행
python3 stream_deck_teacher_status.py
```
USB 케이블을 뽑았다 다시 꽂아야 udev 규칙이 적용되는 경우가 있어요.
## 사용법
- **선생님 이름 버튼을 그냥 누르면** → "수업 중"으로 표시됩니다.
- **과학실/도서관/출장 등 다른 위치로 표시하고 싶으면** → 화면 오른쪽 아래
"⚙ 설정" 버튼을 누르고, 뜨는 위치 목록에서 하나를 고른 뒤(예: "출장"),
이어서 그 선생님 이름 버튼을 누르면 됩니다. 한 번 적용하면 자동으로
다시 기본 모드("수업 중")로 돌아갑니다.
- 선생님이 18명을 넘어가면 자동으로 페이지가 나뉘고, "다음 ▶" 버튼으로
넘길 수 있습니다.
- 새로 교사 회원가입을 한 선생님은 최대 1분 안에 자동으로 목록에 나타납니다
(재시작 필요 없음).
## 부팅할 때 자동 실행하게 하기 (선택)
라즈베리파이를 켤 때마다 자동으로 이 스크립트가 실행되게 하려면
systemd 서비스로 등록하세요:
```bash
sudo tee /etc/systemd/system/streamdeck-teacher.service > /dev/null << 'UNIT'
[Unit]
Description=교무실 Stream Deck 선생님 위치 표시기
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/home/pi/stream_deck_teacher_status/venv/bin/python3 /home/pi/stream_deck_teacher_status/stream_deck_teacher_status.py
Restart=on-failure
RestartSec=5
User=pi
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now streamdeck-teacher.service
```
경로(`/home/pi/...`)는 실제로 이 폴더를 둔 위치에 맞게 바꿔주세요.
로그 확인은 `journalctl -u streamdeck-teacher -f`.
## 참고
- 이 스크립트는 이 저장소(school-backend)와는 별개로, 이미 배포된
`api.backsanhi.shop`의 기존 API만 호출합니다. 서버/앱 배포와는 무관하게
라즈베리파이에서 이 파일만 갖고 있으면 바로 동작합니다.
- `python-elgato-streamdeck` 라이브러리 버전에 따라 내부 헬퍼 함수 이름이
달라서(create_key_image/create_image 등) 코드에 호환 처리를 넣어뒀지만,
혹시 실행 중 `AttributeError`가 나면 캡처해서 알려주세요.
@@ -0,0 +1,3 @@
streamdeck
pillow
requests
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
🎛️ 교무실 출입구용 Stream Deck 선생님 위치 표시기.
선생님이 교무실을 나가실 때 본인 이름 버튼을 한 번 누르면 "수업 중"으로,
"설정" 버튼을 눌러 과학실/도서관/출장 같은 위치를 먼저 고른 뒤 이름 버튼을
누르면 그 위치로 우리 학교 앱(백품타 선생님 호출)에 표시된다.
이미 만들어져 있는 백엔드 API(POST /api/teacher-call/location)를 그대로
호출하기 때문에 서버/앱 쪽은 아무것도 건드릴 필요가 없다. 이 스크립트는
라즈베리파이 같은 작은 컴퓨터에 Stream Deck을 USB로 꽂아두고 계속 띄워두면 된다.
설치/실행 방법은 같은 폴더의 README.md 참고.
"""
import threading
import time
from typing import Optional
import requests
from PIL import Image, ImageDraw, ImageFont
from StreamDeck.DeviceManager import DeviceManager
from StreamDeck.ImageHelpers import PILHelper
# 🩹 [버전 호환] python-elgato-streamdeck 버전에 따라 이 헬퍼 함수 이름이
# create_key_image/create_image, to_native_key_format/to_native_format로
# 다르게 붙어있어서, 설치된 버전에 맞는 이름을 찾아 쓴다.
_create_key_image = getattr(PILHelper, "create_key_image", None) or PILHelper.create_image
_to_native_key_format = getattr(PILHelper, "to_native_key_format", None) or PILHelper.to_native_format
# ──────────────────────────────────────────────────────────────────────────
# ⚙️ 설정
# ──────────────────────────────────────────────────────────────────────────
API_BASE = "https://api.backsanhi.shop"
TEACHERS_URL = f"{API_BASE}/api/teacher-call/teachers"
LOCATION_URL_TMPL = f"{API_BASE}/api/teacher-call/location"
# 이름 버튼을 그냥 한 번 눌렀을 때(설정모드 아닐 때) 적용되는 기본 위치.
DEFAULT_TAP_LOCATION = "수업 중"
# "설정" 모드에서 고를 수 있는 위치 목록 (앱의 kLocationOptions와 맞춰둠).
LOCATION_OPTIONS = [
"교무실", "급식실", "과학실", "도서관",
"출장", "조퇴/퇴근", "상담실", "기숙사",
]
ROSTER_REFRESH_SECONDS = 60 # 선생님 목록(누가 회원가입했는지)을 다시 불러오는 주기.
FONT_PATH_CANDIDATES = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
]
TEACHERS_PER_PAGE = 13 # 페이지당 선생님 버튼 개수 (나머지 2칸은 다음/설정 버튼용).
def _find_font(size: int) -> ImageFont.FreeTypeFont:
for path in FONT_PATH_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except OSError:
continue
print(
"⚠️ 한글 폰트를 못 찾았어요. 'sudo apt install fonts-nanum' 등으로 설치해주세요. "
"일단 기본 폰트로 진행합니다 (한글이 네모로 보일 수 있어요)."
)
return ImageFont.load_default()
class TeacherStatusDeck:
def __init__(self, deck):
self.deck = deck
self.key_font = _find_font(14)
self.small_font = _find_font(11)
self.roster: list[dict] = [] # [{studentId, name, location}]
self.page = 0
self.settings_mode = False
self.pending_location: Optional[str] = None
self._lock = threading.Lock()
# ── 선생님 목록 새로고침 ────────────────────────────────────────────
def refresh_roster(self):
try:
resp = requests.get(TEACHERS_URL, timeout=5)
resp.raise_for_status()
teachers = resp.json().get("teachers", [])
with self._lock:
self.roster = sorted(teachers, key=lambda t: t["name"])
except Exception as e:
print(f"🚨 선생님 목록 불러오기 실패: {e}")
def roster_watcher(self):
while True:
self.refresh_roster()
self.redraw()
time.sleep(ROSTER_REFRESH_SECONDS)
# ── 이미지 그리기 ───────────────────────────────────────────────────
def _blank_key_image(self, color=(30, 30, 30)):
image = _create_key_image(self.deck)
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, image.width, image.height), fill=color)
return image, draw
def _text_key_image(self, lines, bg=(30, 30, 30), fg=(255, 255, 255)):
image, draw = self._blank_key_image(bg)
total_h = len(lines) * 16
y = (image.height - total_h) // 2
for i, (text, font) in enumerate(lines):
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
draw.text(
((image.width - w) // 2, y + i * 16),
text, font=font, fill=fg,
)
return _to_native_key_format(self.deck, image)
def _nav_key_image(self, label, bg=(60, 60, 70)):
return self._text_key_image(
[(part, self.key_font) for part in label.split("\n")], bg=bg
)
def _blank_native_image(self):
image, _ = self._blank_key_image()
return _to_native_key_format(self.deck, image)
# ── 화면(버튼 배치) 그리기 ──────────────────────────────────────────
def redraw(self):
with self._lock:
for key in range(self.deck.key_count()):
self.deck.set_key_image(key, self._render_key(key))
def _render_key(self, key: int):
if self.settings_mode:
return self._render_settings_key(key)
return self._render_teacher_key(key)
def _render_settings_key(self, key: int):
if key < len(LOCATION_OPTIONS):
loc = LOCATION_OPTIONS[key]
selected = self.pending_location == loc
bg = (0, 110, 60) if selected else (45, 45, 45)
return self._text_key_image([(loc, self.key_font)], bg=bg)
if key == self.deck.key_count() - 1:
return self._nav_key_image("취소", bg=(120, 40, 40))
return self._blank_native_image()
def _render_teacher_key(self, key: int):
last_key = self.deck.key_count() - 1
nav_key = self.deck.key_count() - 2
start = self.page * TEACHERS_PER_PAGE
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
if key < len(page_roster):
teacher = page_roster[key]
bg = (0, 90, 140) if self.pending_location else (35, 35, 35)
return self._text_key_image(
[
(teacher["name"], self.key_font),
(teacher.get("location", ""), self.small_font),
],
bg=bg,
)
if key == nav_key:
has_more_pages = len(self.roster) > TEACHERS_PER_PAGE
if not has_more_pages:
return self._blank_native_image()
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
return self._nav_key_image(f"다음 ▶\n({self.page + 1}/{max_page + 1})")
if key == last_key:
label = f"설정\n({self.pending_location})" if self.pending_location else "⚙ 설정"
bg = (150, 90, 0) if self.pending_location else (60, 60, 70)
return self._text_key_image(
[(part, self.key_font) for part in label.split("\n")], bg=bg
)
return self._blank_native_image()
# ── 버튼 입력 처리 ──────────────────────────────────────────────────
def on_key_change(self, deck, key, pressed):
if not pressed:
return
if self.settings_mode:
self._handle_settings_press(key)
else:
self._handle_teacher_press(key)
self.redraw()
def _handle_settings_press(self, key: int):
if key < len(LOCATION_OPTIONS):
self.pending_location = LOCATION_OPTIONS[key]
self.settings_mode = False
return
# 마지막 키 = 취소
self.settings_mode = False
def _handle_teacher_press(self, key: int):
last_key = self.deck.key_count() - 1
nav_key = self.deck.key_count() - 2
start = self.page * TEACHERS_PER_PAGE
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
if key < len(page_roster):
teacher = page_roster[key]
location = self.pending_location or DEFAULT_TAP_LOCATION
self._set_location(teacher, location)
self.pending_location = None # 한 번 적용하면 자동으로 기본 모드로.
return
if key == nav_key and len(self.roster) > TEACHERS_PER_PAGE:
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
self.page = (self.page + 1) % (max_page + 1)
return
if key == last_key:
if self.pending_location:
# 설정 모드에서 위치를 이미 골라둔 상태로 설정 버튼을 또 누르면 취소.
self.pending_location = None
else:
self.settings_mode = True
def _set_location(self, teacher: dict, location: str):
try:
resp = requests.post(
LOCATION_URL_TMPL,
json={"teacherId": teacher["studentId"], "location": location},
timeout=5,
)
resp.raise_for_status()
print(f"✅ {teacher['name']} → {location}")
teacher["location"] = location # 다음 새로고침 전까지 화면에 바로 반영.
except Exception as e:
print(f"🚨 위치 업데이트 실패 ({teacher['name']} → {location}): {e}")
def main():
decks = DeviceManager().enumerate()
if not decks:
print("🚨 연결된 Stream Deck을 찾을 수 없어요. USB 연결과 udev 규칙을 확인해주세요 (README 참고).")
return
deck = decks[0]
deck.open()
deck.reset()
deck.set_brightness(70)
print(f"🎛️ Stream Deck 연결됨: {deck.deck_type()} ({deck.key_count()}키)")
controller = TeacherStatusDeck(deck)
deck.set_key_callback(controller.on_key_change)
controller.refresh_roster()
controller.redraw()
threading.Thread(target=controller.roster_watcher, daemon=True).start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
deck.reset()
deck.close()
if __name__ == "__main__":
main()
+20
View File
@@ -19,6 +19,10 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- 🎨 Safari 등 브라우저가 탭/툴바를 이 색으로 물들이는데, 예전 Flutter 기본 파란색
(#0175C2)이 그대로 남아있어서 스크롤 시 파랗게 보였다. 앱 색상으로 교체. -->
<meta name="theme-color" content="#1C1C1C">
<meta name="msapplication-navbutton-color" content="#1C1C1C">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
@@ -31,6 +35,22 @@
<title>school_attendance</title>
<link rel="manifest" href="manifest.json">
<!-- 🌊 화면 크기에 딱 맞춰서 페이지 자체가 스크롤/바운스되지 않게 고정한다.
position: fixed로 문서를 화면에 못박아 버려서, 트랙패드로 스크롤 경계를
넘어가도 사파리/크롬이 바운스할 대상 자체가 없다 (안쪽 콘텐츠 스크롤은
Flutter가 알아서 처리하므로 영향 없음). -->
<style>
html, body {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
background-color: #EEF0F2;
}
</style>
</head>
<body>
<!--
+2 -2
View File
@@ -3,8 +3,8 @@
"short_name": "school_attendance",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"background_color": "#EEF0F2",
"theme_color": "#1C1C1C",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,