34 Commits
Author SHA1 Message Date
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
50 changed files with 7689 additions and 510 deletions
+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 isLoading => _isLoading;
bool get isWorking => _isWorking; 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() { void _safeNotify() {
if (!_disposed) notifyListeners(); 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 { Future<(bool success, String message)> reject(int requestId) async {
return _postAction('/api/device-checkout/reject', {"requestId": requestId}); 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 String? name;
final bool isDeviceMatched; final bool isDeviceMatched;
final String? errorMessage; final String? errorMessage;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const LoginResult.masterSuccess({required this.studentId, required this.name}) const LoginResult.masterSuccess({required this.studentId, required this.name})
: outcome = LoginOutcome.masterSuccess, : outcome = LoginOutcome.masterSuccess,
role = null, role = null,
isDeviceMatched = true, isDeviceMatched = true,
errorMessage = null; errorMessage = null,
grade = null;
const LoginResult.needsPasswordChange({ const LoginResult.needsPasswordChange({
required this.studentId, required this.studentId,
required this.name, required this.name,
required this.role, required this.role,
required this.isDeviceMatched, required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.needsPasswordChange, }) : outcome = LoginOutcome.needsPasswordChange,
errorMessage = null; errorMessage = null;
@@ -36,6 +39,7 @@ class LoginResult {
required this.studentId, required this.studentId,
required this.name, required this.name,
required this.isDeviceMatched, required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.success, }) : outcome = LoginOutcome.success,
errorMessage = null; errorMessage = null;
@@ -44,7 +48,8 @@ class LoginResult {
role = null, role = null,
studentId = null, studentId = null,
name = null, name = null,
isDeviceMatched = false; isDeviceMatched = false,
grade = null;
} }
class LoginController extends ChangeNotifier { class LoginController extends ChangeNotifier {
@@ -101,6 +106,7 @@ class LoginController extends ChangeNotifier {
String name = ''; String name = '';
String studentId = ''; String studentId = '';
int isFirstLogin = 0; int isFirstLogin = 0;
int? grade;
var userObj = resData['user']; var userObj = resData['user'];
if (userObj != null) { if (userObj != null) {
@@ -114,6 +120,7 @@ class LoginController extends ChangeNotifier {
userObj['studentId']?.toString() ?? userObj['studentId']?.toString() ??
userObj['student_id']?.toString() ?? userObj['student_id']?.toString() ??
id; id;
grade = int.tryParse(userObj['grade']?.toString() ?? '');
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login']; var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
isFirstLogin = (rawFirst is bool) isFirstLogin = (rawFirst is bool)
@@ -126,6 +133,7 @@ class LoginController extends ChangeNotifier {
resData['studentName']?.toString() ?? resData['studentName']?.toString() ??
''; '';
studentId = resData['studentId']?.toString() ?? id; studentId = resData['studentId']?.toString() ?? id;
grade = int.tryParse(resData['grade']?.toString() ?? '');
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login']; var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
isFirstLogin = (rawFirst is bool) isFirstLogin = (rawFirst is bool)
@@ -153,6 +161,7 @@ class LoginController extends ChangeNotifier {
name: name, name: name,
role: role, role: role,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
); );
} }
@@ -161,6 +170,7 @@ class LoginController extends ChangeNotifier {
studentId: studentId, studentId: studentId,
name: name, name: name,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
); );
} else { } else {
String errorMsg = '로그인에 실패했습니다.'; String errorMsg = '로그인에 실패했습니다.';
+4
View File
@@ -9,12 +9,14 @@ class SavedSession {
final String userName; final String userName;
final String role; final String role;
final bool isDeviceMatched; final bool isDeviceMatched;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const SavedSession({ const SavedSession({
required this.userId, required this.userId,
required this.userName, required this.userName,
required this.role, required this.role,
required this.isDeviceMatched, required this.isDeviceMatched,
this.grade,
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -22,6 +24,7 @@ class SavedSession {
'userName': userName, 'userName': userName,
'role': role, 'role': role,
'isDeviceMatched': isDeviceMatched, 'isDeviceMatched': isDeviceMatched,
'grade': grade,
}; };
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession( factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
@@ -29,6 +32,7 @@ class SavedSession {
userName: json['userName'] as String, userName: json['userName'] as String,
role: json['role'] as String, role: json['role'] as String,
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true, isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
grade: json['grade'] as int?,
); );
} }
+150
View File
@@ -0,0 +1,150 @@
// 📅 백품타 출석 달력(개인 스트릭 + 관리자 이벤트) 화면의 기능 담당 컨트롤러.
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;
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;
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;
_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();
}
}
}
+14
View File
@@ -22,6 +22,18 @@ void main() async {
runApp(const SchoolAttendanceApp()); runApp(const SchoolAttendanceApp());
} }
// 🌊 스크롤이 끝에 닿을 때 나오는 파란색 오버스크롤 글로우 효과를 없앤다.
class _NoGlowScrollBehavior extends MaterialScrollBehavior {
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class SchoolAttendanceApp extends StatelessWidget { class SchoolAttendanceApp extends StatelessWidget {
const SchoolAttendanceApp({super.key}); const SchoolAttendanceApp({super.key});
@@ -30,6 +42,7 @@ class SchoolAttendanceApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: '$schoolName 학생 도우미', title: '$schoolName 학생 도우미',
scrollBehavior: _NoGlowScrollBehavior(),
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
scaffoldBackgroundColor: AppPalette.mist, scaffoldBackgroundColor: AppPalette.mist,
@@ -94,6 +107,7 @@ class _StartupGateState extends State<_StartupGate> {
userName: session.userName, userName: session.userName,
role: session.role, role: session.role,
isDeviceMatched: session.isDeviceMatched, isDeviceMatched: session.isDeviceMatched,
grade: session.grade,
); );
} }
return const LoginScreen(); return const LoginScreen();
+4 -9
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config.dart'; import '../config.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../ui/app_notice.dart';
import '../ui/login_screen.dart'; import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요. // 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -22,9 +23,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
Future<void> _updatePassword() async { Future<void> _updatePassword() async {
String newPw = _pwController.text.trim(); String newPw = _pwController.text.trim();
if (newPw.isEmpty || newPw == "1234") { if (newPw.isEmpty || newPw == "1234") {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.');
const SnackBar(content: Text('초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
);
return; return;
} }
@@ -38,9 +37,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final res = jsonDecode(response.body); final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') { if (response.statusCode == 200 && res['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.');
const SnackBar(content: Text('비밀번호 변경 완료! 다시 로그인해 주세요.')),
);
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동 // 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
@@ -48,9 +45,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
); );
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of( AppNotice.show(context, '통신 실패');
context,
).showSnackBar(const SnackBar(content: Text('통신 실패')));
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
+53 -45
View File
@@ -3,7 +3,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/admin_controller.dart'; import '../function/admin_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'title_pill.dart';
// ========================================== // ==========================================
// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료) // 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료)
@@ -27,9 +29,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
Future<void> _resetDatabase() async { Future<void> _resetDatabase() async {
final message = await _controller.resetDatabase(); final message = await _controller.resetDatabase();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
void _showResetConfirmDialog() { void _showResetConfirmDialog() {
@@ -74,9 +74,11 @@ class _AdminDashboardState extends State<AdminDashboard> {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text('관리자 시스템'), backgroundColor: Colors.transparent,
backgroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, elevation: 0,
centerTitle: true,
title: const TitlePill('관리자 시스템'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
@@ -90,50 +92,56 @@ class _AdminDashboardState extends State<AdminDashboard> {
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: ConstrainedBox(
mainAxisAlignment: MainAxisAlignment.center, constraints: const BoxConstraints(maxWidth: 420),
children: [ child: Column(
const Icon( mainAxisAlignment: MainAxisAlignment.center,
Icons.admin_panel_settings, children: [
size: 100, const Icon(
color: AppPalette.ink, Icons.admin_panel_settings,
), size: 100,
const SizedBox(height: 20), color: AppPalette.ink,
const Text( ),
'데이터베이스 관리', const SizedBox(height: 20),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), const Text(
), '데이터베이스 관리',
const SizedBox(height: 40), style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 40),
_controller.isLoading _controller.isLoading
? const CircularProgressIndicator(color: Colors.red) ? const CircularProgressIndicator(color: Colors.red)
: SizedBox( : SizedBox(
width: double.infinity, width: double.infinity,
height: 60, height: 60,
child: ElevatedButton.icon( child: ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[50], backgroundColor: Colors.red[50],
foregroundColor: Colors.red, foregroundColor: Colors.red,
side: const BorderSide( side: const BorderSide(
color: Colors.red, color: Colors.red,
width: 2, width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
shape: RoundedRectangleBorder( icon: const Icon(Icons.delete_forever, size: 28),
borderRadius: BorderRadius.circular(12), 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 final List<_QueuedNotice> _queue = [];
static bool _showing = false; static bool _showing = false;
static void show(BuildContext context, String message, {IconData? icon}) { static void show(
_queue.add(_QueuedNotice(context, message, icon)); BuildContext context,
String message, {
IconData? icon,
Color? color,
}) {
_queue.add(_QueuedNotice(context, message, icon, color));
_tryShowNext(); _tryShowNext();
} }
@@ -28,6 +33,7 @@ class AppNotice {
builder: (context) => _NoticeBanner( builder: (context) => _NoticeBanner(
message: next.message, message: next.message,
icon: next.icon, icon: next.icon,
color: next.color,
onDone: () { onDone: () {
entry.remove(); entry.remove();
_showing = false; _showing = false;
@@ -43,17 +49,20 @@ class _QueuedNotice {
final BuildContext context; final BuildContext context;
final String message; final String message;
final IconData? icon; 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 { class _NoticeBanner extends StatefulWidget {
final String message; final String message;
final IconData? icon; final IconData? icon;
final Color? color;
final VoidCallback onDone; final VoidCallback onDone;
const _NoticeBanner({ const _NoticeBanner({
required this.message, required this.message,
required this.icon, required this.icon,
required this.color,
required this.onDone, required this.onDone,
}); });
@@ -102,7 +111,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
constraints: const BoxConstraints(maxWidth: 360), constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.ink, color: widget.color ?? AppPalette.ink,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
boxShadow: [ boxShadow: [
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: Colors.red)),
),
],
),
);
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: Colors.redAccent,
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: Colors.red[400],
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 'package:flutter/material.dart';
import '../models/dashboard_tile_layout.dart'; import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'title_pill.dart';
class DashboardSettingsPage extends StatelessWidget { class DashboardSettingsPage extends StatelessWidget {
const DashboardSettingsPage({super.key}); const DashboardSettingsPage({super.key});
@@ -12,10 +13,11 @@ class DashboardSettingsPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text('설정'), backgroundColor: Colors.transparent,
backgroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
elevation: 0, elevation: 0,
centerTitle: true,
title: const TitlePill('설정'),
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+206 -18
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/device_checkout_ledger_controller.dart'; import '../function/device_checkout_ledger_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutLedgerPage extends StatefulWidget { class DeviceCheckoutLedgerPage extends StatefulWidget {
final String? teacherId; final String? teacherId;
@@ -38,26 +40,185 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
teacherName: widget.teacherName ?? '간편인증 선생', teacherName: widget.teacherName ?? '간편인증 선생',
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _reject(int id) async { Future<void> _reject(int id) async {
final (_, message) = await _controller.reject(id); final (_, message) = await _controller.reject(id);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context, }
).showSnackBar(SnackBar(content: Text(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: Colors.orange),
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: Colors.orange,
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: Colors.red,
side: const BorderSide(color: Colors.red),
),
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 { Future<void> _confirmReturn(int id) async {
final (_, message) = await _controller.confirmReturn(id); final (_, message) = await _controller.confirmReturn(id);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
({Color color, String label}) _statusInfo(String status) { ({Color color, String label}) _statusInfo(String status) {
@@ -90,23 +251,37 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
final pendingCount = requests final pendingCount = requests
.where((r) => r['status'] == 'PENDING') .where((r) => r['status'] == 'PENDING')
.length; .length;
final longCount = _controller.longPendingRequests.length;
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text( backgroundColor: Colors.transparent,
'스마트기기 반출 대장', foregroundColor: AppPalette.ink,
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
elevation: 0, elevation: 0,
centerTitle: true,
title: const TitlePill('스마트기기 반출 대장'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
onPressed: _controller.fetchAll, onPressed: _controller.fetchAll,
tooltip: '새로고침', tooltip: '새로고침',
), ),
IconButton(
icon: const Icon(Icons.done_all_rounded),
onPressed: _showApproveAllConfirmDialog,
tooltip: '전체 학생 허용',
),
IconButton(
icon: Badge(
isLabelVisible: longCount > 0,
label: Text('$longCount'),
backgroundColor: Colors.red,
child: const Icon(Icons.warning_amber_rounded),
),
onPressed: _showLongRequestsDialog,
tooltip: '많은 시간요청 학생',
),
], ],
), ),
body: _controller.isLoading body: _controller.isLoading
@@ -153,6 +328,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
final String status = r['status']; final String status = r['status'];
final info = _statusInfo(status); final info = _statusInfo(status);
final bool isPending = status == 'PENDING'; final bool isPending = status == 'PENDING';
// 🚨 1시간 59분 넘게 신청했으면 상태와 무관하게 빨간 테두리로 눈에 띄게.
final bool isLong = _controller.isLongRequest(
r,
);
return Card( return Card(
elevation: 0, elevation: 0,
@@ -160,9 +339,18 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: isPending side: isLong
? BorderSide(color: Colors.orange[300]!) ? BorderSide(
: BorderSide(color: AppPalette.sage), color: Colors.red[400]!,
width: 2,
)
: (isPending
? BorderSide(
color: Colors.orange[300]!,
)
: BorderSide(
color: AppPalette.sage,
)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+95 -92
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/device_checkout_controller.dart'; import '../function/device_checkout_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutRequestScreen extends StatefulWidget { class DeviceCheckoutRequestScreen extends StatefulWidget {
final String studentId; final String studentId;
@@ -57,9 +59,7 @@ class _DeviceCheckoutRequestScreenState
Future<void> _submit() async { Future<void> _submit() async {
final purpose = _purposeController.text.trim(); final purpose = _purposeController.text.trim();
if (_startTime == null || _endTime == null || purpose.isEmpty) { if (_startTime == null || _endTime == null || purpose.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '시작/종료 시각과 사용 목적을 모두 입력해주세요.');
const SnackBar(content: Text('시작/종료 시각과 사용 목적을 모두 입력해주세요.')),
);
return; return;
} }
@@ -71,9 +71,7 @@ class _DeviceCheckoutRequestScreenState
endTime: _formatTime(_endTime!), endTime: _formatTime(_endTime!),
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
if (success) Navigator.pop(context); if (success) Navigator.pop(context);
} }
@@ -85,106 +83,111 @@ class _DeviceCheckoutRequestScreenState
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text( backgroundColor: Colors.transparent,
'스마트기기 반출 신청', foregroundColor: AppPalette.ink,
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
elevation: 0, elevation: 0,
centerTitle: true,
title: const TitlePill('스마트기기 반출 신청'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: ConstrainedBox(
children: [ constraints: const BoxConstraints(maxWidth: 420),
const Text( child: Column(
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n' crossAxisAlignment: CrossAxisAlignment.start,
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.', children: [
style: TextStyle( const Text(
color: Colors.black54, '패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
fontSize: 13, '선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
height: 1.4, style: TextStyle(
), color: Colors.black54,
), fontSize: 13,
const SizedBox(height: 24), height: 1.4,
Container( ),
padding: const EdgeInsets.all(20), ),
decoration: BoxDecoration( const SizedBox(height: 24),
color: AppPalette.paper, Container(
borderRadius: BorderRadius.circular(24), padding: const EdgeInsets.all(20),
border: Border.all(color: AppPalette.sage), decoration: BoxDecoration(
), color: AppPalette.paper,
child: Column( borderRadius: BorderRadius.circular(24),
children: [ border: Border.all(color: AppPalette.sage),
Row( ),
child: Column(
children: [ children: [
Expanded( Row(
child: OutlinedButton.icon( children: [
onPressed: _pickStartTime, Expanded(
icon: const Icon(Icons.play_arrow_rounded), child: OutlinedButton.icon(
label: Text( onPressed: _pickStartTime,
_startTime == null icon: const Icon(Icons.play_arrow_rounded),
? '시작 시각' label: Text(
: _formatTime(_startTime!), _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), const SizedBox(height: 20),
Expanded( SizedBox(
child: OutlinedButton.icon( width: double.infinity,
onPressed: _pickEndTime, height: 50,
icon: const Icon(Icons.stop_rounded), child: ElevatedButton(
label: Text( onPressed: _controller.isSubmitting
_endTime == null ? null
? '종료 시각' : _submit,
: _formatTime(_endTime!), 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,
),
),
),
),
],
),
), ),
], ),
), ),
), ),
); );
+289
View File
@@ -0,0 +1,289 @@
// 🧑‍🤝‍🧑 백품타 친구 화면 (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: Colors.red)),
),
],
),
);
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())
: 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),
),
],
),
),
],
),
),
);
},
);
}
}
+527
View File
@@ -0,0 +1,527 @@
// 👥 백품타 그룹 상세 화면 (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: Colors.red)),
),
],
),
);
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 m = seconds ~/ 60;
if (m < 60) return '$m분';
return '${m ~/ 60}시간 ${m % 60}분';
}
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: 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;
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: [
Icon(
attended
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: attended
? Colors.white
: Colors.grey[350],
size: 18,
),
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(
(m['todaySeconds'] as num?)?.toInt() ?? 0,
),
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,
),
),
],
),
);
}
}
+448
View File
@@ -0,0 +1,448 @@
// 👥 백품타 그룹 스터디 목록 화면 (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())
: 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: Colors.red[50],
borderRadius:
BorderRadius.circular(20),
border: Border.all(
color: Colors.red,
),
),
child: Text(
'대기 ${g['pendingRequestCount']}',
style: const TextStyle(
fontSize: 10,
color: Colors.red,
),
),
),
],
],
),
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('참여 신청'),
),
],
),
),
],
],
),
),
);
},
);
}
}
+15 -21
View File
@@ -5,6 +5,7 @@ import '../config.dart' show schoolName;
import '../function/login_controller.dart'; import '../function/login_controller.dart';
import '../function/session_store.dart'; import '../function/session_store.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'main_dashboard.dart'; import 'main_dashboard.dart';
import 'teacher_register_screen.dart'; import 'teacher_register_screen.dart';
@@ -58,9 +59,7 @@ class _LoginScreenState extends State<LoginScreen> {
await SessionStore.clear(); await SessionStore.clear();
} }
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
context,
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -79,17 +78,17 @@ class _LoginScreenState extends State<LoginScreen> {
result.name!, result.name!,
result.role!, result.role!,
result.isDeviceMatched, result.isDeviceMatched,
result.grade,
); );
break; break;
case LoginOutcome.success: case LoginOutcome.success:
ScaffoldMessenger.of( AppNotice.show(context, '${result.name}님 환영합니다!');
context,
).showSnackBar(SnackBar(content: Text('${result.name}님 환영합니다!')));
_navigateBasedOnRole( _navigateBasedOnRole(
result.role!, result.role!,
result.studentId!, result.studentId!,
result.name!, result.name!,
result.isDeviceMatched, result.isDeviceMatched,
result.grade,
); );
break; break;
} }
@@ -101,6 +100,7 @@ class _LoginScreenState extends State<LoginScreen> {
String name, String name,
String role, String role,
bool isDeviceMatched, bool isDeviceMatched,
int? grade,
) { ) {
final TextEditingController newPwController = TextEditingController(); final TextEditingController newPwController = TextEditingController();
@@ -150,9 +150,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () async { onPressed: () async {
String newPassword = newPwController.text.trim(); String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
const SnackBar(content: Text('새 비밀번호를 입력해 주세요.')),
);
return; return;
} }
@@ -167,19 +165,16 @@ class _LoginScreenState extends State<LoginScreen> {
if (success) { if (success) {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
_navigateBasedOnRole( _navigateBasedOnRole(
role, role,
studentId, studentId,
name, name,
isDeviceMatched, isDeviceMatched,
grade,
); );
} else { } else {
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
}, },
child: const Text('변경하고 시작하기'), child: const Text('변경하고 시작하기'),
@@ -198,6 +193,7 @@ class _LoginScreenState extends State<LoginScreen> {
String studentId, String studentId,
String name, String name,
bool isDeviceMatched, bool isDeviceMatched,
int? grade,
) async { ) async {
if (_keepLoggedIn) { if (_keepLoggedIn) {
await SessionStore.save( await SessionStore.save(
@@ -206,6 +202,7 @@ class _LoginScreenState extends State<LoginScreen> {
userName: name, userName: name,
role: role, role: role,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
), ),
); );
} else { } else {
@@ -220,6 +217,7 @@ class _LoginScreenState extends State<LoginScreen> {
userName: name, userName: name,
role: role, role: role,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
), ),
), ),
); );
@@ -269,9 +267,7 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage), MaterialPageRoute(builder: (context) => nextPage),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
);
} }
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -293,9 +289,7 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage), MaterialPageRoute(builder: (context) => nextPage),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
);
} }
}, },
child: const Text('인증하기'), child: const Text('인증하기'),
+151 -9
View File
@@ -12,14 +12,22 @@ import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'admin_dashboard.dart'; import 'admin_dashboard.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'board_home_screen.dart';
import 'board_report_screen.dart';
import 'dashboard_settings_page.dart'; import 'dashboard_settings_page.dart';
import 'device_checkout_ledger_page.dart'; import 'device_checkout_ledger_page.dart';
import 'device_checkout_request_screen.dart'; import 'device_checkout_request_screen.dart';
import 'friends_screen.dart';
import 'group_list_screen.dart';
import 'launchpad_transition.dart'; import 'launchpad_transition.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'nfc_poccket_checkin_screen.dart'; import 'nfc_poccket_checkin_screen.dart';
import 'nfc_tag_writer_screen.dart'; import 'nfc_tag_writer_screen.dart';
import 'study_calendar_screen.dart';
import 'study_timer_screen.dart';
import 'teacher_attendance_page.dart'; import 'teacher_attendance_page.dart';
import 'teacher_call_screen.dart';
import 'teacher_location_screen.dart';
import 'teacher_student_management_page.dart'; import 'teacher_student_management_page.dart';
class MainDashboard extends StatefulWidget { class MainDashboard extends StatefulWidget {
@@ -27,6 +35,7 @@ class MainDashboard extends StatefulWidget {
final String? userName; final String? userName;
final String role; // 'student' | 'teacher' | 'admin' final String role; // 'student' | 'teacher' | 'admin'
final bool isDeviceMatched; final bool isDeviceMatched;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const MainDashboard({ const MainDashboard({
super.key, super.key,
@@ -34,6 +43,7 @@ class MainDashboard extends StatefulWidget {
this.userName, this.userName,
required this.role, required this.role,
this.isDeviceMatched = true, this.isDeviceMatched = true,
this.grade,
}); });
@override @override
@@ -312,6 +322,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) { if (_isTeacherOrAbove) {
@@ -357,6 +457,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) { if (_isAdmin) {
@@ -635,9 +777,9 @@ class _MainDashboardState extends State<MainDashboard> {
: width < 700 : width < 700
? 3 ? 3
: 4; : 4;
// 📏 1칸(1x1) 크기가 대략 계정 프로필 카드만 해지도록 목표 크기를 잡고, // 📏 1칸(1x1) 크기 목표. 화면이 그보다 넓으면 가운데로 모아서 여백을
// 화면이 그보다 넓으면 가운데로 모아서 여백을 준다(좁으면 화면 폭에 맞춤). // 주고, 좁으면(폰) 화면 폭에 맞춘다.
const double targetCellSize = 184; const double targetCellSize = 226;
const double gap = 12; const double gap = 12;
final double idealGridWidth = final double idealGridWidth =
crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap; crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap;
@@ -787,13 +929,13 @@ class _MainDashboardState extends State<MainDashboard> {
: constraints.maxHeight : constraints.maxHeight
: constraints.maxWidth; : constraints.maxWidth;
final double scale = (referenceSize / 110).clamp(1.0, 3.4); final double scale = (referenceSize / 110).clamp(1.0, 3.4);
final double iconBoxPadding = 6 + 6 * (scale - 1); final double iconBoxPadding = 8 + 6 * (scale - 1);
final double iconSize = 16 + 10 * (scale - 1); final double iconSize = 22 + 10 * (scale - 1);
final double iconRadius = 10 + 6 * (scale - 1); final double iconRadius = 12 + 6 * (scale - 1);
final double cardPadding = 8 + 8 * (scale - 1); final double cardPadding = 10 + 8 * (scale - 1);
final double cardRadius = 16 + 6 * (scale - 1); final double cardRadius = 16 + 6 * (scale - 1);
final double titleFontSize = (10 + 3 * (scale - 1)).clamp(10, 18); final double titleFontSize = (12 + 3 * (scale - 1)).clamp(12, 20);
final double subtitleFontSize = (8 + 2 * (scale - 1)).clamp(8, 14); final double subtitleFontSize = (10 + 2 * (scale - 1)).clamp(10, 16);
return InkWell( return InkWell(
onTap: isLoading ? null : onTap, onTap: isLoading ? null : onTap,
+9 -11
View File
@@ -5,6 +5,9 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/nfc_pocket_checkin_controller.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 { class NfcPocketCheckInScreen extends StatefulWidget {
final String studentId; // 로그인된 학생 학번 (예: "2061") final String studentId; // 로그인된 학생 학번 (예: "2061")
@@ -143,9 +146,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
void _showSnackBar(String text, Color color) { void _showSnackBar(String text, Color color) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, text, color: color);
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
} }
@override @override
@@ -159,14 +160,11 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( backgroundColor: Colors.transparent,
"자습실 NFC 출석체크", foregroundColor: AppPalette.ink,
style: TextStyle( elevation: 0,
color: Colors.white, centerTitle: true,
fontWeight: FontWeight.bold, title: const TitlePill("자습실 NFC 출석체크"),
),
),
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
), ),
body: Center( body: Center(
child: Padding( child: Padding(
+12 -8
View File
@@ -3,6 +3,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/nfc_tag_writer_controller.dart'; import '../function/nfc_tag_writer_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class NfcTagWriterScreen extends StatefulWidget { class NfcTagWriterScreen extends StatefulWidget {
const NfcTagWriterScreen({super.key}); const NfcTagWriterScreen({super.key});
@@ -35,11 +37,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
void _showSnackBar(String text, bool isError) { void _showSnackBar(String text, bool isError) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(
SnackBar( context,
content: Text(text), text,
backgroundColor: isError ? Colors.red : Colors.green, color: isError ? Colors.red[700] : Colors.green[700],
), icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded,
); );
} }
@@ -52,9 +54,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text("NFC 주머니 태그 쓰기"), backgroundColor: Colors.transparent,
backgroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, elevation: 0,
centerTitle: true,
title: const TitlePill("NFC 주머니 태그 쓰기"),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
+415
View File
@@ -0,0 +1,415 @@
// 📅 백품타 출석 달력 화면 (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: Colors.red),
),
),
),
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: 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
: Colors.orange,
),
),
],
),
),
);
},
),
const SizedBox(height: 16),
Row(
children: [
_legendDot(AppPalette.ink, '공부한 날'),
const SizedBox(width: 16),
_legendIcon(
Icons.card_giftcard_rounded,
Colors.orange,
'이벤트',
),
],
),
],
),
);
},
);
}
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,
),
);
}
}
+224
View File
@@ -0,0 +1,224 @@
// 🏆 학년별 공부시간 랭킹 화면 (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;
if (h > 0) return '$h시간 $m분';
return '$m분';
}
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: 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,
),
),
);
}
}
+527
View File
@@ -0,0 +1,527 @@
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (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() {
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;
if (h > 0) return '$h시간 $m분';
return '$m분';
}
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: Colors.redAccent, 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: Colors.red[400]!,
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 : Colors.amber[700]!,
onTap: () =>
_runAction(isPaused ? _controller.resume : _controller.pause),
),
const SizedBox(width: 20),
_circleButton(
size: 110,
icon: Icons.stop_rounded,
label: '공부 종료',
color: Colors.red[400]!,
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: 100,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
],
),
);
}
}
+471 -250
View File
@@ -3,6 +3,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.dart'; import '../function/teacher_attendance_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'launchpad_transition.dart';
import 'title_pill.dart';
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화) // 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
@@ -29,13 +32,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
super.dispose(); super.dispose();
} }
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다. /// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다.
Future<void> _runAction(Future<String> Function() action) async { Future<void> _runAction(Future<String> Function() action) async {
final message = await action(); final message = await action();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _showAttendanceTimeDialog() async { Future<void> _showAttendanceTimeDialog() async {
@@ -204,66 +205,20 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
appBar: AppBar( appBar: AppBar(
title: const Text( backgroundColor: Colors.transparent,
'실시간 출석 현황', foregroundColor: AppPalette.ink,
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: Colors.white,
elevation: 0, elevation: 0,
actions: [ actions: [
IconButton( IconButton(
onPressed: (_controller.isLoading || _controller.isRefreshing) icon: const Icon(Icons.more_horiz_rounded),
? null tooltip: '자습실 시간 설정 메뉴',
: _controller.manualRefresh, onPressed: _showTimeSettingsMenu,
icon: _controller.isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.refresh_rounded),
tooltip: '새로고침',
), ),
TextButton.icon( const SizedBox(width: 4),
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),
], ],
), ),
body: _controller.isLoading body: _controller.isLoading
@@ -296,9 +251,30 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final groups = _controller.studentsByGrade; final groups = _controller.studentsByGrade;
return Column( return Column(
children: [ 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(), _buildPermissionBanner(),
_buildFilterChips(), const SizedBox(height: 12),
Expanded( Expanded(
child: groups.isEmpty child: groups.isEmpty
? const Center( ? const Center(
@@ -509,164 +485,356 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) { Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade; final students = _controller.filteredStudents;
return Center( return Padding(
child: ConstrainedBox( padding: const EdgeInsets.all(24.0),
constraints: const BoxConstraints(maxWidth: 1100), child: Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(24.0), children: [
child: Column( _buildPermissionBanner(),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(height: 12),
children: [ Expanded(
Row( child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: _statTile( _buildDesktopSidebar(total, checkedIn, absent),
"전체 학생", const SizedBox(width: 20),
"$total명", Expanded(
Icons.groups_rounded, child: Container(
Colors.blueGrey, width: double.infinity,
), decoration: BoxDecoration(
), color: Colors.white,
const SizedBox(width: 16), borderRadius: BorderRadius.circular(16),
Expanded( boxShadow: [
child: _statTile( BoxShadow(
"출석 완료", color: Colors.black.withValues(alpha: 0.04),
"$checkedIn명", blurRadius: 16,
Icons.check_circle_rounded, offset: const Offset(0, 4),
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),
), ),
],
),
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) { Widget _sidebarStatPill(String label, int count, String filterValue) {
return Container( final bool selected = _controller.filterType == filterValue;
padding: const EdgeInsets.all(20), return InkWell(
decoration: BoxDecoration( onTap: () => _controller.setFilter(filterValue),
color: AppPalette.paper, borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(16), child: Container(
boxShadow: [ padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
BoxShadow( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.04), color: selected ? AppPalette.ink : AppPalette.paper,
blurRadius: 12, borderRadius: BorderRadius.circular(28),
offset: const Offset(0, 4), border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage,
), ),
], ),
), child: Column(
child: Row( children: [
children: [ Text(
Container( label,
padding: const EdgeInsets.all(12), style: TextStyle(
decoration: BoxDecoration( fontSize: 12,
color: color.withValues(alpha: 0.1), color: selected ? Colors.white70 : Colors.grey[600],
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),
), ),
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
? Colors.red[50]!
: isComplete
? Colors.blue[50]!
: isPending
? Colors.orange[50]!
: AppPalette.paper;
final Color border = hasViolation
? Colors.red[300]!
: isComplete
? Colors.blue[200]!
: isPending
? Colors.orange[200]!
: 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( Text(
value, '무단반출',
style: TextStyle( style: TextStyle(
color: color, fontSize: 7.5,
fontSize: 22, color: Colors.red[700],
fontWeight: FontWeight.bold, 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,
),
), ),
], ),
); );
} }
@@ -703,63 +871,116 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
), ),
); );
} }
}
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자) // 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
Widget _buildFilterChips() { class _TimeSettingsMenuCard extends StatelessWidget {
return Padding( final bool isRefreshing;
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), final String attendanceTimeLabel;
child: Column( final ValueChanged<String> onPick;
crossAxisAlignment: CrossAxisAlignment.start,
children: [ const _TimeSettingsMenuCard({
Wrap( required this.isRefreshing,
spacing: 8, required this.attendanceTimeLabel,
runSpacing: 8, 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: [ children: [
FilterChip( _TimeMenuRow(
label: const Text("전체"), icon: isRefreshing ? null : Icons.refresh_rounded,
selected: _controller.filterType == "ALL", label: '새로고침',
onSelected: (_) => _controller.setFilter("ALL"), onTap: () => onPick('refresh'),
), ),
FilterChip( const Divider(height: 1, color: AppPalette.sage),
label: const Text("출석자"), _TimeMenuRow(
selected: _controller.filterType == "CHECKED_IN", icon: Icons.access_time_rounded,
onSelected: (_) => _controller.setFilter("CHECKED_IN"), label: attendanceTimeLabel,
onTap: () => onPick('attendance_time'),
), ),
FilterChip( const Divider(height: 1, color: AppPalette.sage),
label: const Text("미출석자"), _TimeMenuRow(
selected: _controller.filterType == "ABSENT", icon: Icons.timer_outlined,
onSelected: (_) => _controller.setFilter("ABSENT"), 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("학년 전체"), class _TimeMenuRow extends StatelessWidget {
selected: _controller.gradeFilter == "ALL", final IconData? icon;
onSelected: (_) => _controller.setGradeFilter("ALL"), 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"),
),
],
),
],
), ),
); );
} }
+521
View File
@@ -0,0 +1,521 @@
// 📍 선생님 호출 화면(학생용) (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 ? Colors.orange : 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)
: Colors.orange[50],
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? Colors.white54 : Colors.orange,
),
),
child: Text(
'수업중',
style: TextStyle(
fontSize: 9,
color: isSelected ? Colors.white : Colors.orange,
),
),
),
],
),
const Spacer(),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: isSelected ? Colors.white : AppPalette.ink,
),
),
const SizedBox(height: 2),
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 'package:flutter/material.dart';
import '../function/teacher_register_controller.dart'; import '../function/teacher_register_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class TeacherRegisterScreen extends StatefulWidget { class TeacherRegisterScreen extends StatefulWidget {
const TeacherRegisterScreen({super.key}); const TeacherRegisterScreen({super.key});
@@ -35,9 +37,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
String secret = _secretController.text.trim(); String secret = _secretController.text.trim();
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) { if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
ScaffoldMessenger.of( AppNotice.show(context, '모든 빈칸을 입력해 주세요.');
context,
).showSnackBar(const SnackBar(content: Text('모든 빈칸을 입력해 주세요.')));
return; return;
} }
@@ -48,9 +48,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
secretCode: secret, secretCode: secret,
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
if (success) { if (success) {
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀 Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
} }
@@ -64,9 +62,11 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text('교사 회원가입'), backgroundColor: Colors.transparent,
backgroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, elevation: 0,
centerTitle: true,
title: const TitlePill('교사 회원가입'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
+13 -27
View File
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart'; import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.dart'; import '../function/teacher_student_management_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다. // 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
const double kAttendanceTileWidth = 220; const double kAttendanceTileWidth = 220;
@@ -51,9 +53,7 @@ class _TeacherStudentManagementPageState
final sName = _addNameController.text.trim(); final sName = _addNameController.text.trim();
if (sId.isEmpty || sName.isEmpty) { if (sId.isEmpty || sName.isEmpty) {
ScaffoldMessenger.of( AppNotice.show(context, '모든 입력란을 채워주세요.');
context,
).showSnackBar(const SnackBar(content: Text('모든 입력란을 채워주세요.')));
return; return;
} }
@@ -69,9 +69,7 @@ class _TeacherStudentManagementPageState
_addNameController.clear(); _addNameController.clear();
setState(() => _selectedGrade = null); setState(() => _selectedGrade = null);
} }
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기 // 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
@@ -89,16 +87,12 @@ class _TeacherStudentManagementPageState
rows = _controller.parseExcelBytes(bytes); rows = _controller.parseExcelBytes(bytes);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
context,
).showSnackBar(SnackBar(content: Text('엑셀 파일을 읽는 데 실패했습니다: $e')));
return; return;
} }
if (rows.isEmpty) { if (rows.isEmpty) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)');
const SnackBar(content: Text('유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)')),
);
return; return;
} }
_showBulkPreviewDialog(rows); _showBulkPreviewDialog(rows);
@@ -280,9 +274,7 @@ class _TeacherStudentManagementPageState
grade: selected, grade: selected,
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화. // 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
@@ -312,9 +304,7 @@ class _TeacherStudentManagementPageState
studentName, studentName,
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
}, },
child: const Text('초기화 승인'), child: const Text('초기화 승인'),
), ),
@@ -358,9 +348,7 @@ class _TeacherStudentManagementPageState
studentId, studentId,
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( AppNotice.show(context, message);
context,
).showSnackBar(SnackBar(content: Text(message)));
}, },
child: const Text('영구 삭제'), child: const Text('영구 삭제'),
), ),
@@ -671,13 +659,11 @@ class _TeacherStudentManagementPageState
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
title: const Text( backgroundColor: Colors.transparent,
'학생 통합 관리 센터', foregroundColor: AppPalette.ink,
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
elevation: 0, elevation: 0,
centerTitle: true,
title: const TitlePill('학생 통합 관리 센터'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
+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,
),
),
),
);
}
}
+20
View File
@@ -19,6 +19,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <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 --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -31,6 +35,22 @@
<title>school_attendance</title> <title>school_attendance</title>
<link rel="manifest" href="manifest.json"> <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> </head>
<body> <body>
<!-- <!--
+2 -2
View File
@@ -3,8 +3,8 @@
"short_name": "school_attendance", "short_name": "school_attendance",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#EEF0F2",
"theme_color": "#0175C2", "theme_color": "#1C1C1C",
"description": "A new Flutter project.", "description": "A new Flutter project.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,