Compare commits
11
Commits
77ef666e16
..
v1.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e12b279e2 | ||
|
|
d864927957 | ||
|
|
61aedac312 | ||
|
|
5ab1c4c37c | ||
|
|
3a62ebacd8 | ||
|
|
26fb4c32cf | ||
|
|
4137a6815c | ||
|
|
4ff063c76d | ||
|
|
77688170b7 | ||
|
|
c17a28bad9 | ||
|
|
9adaba10cd |
+1
-1
@@ -1 +1 @@
|
|||||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}]}}
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
|
||||||
@@ -34,6 +34,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
String? _activePocketNumber;
|
String? _activePocketNumber;
|
||||||
|
|
||||||
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
||||||
|
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
|
||||||
|
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
|
||||||
|
|
||||||
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
@@ -43,10 +45,24 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
_startNfcSession();
|
_startNfcSession();
|
||||||
if (_watchServiceSupported) {
|
if (_watchServiceSupported) {
|
||||||
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
||||||
|
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
||||||
|
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
||||||
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
||||||
event,
|
event,
|
||||||
) {
|
) {
|
||||||
if (mounted) _showViolationDialog(event?['pocketNumber']?.toString());
|
if (!mounted) return;
|
||||||
|
setState(() => _isWatching = false);
|
||||||
|
_showViolationDialog(event?['pocketNumber']?.toString());
|
||||||
|
});
|
||||||
|
// 🏫 하교 처리 등으로 반출이 허용된 상태에서 폰을 꺼내면, 위반이 아니라 정상 회수로 처리된다.
|
||||||
|
_checkedOutSub = FlutterBackgroundService().on('checked_out').listen((event) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isWatching = false;
|
||||||
|
_activePocketNumber = null;
|
||||||
|
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
|
||||||
|
});
|
||||||
|
_showSnackBar("✅ 폰이 정상적으로 회수되었습니다.", Colors.green);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
||||||
NfcManager.instance.stopSession();
|
NfcManager.instance.stopSession();
|
||||||
_violationSub?.cancel();
|
_violationSub?.cancel();
|
||||||
|
_checkedOutSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +224,18 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
|
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
|
||||||
|
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
|
||||||
|
void _closeAnyOpenDialog() {
|
||||||
|
if (_isDialogOpen && mounted) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
}
|
||||||
|
_isDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
void _showViolationDialog(String? pocketNumber) {
|
void _showViolationDialog(String? pocketNumber) {
|
||||||
|
_closeAnyOpenDialog();
|
||||||
|
_isDialogOpen = true;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -226,11 +254,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
).then((_) => _isDialogOpen = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🎊 출석 성공 알림창
|
/// 🎊 출석 성공 알림창
|
||||||
void _showSuccessDialog(String pocketNumber) {
|
void _showSuccessDialog(String pocketNumber) {
|
||||||
|
_closeAnyOpenDialog();
|
||||||
|
_isDialogOpen = true;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -245,7 +275,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
).then((_) => _isDialogOpen = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSnackBar(String text, Color color) {
|
void _showSnackBar(String text, Color color) {
|
||||||
@@ -354,22 +384,10 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.",
|
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _checkOut,
|
|
||||||
icon: const Icon(Icons.logout_rounded, color: Colors.white70),
|
|
||||||
label: const Text(
|
|
||||||
"폰 회수하고 감시 종료 (테스트용)",
|
|
||||||
style: TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.white30),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
|
||||||
|
Future<void> handlePhoneRemoved() async {
|
||||||
|
bool isPermitted = false;
|
||||||
|
try {
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse("$baseUrl/api/permissions/check?studentId=$studentId"),
|
||||||
|
);
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(res.bodyBytes));
|
||||||
|
isPermitted = data['permitted'] == true;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
isPermitted = false; // 네트워크 오류 시엔 안전하게 위반으로 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermitted) {
|
||||||
|
updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
|
||||||
|
service.invoke('checked_out', {"pocketNumber": pocketNumber});
|
||||||
|
service.stopSelf();
|
||||||
|
} else {
|
||||||
|
reportViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void onLightReading(int luxValue) {
|
void onLightReading(int luxValue) {
|
||||||
if (violated) return;
|
if (violated) return;
|
||||||
|
|
||||||
@@ -129,7 +153,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
if (luxValue > baselineLux! + _luxJumpThreshold) {
|
if (luxValue > baselineLux! + _luxJumpThreshold) {
|
||||||
violated = true;
|
violated = true;
|
||||||
lightSub?.cancel();
|
lightSub?.cancel();
|
||||||
reportViolation();
|
handlePhoneRemoved();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (role == 'teacher') {
|
if (role == 'teacher') {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => const TeacherDashboard()),
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
TeacherDashboard(teacherId: studentId, teacherName: name),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (role == 'admin') {
|
} else if (role == 'admin') {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
||||||
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
||||||
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
||||||
|
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
|
||||||
|
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
|
||||||
|
String? _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
||||||
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -41,17 +45,29 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _manualRefresh() async {
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
await _fetchAll();
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchAll() async {
|
Future<void> _fetchAll() async {
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
http.get(Uri.parse('$baseUrl/api/users')),
|
http.get(Uri.parse('$baseUrl/api/users')),
|
||||||
http.get(Uri.parse('$baseUrl/api/logs')),
|
http.get(Uri.parse('$baseUrl/api/logs')),
|
||||||
http.get(Uri.parse('$baseUrl/api/violations/active')),
|
http.get(Uri.parse('$baseUrl/api/violations/active')),
|
||||||
|
http.get(Uri.parse('$baseUrl/api/dismissal/latest')),
|
||||||
|
http.get(Uri.parse('$baseUrl/api/settings/attendance-time')),
|
||||||
|
http.get(Uri.parse('$baseUrl/api/permissions/status')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final usersRes = results[0];
|
final usersRes = results[0];
|
||||||
final logsRes = results[1];
|
final logsRes = results[1];
|
||||||
final violationsRes = results[2];
|
final violationsRes = results[2];
|
||||||
|
final dismissalRes = results[3];
|
||||||
|
final attendanceTimeRes = results[4];
|
||||||
|
final permissionStatusRes = results[5];
|
||||||
|
|
||||||
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
||||||
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
||||||
@@ -65,10 +81,35 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? dismissedAt;
|
||||||
|
if (dismissalRes.statusCode == 200) {
|
||||||
|
final dismissalData = jsonDecode(utf8.decode(dismissalRes.bodyBytes));
|
||||||
|
dismissedAt = dismissalData['dismissedAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? attendanceTime;
|
||||||
|
if (attendanceTimeRes.statusCode == 200) {
|
||||||
|
final attendanceTimeData =
|
||||||
|
jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes));
|
||||||
|
attendanceTime = attendanceTimeData['attendanceTime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? globalPermissionUntil;
|
||||||
|
if (permissionStatusRes.statusCode == 200) {
|
||||||
|
final permissionStatusData =
|
||||||
|
jsonDecode(utf8.decode(permissionStatusRes.bodyBytes));
|
||||||
|
if (permissionStatusData['active'] == true) {
|
||||||
|
globalPermissionUntil = permissionStatusData['permittedUntil'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_roster = usersData['users'] ?? [];
|
_roster = usersData['users'] ?? [];
|
||||||
_logs = logsData['logs'] ?? [];
|
_logs = logsData['logs'] ?? [];
|
||||||
_activeViolationsByStudentId = activeViolations;
|
_activeViolationsByStudentId = activeViolations;
|
||||||
|
_dismissedAt = dismissedAt;
|
||||||
|
_attendanceTime = attendanceTime;
|
||||||
|
_globalPermissionUntil = globalPermissionUntil;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -79,6 +120,24 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
|
||||||
|
Future<void> _dismissAll() async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(Uri.parse('$baseUrl/api/dismiss'));
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['message'] ?? '하교 처리되었습니다.')),
|
||||||
|
);
|
||||||
|
_fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ 하교 처리 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
|
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
|
||||||
Future<void> _allowRemoval(String studentId, int minutes) async {
|
Future<void> _allowRemoval(String studentId, int minutes) async {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +182,96 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
|
||||||
|
Future<void> _setAttendanceTime(String time) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/settings/attendance-time'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"time": time}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['message'] ?? '처리되었습니다.')),
|
||||||
|
);
|
||||||
|
_fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ 출석시간 설정 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAttendanceTimeDialog() async {
|
||||||
|
final TimeOfDay initial = _attendanceTime != null
|
||||||
|
? TimeOfDay(
|
||||||
|
hour: int.parse(_attendanceTime!.split(':')[0]),
|
||||||
|
minute: int.parse(_attendanceTime!.split(':')[1]),
|
||||||
|
)
|
||||||
|
: const TimeOfDay(hour: 19, minute: 0);
|
||||||
|
|
||||||
|
final TimeOfDay? picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: initial,
|
||||||
|
helpText: '자습실 출석시간 지정',
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
|
||||||
|
final String formatted =
|
||||||
|
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
||||||
|
_setAttendanceTime(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다.
|
||||||
|
Future<void> _resetTestPermissions() async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(Uri.parse('$baseUrl/api/debug/reset-permissions'));
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['message'] ?? '처리되었습니다.')),
|
||||||
|
);
|
||||||
|
_fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ 초기화 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTestResetConfirmDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('🧪 테스트용 허용시간 초기화'),
|
||||||
|
content: const Text(
|
||||||
|
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
|
||||||
|
'(무단반출 감지 테스트할 때만 사용하세요)',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_resetTestPermissions();
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('초기화'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showAllowDialog(String studentId, String studentName) {
|
void _showAllowDialog(String studentId, String studentName) {
|
||||||
final controller = TextEditingController(text: "5");
|
final controller = TextEditingController(text: "5");
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -198,17 +347,45 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
/// 🏫 하교 처리: 시간 입력 없이 바로 전체 학생의 반출을 (사실상 무기한) 허용한다.
|
||||||
|
void _showDismissalConfirmDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('🏫 하교 처리'),
|
||||||
|
content: const Text(
|
||||||
|
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_dismissAll();
|
||||||
|
},
|
||||||
|
child: const Text('하교 처리'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
|
||||||
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
||||||
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
// "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
|
||||||
0,
|
final String cutoff =
|
||||||
10,
|
_dismissedAt ??
|
||||||
); // "YYYY-MM-DD"
|
'${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
|
||||||
final Map<String, Map<String, String>> result = {};
|
final Map<String, Map<String, String>> result = {};
|
||||||
|
|
||||||
for (final log in _logs) {
|
for (final log in _logs) {
|
||||||
final String time = log['time']?.toString() ?? '';
|
final String time = log['time']?.toString() ?? '';
|
||||||
if (!time.startsWith(todayPrefix)) continue; // 오늘 기록이 아니면 무시
|
if (time.isEmpty || time.compareTo(cutoff) <= 0) {
|
||||||
|
continue; // 하교 처리 시각(또는 오늘 자정) 이전 기록이면 무시
|
||||||
|
}
|
||||||
|
|
||||||
final String studentId = log['student_id']?.toString() ?? '';
|
final String studentId = log['student_id']?.toString() ?? '';
|
||||||
if (studentId.isEmpty) continue;
|
if (studentId.isEmpty) continue;
|
||||||
@@ -227,18 +404,47 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 지금까지(오늘 이전 포함) 단 한 번이라도 태깅한 적 있는 학생 학번 집합.
|
||||||
|
/// (오늘 미제출인 학생 중에서도 "한 번도 태깅 안 해본 애"를 구분하기 위함)
|
||||||
|
Set<String> get _everCheckedInStudentIds {
|
||||||
|
final Set<String> ids = {};
|
||||||
|
for (final log in _logs) {
|
||||||
|
final String studentId = log['student_id']?.toString() ?? '';
|
||||||
|
if (studentId.isNotEmpty) ids.add(studentId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다.
|
||||||
|
/// - NONE: 아직 태깅 안 함
|
||||||
|
/// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침
|
||||||
|
/// - COMPLETE: 출석시간 미지정이거나, 지정된 출석시간 이후에 태깅함
|
||||||
|
String _computeAttendanceStatus(String? checkInTime) {
|
||||||
|
if (checkInTime == null) return 'NONE';
|
||||||
|
if (_attendanceTime == null) return 'COMPLETE';
|
||||||
|
|
||||||
|
final String todayStr = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
|
final String cutoff = '$todayStr $_attendanceTime:00';
|
||||||
|
return checkInTime.compareTo(cutoff) >= 0 ? 'COMPLETE' : 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
List<Map<String, dynamic>> get _combinedStudentStatus {
|
List<Map<String, dynamic>> get _combinedStudentStatus {
|
||||||
final checkIns = _todaysCheckInsByStudentId;
|
final checkIns = _todaysCheckInsByStudentId;
|
||||||
|
final everCheckedIn = _everCheckedInStudentIds;
|
||||||
return _roster.map((u) {
|
return _roster.map((u) {
|
||||||
final String id = u['id']?.toString() ?? '';
|
final String id = u['id']?.toString() ?? '';
|
||||||
final checkIn = checkIns[id];
|
final checkIn = checkIns[id];
|
||||||
final violation = _activeViolationsByStudentId[id];
|
final violation = _activeViolationsByStudentId[id];
|
||||||
|
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']);
|
||||||
return {
|
return {
|
||||||
'studentId': id,
|
'studentId': id,
|
||||||
'studentName': u['name']?.toString() ?? '',
|
'studentName': u['name']?.toString() ?? '',
|
||||||
'isCheckedIn': checkIn != null,
|
'isCheckedIn': checkIn != null,
|
||||||
'pocketNumber': checkIn?['pocketNumber'],
|
'pocketNumber': checkIn?['pocketNumber'],
|
||||||
'checkInTime': checkIn?['time'],
|
'checkInTime': checkIn?['time'],
|
||||||
|
'attendanceStatus': attendanceStatus,
|
||||||
|
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
|
||||||
|
'hasEverCheckedIn': everCheckedIn.contains(id),
|
||||||
'hasActiveViolation': violation != null,
|
'hasActiveViolation': violation != null,
|
||||||
'violationPocket': violation?['pocket_number'],
|
'violationPocket': violation?['pocket_number'],
|
||||||
'violationTime': violation?['time'],
|
'violationTime': violation?['time'],
|
||||||
@@ -249,9 +455,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
List<Map<String, dynamic>> get _filteredStudents {
|
List<Map<String, dynamic>> get _filteredStudents {
|
||||||
final all = _combinedStudentStatus;
|
final all = _combinedStudentStatus;
|
||||||
if (_filterType == "CHECKED_IN") {
|
if (_filterType == "CHECKED_IN") {
|
||||||
return all.where((s) => s['isCheckedIn'] == true).toList();
|
return all.where((s) => s['isAttendanceComplete'] == true).toList();
|
||||||
} else if (_filterType == "ABSENT") {
|
} else if (_filterType == "ABSENT") {
|
||||||
return all.where((s) => s['isCheckedIn'] == false).toList();
|
return all.where((s) => s['isAttendanceComplete'] == false).toList();
|
||||||
}
|
}
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
@@ -260,7 +466,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final all = _combinedStudentStatus;
|
final all = _combinedStudentStatus;
|
||||||
final int totalCount = all.length;
|
final int totalCount = all.length;
|
||||||
final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length;
|
final int checkedInCount =
|
||||||
|
all.where((s) => s['isAttendanceComplete'] == true).length;
|
||||||
final int absentCount = totalCount - checkedInCount;
|
final int absentCount = totalCount - checkedInCount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -274,6 +481,28 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: (_isLoading || _isRefreshing) ? null : _manualRefresh,
|
||||||
|
icon: _isRefreshing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
tooltip: '새로고침',
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _showAttendanceTimeDialog,
|
||||||
|
icon: const Icon(Icons.access_time_rounded, color: Colors.white),
|
||||||
|
label: Text(
|
||||||
|
_attendanceTime != null ? '출석시간 $_attendanceTime' : '자습실 출석시간 설정',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _showPermissionWindowDialog,
|
onPressed: _showPermissionWindowDialog,
|
||||||
icon: const Icon(Icons.timer_outlined, color: Colors.white),
|
icon: const Icon(Icons.timer_outlined, color: Colors.white),
|
||||||
@@ -282,6 +511,19 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _showDismissalConfirmDialog,
|
||||||
|
icon: const Icon(Icons.school_rounded, color: Colors.white),
|
||||||
|
label: const Text(
|
||||||
|
'하교',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _showTestResetConfirmDialog,
|
||||||
|
icon: const Icon(Icons.bug_report_outlined, color: Colors.white70),
|
||||||
|
tooltip: '🧪 테스트용: 허용시간 초기화',
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -305,6 +547,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryCards(total, checkedIn, absent),
|
_buildSummaryCards(total, checkedIn, absent),
|
||||||
|
_buildPermissionBanner(),
|
||||||
_buildFilterChips(),
|
_buildFilterChips(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _filteredStudents.isEmpty
|
child: _filteredStudents.isEmpty
|
||||||
@@ -321,6 +564,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
final student = _filteredStudents[index];
|
final student = _filteredStudents[index];
|
||||||
final bool isCheckedIn = student['isCheckedIn'];
|
final bool isCheckedIn = student['isCheckedIn'];
|
||||||
final bool hasViolation = student['hasActiveViolation'] == true;
|
final bool hasViolation = student['hasActiveViolation'] == true;
|
||||||
|
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
||||||
|
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
||||||
|
final bool emphasizeTime = isComplete && _attendanceTime != null;
|
||||||
|
final Color statusColor = hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isPending
|
||||||
|
? Colors.orange
|
||||||
|
: (isComplete ? Colors.blue : Colors.red));
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
@@ -349,21 +600,18 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (hasViolation
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
? Colors.red
|
|
||||||
: (isCheckedIn ? Colors.blue : Colors.red))
|
|
||||||
.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
hasViolation
|
hasViolation
|
||||||
? Icons.warning_amber_rounded
|
? Icons.warning_amber_rounded
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? Icons.hourglass_bottom_rounded
|
||||||
|
: (isComplete
|
||||||
? Icons.check_circle_rounded
|
? Icons.check_circle_rounded
|
||||||
: Icons.error_rounded),
|
: Icons.error_rounded)),
|
||||||
color: hasViolation
|
color: statusColor,
|
||||||
? Colors.red
|
|
||||||
: (isCheckedIn ? Colors.blue : Colors.red),
|
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -383,17 +631,23 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
Text(
|
Text(
|
||||||
hasViolation
|
hasViolation
|
||||||
? '🚨 무단반출 감지! (${student['violationTime']})'
|
? '🚨 무단반출 감지! (${student['violationTime']})'
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||||
|
: (isComplete
|
||||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||||
: '학번: ${student['studentId']} | 미제출'),
|
: (student['hasEverCheckedIn'] == true
|
||||||
|
? '학번: ${student['studentId']} | 미제출'
|
||||||
|
: '학번: ${student['studentId']} | 미등록'))),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: hasViolation
|
color: hasViolation
|
||||||
? Colors.red[700]
|
? Colors.red[700]
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? Colors.orange[800]
|
||||||
|
: (isComplete
|
||||||
? Colors.grey[600]
|
? Colors.grey[600]
|
||||||
: Colors.red[400]),
|
: Colors.red[400])),
|
||||||
fontSize: 12,
|
fontSize: emphasizeTime ? 14 : 12,
|
||||||
fontWeight: hasViolation
|
fontWeight: (hasViolation || emphasizeTime)
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -496,6 +750,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
_buildPermissionBanner(),
|
||||||
_buildFilterChips(),
|
_buildFilterChips(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -536,23 +791,34 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
final bool isCheckedIn = student['isCheckedIn'];
|
final bool isCheckedIn = student['isCheckedIn'];
|
||||||
final bool hasViolation =
|
final bool hasViolation =
|
||||||
student['hasActiveViolation'] == true;
|
student['hasActiveViolation'] == true;
|
||||||
|
final bool isPending =
|
||||||
|
student['attendanceStatus'] == 'PENDING';
|
||||||
|
final bool isComplete =
|
||||||
|
student['attendanceStatus'] == 'COMPLETE';
|
||||||
|
final bool emphasizeTime =
|
||||||
|
isComplete && _attendanceTime != null;
|
||||||
|
final Color statusColor = hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isPending
|
||||||
|
? Colors.orange
|
||||||
|
: (isComplete ? Colors.blue : Colors.red));
|
||||||
return DataRow(
|
return DataRow(
|
||||||
color: hasViolation
|
color: hasViolation
|
||||||
? WidgetStateProperty.all(Colors.red[50])
|
? WidgetStateProperty.all(Colors.red[50])
|
||||||
: null,
|
: (isPending
|
||||||
|
? WidgetStateProperty.all(Colors.orange[50])
|
||||||
|
: null),
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(
|
DataCell(
|
||||||
Icon(
|
Icon(
|
||||||
hasViolation
|
hasViolation
|
||||||
? Icons.warning_amber_rounded
|
? Icons.warning_amber_rounded
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? Icons.hourglass_bottom_rounded
|
||||||
|
: (isComplete
|
||||||
? Icons.check_circle_rounded
|
? Icons.check_circle_rounded
|
||||||
: Icons.error_rounded),
|
: Icons.error_rounded)),
|
||||||
color: hasViolation
|
color: statusColor,
|
||||||
? Colors.red
|
|
||||||
: (isCheckedIn
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.red),
|
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -569,16 +835,23 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
Text(
|
Text(
|
||||||
hasViolation
|
hasViolation
|
||||||
? '🚨 무단반출 (${student['violationTime']})'
|
? '🚨 무단반출 (${student['violationTime']})'
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||||
|
: (isComplete
|
||||||
? '${student['checkInTime']}'
|
? '${student['checkInTime']}'
|
||||||
: '미제출'),
|
: (student['hasEverCheckedIn'] == true
|
||||||
|
? '미제출'
|
||||||
|
: '미등록'))),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: hasViolation
|
color: hasViolation
|
||||||
? Colors.red[700]
|
? Colors.red[700]
|
||||||
: (isCheckedIn
|
: (isPending
|
||||||
|
? Colors.orange[800]
|
||||||
|
: (isComplete
|
||||||
? Colors.grey[700]
|
? Colors.grey[700]
|
||||||
: Colors.red[400]),
|
: Colors.red[400])),
|
||||||
fontWeight: hasViolation
|
fontSize: emphasizeTime ? 15 : 14,
|
||||||
|
fontWeight: (hasViolation || emphasizeTime)
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -730,6 +1003,39 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
||||||
|
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
|
||||||
|
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
|
||||||
|
Widget _buildPermissionBanner() {
|
||||||
|
if (_globalPermissionUntil == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber[100],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.amber[400]!),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'🔓 지금 전체 반출 허용 중입니다 ($_globalPermissionUntil 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.amber[900],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFilterChips() {
|
Widget _buildFilterChips() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.1.4+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
Reference in New Issue
Block a user