diff --git a/image/checkup_media/2197/IMG_2197_20251223_060623_312.jpg b/image/checkup_media/2197/IMG_2197_20251223_060623_312.jpg new file mode 100644 index 0000000..6960c10 Binary files /dev/null and b/image/checkup_media/2197/IMG_2197_20251223_060623_312.jpg differ diff --git a/image/checkup_media/2197/IMG_2197_20251223_060625_544.jpg b/image/checkup_media/2197/IMG_2197_20251223_060625_544.jpg new file mode 100644 index 0000000..cb5dabc Binary files /dev/null and b/image/checkup_media/2197/IMG_2197_20251223_060625_544.jpg differ diff --git a/src/main/java/BsK/client/network/ConnectionManager.java b/src/main/java/BsK/client/network/ConnectionManager.java new file mode 100644 index 0000000..b440d21 --- /dev/null +++ b/src/main/java/BsK/client/network/ConnectionManager.java @@ -0,0 +1,462 @@ +package BsK.client.network; + +import BsK.client.network.handler.ClientHandler; +import BsK.common.exception.NetworkException; +import BsK.common.packet.Packet; +import BsK.common.packet.PacketSerializer; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.timeout.IdleStateHandler; +import lombok.extern.slf4j.Slf4j; + +import javax.swing.SwingUtilities; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Manages the WebSocket connection lifecycle with automatic reconnection support. + * This singleton handles connection, disconnection, and reconnection with exponential backoff. + */ +@Slf4j +public class ConnectionManager { + + private static final ConnectionManager INSTANCE = new ConnectionManager(); + + public static ConnectionManager getInstance() { + return INSTANCE; + } + + /** + * Connection states + */ + public enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + RECONNECTING + } + + /** + * Listener interface for connection state changes + */ + public interface ConnectionStateListener { + void onStateChanged(ConnectionState newState, String message); + void onReconnectCountdown(int secondsRemaining, int attemptNumber); + } + + // Connection configuration + private String serverAddress = "localhost"; + private int serverPort = 1999; + private URI serverUri; + + // Connection state + private final AtomicReference state = new AtomicReference<>(ConnectionState.DISCONNECTED); + private volatile Channel channel; + private EventLoopGroup eventLoopGroup; + + // Reconnection settings + private static final int INITIAL_RETRY_DELAY_SECONDS = 1; + private static final int MAX_RETRY_DELAY_SECONDS = 30; + private static final double BACKOFF_MULTIPLIER = 2.0; + + // Ping/Pong timeout settings - MORE TOLERANT (1 minute total) + // Send ping every 15 seconds, allow 4 missed pongs = 60 seconds tolerance + private static final int WRITER_IDLE_SECONDS = 15; // Send ping every 15 seconds when idle + private static final int READER_IDLE_SECONDS = 90; // Fallback: no data for 90 seconds = disconnect + + private final AtomicInteger currentRetryDelay = new AtomicInteger(INITIAL_RETRY_DELAY_SECONDS); + private final AtomicInteger reconnectAttempt = new AtomicInteger(0); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "ConnectionManager-Scheduler"); + t.setDaemon(true); + return t; + }); + private ScheduledFuture reconnectFuture; + private ScheduledFuture countdownFuture; + + // Listeners + private final List listeners = new CopyOnWriteArrayList<>(); + + // Track if handshake is complete + private volatile boolean handshakeComplete = false; + + private ConnectionManager() { + // Private constructor for singleton + } + + /** + * Initialize connection parameters from config + */ + public void initialize(Properties config) { + this.serverAddress = config.getProperty("server.address", "localhost"); + String portStr = config.getProperty("server.port", "1999"); + this.serverPort = Integer.parseInt(portStr); + + try { + this.serverUri = new URI("ws://" + serverAddress + ":" + serverPort + "/"); + log.info("ConnectionManager initialized with server: {}:{}", serverAddress, serverPort); + } catch (URISyntaxException e) { + log.error("Invalid server URI", e); + throw new RuntimeException("Invalid server URI configuration", e); + } + } + + /** + * Add a connection state listener + */ + public void addListener(ConnectionStateListener listener) { + listeners.add(listener); + // Notify immediately of current state + listener.onStateChanged(state.get(), getStateMessage(state.get())); + } + + /** + * Remove a connection state listener + */ + public void removeListener(ConnectionStateListener listener) { + listeners.remove(listener); + } + + /** + * Start the connection process + */ + public void startConnection() { + if (state.get() == ConnectionState.CONNECTING || state.get() == ConnectionState.CONNECTED) { + log.info("Connection already in progress or established"); + return; + } + + setState(ConnectionState.CONNECTING, "Đang kết nối đến máy chủ..."); + connectAsync(); + } + + /** + * Manual retry triggered by user + */ + public void retryNow() { + log.info("Manual retry requested"); + + // Cancel any pending automatic retry + cancelPendingReconnect(); + + // Reset retry delay for manual retry + currentRetryDelay.set(INITIAL_RETRY_DELAY_SECONDS); + + // Start connection + setState(ConnectionState.RECONNECTING, "Đang thử kết nối lại..."); + connectAsync(); + } + + /** + * Called when connection is established and handshake is complete + */ + public void onConnected() { + log.info("Connection established and handshake complete"); + handshakeComplete = true; + reconnectAttempt.set(0); + currentRetryDelay.set(INITIAL_RETRY_DELAY_SECONDS); + setState(ConnectionState.CONNECTED, "Đã kết nối"); + } + + /** + * Called when connection is lost + */ + public void onDisconnected(String reason) { + log.warn("Connection lost: {}", reason); + handshakeComplete = false; + channel = null; + + // Don't reconnect if we're already disconnected (intentional disconnect) + if (state.get() == ConnectionState.DISCONNECTED) { + return; + } + + // Start reconnection process + scheduleReconnect(); + } + + /** + * Get current connection state + */ + public ConnectionState getState() { + return state.get(); + } + + /** + * Check if connected and ready to send packets + */ + public boolean isConnected() { + return state.get() == ConnectionState.CONNECTED && + channel != null && + channel.isActive() && + handshakeComplete; + } + + /** + * Get the current channel for sending packets + */ + public Channel getChannel() { + return channel; + } + + /** + * Send a packet, throwing NetworkException if not connected + */ + public void sendPacket(Packet packet) throws NetworkException { + if (!isConnected()) { + throw new NetworkException("Không có kết nối đến máy chủ. Vui lòng đợi kết nối được khôi phục."); + } + + try { + String text = PacketSerializer.GSON.toJson(packet); + channel.writeAndFlush(new TextWebSocketFrame(text)); + } catch (Exception e) { + log.error("Failed to send packet", e); + throw new NetworkException("Lỗi gửi dữ liệu: " + e.getMessage()); + } + } + + /** + * Get the current server address + */ + public String getServerAddress() { + return serverAddress; + } + + /** + * Get the current server port + */ + public int getServerPort() { + return serverPort; + } + + // ==================== Private Methods ==================== + + private void connectAsync() { + new Thread(() -> { + try { + doConnect(); + } catch (Exception e) { + log.error("Connection attempt failed", e); + scheduleReconnect(); + } + }, "ConnectionManager-Connect").start(); + } + + private void doConnect() throws InterruptedException { + log.info("Attempting to connect to {}:{}", serverAddress, serverPort); + + // Cleanup previous connection if any + if (eventLoopGroup != null && !eventLoopGroup.isShutdown()) { + eventLoopGroup.shutdownGracefully().sync(); + } + + eventLoopGroup = new NioEventLoopGroup(); + + try { + Bootstrap bootstrap = new Bootstrap() + .group(eventLoopGroup) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline() + .addLast(new HttpClientCodec()) + .addLast(new HttpObjectAggregator(50 * 1024 * 1024)) + .addLast(new WebSocketClientProtocolHandler( + serverUri, + WebSocketVersion.V13, + null, + true, + new DefaultHttpHeaders(), + 50 * 1024 * 1024)) + // MORE TOLERANT TIMEOUT SETTINGS: + // Reader timeout: 90 seconds - fallback disconnect if no data at all + // Writer timeout: 15 seconds - send ping every 15 seconds when idle + // Combined with MAX_MISSED_PONGS=4, this gives ~60 seconds tolerance + .addLast(new IdleStateHandler(READER_IDLE_SECONDS, WRITER_IDLE_SECONDS, 0)) + .addLast("ws", ClientHandler.INSTANCE); + } + }); + + ChannelFuture connectFuture = bootstrap.connect(serverAddress, serverPort); + + // Add listener for connection result + connectFuture.addListener(future -> { + if (future.isSuccess()) { + log.info("TCP connection established, waiting for WebSocket handshake..."); + channel = connectFuture.channel(); + + // Add close listener + channel.closeFuture().addListener(closeFuture -> { + log.info("Channel closed"); + if (state.get() != ConnectionState.DISCONNECTED) { + onDisconnected("Kết nối bị đóng"); + } + }); + } else { + log.error("Connection failed: {}", future.cause().getMessage()); + scheduleReconnect(); + } + }); + + } catch (Exception e) { + log.error("Error during connection setup", e); + scheduleReconnect(); + } + } + + private void scheduleReconnect() { + int attempt = reconnectAttempt.incrementAndGet(); + int delay = currentRetryDelay.get(); + + log.info("Scheduling reconnect attempt {} in {} seconds", attempt, delay); + setState(ConnectionState.RECONNECTING, + String.format("Đang chờ kết nối lại (lần thử %d)...", attempt)); + + // Start countdown notifications + startCountdown(delay, attempt); + + // Schedule the actual reconnect + reconnectFuture = scheduler.schedule(() -> { + cancelCountdown(); + setState(ConnectionState.RECONNECTING, "Đang kết nối lại..."); + connectAsync(); + }, delay, TimeUnit.SECONDS); + + // Calculate next delay with exponential backoff + int nextDelay = (int) Math.min(delay * BACKOFF_MULTIPLIER, MAX_RETRY_DELAY_SECONDS); + currentRetryDelay.set(nextDelay); + } + + private void startCountdown(int seconds, int attemptNumber) { + final AtomicInteger remaining = new AtomicInteger(seconds); + + countdownFuture = scheduler.scheduleAtFixedRate(() -> { + int r = remaining.getAndDecrement(); + if (r > 0) { + notifyCountdown(r, attemptNumber); + } + }, 0, 1, TimeUnit.SECONDS); + } + + private void cancelCountdown() { + if (countdownFuture != null && !countdownFuture.isDone()) { + countdownFuture.cancel(false); + } + } + + private void cancelPendingReconnect() { + cancelCountdown(); + if (reconnectFuture != null && !reconnectFuture.isDone()) { + reconnectFuture.cancel(false); + } + } + + private void setState(ConnectionState newState, String message) { + ConnectionState oldState = state.getAndSet(newState); + if (oldState != newState) { + log.info("Connection state changed: {} -> {} ({})", oldState, newState, message); + notifyStateChange(newState, message); + } + } + + private void notifyStateChange(ConnectionState newState, String message) { + SwingUtilities.invokeLater(() -> { + for (ConnectionStateListener listener : listeners) { + try { + listener.onStateChanged(newState, message); + } catch (Exception e) { + log.error("Error notifying listener", e); + } + } + }); + } + + private void notifyCountdown(int secondsRemaining, int attemptNumber) { + SwingUtilities.invokeLater(() -> { + for (ConnectionStateListener listener : listeners) { + try { + listener.onReconnectCountdown(secondsRemaining, attemptNumber); + } catch (Exception e) { + log.error("Error notifying countdown", e); + } + } + }); + } + + private String getStateMessage(ConnectionState state) { + return switch (state) { + case DISCONNECTED -> "Chưa kết nối"; + case CONNECTING -> "Đang kết nối..."; + case CONNECTED -> "Đã kết nối"; + case RECONNECTING -> "Đang kết nối lại..."; + }; + } + + /** + * Shutdown the connection manager (for application exit) + * Sends a proper WebSocket close frame so the server can clean up the session + */ + public void shutdown() { + log.info("Shutting down ConnectionManager"); + state.set(ConnectionState.DISCONNECTED); + cancelPendingReconnect(); + + if (channel != null && channel.isActive()) { + try { + // Send WebSocket close frame for proper server-side cleanup + log.info("Sending WebSocket close frame to server..."); + channel.writeAndFlush(new io.netty.handler.codec.http.websocketx.CloseWebSocketFrame()) + .sync(); // Wait for close frame to be sent + + // Give server a moment to process the close + Thread.sleep(100); + + // Now close the channel + channel.close().sync(); + log.info("Channel closed cleanly"); + } catch (InterruptedException e) { + log.warn("Interrupted while closing channel", e); + Thread.currentThread().interrupt(); + // Force close if interrupted + channel.close(); + } catch (Exception e) { + log.warn("Error during graceful close, forcing close", e); + channel.close(); + } + } + + if (eventLoopGroup != null && !eventLoopGroup.isShutdown()) { + try { + eventLoopGroup.shutdownGracefully().sync(); + } catch (InterruptedException e) { + log.warn("Interrupted while shutting down event loop", e); + Thread.currentThread().interrupt(); + } + } + + scheduler.shutdown(); + } +} diff --git a/src/main/java/BsK/client/ui/component/common/AddDialog/AddDialog.java b/src/main/java/BsK/client/ui/component/common/AddDialog/AddDialog.java index 0329192..125f679 100644 --- a/src/main/java/BsK/client/ui/component/common/AddDialog/AddDialog.java +++ b/src/main/java/BsK/client/ui/component/common/AddDialog/AddDialog.java @@ -1075,7 +1075,7 @@ private void setupDebounceSearch() { try { Date parsedDate; - if (patientDob.matches("\\d+")) { // Check if the string is a timestamp + if (patientDob.matches("-?\\d+")) { // Check if the string is a timestamp long timestamp = Long.parseLong(patientDob); // Convert the string to a long parsedDate = new Date(timestamp); // Convert the timestamp to a Date } else { diff --git a/src/main/java/BsK/client/ui/component/common/ConnectionStatusOverlay.java b/src/main/java/BsK/client/ui/component/common/ConnectionStatusOverlay.java new file mode 100644 index 0000000..35b7ad9 --- /dev/null +++ b/src/main/java/BsK/client/ui/component/common/ConnectionStatusOverlay.java @@ -0,0 +1,289 @@ +package BsK.client.ui.component.common; + +import BsK.client.network.ConnectionManager; +import BsK.client.network.ConnectionManager.ConnectionState; +import BsK.client.network.ConnectionManager.ConnectionStateListener; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Arc2D; + +/** + * Glass pane overlay that blocks UI interaction when connection is lost. + * Shows connection status, retry countdown, and manual retry button. + */ +public class ConnectionStatusOverlay extends JPanel implements ConnectionStateListener { + + private static final Color OVERLAY_COLOR = new Color(0, 0, 0, 180); + private static final Color CARD_BACKGROUND = new Color(45, 45, 48); + private static final Color TEXT_COLOR = new Color(230, 230, 230); + private static final Color ACCENT_COLOR = new Color(0, 122, 204); + private static final Color BUTTON_HOVER_COLOR = new Color(0, 150, 230); + + private final JLabel statusLabel; + private final JLabel messageLabel; + private final JLabel countdownLabel; + private final JButton retryButton; + private final SpinnerPanel spinnerPanel; + + private volatile boolean showSpinner = true; + private volatile int countdownSeconds = 0; + private volatile int attemptNumber = 0; + + public ConnectionStatusOverlay() { + setOpaque(false); + setLayout(new GridBagLayout()); + + // Block all mouse events on the overlay + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + e.consume(); + } + @Override + public void mousePressed(MouseEvent e) { + e.consume(); + } + }); + addMouseMotionListener(new MouseAdapter() {}); + + // Create center card panel + JPanel cardPanel = createCardPanel(); + add(cardPanel); + + // Initialize components + spinnerPanel = new SpinnerPanel(); + statusLabel = createLabel("Đang kết nối...", 18, Font.BOLD); + messageLabel = createLabel("Vui lòng đợi trong giây lát", 14, Font.PLAIN); + countdownLabel = createLabel("", 12, Font.PLAIN); + countdownLabel.setForeground(new Color(180, 180, 180)); + + retryButton = createRetryButton(); + + // Layout components in card + cardPanel.setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = 0; + gbc.insets = new Insets(10, 20, 10, 20); + + cardPanel.add(spinnerPanel, gbc); + + gbc.gridy = 1; + gbc.insets = new Insets(15, 20, 5, 20); + cardPanel.add(statusLabel, gbc); + + gbc.gridy = 2; + gbc.insets = new Insets(5, 20, 5, 20); + cardPanel.add(messageLabel, gbc); + + gbc.gridy = 3; + gbc.insets = new Insets(5, 20, 10, 20); + cardPanel.add(countdownLabel, gbc); + + gbc.gridy = 4; + gbc.insets = new Insets(10, 20, 20, 20); + cardPanel.add(retryButton, gbc); + + // Initially hidden until state is set + retryButton.setVisible(false); + countdownLabel.setVisible(false); + + // Register as listener + ConnectionManager.getInstance().addListener(this); + } + + private JPanel createCardPanel() { + JPanel card = new JPanel() { + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(CARD_BACKGROUND); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 20, 20); + g2.dispose(); + } + }; + card.setOpaque(false); + card.setPreferredSize(new Dimension(400, 280)); + return card; + } + + private JLabel createLabel(String text, int size, int style) { + JLabel label = new JLabel(text); + label.setFont(new Font("Segoe UI", style, size)); + label.setForeground(TEXT_COLOR); + label.setHorizontalAlignment(SwingConstants.CENTER); + return label; + } + + private JButton createRetryButton() { + JButton button = new JButton("Thử lại ngay") { + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + if (getModel().isPressed()) { + g2.setColor(ACCENT_COLOR.darker()); + } else if (getModel().isRollover()) { + g2.setColor(BUTTON_HOVER_COLOR); + } else { + g2.setColor(ACCENT_COLOR); + } + + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 10, 10); + + g2.setColor(Color.WHITE); + g2.setFont(getFont()); + FontMetrics fm = g2.getFontMetrics(); + int x = (getWidth() - fm.stringWidth(getText())) / 2; + int y = (getHeight() + fm.getAscent() - fm.getDescent()) / 2; + g2.drawString(getText(), x, y); + + g2.dispose(); + } + }; + + button.setFont(new Font("Segoe UI", Font.BOLD, 14)); + button.setForeground(Color.WHITE); + button.setPreferredSize(new Dimension(150, 40)); + button.setBorderPainted(false); + button.setContentAreaFilled(false); + button.setFocusPainted(false); + button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + + button.addActionListener(e -> { + ConnectionManager.getInstance().retryNow(); + }); + + return button; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setColor(OVERLAY_COLOR); + g2.fillRect(0, 0, getWidth(), getHeight()); + g2.dispose(); + } + + @Override + public void onStateChanged(ConnectionState newState, String message) { + SwingUtilities.invokeLater(() -> { + switch (newState) { + case DISCONNECTED -> { + setVisible(true); + statusLabel.setText("Chưa kết nối"); + messageLabel.setText("Ứng dụng đang chờ kết nối đến máy chủ"); + showSpinner = false; + retryButton.setVisible(true); + countdownLabel.setVisible(false); + } + case CONNECTING -> { + setVisible(true); + statusLabel.setText("Đang kết nối..."); + messageLabel.setText(message); + showSpinner = true; + retryButton.setVisible(false); + countdownLabel.setVisible(false); + } + case CONNECTED -> { + setVisible(false); + showSpinner = false; + } + case RECONNECTING -> { + setVisible(true); + statusLabel.setText("Đang kết nối lại..."); + messageLabel.setText(message); + showSpinner = true; + retryButton.setVisible(true); + countdownLabel.setVisible(true); + } + } + spinnerPanel.setSpinning(showSpinner); + repaint(); + }); + } + + @Override + public void onReconnectCountdown(int secondsRemaining, int attemptNumber) { + SwingUtilities.invokeLater(() -> { + this.countdownSeconds = secondsRemaining; + this.attemptNumber = attemptNumber; + countdownLabel.setText(String.format("Thử lại sau %d giây (lần thử %d)", + secondsRemaining, attemptNumber)); + countdownLabel.setVisible(true); + }); + } + + /** + * Inner panel that shows a spinning animation + */ + private class SpinnerPanel extends JPanel { + private volatile boolean spinning = true; + private int angle = 0; + private Timer timer; + + public SpinnerPanel() { + setOpaque(false); + setPreferredSize(new Dimension(60, 60)); + + timer = new Timer(30, e -> { + if (spinning) { + angle = (angle + 10) % 360; + repaint(); + } + }); + timer.start(); + } + + public void setSpinning(boolean spinning) { + this.spinning = spinning; + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int size = 50; + int x = (getWidth() - size) / 2; + int y = (getHeight() - size) / 2; + + if (spinning) { + // Draw spinning arc + g2.setStroke(new BasicStroke(4, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g2.setColor(new Color(80, 80, 85)); + g2.drawOval(x, y, size, size); + + g2.setColor(ACCENT_COLOR); + g2.draw(new Arc2D.Double(x, y, size, size, angle, 90, Arc2D.OPEN)); + } else { + // Draw static circle with X + g2.setStroke(new BasicStroke(3)); + g2.setColor(new Color(200, 80, 80)); + g2.drawOval(x, y, size, size); + + int padding = 15; + g2.drawLine(x + padding, y + padding, x + size - padding, y + size - padding); + g2.drawLine(x + size - padding, y + padding, x + padding, y + size - padding); + } + + g2.dispose(); + } + } + + /** + * Cleanup when overlay is no longer needed + */ + public void cleanup() { + ConnectionManager.getInstance().removeListener(this); + } +} + diff --git a/src/main/java/BsK/client/ui/component/common/HistoryViewDialog/HistoryViewDialog.java b/src/main/java/BsK/client/ui/component/common/HistoryViewDialog/HistoryViewDialog.java index 053b2b4..07912a5 100644 --- a/src/main/java/BsK/client/ui/component/common/HistoryViewDialog/HistoryViewDialog.java +++ b/src/main/java/BsK/client/ui/component/common/HistoryViewDialog/HistoryViewDialog.java @@ -773,7 +773,7 @@ public class HistoryViewDialog extends JDialog { private String formatDate(String dateStr) { try { - if (dateStr != null && dateStr.matches("\\d+")) { + if (dateStr != null && dateStr.matches("-?\\d+")) { // Timestamp - Use Vietnam timezone (UTC+7) for consistent display Date date = new Date(Long.parseLong(dateStr)); return DateUtils.createVietnamDateFormat("dd/MM/yyyy HH:mm").format(date); diff --git a/src/main/java/BsK/common/exception/NetworkException.java b/src/main/java/BsK/common/exception/NetworkException.java new file mode 100644 index 0000000..078919f --- /dev/null +++ b/src/main/java/BsK/common/exception/NetworkException.java @@ -0,0 +1,24 @@ +package BsK.common.exception; + +/** + * Unchecked exception thrown when a network operation fails. + * Contains a user-friendly message suitable for display in dialogs or toasts. + */ +public class NetworkException extends RuntimeException { + + public NetworkException(String message) { + super(message); + } + + public NetworkException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Get a user-friendly message suitable for display + */ + public String getUserMessage() { + return getMessage(); + } +} + diff --git a/src/main/java/BsK/common/util/date/DateUtils.java b/src/main/java/BsK/common/util/date/DateUtils.java index 583d934..2317eee 100644 --- a/src/main/java/BsK/common/util/date/DateUtils.java +++ b/src/main/java/BsK/common/util/date/DateUtils.java @@ -55,7 +55,7 @@ public class DateUtils { try { // Check if it's a timestamp (all digits) - if (dateInput.matches("\\d+")) { + if (dateInput.matches("-?\\d+")) { Date date = new Date(Long.parseLong(dateInput)); synchronized (DISPLAY_FORMAT) { return DISPLAY_FORMAT.format(date); @@ -90,7 +90,7 @@ public class DateUtils { try { // Check if it's a timestamp (all digits) - if (dateInput.matches("\\d+")) { + if (dateInput.matches("-?\\d+")) { return new Date(Long.parseLong(dateInput)); } else { // Try to parse as dd/MM/yyyy format using Vietnam timezone