// ๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ ํ™”๋ฉด. ์ „์ฒด ํ•™์ƒ ๋ช…๋‹จ(/api/users)๊ณผ ์ถœ์„ ๋กœ๊ทธ(/api/logs)๋ฅผ // ํ•ฉ์ณ์„œ ์ถœ์„/๋ฏธ์ถœ์„ ์š”์•ฝ ์นด๋“œ ๋ฐ ํ•„ํ„ฐ๋ง๋œ ๋ชฉ๋ก์„ 3์ดˆ๋งˆ๋‹ค ๊ฐฑ์‹ ํ•ด ๋ณด์—ฌ์ค€๋‹ค. import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../config.dart'; // ----------------------------------------------------------------------------- // ๐Ÿ“… [์„œ๋ธŒ ํ™”๋ฉด 1] ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ™•์ธ ๋ž€ (StudentDashboard ์นด๋“œ ์Šคํƒ€์ผ ๋ฆฌ์ŠคํŠธํ™”) // ----------------------------------------------------------------------------- class TeacherAttendancePage extends StatefulWidget { const TeacherAttendancePage({super.key}); @override State createState() => _TeacherAttendancePageState(); } // ๋กœ๊ทธ์˜ 'name' ์ปฌ๋Ÿผ์€ ๋ฐฑ์—”๋“œ์—์„œ "ํ•™์ƒ์ด๋ฆ„ (์ฃผ๋จธ๋‹ˆ์ •๋ณด)" ํ˜•ํƒœ๋กœ ํ•ฉ์ณ์ ธ ์ €์žฅ๋˜์–ด ์žˆ์–ด์„œ // ์ด๋ฆ„๊ณผ ์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ๋ณด์—ฌ์ฃผ๋ ค๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ ํŒŒ์‹ฑํ•ด์•ผ ํ•œ๋‹ค. final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$'); class _TeacherAttendancePageState extends State { List _roster = []; // ์ „์ฒด ํ•™์ƒ ๋ช…๋‹จ (/api/users) List _logs = []; // ์ถœ์„ ๋กœ๊ทธ (/api/logs) Map _activeViolationsByStudentId = {}; // ๋ฌด๋‹จ๋ฐ˜์ถœ ์ค‘์ธ ํ•™์ƒ (/api/violations/active) Timer? _timer; bool _isLoading = true; bool _isRefreshing = false; // ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์ž ๊น ๋„๋Š” ํ‘œ์‹œ์šฉ String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT" @override void initState() { super.initState(); _fetchAll(); _timer = Timer.periodic(const Duration(seconds: 3), (timer) => _fetchAll()); } @override void dispose() { _timer?.cancel(); super.dispose(); } Future _manualRefresh() async { setState(() => _isRefreshing = true); await _fetchAll(); if (mounted) setState(() => _isRefreshing = false); } Future _fetchAll() async { try { final results = await Future.wait([ http.get(Uri.parse('$baseUrl/api/users')), http.get(Uri.parse('$baseUrl/api/logs')), http.get(Uri.parse('$baseUrl/api/violations/active')), ]); final usersRes = results[0]; final logsRes = results[1]; final violationsRes = results[2]; if (usersRes.statusCode == 200 && logsRes.statusCode == 200) { final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes)); final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes)); Map activeViolations = {}; if (violationsRes.statusCode == 200) { final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes)); for (final v in (violationsData['violations'] ?? [])) { activeViolations[v['student_id'].toString()] = v; } } setState(() { _roster = usersData['users'] ?? []; _logs = logsData['logs'] ?? []; _activeViolationsByStudentId = activeViolations; _isLoading = false; }); } else { setState(() => _isLoading = false); } } catch (e) { setState(() => _isLoading = false); } } /// ๐Ÿšจ ์„ ์ƒ๋‹˜์ด ํŠน์ • ํ•™์ƒ์—๊ฒŒ ์ง€๊ธˆ๋ถ€ํ„ฐ N๋ถ„๊ฐ„ ๋ฐ˜์ถœ์„ ํ—ˆ์šฉํ•œ๋‹ค. Future _allowRemoval(String studentId, int minutes) async { try { final response = await http.post( Uri.parse('$baseUrl/api/violations/allow'), headers: {"Content-Type": "application/json"}, body: jsonEncode({"studentId": studentId, "minutes": minutes}), ); 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๋ถ„๊ฐ„ ์ „์ฒด ํ•™์ƒ์˜ ๋ฐ˜์ถœ์„ ์ž๋™์œผ๋กœ ํ—ˆ์šฉํ•œ๋‹ค (์‰ฌ๋Š”์‹œ๊ฐ„ ๋“ฑ). Future _setPermissionWindow(int minutes) async { try { final response = await http.post( Uri.parse('$baseUrl/api/permissions/window'), headers: {"Content-Type": "application/json"}, body: jsonEncode({"minutes": minutes}), ); 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 _showAllowDialog(String studentId, String studentName) { final controller = TextEditingController(text: "5"); showDialog( context: context, builder: (context) => AlertDialog( title: Text('$studentName ํ•™์ƒ ๋ฐ˜์ถœ ํ—ˆ์šฉ'), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'ํ—ˆ์šฉ ์‹œ๊ฐ„ (๋ถ„)', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 5; Navigator.pop(context); _allowRemoval(studentId, minutes); }, child: const Text('ํ—ˆ์šฉํ•˜๊ธฐ'), ), ], ), ); } void _showPermissionWindowDialog() { final controller = TextEditingController(text: "10"); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('โฐ ์ „์ฒด ํ•™์ƒ ๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '์‰ฌ๋Š”์‹œ๊ฐ„์ฒ˜๋Ÿผ ์ง€๊ธˆ๋ถ€ํ„ฐ ์ผ์ • ์‹œ๊ฐ„ ๋™์•ˆ ๋ชจ๋“  ํ•™์ƒ์˜ ๋ฐ˜์ถœ์„ ์ž๋™์œผ๋กœ ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.', style: TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 16), TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '์ง€๊ธˆ๋ถ€ํ„ฐ ํ—ˆ์šฉ ์‹œ๊ฐ„ (๋ถ„)', border: OutlineInputBorder(), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('์ทจ์†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 10; Navigator.pop(context); _setPermissionWindow(minutes); }, child: const Text('์„ค์ •ํ•˜๊ธฐ'), ), ], ), ); } /// ์˜ค๋Š˜ ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ํ•™๋ฒˆ๋ณ„ "๊ฐ€์žฅ ์ตœ๊ทผ ์ถœ์„ ๊ธฐ๋ก"๋งŒ ๋‚จ๊ธด ๋งต์„ ๋งŒ๋“ ๋‹ค. Map> get _todaysCheckInsByStudentId { final String todayPrefix = DateTime.now().toIso8601String().substring( 0, 10, ); // "YYYY-MM-DD" final Map> result = {}; for (final log in _logs) { final String time = log['time']?.toString() ?? ''; if (!time.startsWith(todayPrefix)) continue; // ์˜ค๋Š˜ ๊ธฐ๋ก์ด ์•„๋‹ˆ๋ฉด ๋ฌด์‹œ final String studentId = log['student_id']?.toString() ?? ''; if (studentId.isEmpty) continue; // ์ด๋ฏธ ๋” ์ตœ์‹  ๊ธฐ๋ก์„ ์ฐพ์•˜๋‹ค๋ฉด(๋กœ๊ทธ๋Š” ์ตœ์‹ ์ˆœ ์ •๋ ฌ) ๊ฑด๋„ˆ๋›ด๋‹ค. if (result.containsKey(studentId)) continue; final String rawName = log['name']?.toString() ?? ''; final match = _logNamePattern.firstMatch(rawName); result[studentId] = { 'name': match != null ? match.group(1)! : rawName, 'pocketNumber': match != null ? match.group(2)! : '์ฃผ๋จธ๋‹ˆ ๋ฏธ์ง€์ •', 'time': time, }; } return result; } List> get _combinedStudentStatus { final checkIns = _todaysCheckInsByStudentId; return _roster.map((u) { final String id = u['id']?.toString() ?? ''; final checkIn = checkIns[id]; final violation = _activeViolationsByStudentId[id]; return { 'studentId': id, 'studentName': u['name']?.toString() ?? '', 'isCheckedIn': checkIn != null, 'pocketNumber': checkIn?['pocketNumber'], 'checkInTime': checkIn?['time'], 'hasActiveViolation': violation != null, 'violationPocket': violation?['pocket_number'], 'violationTime': violation?['time'], }; }).toList(); } List> get _filteredStudents { final all = _combinedStudentStatus; if (_filterType == "CHECKED_IN") { return all.where((s) => s['isCheckedIn'] == true).toList(); } else if (_filterType == "ABSENT") { return all.where((s) => s['isCheckedIn'] == false).toList(); } return all; } @override Widget build(BuildContext context) { final all = _combinedStudentStatus; final int totalCount = all.length; final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length; final int absentCount = totalCount - checkedInCount; return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( title: const Text( '๐Ÿ“‹ ์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ', style: TextStyle(fontWeight: FontWeight.bold), ), backgroundColor: Colors.blue, foregroundColor: Colors.white, elevation: 0, 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: _showPermissionWindowDialog, icon: const Icon(Icons.timer_outlined, color: Colors.white), label: const Text( '๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„ ์„ค์ •', style: TextStyle(color: Colors.white), ), ), const SizedBox(width: 8), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder( builder: (context, constraints) { final bool isWide = constraints.maxWidth >= 800; return isWide ? _buildDesktopBody(totalCount, checkedInCount, absentCount) : _buildMobileBody(totalCount, checkedInCount, absentCount); }, ), ); } // ----------------------------------------------------------------------- // ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ๋ ˆ์ด์•„์›ƒ (๊ธฐ์กด ์นด๋“œ ๋ฆฌ์ŠคํŠธ) // ----------------------------------------------------------------------- Widget _buildMobileBody(int total, int checkedIn, int absent) { return Column( children: [ _buildSummaryCards(total, checkedIn, absent), _buildFilterChips(), Expanded( child: _filteredStudents.isEmpty ? const Center( child: Text( 'ํ•ด๋‹นํ•˜๋Š” ํ•™์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค.', style: TextStyle(color: Colors.grey), ), ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 12), itemCount: _filteredStudents.length, itemBuilder: (context, index) { final student = _filteredStudents[index]; final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; return Container( margin: const EdgeInsets.symmetric( horizontal: 24, vertical: 8, ), padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: hasViolation ? Colors.red[50] : Colors.white, borderRadius: BorderRadius.circular(20), border: hasViolation ? Border.all(color: Colors.red[300]!, width: 1.5) : null, 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(10), decoration: BoxDecoration( color: (hasViolation ? Colors.red : (isCheckedIn ? Colors.blue : Colors.red)) .withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( hasViolation ? Icons.warning_amber_rounded : (isCheckedIn ? Icons.check_circle_rounded : Icons.error_rounded), color: hasViolation ? Colors.red : (isCheckedIn ? Colors.blue : Colors.red), size: 24, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${student['studentName']} ํ•™์ƒ', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 4), Text( hasViolation ? '๐Ÿšจ ๋ฌด๋‹จ๋ฐ˜์ถœ ๊ฐ์ง€! (${student['violationTime']})' : (isCheckedIn ? 'ํ•™๋ฒˆ: ${student['studentId']} | ์ œ์ถœ์‹œ๊ฐ„: ${student['checkInTime']}' : 'ํ•™๋ฒˆ: ${student['studentId']} | ๋ฏธ์ œ์ถœ'), style: TextStyle( color: hasViolation ? Colors.red[700] : (isCheckedIn ? Colors.grey[600] : Colors.red[400]), fontSize: 12, fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), if (isCheckedIn && student['pocketNumber'] != null) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.blue.shade200), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.blueAccent, fontSize: 12, ), ), ), ], ), if (hasViolation) Padding( padding: const EdgeInsets.only(top: 12), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () => _showAllowDialog( student['studentId'], student['studentName'], ), icon: const Icon(Icons.check, size: 18), label: const Text('๋ฐ˜์ถœ ํ—ˆ์šฉ'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[600], foregroundColor: Colors.white, ), ), ), ), ], ), ); }, ), ), ], ); } // ----------------------------------------------------------------------- // ๐Ÿ–ฅ๏ธ ๋ฐ์Šคํฌํ†ฑ ๋ ˆ์ด์•„์›ƒ (์„ ์ƒ๋‹˜์ด ๊ต์‹ค ์ปดํ“จํ„ฐ ๋ธŒ๋ผ์šฐ์ €๋กœ ์ ‘์†ํ–ˆ์„ ๋•Œ) // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1100), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: _statTile( "์ „์ฒด ํ•™์ƒ", "$total๋ช…", Icons.groups_rounded, Colors.blueGrey, ), ), const SizedBox(width: 16), Expanded( child: _statTile( "์ถœ์„ ์™„๋ฃŒ", "$checkedIn๋ช…", Icons.check_circle_rounded, Colors.green, ), ), const SizedBox(width: 16), Expanded( child: _statTile( "๋ฏธ์ถœ์„", "$absent๋ช…", Icons.error_rounded, Colors.red, ), ), ], ), const SizedBox(height: 20), _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: _filteredStudents.isEmpty ? const Center( child: Text( 'ํ•ด๋‹นํ•˜๋Š” ํ•™์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค.', style: TextStyle(color: Colors.grey), ), ) : SingleChildScrollView( child: DataTable( headingRowColor: WidgetStateProperty.all( Colors.grey[50], ), columns: const [ DataColumn(label: Text('์ƒํƒœ')), DataColumn(label: Text('ํ•™๋ฒˆ')), DataColumn(label: Text('์ด๋ฆ„')), DataColumn(label: Text('์ œ์ถœ ์‹œ๊ฐ„')), DataColumn(label: Text('์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ')), DataColumn(label: Text('์ž‘์—…')), ], rows: _filteredStudents.map((student) { final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; return DataRow( color: hasViolation ? WidgetStateProperty.all(Colors.red[50]) : null, cells: [ DataCell( Icon( hasViolation ? Icons.warning_amber_rounded : (isCheckedIn ? Icons.check_circle_rounded : Icons.error_rounded), color: hasViolation ? Colors.red : (isCheckedIn ? Colors.blue : Colors.red), size: 20, ), ), DataCell(Text('${student['studentId']}')), DataCell( Text( '${student['studentName']}', style: const TextStyle( fontWeight: FontWeight.bold, ), ), ), DataCell( Text( hasViolation ? '๐Ÿšจ ๋ฌด๋‹จ๋ฐ˜์ถœ (${student['violationTime']})' : (isCheckedIn ? '${student['checkInTime']}' : '๋ฏธ์ œ์ถœ'), style: TextStyle( color: hasViolation ? Colors.red[700] : (isCheckedIn ? Colors.grey[700] : Colors.red[400]), fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal, ), ), ), DataCell( isCheckedIn && student['pocketNumber'] != null ? Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), border: Border.all( color: Colors.blue.shade200, ), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.blueAccent, fontSize: 12, ), ), ) : const Text('-'), ), DataCell( hasViolation ? ElevatedButton.icon( onPressed: () => _showAllowDialog( student['studentId'], student['studentName'], ), icon: const Icon( Icons.check, size: 16, ), label: const Text('๋ฐ˜์ถœ ํ—ˆ์šฉ'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[600], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 12, ), ), ) : const Text('-'), ), ], ); }).toList(), ), ), ), ), ], ), ), ), ); } Widget _statTile(String label, String value, IconData icon, Color color) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: color, size: 26), ), const SizedBox(width: 14), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle(color: Colors.grey[600], fontSize: 13), ), const SizedBox(height: 2), Text( value, style: TextStyle( color: color, fontSize: 22, fontWeight: FontWeight.bold, ), ), ], ), ], ), ); } /// ๐Ÿ“Š ์ƒ๋‹จ ์š”์•ฝ ์นด๋“œ ๋ทฐ (์ „์ฒด / ์ถœ์„ ์™„๋ฃŒ / ๋ฏธ์ถœ์„) Widget _buildSummaryCards(int total, int checkedIn, int absent) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), color: const Color(0xFF1E293B), 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, ), ), ], ); } /// ๐Ÿ”˜ ํ•„ํ„ฐ ์นฉ๋ฒ„ํŠผ (์ „์ฒด / ์ถœ์„์ž / ๋ฏธ์ถœ์„์ž) Widget _buildFilterChips() { return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( children: [ FilterChip( label: const Text("์ „์ฒด"), selected: _filterType == "ALL", onSelected: (_) => setState(() => _filterType = "ALL"), ), const SizedBox(width: 8), FilterChip( label: const Text("๐ŸŸข ์ถœ์„์ž"), selected: _filterType == "CHECKED_IN", onSelected: (_) => setState(() => _filterType = "CHECKED_IN"), ), const SizedBox(width: 8), FilterChip( label: const Text("๐Ÿ”ด ๋ฏธ์ถœ์„์ž"), selected: _filterType == "ABSENT", onSelected: (_) => setState(() => _filterType = "ABSENT"), ), ], ), ); } }