diff --git a/backend/index.js b/backend/index.js index 24c5bab..ae3ed1d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -26,6 +26,7 @@ const requirementRoutes = require('./src/routes/requirements'); const ocrRoutes = require('./src/routes/ocr.routes.js'); const notificationRoutes = require('./src/routes/notifications'); const backupRoutes = require('./src/routes/backup'); +const serverRoutes = require('./src/routes/server.routes'); const app = express(); const PORT = process.env.PORT || 18484; @@ -53,6 +54,7 @@ app.use('/api/requirements', requirementRoutes); app.use('/api/ocr', ocrRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/backup', backupRoutes); +app.use('/api/server', serverRoutes); app.get(['/', '/api'], (req, res) => { res.set('X-TIS-RMS', 'true'); diff --git a/backend/src/controllers/connectedUsersController.js b/backend/src/controllers/connectedUsersController.js new file mode 100644 index 0000000..8ff8626 --- /dev/null +++ b/backend/src/controllers/connectedUsersController.js @@ -0,0 +1,124 @@ +const os = require('os'); + +// ───────────────────────────────────────────────────────────────────────────── +// In-memory connected-users store +// Key: username (string) +// Value: { username, role, platform, ip, loginTime, lastSeen } +// ───────────────────────────────────────────────────────────────────────────── +const connectedUsers = new Map(); +const SERVER_START_TIME = Date.now(); + +// Evict users whose lastSeen is older than 3 minutes (3 × heartbeat interval) +const EVICT_MS = 3 * 60 * 1000; + +function evictStale() { + const now = Date.now(); + for (const [key, user] of connectedUsers) { + if (now - new Date(user.lastSeen).getTime() > EVICT_MS) { + connectedUsers.delete(key); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/server/heartbeat +// Body: { username, role, platform, ip } +// Upserts the user into the in-memory store. +// ───────────────────────────────────────────────────────────────────────────── +exports.heartbeat = (req, res) => { + evictStale(); + const { username, role, platform, ip } = req.body; + + if (!username) { + return res.status(400).json({ message: 'username is required.' }); + } + + const now = new Date().toISOString(); + const existing = connectedUsers.get(username); + + connectedUsers.set(username, { + username, + role: role || existing?.role || 'unknown', + platform: platform || existing?.platform || 'unknown', + ip: ip || existing?.ip || null, + loginTime: existing?.loginTime || now, + lastSeen: now, + }); + + res.json({ ok: true }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/server/logout +// Body: { username } +// Removes the user from the in-memory store. +// ───────────────────────────────────────────────────────────────────────────── +exports.logout = (req, res) => { + const { username } = req.body; + if (username) connectedUsers.delete(username); + res.json({ ok: true }); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/server/connected-users +// Returns the current list of connected users (stale entries evicted first). +// ───────────────────────────────────────────────────────────────────────────── +exports.getConnectedUsers = (req, res) => { + evictStale(); + res.json(Array.from(connectedUsers.values())); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/server/status +// Returns CPU %, memory usage, uptime, and connected user count. +// ───────────────────────────────────────────────────────────────────────────── + +/** Computes an instantaneous CPU usage percentage by sampling os.cpus() twice. */ +function getCpuPercent() { + return new Promise((resolve) => { + const cpusBefore = os.cpus(); + + setTimeout(() => { + const cpusAfter = os.cpus(); + let totalIdle = 0; + let totalTick = 0; + + for (let i = 0; i < cpusBefore.length; i++) { + const before = cpusBefore[i].times; + const after = cpusAfter[i].times; + + const idleDiff = after.idle - before.idle; + const totalDiff = Object.values(after).reduce((a, v) => a + v, 0) + - Object.values(before).reduce((a, v) => a + v, 0); + + totalIdle += idleDiff; + totalTick += totalDiff; + } + + const percent = totalTick === 0 + ? 0 + : parseFloat(((1 - totalIdle / totalTick) * 100).toFixed(1)); + resolve(percent); + }, 200); // 200 ms sample window + }); +} + +exports.getServerStatus = async (req, res) => { + evictStale(); + try { + const cpuPercent = await getCpuPercent(); + const memTotalMB = Math.round(os.totalmem() / 1024 / 1024); + const memUsedMB = Math.round((os.totalmem() - os.freemem()) / 1024 / 1024); + const uptimeSec = Math.round((Date.now() - SERVER_START_TIME) / 1000); + + res.json({ + uptime: uptimeSec, + cpuPercent, + memUsedMB, + memTotalMB, + connectedUserCount: connectedUsers.size, + }); + } catch (err) { + res.status(500).json({ message: 'Failed to get server status.', error: err.message }); + } +}; diff --git a/backend/src/routes/server.routes.js b/backend/src/routes/server.routes.js new file mode 100644 index 0000000..3baafbb --- /dev/null +++ b/backend/src/routes/server.routes.js @@ -0,0 +1,23 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateToken } = require('../middleware/auth'); +const { + heartbeat, + logout, + getConnectedUsers, + getServerStatus, +} = require('../controllers/connectedUsersController'); + +// POST /api/server/heartbeat — upsert caller into connected-users list +router.post('/heartbeat', authenticateToken, heartbeat); + +// POST /api/server/logout — remove caller from connected-users list +router.post('/logout', authenticateToken, logout); + +// GET /api/server/connected-users — list all currently connected users +router.get('/connected-users', authenticateToken, getConnectedUsers); + +// GET /api/server/status — cpu%, mem, uptime, user count +router.get('/status', authenticateToken, getServerStatus); + +module.exports = router; diff --git a/frontend/lib/domain/entities/connected_user_model.dart b/frontend/lib/domain/entities/connected_user_model.dart new file mode 100644 index 0000000..e456a00 --- /dev/null +++ b/frontend/lib/domain/entities/connected_user_model.dart @@ -0,0 +1,39 @@ +class ConnectedUserModel { + final String username; + final String role; + final String platform; + final String? ip; + final String loginTime; + final String lastSeen; + + ConnectedUserModel({ + required this.username, + required this.role, + required this.platform, + this.ip, + required this.loginTime, + required this.lastSeen, + }); + + factory ConnectedUserModel.fromJson(Map json) { + return ConnectedUserModel( + username: json['username'] as String? ?? 'unknown', + role: json['role'] as String? ?? 'unknown', + platform: json['platform'] as String? ?? 'unknown', + ip: json['ip'] as String?, + loginTime: json['loginTime'] as String? ?? '', + lastSeen: json['lastSeen'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'username': username, + 'role': role, + 'platform': platform, + 'ip': ip, + 'loginTime': loginTime, + 'lastSeen': lastSeen, + }; + } +} diff --git a/frontend/lib/domain/repositories/connected_users_repository.dart b/frontend/lib/domain/repositories/connected_users_repository.dart new file mode 100644 index 0000000..79c1632 --- /dev/null +++ b/frontend/lib/domain/repositories/connected_users_repository.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../core/network/api_constants.dart'; +import '../entities/connected_user_model.dart'; + +class ConnectedUsersRepository { + final Dio _dio = ApiConstants.createDio(); + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + Future _getAuthOptions() async { + final token = await _storage.read(key: 'jwt_token'); + return Options(headers: {'Authorization': 'Bearer $token'}); + } + + Future sendHeartbeat({ + required String username, + required String role, + required String platform, + String? ip, + }) async { + try { + final options = await _getAuthOptions(); + await _dio.post( + '/server/heartbeat', + options: options, + data: { + 'username': username, + 'role': role, + 'platform': platform, + 'ip': ip, + }, + ); + } catch (_) { + // Ignore heartbeat network errors silently so UI is undisturbed + } + } + + Future sendLogout({required String username}) async { + try { + final options = await _getAuthOptions(); + await _dio.post( + '/server/logout', + options: options, + data: {'username': username}, + ); + } catch (_) { + // Ignore logout network errors silently + } + } + + Future> getConnectedUsers() async { + try { + final options = await _getAuthOptions(); + final response = await _dio.get( + '/server/connected-users', + options: options, + ); + return (response.data as List) + .map((u) => ConnectedUserModel.fromJson(u)) + .toList(); + } on DioException catch (e) { + throw Exception( + e.response?.data['message'] ?? 'Failed to fetch connected users.', + ); + } + } +} diff --git a/frontend/lib/ui/providers/auth_provider.dart b/frontend/lib/ui/providers/auth_provider.dart index 83cf54a..35d558c 100644 --- a/frontend/lib/ui/providers/auth_provider.dart +++ b/frontend/lib/ui/providers/auth_provider.dart @@ -12,6 +12,7 @@ import 'dashboard_provider.dart'; import 'setup_provider.dart'; import 'reports_provider.dart'; import 'ocr_provider.dart'; +import 'connected_users_provider.dart'; final authRepositoryProvider = Provider( (ref) => AuthRepository(), @@ -41,6 +42,7 @@ class AuthNotifier extends AsyncNotifier { rememberMe: rememberMe, ); state = AsyncData(user); + ref.read(heartbeatServiceProvider).start(user); return true; } catch (e, st) { state = AsyncError(e, st); @@ -55,6 +57,7 @@ class AuthNotifier extends AsyncNotifier { final user = await repository.tryAutoLogin(); if (user != null) { state = AsyncData(user); + ref.read(heartbeatServiceProvider).start(user); } return user; } catch (_) { @@ -63,6 +66,7 @@ class AuthNotifier extends AsyncNotifier { } Future logout() async { + ref.read(heartbeatServiceProvider).stop(); final repository = ref.read(authRepositoryProvider); await repository.logout(); state = const AsyncData(null); diff --git a/frontend/lib/ui/providers/connected_users_provider.dart b/frontend/lib/ui/providers/connected_users_provider.dart new file mode 100644 index 0000000..f72efe7 --- /dev/null +++ b/frontend/lib/ui/providers/connected_users_provider.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/repositories/connected_users_repository.dart'; +import '../../domain/entities/connected_user_model.dart'; +import '../../domain/entities/user_model.dart'; + +final connectedUsersRepositoryProvider = Provider( + (ref) => ConnectedUsersRepository(), +); + +class HeartbeatService { + final ConnectedUsersRepository _repository; + Timer? _timer; + UserModel? _currentUser; + + HeartbeatService(this._repository); + + String get _platformName { + if (Platform.isWindows) return 'windows'; + if (Platform.isAndroid) return 'android'; + return 'other'; + } + + void start(UserModel user) { + stop(); // stop any existing timer + _currentUser = user; + _sendHeartbeat(); + _timer = Timer.periodic(const Duration(minutes: 1), (_) { + _sendHeartbeat(); + }); + } + + void _sendHeartbeat() { + final user = _currentUser; + if (user == null) return; + _repository.sendHeartbeat( + username: user.username, + role: user.role, + platform: _platformName, + ); + } + + void stop() { + _timer?.cancel(); + _timer = null; + final user = _currentUser; + if (user != null) { + _repository.sendLogout(username: user.username); + _currentUser = null; + } + } +} + +final heartbeatServiceProvider = Provider((ref) { + final repository = ref.read(connectedUsersRepositoryProvider); + final service = HeartbeatService(repository); + ref.onDispose(() => service.stop()); + return service; +}); + +final connectedUsersProvider = + FutureProvider.autoDispose>((ref) async { + final repository = ref.read(connectedUsersRepositoryProvider); + return await repository.getConnectedUsers(); +}); diff --git a/frontend/lib/ui/screens/students/widgets/add_edit_student_modal.dart b/frontend/lib/ui/screens/students/widgets/add_edit_student_modal.dart index ebece58..3a575ce 100644 --- a/frontend/lib/ui/screens/students/widgets/add_edit_student_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/add_edit_student_modal.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_holo_date_picker/flutter_holo_date_picker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_colors.dart'; import '../../../../core/constants/app_sizes.dart'; @@ -1712,6 +1711,37 @@ class _ExtensionNameField extends StatelessWidget { ); } } +// ================================================================ +// DOB MASKED INPUT FORMATTER +// Accepts digits only. Auto-inserts dashes: MM-DD-YYYY +// ================================================================ +class _DobInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + // Strip everything that's not a digit + final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), ''); + // Limit to 8 raw digits (MMDDYYYY) + final d = digits.length > 8 ? digits.substring(0, 8) : digits; + + final buf = StringBuffer(); + for (int i = 0; i < d.length; i++) { + buf.write(d[i]); + if (i == 1 || i == 3) buf.write('-'); // after MM and DD + } + final result = buf.toString(); + return TextEditingValue( + text: result, + selection: TextSelection.collapsed(offset: result.length), + ); + } +} + +// ================================================================ +// DOB PICKER — single MM-DD-YYYY typed field with suggestion overlay +// ================================================================ class _DobPicker extends StatefulWidget { final DateTime? initialDate; final ValueChanged onChanged; @@ -1724,34 +1754,131 @@ class _DobPicker extends StatefulWidget { } class _DobPickerState extends State<_DobPicker> { - int? _month; - int? _day; - late final TextEditingController _yearController; + late final TextEditingController _ctrl; + late final FocusNode _focusNode; + OverlayEntry? _overlayEntry; + final LayerLink _layerLink = LayerLink(); - static const List _months = [ + static const _monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' + 'July', 'August', 'September', 'October', 'November', 'December', ]; + static String _fmt(DateTime d) => + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}-' + '${d.year}'; + @override void initState() { super.initState(); - _yearController = TextEditingController(); - if (widget.initialDate != null) { - _month = widget.initialDate!.month; - _day = widget.initialDate!.day; - _yearController.text = widget.initialDate!.year.toString(); + _ctrl = TextEditingController( + text: widget.initialDate != null ? _fmt(widget.initialDate!) : '', + ); + _focusNode = FocusNode(); + _ctrl.addListener(_onTextChanged); + _focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(covariant _DobPicker oldWidget) { + super.didUpdateWidget(oldWidget); + // External update (e.g. OCR fill): sync the text controller + if (widget.initialDate != oldWidget.initialDate && + widget.initialDate != null) { + final formatted = _fmt(widget.initialDate!); + if (_ctrl.text != formatted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _ctrl.text = formatted; + }); + } + } + if (widget.initialDate == null && oldWidget.initialDate != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _ctrl.clear(); + }); } } @override void dispose() { - _yearController.dispose(); + _removeOverlay(); + _ctrl.removeListener(_onTextChanged); + _focusNode.removeListener(_onFocusChanged); + _ctrl.dispose(); + _focusNode.dispose(); super.dispose(); } - int _getDaysInMonth(int? month, int? year) { - if (month == null) return 31; + // ── Overlay management ────────────────────────────────────────── + + void _onFocusChanged() { + if (_focusNode.hasFocus) { + _showOverlay(); + } else { + _removeOverlay(); + } + } + + void _onTextChanged() { + _parseAndNotify(); + if (_focusNode.hasFocus) { + _overlayEntry?.markNeedsBuild(); + } + } + + void _showOverlay() { + _removeOverlay(); + _overlayEntry = OverlayEntry(builder: (_) => _buildOverlayWidget()); + Overlay.of(context).insert(_overlayEntry!); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + // ── Suggestion computation ────────────────────────────────────── + + /// Returns typed digits stripped of dashes. + String get _rawDigits => _ctrl.text.replaceAll('-', ''); + + List _buildSuggestions() { + final raw = _rawDigits; + final len = raw.length; + + // Phase 1: typing MM (0–2 digits) + if (len <= 2) { + return List.generate(12, (i) { + final mm = (i + 1).toString().padLeft(2, '0'); + return '$mm - ${_monthNames[i]}'; + }).where((s) { + if (len == 0) return true; + return s.startsWith(raw); + }).toList(); + } + + // Phase 2: typing DD (3–4 digits) + if (len <= 4) { + final mm = int.tryParse(raw.substring(0, 2)); + if (mm == null || mm < 1 || mm > 12) return []; + final maxDay = _maxDayForMonth(mm, null); + final ddTyped = raw.length >= 3 ? raw.substring(2) : ''; + return List.generate(maxDay, (i) { + final dd = (i + 1).toString().padLeft(2, '0'); + return dd; + }).where((dd) => ddTyped.isEmpty || dd.startsWith(ddTyped)).toList(); + } + + // Phase 3: typing YYYY (5–8 digits) + final now = DateTime.now().year; + final yyyyTyped = raw.length >= 5 ? raw.substring(4) : ''; + return List.generate(now - 1985, (i) { + return (now - 3 - i).toString(); + }).where((y) => yyyyTyped.isEmpty || y.startsWith(yyyyTyped)).take(8).toList(); + } + + int _maxDayForMonth(int month, int? year) { if (month == 2) { if (year != null && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) { return 29; @@ -1762,97 +1889,173 @@ class _DobPickerState extends State<_DobPicker> { return 31; } - void _update() { - int? year = int.tryParse(_yearController.text); - int maxDays = _getDaysInMonth(_month, year); - if (_day != null && _day! > maxDays) { - _day = maxDays; - } - - if (_month != null && _day != null && year != null && _yearController.text.length == 4) { - widget.onChanged(DateTime(year, _month!, _day!)); + // ── Suggestion tap handler ────────────────────────────────────── + + void _onSuggestionTap(String suggestion) { + final raw = _rawDigits; + final len = raw.length; + String newRaw; + + if (len <= 2) { + // suggestion = "01 - January" → extract "01" + newRaw = suggestion.split(' ').first; + } else if (len <= 4) { + // suggestion is just "DD" + final mm = raw.substring(0, 2); + newRaw = mm + suggestion; } else { - widget.onChanged(null); + // suggestion is a 4-digit year string + final mmdd = raw.substring(0, 4); + newRaw = mmdd + suggestion; + } + + // Rebuild formatted string MM-DD-YYYY from newRaw + final buf = StringBuffer(); + for (int i = 0; i < newRaw.length && i < 8; i++) { + buf.write(newRaw[i]); + if (i == 1 || i == 3) buf.write('-'); + } + _ctrl.text = buf.toString(); + _parseAndNotify(); + // Move to next phase or close if complete + if (newRaw.length >= 8) { + _focusNode.unfocus(); + } else { + _overlayEntry?.markNeedsBuild(); } - setState(() {}); } + // ── Parse & notify parent ─────────────────────────────────────── + + void _parseAndNotify() { + final raw = _rawDigits; + if (raw.length == 8) { + final mm = int.tryParse(raw.substring(0, 2)); + final dd = int.tryParse(raw.substring(2, 4)); + final yyyy = int.tryParse(raw.substring(4, 8)); + final now = DateTime.now().year; + if (mm != null && dd != null && yyyy != null && + mm >= 1 && mm <= 12 && + dd >= 1 && dd <= _maxDayForMonth(mm, yyyy) && + yyyy >= 1900 && yyyy <= now) { + widget.onChanged(DateTime(yyyy, mm, dd)); + return; + } + } + widget.onChanged(null); + } + + // ── Overlay widget builder ────────────────────────────────────── + + Widget _buildOverlayWidget() { + final suggestions = _buildSuggestions(); + if (suggestions.isEmpty) return const SizedBox.shrink(); + + final raw = _rawDigits; + String label; + if (raw.length <= 2) { + label = 'Select month'; + } else if (raw.length <= 4) { + label = 'Select day'; + } else { + label = 'Select year'; + } + + return Positioned( + width: 260, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset(0, 52), + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(AppSizes.radiusMedium), + color: Colors.white, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + letterSpacing: 0.5, + ), + ), + ), + const Divider(height: 1), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: suggestions.length, + itemBuilder: (ctx, i) { + final s = suggestions[i]; + return InkWell( + onTap: () => _onSuggestionTap(s), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 9, + ), + child: Text( + s, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + // ── Build ─────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { - int? year = int.tryParse(_yearController.text); - int maxDays = _getDaysInMonth(_month, year); - - return InputDecorator( - decoration: const InputDecoration( - labelText: 'DATE OF BIRTH (Optional)', - prefixIcon: Icon(Icons.calendar_today, color: AppColors.textSecondary), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - ), - child: Row( - children: [ - Expanded( - flex: 4, - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - hint: const Text('Month', style: TextStyle(fontSize: 14)), - value: _month, - items: List.generate(12, (index) { - return DropdownMenuItem( - value: index + 1, - child: Text(_months[index], style: const TextStyle(fontSize: 14)), - ); - }), - onChanged: (val) { - _month = val; - _update(); - }, - ), - ), + return CompositedTransformTarget( + link: _layerLink, + child: TextFormField( + controller: _ctrl, + focusNode: _focusNode, + keyboardType: TextInputType.number, + inputFormatters: [_DobInputFormatter()], + style: const TextStyle(fontSize: 14, letterSpacing: 0.5), + decoration: InputDecoration( + labelText: 'DATE OF BIRTH (Optional)', + hintText: 'MM-DD-YYYY', + prefixIcon: const Icon( + Icons.cake_outlined, + color: AppColors.textSecondary, ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - hint: const Text('Day', style: TextStyle(fontSize: 14)), - value: _day, - items: List.generate(maxDays, (index) { - return DropdownMenuItem( - value: index + 1, - child: Text('${index + 1}', style: const TextStyle(fontSize: 14)), - ); - }), - onChanged: (val) { - _day = val; - _update(); - }, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 3, - child: TextField( - controller: _yearController, - keyboardType: TextInputType.number, - maxLength: 4, - style: const TextStyle(fontSize: 14), - decoration: const InputDecoration( - hintText: 'Year', - counterText: '', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - onChanged: (_) => _update(), - ), - ), - ], + suffixIcon: _ctrl.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _ctrl.clear(); + widget.onChanged(null); + _overlayEntry?.markNeedsBuild(); + }, + ) + : null, + ), ), ); } } +