From 0c3b1bb22fca49e1fbc8cf790f038aebe949716d Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 8 May 2026 01:20:05 +0300 Subject: [PATCH 1/2] feat: implement LX design system across all 7 screens + backend metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter — new LX design system (deep space theme, glassmorphism, electric-cyan accent): - Add lx_theme.dart with full design token set (colors, radii, spacing) - Add lx_glass.dart — glassmorphism surface widget (40px blur, hairline border, accent variant) - Add lx_sparkline.dart, lx_ring.dart — custom painter primitives for Monitor/Dashboard - Add lx_background.dart — radial gradient + dot-grid page background replacing AnimatedAuroraBackground - Add lx_tab_bar.dart — floating glass bottom nav bar with 5 tabs and cyan active indicator - Add lx_header.dart — standard screen header with back chevron, eyebrow, and action slot - Rewrite dashboard_screen.dart — hub with connection hero card, LxRing, sparkline, 6-module grid; tab-bar navigation; media/touchpad tabs hidden from bar - Add lx_tab_bar.dart — floating glass bottom nav bar with 5 tabs and cyan active indicator - Add lx_header.dart — standard screen header with back chevron, eyebrow, and action slot - Rewrite dashboard_screen.dart — hub with connection hero card, LxRing, sparkline, 6-module grid; tab-bar navigation; media/touchpad tabs hidden from bar --- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 5 +- LinqoraHost/go.mod | 2 +- LinqoraHost/internal/collectors/metrics.go | 22 +- LinqoraHost/internal/metrics/gpu.go | 21 + LinqoraHost/internal/power/power.go | 1 + LinqoraHost/internal/power/power_darwin.go | 2 + LinqoraHost/internal/power/power_linux.go | 2 + LinqoraHost/internal/power/power_windows.go | 4 + LinqoraHost/internal/ws/server.go | 31 +- linqoraremote/lib/core/themes/lx_theme.dart | 36 + linqoraremote/lib/data/models/host_info.dart | 38 + .../presentation/pages/device_home_page.dart | 212 ++--- .../widgets/dashboard_screen.dart | 515 +++++++++--- .../widgets/filebrowser_view.dart | 738 +++++++++++----- .../presentation/widgets/host_info_card.dart | 46 + .../presentation/widgets/lx_background.dart | 94 +++ .../lib/presentation/widgets/lx_glass.dart | 65 ++ .../lib/presentation/widgets/lx_header.dart | 92 ++ .../lib/presentation/widgets/lx_ring.dart | 125 +++ .../presentation/widgets/lx_sparkline.dart | 113 +++ .../lib/presentation/widgets/lx_tab_bar.dart | 110 +++ .../lib/presentation/widgets/media_view.dart | 772 +++++++++-------- .../presentation/widgets/monitoring_view.dart | 551 ++++++++++-- .../widgets/powermanagement_view.dart | 389 +++++++-- .../presentation/widgets/scripts_view.dart | 787 ++++++++++-------- .../presentation/widgets/touchpad_view.dart | 509 +++++++---- 27 files changed, 3829 insertions(+), 1455 deletions(-) create mode 100644 linqoraremote/lib/core/themes/lx_theme.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_background.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_glass.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_header.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_ring.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_sparkline.dart create mode 100644 linqoraremote/lib/presentation/widgets/lx_tab_bar.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b65c494..4bf150a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: sudo apt-get update -q sudo apt-get install -y gcc libgl1-mesa-dev libx11-dev \ libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev \ - gcc-mingw-w64-x86-64 + libxxf86vm-dev gcc-mingw-w64-x86-64 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bd9e85f..5027547 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,11 +20,10 @@ builds: goarch: - amd64 archives: - - format: tar.gz - # use zip for windows archives + - formats: [tar.gz] format_overrides: - goos: windows - format: zip + formats: [zip] checksum: name_template: 'checksums.txt' snapshot: diff --git a/LinqoraHost/go.mod b/LinqoraHost/go.mod index da471f2..1eb199d 100644 --- a/LinqoraHost/go.mod +++ b/LinqoraHost/go.mod @@ -1,6 +1,6 @@ module LinqoraHost -go 1.25.0 +go 1.24 require ( fyne.io/fyne/v2 v2.7.3 diff --git a/LinqoraHost/internal/collectors/metrics.go b/LinqoraHost/internal/collectors/metrics.go index 3492117..dd65dba 100644 --- a/LinqoraHost/internal/collectors/metrics.go +++ b/LinqoraHost/internal/collectors/metrics.go @@ -11,9 +11,10 @@ import ( // SystemMetrics represents a snapshot of the system's performance metrics. type SystemMetrics struct { - CPUUMetrics metrics.CPUMetrics `json:"cpuMetrics"` - RamMetrics metrics.RamMetrics `json:"ramMetrics"` - Timestamp int64 `json:"timestamp"` + CPUUMetrics metrics.CPUMetrics `json:"cpuMetrics"` + RamMetrics metrics.RamMetrics `json:"ramMetrics"` + GpuLoadPercent int `json:"gpuLoadPercent"` + Timestamp int64 `json:"timestamp"` } // MetricsCollector periodically gathers and broadcasts system performance data. @@ -113,7 +114,7 @@ func (mc *MetricsCollector) collectAndSend() { mc.broadcaster(metricsJSON) } -// collectMetrics retrieves CPU and RAM performance data. +// collectMetrics retrieves CPU, RAM, and GPU performance data. func (mc *MetricsCollector) collectMetrics() (*SystemMetrics, error) { cpuMetrics, err := metrics.GetCPUMetrics() if err != nil { @@ -125,11 +126,14 @@ func (mc *MetricsCollector) collectMetrics() (*SystemMetrics, error) { return nil, err } - metrics := &SystemMetrics{ - CPUUMetrics: cpuMetrics, - RamMetrics: ramMetrics, - Timestamp: time.Now().Unix(), + gpuLoad := metrics.GetGPULoadPercent() + + result := &SystemMetrics{ + CPUUMetrics: cpuMetrics, + RamMetrics: ramMetrics, + GpuLoadPercent: gpuLoad, + Timestamp: time.Now().Unix(), } - return metrics, nil + return result, nil } diff --git a/LinqoraHost/internal/metrics/gpu.go b/LinqoraHost/internal/metrics/gpu.go index 0061a2e..e13d911 100644 --- a/LinqoraHost/internal/metrics/gpu.go +++ b/LinqoraHost/internal/metrics/gpu.go @@ -165,6 +165,27 @@ func getMacOSGPUInfo() (GPUInfo, error) { return info, nil } +// ── GPU load ───────────────────────────────────────────────────────────────── + +// GetGPULoadPercent returns the current GPU utilisation as an integer percentage +// (0-100). It tries nvidia-smi first; if that is unavailable or fails it +// returns 0 without an error so callers can treat missing data gracefully. +func GetGPULoadPercent() int { + out, err := exec.Command( + "nvidia-smi", + "--query-gpu=utilization.gpu", + "--format=csv,noheader,nounits", + ).Output() + if err != nil || len(out) == 0 { + return 0 + } + val, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0 + } + return val +} + // ── helpers ────────────────────────────────────────────────────────────────── func extractGPUModel(line string) string { diff --git a/LinqoraHost/internal/power/power.go b/LinqoraHost/internal/power/power.go index 6b53b96..212fad9 100644 --- a/LinqoraHost/internal/power/power.go +++ b/LinqoraHost/internal/power/power.go @@ -16,6 +16,7 @@ const ( Shutdown Action = iota Restart Lock + Sleep ) // ExecutePowerAction performs a power management action. diff --git a/LinqoraHost/internal/power/power_darwin.go b/LinqoraHost/internal/power/power_darwin.go index cf16858..20f251e 100644 --- a/LinqoraHost/internal/power/power_darwin.go +++ b/LinqoraHost/internal/power/power_darwin.go @@ -17,6 +17,8 @@ func executePlatformAction(action Action) error { case Lock: cmd = exec.Command("osascript", "-e", "tell application \"System Events\" to keystroke \"q\" using {command down, control down}") + case Sleep: + cmd = exec.Command("pmset", "sleepnow") default: return fmt.Errorf("unknown power action: %d", action) } diff --git a/LinqoraHost/internal/power/power_linux.go b/LinqoraHost/internal/power/power_linux.go index 9e09aba..2692379 100644 --- a/LinqoraHost/internal/power/power_linux.go +++ b/LinqoraHost/internal/power/power_linux.go @@ -24,6 +24,8 @@ func executePlatformAction(action Action) error { } else { return fmt.Errorf("no suitable screen lock command found") } + case Sleep: + cmd = exec.Command("systemctl", "suspend") default: return fmt.Errorf("unknown power action: %d", action) } diff --git a/LinqoraHost/internal/power/power_windows.go b/LinqoraHost/internal/power/power_windows.go index 71c6129..51038af 100644 --- a/LinqoraHost/internal/power/power_windows.go +++ b/LinqoraHost/internal/power/power_windows.go @@ -25,6 +25,10 @@ func executePlatformAction(action Action) error { return cmd.Run() case Lock: return lockWorkStationAPI() + case Sleep: + cmd := exec.Command("rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0") + log.Printf("Executing Windows sleep: %v", cmd.Args) + return cmd.Run() default: return fmt.Errorf("unknown power action: %d", action) } diff --git a/LinqoraHost/internal/ws/server.go b/LinqoraHost/internal/ws/server.go index 5faf868..5c8c3ac 100644 --- a/LinqoraHost/internal/ws/server.go +++ b/LinqoraHost/internal/ws/server.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "runtime" "sync" "time" @@ -24,6 +25,7 @@ import ( "LinqoraHost/internal/interfaces" "github.com/gorilla/websocket" + gopshost "github.com/shirou/gopsutil/v4/host" ) // WSServer represents the primary WebSocket server coordinating communication @@ -364,16 +366,29 @@ func (s *WSServer) handleHostInfoMessage(client *Client) { gpuInfo, _ := metrics.GetGPUInfo() diskInfo, _ := metrics.GetDiskInfo() batteryInfo, _ := metrics.GetBatteryInfo() + hostStat, _ := gopshost.Info() + uptime, _ := gopshost.Uptime() + + kernelVersion := "" + platformVersion := "" + if hostStat != nil { + kernelVersion = hostStat.KernelVersion + platformVersion = hostStat.PlatformVersion + } hostInfo := map[string]interface{}{ - "os": deviceInfo.OS, - "hostname": deviceInfo.Hostname, - "su": privileges.CheckAdminPrivileges(), - "cpu": cpuInfo, - "ram": ramInfo, - "gpu": gpuInfo, - "disks": diskInfo, - "battery": batteryInfo, + "os": deviceInfo.OS, + "hostname": deviceInfo.Hostname, + "su": privileges.CheckAdminPrivileges(), + "cpu": cpuInfo, + "ram": ramInfo, + "gpu": gpuInfo, + "disks": diskInfo, + "battery": batteryInfo, + "uptime": uptime, + "architecture": runtime.GOARCH, + "kernelVersion": kernelVersion, + "platformVersion": platformVersion, } client.SendSuccess("host_info", hostInfo) diff --git a/linqoraremote/lib/core/themes/lx_theme.dart b/linqoraremote/lib/core/themes/lx_theme.dart new file mode 100644 index 0000000..50ab150 --- /dev/null +++ b/linqoraremote/lib/core/themes/lx_theme.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +// Design tokens — Linqora Stylish Minimalism palette +const Color lxBg = Color(0xFF0A0C10); +const Color lxBgGrad = Color(0xFF1A1F2B); +const Color lxSurface = Color(0xFF0F172A); +const Color lxAccent = Color(0xFF00E5FF); +const Color lxText = Color(0xFFFFFFFF); +const Color lxTextDim = Color(0x99FFFFFF); // 0.6 +const Color lxTextFaint = Color(0x59FFFFFF); // 0.35 +const Color lxTextGhost = Color(0x2EFFFFFF); // 0.18 +const Color lxHairline = Color(0x14FFFFFF); // 0.08 +const Color lxHairlineHi= Color(0x24FFFFFF); // 0.14 +const Color lxGlass = Color(0x0AFFFFFF); // 0.04 +const Color lxGlass2 = Color(0x0FFFFFFF); // 0.06 +const Color lxRed = Color(0xFFFF4D5E); +const Color lxAmber = Color(0xFFFFB547); +const Color lxGreen = Color(0xFF3BD2A2); + +// Radius tokens +const double lxRadiusCard = 18; +const double lxRadiusTile = 12; +const double lxRadiusInner = 10; +const double lxRadiusModal = 22; +const double lxRadiusTabBar = 22; + +// Spacing scale (px) +const double sp4 = 4; +const double sp6 = 6; +const double sp8 = 8; +const double sp10 = 10; +const double sp12 = 12; +const double sp14 = 14; +const double sp18 = 18; +const double sp20 = 20; +const double sp22 = 22; diff --git a/linqoraremote/lib/data/models/host_info.dart b/linqoraremote/lib/data/models/host_info.dart index 65247b7..038536a 100644 --- a/linqoraremote/lib/data/models/host_info.dart +++ b/linqoraremote/lib/data/models/host_info.dart @@ -121,12 +121,40 @@ class DiskInfo { } } +class BatteryInfo { + final bool isPresent; + final int level; + final bool isCharging; + final String status; + + const BatteryInfo({ + this.isPresent = false, + this.level = 0, + this.isCharging = false, + this.status = 'Unknown', + }); + + factory BatteryInfo.fromJson(Map json) { + return BatteryInfo( + isPresent: json['isPresent'] ?? false, + level: (json['level'] ?? 0).toInt(), + isCharging: json['isCharging'] ?? false, + status: json['status'] ?? 'Unknown', + ); + } +} + class HostSystemInfo { final BaseSystemInfo baseInfo; final CPUInfo cpu; final RAMInfo ram; final GPUInfo gpu; final List disks; + final BatteryInfo battery; + final int uptime; + final String architecture; + final String kernelVersion; + final String platformVersion; HostSystemInfo({ required this.baseInfo, @@ -134,6 +162,11 @@ class HostSystemInfo { required this.ram, required this.gpu, this.disks = const [], + this.battery = const BatteryInfo(), + this.uptime = 0, + this.architecture = '', + this.kernelVersion = '', + this.platformVersion = '', }); factory HostSystemInfo.fromJson(Map json) { @@ -150,6 +183,11 @@ class HostSystemInfo { ram: RAMInfo.fromJson(json['ram'] ?? {}), gpu: GPUInfo.fromJson(json['gpu'] ?? {}), disks: disks, + uptime: (json['uptime'] ?? 0).toInt(), + architecture: json['architecture'] ?? '', + kernelVersion: json['kernelVersion'] ?? '', + platformVersion: json['platformVersion'] ?? '', + battery: json['battery'] != null ? BatteryInfo.fromJson(json['battery']) : const BatteryInfo(), ); } diff --git a/linqoraremote/lib/presentation/pages/device_home_page.dart b/linqoraremote/lib/presentation/pages/device_home_page.dart index 5019d63..964341e 100644 --- a/linqoraremote/lib/presentation/pages/device_home_page.dart +++ b/linqoraremote/lib/presentation/pages/device_home_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; -import 'package:linqoraremote/presentation/widgets/app_bar_home.dart'; +import 'package:linqoraremote/presentation/widgets/lx_background.dart'; import '../controllers/device_home_controller.dart'; import '../widgets/dashboard_screen.dart'; @@ -14,120 +14,120 @@ class DeviceHomePage extends GetView { Widget build(BuildContext context) { final webSocketProvider = Get.find(); - return WillPopScope( - onWillPop: () async { - if (controller.selectedMenuIndex.value != -1) { - controller.selectMenuItem(-1); - return false; - } - if (controller.webSocketProvider.isConnected && - controller.selectedMenuIndex.value == -1) { - await DisconnectConfirmationDialog.show( - onConfirm: () => {controller.disconnectFromDevice(isCleaned: true)}, - onCancel: () => Get.back(), - ); - } else { - controller.disconnectFromDevice(isCleaned: true); - return true; - } - return false; - }, - + return LxBackground( child: Scaffold( - appBar: AppBarHomePage(), - body: Column( - children: [ - Obx(() { - final state = webSocketProvider.reconnectState.value; - if (state == ReconnectState.reconnecting) { - return Container( - width: double.infinity, - color: Colors.orange.withOpacity(0.85), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Obx( - () => Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), + backgroundColor: Colors.transparent, + body: WillPopScope( + onWillPop: () async { + if (controller.selectedMenuIndex.value != -1) { + controller.selectMenuItem(-1); + return false; + } + if (controller.webSocketProvider.isConnected && + controller.selectedMenuIndex.value == -1) { + await DisconnectConfirmationDialog.show( + onConfirm: () => + {controller.disconnectFromDevice(isCleaned: true)}, + onCancel: () => Get.back(), + ); + } else { + controller.disconnectFromDevice(isCleaned: true); + return true; + } + return false; + }, + child: Column( + children: [ + Obx(() { + final state = webSocketProvider.reconnectState.value; + if (state == ReconnectState.reconnecting) { + return Container( + width: double.infinity, + color: Colors.orange.withOpacity(0.85), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Obx( + () => Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), ), - ), - const SizedBox(width: 10), - Text( - '${'reconnecting'.tr}... (${webSocketProvider.reconnectSecondsLeft.value}s)', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 13, + const SizedBox(width: 10), + Text( + '${'reconnecting'.tr}... (${webSocketProvider.reconnectSecondsLeft.value}s)', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), - ), - ], - ), - ), - ); - } else if (state == ReconnectState.failed) { - return Container( - width: double.infinity, - color: Colors.red.shade700.withOpacity(0.9), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - const Icon( - Icons.wifi_off_rounded, - color: Colors.white, - size: 18, + ], ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'connection_failed'.tr, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 13, - ), + ), + ); + } else if (state == ReconnectState.failed) { + return Container( + width: double.infinity, + color: Colors.red.shade700.withOpacity(0.9), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + const Icon( + Icons.wifi_off_rounded, + color: Colors.white, + size: 18, ), - ), - TextButton( - onPressed: () => webSocketProvider.retryReconnect(), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, + const SizedBox(width: 10), + Expanded( + child: Text( + 'connection_failed'.tr, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), - side: const BorderSide(color: Colors.white54), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: Text( - 'retry'.tr, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, + TextButton( + onPressed: () => webSocketProvider.retryReconnect(), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + side: const BorderSide(color: Colors.white54), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'retry'.tr, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), ), ), - ), - ], - ), - ); - } - return const SizedBox.shrink(); - }), - Expanded( - child: SizedBox(width: double.infinity, child: DashboardScreen()), - ), - ], + ], + ), + ); + } + return const SizedBox.shrink(); + }), + const Expanded(child: DashboardScreen()), + ], + ), ), ), ); diff --git a/linqoraremote/lib/presentation/widgets/dashboard_screen.dart b/linqoraremote/lib/presentation/widgets/dashboard_screen.dart index 2b53507..f196510 100644 --- a/linqoraremote/lib/presentation/widgets/dashboard_screen.dart +++ b/linqoraremote/lib/presentation/widgets/dashboard_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:linqoraremote/presentation/widgets/animated_aurora_background.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; +import 'package:linqoraremote/presentation/controllers/monitoring_controller.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_ring.dart'; +import 'package:linqoraremote/presentation/widgets/lx_sparkline.dart'; +import 'package:linqoraremote/presentation/widgets/lx_tab_bar.dart'; +import 'package:linqoraremote/presentation/widgets/dialogs/dialog_cancel_connect_device.dart'; import '../controllers/device_home_controller.dart'; import '../dashboard_items.dart'; -import 'host_info_card.dart'; -import 'menu_option_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -26,120 +29,424 @@ class _DashboardScreenState extends State { @override void dispose() { - if (_isControllerRegistered()) { + if (Get.isRegistered()) { Get.delete(); } super.dispose(); } - bool _isControllerRegistered() { - return Get.isRegistered(); + MonitoringController? get _monitoringController { + try { + return Get.find(); + } catch (_) { + return null; + } } - @override - Widget build(BuildContext context) { - return AnimatedAuroraBackground( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Obx(() { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: - Tween( - begin: const Offset(0.1, 0.0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutQuart, - ), - ), - child: child, + // Tab index <-> menu index mapping helpers + int _menuIndexForTab(int tab) { + switch (tab) { + case 1: + return 0; // Monitor + case 2: + return 5; // Files + case 3: + return 4; // Scripts/Console + case 4: + return 2; // Power + default: + return -1; // Hub + } + } + + int _activeTabIndex() { + final idx = _homeController.selectedMenuIndex.value; + switch (idx) { + case 0: + return 1; + case 5: + return 2; + case 4: + return 3; + case 2: + return 4; + default: + return 0; // Hub, Media, Touchpad all show Hub tab (or hide) + } + } + + bool _showTabBar() { + final idx = _homeController.selectedMenuIndex.value; + return idx != 1 && idx != 3; // hide for Media and Touchpad + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return 'Online'; + final d = seconds ~/ 86400; + final h = (seconds % 86400) ~/ 3600; + if (d > 0) return '${d}d ${h}h'; + return '${h}h'; + } + + Widget _statLabel(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10, + color: lxTextFaint, + letterSpacing: 0.8, + fontWeight: FontWeight.w500, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 13, + color: lxText, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ), + ], + ); + } + + Widget _moduleCard({ + required IconData icon, + required String label, + required String sub, + required int index, + bool accent = false, + }) { + return LxGlass( + accent: accent, + onTap: () => _homeController.selectMenuItem(index), + padding: const EdgeInsets.all(14), + child: SizedBox( + height: 96, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent + ? const Color(0x1A00E5FF) + : lxGlass2, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: accent + ? const Color(0x4D00E5FF) + : lxHairline, ), - ); - }, - child: _homeController.selectedMenuIndex.value >= 0 - ? Column( - key: const ValueKey('detail_view'), - children: [ - Expanded( - child: - menuOptions[_homeController.selectedMenuIndex.value] - .view, + ), + child: Icon( + icon, + size: 16, + color: accent ? lxAccent : lxText, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: lxText, + ), + ), + Text( + sub, + style: const TextStyle( + fontSize: 11, + color: lxTextDim, + height: 1.3, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildHub() { + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + // --- Connection header --- + const Text( + 'CONNECTED TO', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Obx(() => Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + _homeController.hostInfo.value?.hostname ?? 'LINQORA', + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + color: lxText, ), + ), + const SizedBox(width: 8), + Text( + '· ${_homeController.hostInfo.value?.kernelVersion ?? ''}', + style: const TextStyle( + fontSize: 12, + color: lxTextDim, + ), + ), + ], + )), + const SizedBox(height: 8), + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: lxGreen, + boxShadow: [ + BoxShadow(color: lxGreen, blurRadius: 6), ], - ) - : Column( - key: const ValueKey('grid_view'), - children: [ - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: Obx( - () => _homeController.hostInfo.value != null - ? Padding( - padding: const EdgeInsets.all(20), - child: HostInfoCard( - host: _homeController.hostInfo.value!, - refresh: - _homeController.refreshHostInfo, - toggleShowHostFull: - _homeController.toggleShowHostFull, - isExpanded: - _homeController.showHostFull.value, - ), - ) - .animate() - .fadeIn(duration: 600.ms) - .slideY(begin: -0.1) - : const Padding( - padding: EdgeInsets.all(20), - child: HostInfoCardSkeleton(), - ), - ), + ), + ), + const SizedBox(width: 6), + const Text( + 'Online', + style: TextStyle(fontSize: 12, color: lxTextDim), + ), + const Text(' · ', style: TextStyle(color: lxTextGhost)), + Obx(() { + final device = _homeController.authDevice.value; + return Text( + device != null ? device.address : '', + style: const TextStyle(fontSize: 12, color: lxTextDim), + ); + }), + const Spacer(), + GestureDetector( + onTap: () async { + if (_homeController.webSocketProvider.isConnected) { + await DisconnectConfirmationDialog.show( + onConfirm: () => { + _homeController.disconnectFromDevice(isCleaned: true), + }, + onCancel: () => Get.back(), + ); + } else { + _homeController.disconnectFromDevice(isCleaned: true); + } + }, + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 38, + height: 38, + child: const Icon( + Icons.power_settings_new_rounded, + size: 16, + color: lxTextDim, ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 1.1, + ), + ), + ), + ], + ), + // --- Hero stat card --- + const SizedBox(height: 16), + Obx(() { + final mc = _monitoringController; + final cpu = mc?.getCurrentCPUMetrics(); + final ram = mc?.getCurrentRAMMetrics(); + final cpuVal = cpu?.loadPercent.toDouble() ?? 0.0; + final cpuLoads = mc?.getCPULoads() ?? []; + return LxGlass( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + LxRing( + value: cpuVal, + size: 64, + strokeWidth: 2.5, + label: 'LOAD', + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'PAST 60 MIN', + style: TextStyle( + fontSize: 11, + color: lxTextFaint, + letterSpacing: 1.2, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + LxSparkline( + data: cpuLoads, + width: 170, + height: 36, + ), + const SizedBox(height: 4), + Row( + children: [ + _statLabel( + 'CPU', + cpu != null ? '${cpu.loadPercent}%' : '--%', + ), + const SizedBox(width: 14), + _statLabel( + 'RAM', + ram != null ? '${ram.loadPercent}%' : '--%', ), - itemCount: menuOptions.length, - itemBuilder: (context, index) { - return Hero( - tag: 'menu_item_$index', - child: MenuOptionCard( - title: menuOptions[index].title, - icon: menuOptions[index].icon, - onTap: () => - _homeController.selectMenuItem(index), - ), - ) - .animate() - .fadeIn( - delay: (index * 50).ms, - duration: 500.ms, - ) - .scale( - begin: const Offset(0.8, 0.8), - curve: Curves.easeOutBack, - ); - }, - ), + ], + ), + ], ), - ], - ), - ); - }), + ), + ], + ), + ); + }), + // --- Modules --- + const SizedBox(height: 14), + const Text( + 'MODULES', + style: TextStyle( + fontSize: 11, + color: lxTextFaint, + letterSpacing: 1.4, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + final cardHeight = 96.0; + final cardWidth = (screenWidth - 10) / 2; + final ratio = cardWidth / cardHeight; + final uptime = _formatUptime( + _homeController.hostInfo.value?.uptime ?? 0, + ); + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: ratio, + children: [ + _moduleCard( + icon: Icons.monitor_heart_outlined, + label: 'System', + sub: 'Live metrics', + index: 0, + ), + _moduleCard( + icon: Icons.volume_up_outlined, + label: 'Media', + sub: 'Now playing', + index: 1, + ), + _moduleCard( + icon: Icons.folder_outlined, + label: 'Files', + sub: 'Browse files', + index: 5, + ), + _moduleCard( + icon: Icons.code_rounded, + label: 'Scripts', + sub: 'Run commands', + index: 4, + ), + _moduleCard( + icon: Icons.mouse_outlined, + label: 'Touchpad', + sub: 'Remote input', + index: 3, + ), + _moduleCard( + icon: Icons.power_settings_new_rounded, + label: 'Power', + sub: 'Online · $uptime', + index: 2, + accent: true, + ), + ], + ); + }, + ), + const SizedBox(height: 100), + ], + ), ), ); } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Main content + Obx(() { + final idx = _homeController.selectedMenuIndex.value; + if (idx >= 0) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + child: KeyedSubtree( + key: ValueKey(idx), + child: menuOptions[idx].view, + ), + ); + } + return _buildHub(); + }), + // Floating tab bar + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Obx( + () => _showTabBar() + ? LxTabBar( + activeIndex: _activeTabIndex(), + onTap: (i) => + _homeController.selectMenuItem(_menuIndexForTab(i)), + ) + : const SizedBox.shrink(), + ), + ), + ], + ); + } } diff --git a/linqoraremote/lib/presentation/widgets/filebrowser_view.dart b/linqoraremote/lib/presentation/widgets/filebrowser_view.dart index 02cb1ec..7bf693f 100644 --- a/linqoraremote/lib/presentation/widgets/filebrowser_view.dart +++ b/linqoraremote/lib/presentation/widgets/filebrowser_view.dart @@ -1,12 +1,13 @@ -// Note: requires file_delete handler in LinqoraHost/internal/ws/server.go +import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; -import 'package:linqoraremote/core/themes/lin_styles.dart'; +import '../../core/themes/lx_theme.dart'; +import '../../data/models/file_item.dart'; import '../controllers/filebrowser_controller.dart'; import '../controllers/device_home_controller.dart'; -import '../../data/models/file_item.dart'; +import 'lx_glass.dart'; +import 'lx_header.dart'; class FileBrowserView extends StatefulWidget { const FileBrowserView({super.key}); @@ -17,6 +18,8 @@ class FileBrowserView extends StatefulWidget { class _FileBrowserViewState extends State { late final FileBrowserController _controller; + FileItem? _previewItem; + String _searchQuery = ''; @override void initState() { @@ -24,45 +27,6 @@ class _FileBrowserViewState extends State { _controller = Get.put( FileBrowserController(webSocketProvider: Get.find()), ); - final homeController = Get.find(); - - // Inject actions into Dashboard AppBar - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateAppBar(homeController); - }); - - // Listen to path changes to update AppBar - _controller.currentPath.listen((_) => _updateAppBar(homeController)); - } - - void _updateAppBar(DeviceHomeController homeController) { - if (!mounted) return; - - homeController.appBarActions.assignAll([ - IconButton( - icon: const Icon(Icons.upload_file_rounded, color: Colors.white), - onPressed: _controller.uploadFile, - tooltip: 'upload'.tr, - ), - IconButton( - icon: const Icon(Icons.refresh_rounded, color: Colors.white), - onPressed: _controller.refresh, - tooltip: 'refresh'.tr, - ), - ]); - - homeController.onBackPressed.value = () { - if (_controller.pathStack.isEmpty) { - homeController.selectMenuItem(-1); - } else { - _controller.navigateUp(); - } - }; - - homeController.appBarTitleOverride.value = - _controller.currentPath.value.isEmpty - ? 'file_manager'.tr - : _controller.currentPath.value.split('/').last; } @override @@ -73,210 +37,550 @@ class _FileBrowserViewState extends State { super.dispose(); } - @override - Widget build(BuildContext context) { - return _buildContent(context); + void _showPreview(FileItem item) { + setState(() => _previewItem = item); } - - - - Widget _buildContent(BuildContext context) { - return Obx(() { - if (_controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - if (_controller.errorMessage.value.isNotEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 12), - Text( - _controller.errorMessage.value, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _controller.refresh, - icon: const Icon(Icons.refresh), - label: Text('retry'.tr), - ), - ], - ), - ); - } - if (_controller.items.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_open, - size: 48, - color: Theme.of(context).colorScheme.onSurface.withAlpha(80), - ), - const SizedBox(height: 12), - Text( - 'empty_directory'.tr, - style: TextStyle( - color: - Theme.of(context).colorScheme.onSurface.withAlpha(120), - ), - ), - ], - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _controller.items.length, - itemBuilder: (context, index) { - final item = _controller.items[index]; - return _buildFileItem(context, item, index); - }, - ); - }); + String _itemMeta(FileItem item) { + if (item.isDir) { + return 'Folder'; + } + final size = item.formattedSize; + final date = _formatDate(item.modTime); + return size.isNotEmpty ? '$size · $date' : date; } - Widget _buildFileItem(BuildContext context, FileItem item, int index) { - return Dismissible( - key: Key('${_controller.currentPath.value}/${item.name}'), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - color: Theme.of(context).colorScheme.error, - child: const Icon(Icons.delete, color: Colors.white), - ), - confirmDismiss: (_) async { - return await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text('confirm'.tr), - content: Text('${'confirm_delete'.tr} "${item.name}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('cancel'.tr), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text( - 'delete'.tr, - style: TextStyle( - color: Theme.of(ctx).colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - onDismissed: (_) => _controller.deleteItem(item), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: - item.isDir - ? Theme.of(context).colorScheme.primary.withOpacity(0.1) - : Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - item.isDir ? Icons.folder_rounded : _iconForFile(item.name), - color: - item.isDir - ? Theme.of(context).colorScheme.primary - : Colors.white70, - ), - ), - title: Text( - item.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - subtitle: - item.isDir - ? null - : Text( - item.formattedSize, - style: TextStyle(color: Colors.white.withOpacity(0.4)), - ), - trailing: - item.isDir - ? const Icon(Icons.chevron_right_rounded, color: Colors.white24) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon( - Icons.visibility_rounded, - color: Colors.white38, - size: 20, - ), - onPressed: () => _controller.viewFile(item), - tooltip: 'view'.tr, - ), - IconButton( - icon: const Icon( - Icons.download_rounded, - color: Colors.white38, - size: 20, - ), - onPressed: () => _controller.downloadFile(item), - tooltip: 'download'.tr, - ), - ], - ), - onTap: item.isDir ? () => _controller.navigateTo(item) : null, - ).animate().fadeIn(delay: (index * 20).ms, duration: 300.ms), - ); + String _formatDate(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inDays == 0) return 'Today'; + if (diff.inDays == 1) return 'Yesterday'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; } - IconData _iconForFile(String name) { - final ext = name.split('.').last.toLowerCase(); + IconData _iconForItem(FileItem item) { + if (item.isDir) return Icons.folder_rounded; + final ext = item.name.contains('.') + ? item.name.split('.').last.toLowerCase() + : ''; switch (ext) { case 'pdf': - return Icons.picture_as_pdf; + return Icons.picture_as_pdf_rounded; case 'jpg': case 'jpeg': case 'png': case 'gif': case 'webp': - return Icons.image; + return Icons.image_rounded; case 'mp4': case 'mkv': case 'avi': case 'mov': - return Icons.videocam; + return Icons.videocam_rounded; case 'mp3': case 'wav': case 'flac': case 'ogg': - return Icons.music_note; + return Icons.music_note_rounded; case 'zip': case 'tar': case 'gz': case '7z': case 'rar': - return Icons.archive; + return Icons.archive_rounded; case 'txt': case 'md': case 'log': - return Icons.article; + return Icons.article_rounded; default: - return Icons.insert_drive_file; + return Icons.insert_drive_file_outlined; } } + + Widget _buildBreadcrumb() { + final path = _controller.currentPath.value; + if (path.isEmpty) { + return Text( + 'Home', + style: const TextStyle(fontSize: 11, color: lxTextGhost), + ); + } + final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList(); + final widgets = []; + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + widgets.add( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text( + '/', + style: TextStyle(fontSize: 11, color: lxTextGhost), + ), + ), + ); + } + widgets.add( + Text( + parts[i], + style: const TextStyle(fontSize: 11, color: lxTextGhost), + ), + ); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: widgets), + ); + } + + Widget _buildFileRow(FileItem item) { + return GestureDetector( + onTap: () { + if (item.isDir) { + _controller.navigateTo(item); + } else { + _showPreview(item); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.transparent, + border: const Border( + bottom: BorderSide(color: lxHairline, width: 1), + ), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: lxGlass2, + border: Border.all(color: lxHairline), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + _iconForItem(item), + size: 14, + color: item.isDir ? lxAccent : lxTextDim, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w500, + letterSpacing: -0.1, + color: lxText, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + _itemMeta(item), + style: const TextStyle(fontSize: 10.5, color: lxTextFaint), + ), + ], + ), + ), + if (item.isDir) + const Icon( + Icons.chevron_right_rounded, + size: 11, + color: lxTextGhost, + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: lxGlass2, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.name.contains('.') + ? item.name.split('.').last.toUpperCase() + : 'FILE', + style: const TextStyle( + fontSize: 10, + color: lxTextFaint, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFileList() { + final query = _searchQuery.toLowerCase(); + final filtered = _controller.items + .where((item) => query.isEmpty || item.name.toLowerCase().contains(query)) + .toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.folder_open_rounded, size: 36, color: lxTextFaint), + const SizedBox(height: 12), + Text( + _searchQuery.isEmpty ? 'Empty directory' : 'No results', + style: const TextStyle(fontSize: 13, color: lxTextFaint), + ), + ], + ), + ); + } + + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: filtered.length, + itemBuilder: (context, index) => _buildFileRow(filtered[index]), + ); + } + + Widget _buildPreviewModal(FileItem item) { + return GestureDetector( + onTap: () => setState(() => _previewItem = null), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + color: const Color(0x8C0A0C10), + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: GestureDetector( + onTap: () {}, + child: Container( + decoration: BoxDecoration( + color: const Color(0xD90F172A), + borderRadius: BorderRadius.circular(lxRadiusModal), + border: Border.all(color: lxHairlineHi), + boxShadow: const [ + BoxShadow( + color: Color(0x80000000), + blurRadius: 60, + offset: Offset(0, 30), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'PREVIEW', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + GestureDetector( + onTap: () => setState(() => _previewItem = null), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: lxGlass2, + border: Border.all(color: lxHairline), + ), + child: const Center( + child: Icon( + Icons.close_rounded, + size: 11, + color: lxTextDim, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Preview body + Container( + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: lxHairline), + color: lxSurface, + ), + child: Center( + child: Icon( + _iconForItem(item), + size: 36, + color: lxTextFaint, + ), + ), + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + color: lxText, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + item.formattedSize, + style: const TextStyle( + fontSize: 11, + color: lxTextFaint, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _controller.downloadFile(item), + child: Container( + height: 40, + decoration: BoxDecoration( + color: lxAccent, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: lxAccent.withValues(alpha: 0.35), + blurRadius: 20, + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_rounded, + size: 14, + color: lxBg, + ), + SizedBox(width: 6), + Text( + 'Download', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: lxBg, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: lxGlass2, + border: Border.all(color: lxHairline), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + Icons.more_horiz_rounded, + size: 14, + color: lxText, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final hostname = + Get.find().hostInfo.value?.hostname ?? 'Device'; + + final body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LxHeader( + title: 'Files', + eyebrow: '~/$hostname', + showBack: false, + action: GestureDetector( + onTap: _controller.uploadFile, + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 36, + height: 36, + child: Center( + child: Icon(Icons.add_rounded, size: 14, color: lxTextDim), + ), + ), + ), + ), + ), + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 14), + child: LxGlass( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + const Icon(Icons.search_rounded, size: 14, color: lxTextFaint), + const SizedBox(width: 10), + Expanded( + child: TextField( + onChanged: (v) => setState(() => _searchQuery = v), + style: const TextStyle(color: lxText, fontSize: 13), + decoration: const InputDecoration( + hintText: 'Search files…', + hintStyle: TextStyle(color: lxTextFaint, fontSize: 13), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: lxGlass2, + border: Border.all(color: lxHairline), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '⌘K', + style: TextStyle( + fontSize: 10, + color: lxTextFaint, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ), + // Breadcrumb row + Obx( + () => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Expanded(child: _buildBreadcrumb()), + ], + ), + ), + ), + // Top border + file list + Expanded( + child: Column( + children: [ + const Divider(color: lxHairline, height: 1, thickness: 1), + Expanded( + child: Obx(() { + if (_controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator( + color: lxAccent, + strokeWidth: 2, + ), + ); + } + if (_controller.errorMessage.value.isNotEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + size: 36, + color: lxTextFaint, + ), + const SizedBox(height: 12), + Text( + _controller.errorMessage.value, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + color: lxTextFaint, + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: _controller.refresh, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: lxGlass2, + border: Border.all(color: lxHairline), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Retry', + style: TextStyle( + fontSize: 13, + color: lxTextDim, + ), + ), + ), + ), + ], + ), + ); + } + return _buildFileList(); + }), + ), + ], + ), + ), + ], + ); + + return Stack( + children: [ + body, + if (_previewItem != null) + Positioned.fill( + child: _buildPreviewModal(_previewItem!), + ), + ], + ); + } } diff --git a/linqoraremote/lib/presentation/widgets/host_info_card.dart b/linqoraremote/lib/presentation/widgets/host_info_card.dart index 6cb7e1e..827b4bf 100644 --- a/linqoraremote/lib/presentation/widgets/host_info_card.dart +++ b/linqoraremote/lib/presentation/widgets/host_info_card.dart @@ -115,6 +115,41 @@ class HostInfoCard extends StatelessWidget { const SizedBox(height: 16), _buildDisksInfo(context), ], + if (host.battery.isPresent) ...[ + const SizedBox(height: 16), + _buildInfoRow( + context, + host.battery.isCharging + ? Icons.battery_charging_full_rounded + : Icons.battery_std_rounded, + 'BATTERY', + '${host.battery.level}%', + host.battery.status, + ), + ], + if (host.uptime > 0) ...[ + const SizedBox(height: 16), + _buildInfoRow( + context, + Icons.timer_outlined, + 'UPTIME', + _formatUptime(host.uptime), + '', + ), + ], + if (host.architecture.isNotEmpty || host.kernelVersion.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildInfoRow( + context, + Icons.info_outline_rounded, + 'PLATFORM', + host.architecture, + host.kernelVersion + + (host.platformVersion.isNotEmpty + ? ' • ${host.platformVersion}' + : ''), + ), + ], ], ], ), @@ -150,6 +185,17 @@ class HostInfoCard extends StatelessWidget { return ""; } + String _formatUptime(int seconds) { + final d = seconds ~/ 86400; + final h = (seconds % 86400) ~/ 3600; + final m = (seconds % 3600) ~/ 60; + final parts = []; + if (d > 0) parts.add('${d}d'); + if (h > 0) parts.add('${h}h'); + if (m > 0 || parts.isEmpty) parts.add('${m}m'); + return parts.join(' '); + } + Widget _buildDisksInfo(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/linqoraremote/lib/presentation/widgets/lx_background.dart b/linqoraremote/lib/presentation/widgets/lx_background.dart new file mode 100644 index 0000000..f2754c4 --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_background.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; + +// Page-level background: deep space black + radial gradients + faint grid. +// Replaces AnimatedAuroraBackground for a simpler, design-spec-accurate version. +class LxBackground extends StatelessWidget { + final Widget child; + + const LxBackground({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + color: lxBg, + child: Stack( + children: [ + // Radial gradients + Positioned.fill( + child: CustomPaint(painter: _BgPainter()), + ), + // Faint dot-grid texture + Positioned.fill( + child: Opacity( + opacity: 0.07, + child: _GridTexture(), + ), + ), + child, + ], + ), + ); + } +} + +class _BgPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + // Top radial: bgGrad at 50%/0% + final topGrad = RadialGradient( + center: const Alignment(0, -1), + radius: 1.1, + colors: [lxBgGrad, const Color(0x001A1F2B)], + stops: const [0.0, 0.55], + ); + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..shader = topGrad.createShader( + Rect.fromLTWH(0, 0, size.width, size.height), + ), + ); + // Bottom-right radial: subtle cyan glow + final brGrad = RadialGradient( + center: const Alignment(1, 1), + radius: 0.8, + colors: [const Color(0x0D00E5FF), const Color(0x0000E5FF)], + stops: const [0.0, 0.6], + ); + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..shader = brGrad.createShader( + Rect.fromLTWH(0, 0, size.width, size.height), + ), + ); + } + + @override + bool shouldRepaint(_BgPainter old) => false; +} + +class _GridTexture extends StatelessWidget { + @override + Widget build(BuildContext context) { + return CustomPaint(painter: _GridPainter()); + } +} + +class _GridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + const spacing = 48.0; + final paint = Paint() + ..color = const Color(0x14FFFFFF) + ..strokeWidth = 0.5; + for (double x = 0; x <= size.width; x += spacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + for (double y = 0; y <= size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(_GridPainter old) => false; +} diff --git a/linqoraremote/lib/presentation/widgets/lx_glass.dart b/linqoraremote/lib/presentation/widgets/lx_glass.dart new file mode 100644 index 0000000..4730fdd --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_glass.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; + +// Glass surface — 40px blur, 1px hairline, low-op fill. +// [accent] adds cyan border + soft glow. +// [hi] uses lxGlass2 fill instead of lxGlass. +class LxGlass extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final BorderRadius? borderRadius; + final bool accent; + final bool hi; + final VoidCallback? onTap; + + const LxGlass({ + super.key, + required this.child, + this.padding, + this.borderRadius, + this.accent = false, + this.hi = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final br = borderRadius ?? BorderRadius.circular(lxRadiusCard); + final border = accent + ? Border.all(color: const Color(0x59000000).withAlpha(0) /*unused*/, width: 0) + : Border.all(color: lxHairline, width: 1); + final accentBorder = Border.all(color: const Color(0x5900E5FF), width: 1); + final fill = hi ? lxGlass2 : lxGlass; + + Widget content = ClipRRect( + borderRadius: br, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: fill, + borderRadius: br, + border: accent ? accentBorder : border, + boxShadow: accent + ? [ + BoxShadow( + color: lxAccent.withValues(alpha: 0.4 * 0.25), + blurRadius: 30, + spreadRadius: -10, + ), + ] + : null, + ), + child: child, + ), + ), + ); + + if (onTap != null) { + return GestureDetector(onTap: onTap, child: content); + } + return content; + } +} diff --git a/linqoraremote/lib/presentation/widgets/lx_header.dart b/linqoraremote/lib/presentation/widgets/lx_header.dart new file mode 100644 index 0000000..7db3505 --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_header.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; +import 'lx_glass.dart'; + +// Standard screen header — back chevron · optional eyebrow + large title · action button +class LxHeader extends StatelessWidget { + final String title; + final String? eyebrow; + final Widget? action; + final bool showBack; + final VoidCallback? onBack; + final bool dense; + + const LxHeader({ + super.key, + required this.title, + this.eyebrow, + this.action, + this.showBack = true, + this.onBack, + this.dense = false, + }); + + @override + Widget build(BuildContext context) { + final pt = dense ? 8.0 : 14.0; + final pb = dense ? 10.0 : 18.0; + + return Padding( + padding: EdgeInsets.fromLTRB(sp20, pt, sp20, pb), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (showBack) + _circleBtn( + child: const Icon( + Icons.chevron_left_rounded, + color: lxText, + size: 20, + ), + onTap: onBack ?? () => Navigator.of(context).maybePop(), + ) + else + const SizedBox(width: 36), + const Spacer(), + if (action != null) action! else const SizedBox(width: 36), + ], + ), + if (eyebrow != null) ...[ + const SizedBox(height: 14), + Text( + eyebrow!.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 1.4, + color: lxTextFaint, + ), + ), + ], + SizedBox(height: eyebrow != null ? 6 : 14), + Text( + title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.6, + color: lxText, + height: 1.05, + ), + ), + ], + ), + ); + } + + Widget _circleBtn({required Widget child, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 36, + height: 36, + child: Center(child: child), + ), + ), + ); + } +} diff --git a/linqoraremote/lib/presentation/widgets/lx_ring.dart b/linqoraremote/lib/presentation/widgets/lx_ring.dart new file mode 100644 index 0000000..71bde7a --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_ring.dart @@ -0,0 +1,125 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; + +// Circular progress ring with a centered label. +class LxRing extends StatelessWidget { + final double value; // 0–100 + final double size; + final double strokeWidth; + final Color color; + final String? label; // eyebrow below the number + + const LxRing({ + super.key, + required this.value, + this.size = 80, + this.strokeWidth = 3, + this.color = lxAccent, + this.label, + }); + + @override + Widget build(BuildContext context) { + final numStr = value.toInt().toString(); + final numFontSize = size * 0.26; + final labelFontSize = size * 0.12; + + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _RingPainter( + value: value.clamp(0, 100), + color: color, + strokeWidth: strokeWidth, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: numStr, + style: TextStyle( + fontFamily: 'Inter', + fontSize: numFontSize, + fontWeight: FontWeight.w600, + color: lxText, + letterSpacing: -0.6, + ), + ), + TextSpan( + text: '%', + style: TextStyle( + fontFamily: 'Inter', + fontSize: numFontSize * 0.55, + color: lxTextDim, + ), + ), + ], + ), + ), + if (label != null) + Text( + label!.toUpperCase(), + style: TextStyle( + fontFamily: 'Inter', + fontSize: labelFontSize, + fontWeight: FontWeight.w500, + color: lxTextFaint, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + final double value; + final Color color; + final double strokeWidth; + + _RingPainter({ + required this.value, + required this.color, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - strokeWidth - 1; + final trackPaint = Paint() + ..color = lxHairlineHi + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + canvas.drawCircle(center, radius, trackPaint); + + final sweep = 2 * math.pi * (value / 100); + final arcPaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 3); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweep, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(_RingPainter old) => + old.value != value || old.color != color; +} diff --git a/linqoraremote/lib/presentation/widgets/lx_sparkline.dart b/linqoraremote/lib/presentation/widgets/lx_sparkline.dart new file mode 100644 index 0000000..e7eb56f --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_sparkline.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; + +class LxSparkline extends StatelessWidget { + final List data; + final double width; + final double height; + final Color color; + final bool fill; + final double strokeWidth; + + const LxSparkline({ + super.key, + required this.data, + required this.width, + required this.height, + this.color = lxAccent, + this.fill = true, + this.strokeWidth = 1.4, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: _SparklinePainter( + data: data, + color: color, + fill: fill, + strokeWidth: strokeWidth, + ), + ), + ); + } +} + +class _SparklinePainter extends CustomPainter { + final List data; + final Color color; + final bool fill; + final double strokeWidth; + + _SparklinePainter({ + required this.data, + required this.color, + required this.fill, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + + final doubles = data.map((e) => e.toDouble()).toList(); + final minV = doubles.reduce((a, b) => a < b ? a : b); + final maxV = doubles.reduce((a, b) => a > b ? a : b); + final range = (maxV - minV).abs(); + final step = size.width / (doubles.length - 1); + + List pts = []; + for (int i = 0; i < doubles.length; i++) { + final x = i * step; + final norm = range == 0 ? 0.5 : (doubles[i] - minV) / range; + final y = size.height - (norm * size.height * 0.92) - 2; + pts.add(Offset(x, y)); + } + + final linePaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 2); + + final linePath = Path()..moveTo(pts[0].dx, pts[0].dy); + for (int i = 1; i < pts.length; i++) { + linePath.lineTo(pts[i].dx, pts[i].dy); + } + + if (fill) { + final fillPath = Path() + ..moveTo(pts[0].dx, pts[0].dy); + for (int i = 1; i < pts.length; i++) { + fillPath.lineTo(pts[i].dx, pts[i].dy); + } + fillPath + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + + final fillPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.28), + color.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..style = PaintingStyle.fill; + canvas.drawPath(fillPath, fillPaint); + } + + canvas.drawPath(linePath, linePaint); + } + + @override + bool shouldRepaint(_SparklinePainter old) => + old.data != data || old.color != color; +} diff --git a/linqoraremote/lib/presentation/widgets/lx_tab_bar.dart b/linqoraremote/lib/presentation/widgets/lx_tab_bar.dart new file mode 100644 index 0000000..710d4fa --- /dev/null +++ b/linqoraremote/lib/presentation/widgets/lx_tab_bar.dart @@ -0,0 +1,110 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../../core/themes/lx_theme.dart'; + +// Floating glass tab bar — 5 anchors: Hub · Monitor · Files · Console · Power +// activeIndex: 0=Hub 1=Monitor 2=Files 3=Console 4=Power +class LxTabBar extends StatelessWidget { + final int activeIndex; + final ValueChanged onTap; + + const LxTabBar({super.key, required this.activeIndex, required this.onTap}); + + static const _tabs = [ + _Tab(label: 'Hub', icon: Icons.hub_outlined), + _Tab(label: 'Monitor', icon: Icons.monitor_heart_outlined), + _Tab(label: 'Files', icon: Icons.folder_outlined), + _Tab(label: 'Console', icon: Icons.terminal_outlined), + _Tab(label: 'Power', icon: Icons.power_settings_new_rounded), + ]; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: ClipRRect( + borderRadius: BorderRadius.circular(lxRadiusTabBar), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), + child: Container( + height: 64, + decoration: BoxDecoration( + color: const Color(0xB20F172A), // rgba(15,23,42,0.7) + borderRadius: BorderRadius.circular(lxRadiusTabBar), + border: Border.all(color: lxHairline, width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 40, + offset: Offset(0, 12), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(_tabs.length, (i) { + final on = i == activeIndex; + return GestureDetector( + onTap: () => onTap(i), + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 60, + child: Stack( + alignment: Alignment.center, + children: [ + if (on) + Positioned( + top: 0, + child: Container( + width: 18, + height: 2, + decoration: BoxDecoration( + color: lxAccent, + borderRadius: BorderRadius.circular(2), + boxShadow: [ + BoxShadow( + color: lxAccent.withValues(alpha: 0.8), + blurRadius: 8, + ), + ], + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _tabs[i].icon, + size: 20, + color: on ? lxAccent : lxTextFaint, + ), + const SizedBox(height: 3), + Text( + _tabs[i].label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + color: on ? lxAccent : lxTextFaint, + ), + ), + ], + ), + ], + ), + ), + ); + }), + ), + ), + ), + ), + ); + } +} + +class _Tab { + final String label; + final IconData icon; + const _Tab({required this.label, required this.icon}); +} diff --git a/linqoraremote/lib/presentation/widgets/media_view.dart b/linqoraremote/lib/presentation/widgets/media_view.dart index 15464be..0c735ef 100644 --- a/linqoraremote/lib/presentation/widgets/media_view.dart +++ b/linqoraremote/lib/presentation/widgets/media_view.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; -import 'package:linqoraremote/presentation/widgets/banner.dart'; -import 'package:linqoraremote/presentation/widgets/loading_view.dart'; -import 'package:linqoraremote/presentation/widgets/shimmer_effect.dart'; -import 'package:loading_animation_widget/loading_animation_widget.dart'; -import 'package:linqoraremote/core/themes/lin_styles.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_header.dart'; +import 'package:linqoraremote/presentation/controllers/device_home_controller.dart'; import '../../data/media_commands.dart'; import '../controllers/media_controller.dart'; @@ -42,397 +41,462 @@ class _MediaScreenViewState extends State { @override Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Obx(() { - if (_mediaController.capabilities.value == null) { - return const LoadingView(); - } else { - return Column( - children: [ - if (!_mediaController.capabilities.value!.isControlledByRemote || - _mediaController.nowPlaying.value == null) - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: _mediaController.nowPlaying.value == null - ? Column( - children: [ - MessageBanner( - message: 'info_no_playing_remote'.tr, - isLoading: false, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () async { - final ws = _mediaController.webSocketProvider; - await ws.leaveRoom('media'); - await ws.joinRoom('media'); - }, - icon: const Icon(Icons.refresh_rounded, size: 18), - label: Text('retry'.tr), - ), - ], - ) - : MessageBanner( - message: 'error_control_remote'.tr, - isLoading: false, - ), - ), - _volumeCard(), - const SizedBox(height: 20), - if (_mediaController.capabilities.value!.isControlledByRemote && - _mediaController.nowPlaying.value != null) - _mediaCard(), + return Obx(() { + final caps = _mediaController.capabilities.value; + if (caps == null) return _loading(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + child: Column( + children: [ + _header(), + const SizedBox(height: 6), + _albumArt(), + const SizedBox(height: 22), + _trackInfo(), + const SizedBox(height: 22), + if (caps.isControlledByRemote && + _mediaController.nowPlaying.value != null) ...[ + _scrubber(), + const SizedBox(height: 22), + _transport(), ], - ); - } - }), - ); + const SizedBox(height: 22), + _volumeRow(context), + const SizedBox(height: 10), + _outputRow(), + ], + ), + ); + }); } - Widget _volumeCard() { - return LinStyles.glassMorphism( + Widget _loading() { + return const Center( child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.volume_up_rounded, - color: Theme.of(context).colorScheme.primary, - size: 20, - ), - const SizedBox(width: 12), - Text( - 'control_sound'.tr.toUpperCase(), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - fontWeight: FontWeight.w900, - letterSpacing: 1.2, + padding: EdgeInsets.all(60), + child: CircularProgressIndicator(color: lxAccent, strokeWidth: 2), + ), + ); + } + + Widget _header() { + final hostname = + Get.find().hostInfo.value?.hostname ?? 'Device'; + final app = _mediaController.nowPlaying.value?.application ?? ''; + final eyebrow = app.isNotEmpty ? '$app · $hostname' : hostname; + return LxHeader( + title: 'Now Playing', + eyebrow: eyebrow, + showBack: false, + action: LxGlass( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 36, + height: 36, + child: Center( + child: const Icon( + Icons.more_horiz_rounded, + size: 16, + color: lxTextDim, + ), + ), + ), + ), + ); + } + + Widget _albumArt() { + return Center( + child: Container( + width: 240, + height: 240, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: lxHairline), + boxShadow: const [ + BoxShadow( + color: Color(0x2600E5FF), + blurRadius: 60, + offset: Offset(0, 30), + ), + BoxShadow( + color: Color(0x80000000), + blurRadius: 30, + offset: Offset(0, 12), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + fit: StackFit.expand, + children: [ + // Base gradient — cyan blob top-left + Container( + decoration: const BoxDecoration( + color: lxSurface, + gradient: RadialGradient( + center: Alignment(-0.6, -0.4), + radius: 0.8, + colors: [Color(0x8C00E5FF), Color(0x007C9CFF)], ), ), - ], - ), - const SizedBox(height: 32), - Row( - children: [ - Obx( - () => IconButton( - icon: Icon( - _mediaController.isMuted.value - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - size: 28, - color: _mediaController.isMuted.value - ? Colors.redAccent - : Colors.white, - ), - onPressed: _mediaController.setMuted, + ), + // Lavender blob bottom-right + Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.6, 0.4), + radius: 0.9, + colors: [Color(0x807C9CFF), Color(0x007C9CFF)], ), ), - Expanded( - child: Obx( - () => SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 8, - activeTrackColor: Theme.of(context).colorScheme.primary, - inactiveTrackColor: Colors.white.withOpacity(0.1), - thumbColor: Colors.white, - overlayColor: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.2), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 10, - elevation: 5, - ), - ), - child: Slider( - value: _mediaController.volume.value, - min: 0, - max: 100, - onChanged: (newValue) { - _mediaController.volume.value = newValue; - }, - onChangeEnd: (newValue) { - _mediaController.slideVolume(newValue); - }, - ), - ), + ), + // Soft red blob top-right + Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.2, -0.6), + radius: 0.7, + colors: [Color(0x59FF4D5E), Color(0x00FF4D5E)], ), ), - Obx( - () => SizedBox( - width: 45, - child: Text( - '${_mediaController.volume.value.toInt()}%', - textAlign: TextAlign.end, - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 14, - ), - ), + ), + // Scanline texture + Opacity( + opacity: 0.08, + child: CustomPaint(painter: _ScanlinePainter()), + ), + // "SIDE A" micro-label + const Positioned( + bottom: 14, + left: 14, + child: Text( + 'SIDE A · 2024', + style: TextStyle( + fontSize: 10, + color: Color(0xB3FFFFFF), + letterSpacing: 2, + fontWeight: FontWeight.w500, ), ), - ], - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildVolumePresetButton('10%', 10), - _buildVolumePresetButton('30%', 30), - _buildVolumePresetButton('50%', 50), - _buildVolumePresetButton('70%', 70), - _buildVolumePresetButton('100%', 100), - ], - ), - ], + ), + ], + ), ), ), ); } - Widget _mediaCard() { - return LinStyles.glassMorphism( - child: Padding( - padding: const EdgeInsets.all(24), - child: Obx(() { - if (!_mediaController.capabilities.value!.canControlMedia) { - return Center( - child: Text( - 'error_control_remote'.tr, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16), - ), - ); - } + Widget _trackInfo() { + return Obx(() { + final np = _mediaController.nowPlaying.value; + return Column( + children: [ + Text( + np?.title ?? 'Nothing playing', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: lxText, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + np != null + ? '${np.artist}${np.album.isNotEmpty ? ' · ${np.album}' : ''}' + : '', + style: const TextStyle(fontSize: 13, color: lxTextDim), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + }); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + Widget _scrubber() { + return Obx(() { + final np = _mediaController.nowPlaying.value; + final progress = (np?.progress ?? 0.0).toDouble(); + return Column( + children: [ + LayoutBuilder( + builder: (ctx, c) { + return Stack( + clipBehavior: Clip.none, children: [ - Icon( - Icons.music_note_rounded, - color: Theme.of(context).colorScheme.primary, - size: 20, + Container( + height: 4, + decoration: BoxDecoration( + color: lxHairline, + borderRadius: BorderRadius.circular(4), + ), ), - const SizedBox(width: 12), - Text( - 'now_playing'.tr.toUpperCase(), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - fontWeight: FontWeight.w900, - letterSpacing: 1.2, + FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + height: 4, + decoration: BoxDecoration( + color: lxAccent, + borderRadius: BorderRadius.circular(4), + boxShadow: const [ + BoxShadow(color: Color(0x8800E5FF), blurRadius: 8), + ], + ), ), ), - const Spacer(), - Obx( - () => !_mediaController.isLoadingMedia.value - ? Icon( - _mediaController.nowPlaying.value?.isPlaying ?? - false - ? Icons.graphic_eq_rounded - : Icons.pause_rounded, - color: - _mediaController.nowPlaying.value?.isPlaying ?? - false - ? Colors.greenAccent - : Colors.white24, - size: 20, - ) - : LoadingAnimationWidget.staggeredDotsWave( - color: Theme.of(context).colorScheme.primary, - size: 20, + Positioned( + left: (c.maxWidth * progress - 6).clamp( + 0, + c.maxWidth - 12, + ), + top: -4, + child: Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: lxText, + boxShadow: [ + BoxShadow( + color: Color(0x2E00E5FF), + spreadRadius: 4, + blurRadius: 0, + ), + BoxShadow( + color: Color(0x66000000), + blurRadius: 6, + offset: Offset(0, 2), ), + ], + ), + ), ), ], + ); + }, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + np?.stringPosition ?? '0:00', + style: const TextStyle(fontSize: 11, color: lxTextDim), + ), + Text( + np?.stringDuration ?? '0:00', + style: const TextStyle(fontSize: 11, color: lxTextDim), ), - const SizedBox(height: 20), - if (_mediaController.capabilities.value!.canGetMediaInfo) ...[ - _buildMediaInfoSection(), - Obx( - () => _buildPlaybackControls( - !_mediaController.isLoadingMedia.value, - ), - ), - ], ], - ); - }), - ), - ); + ), + ], + ); + }); } - Widget _buildVolumePresetButton(String label, int volumeValue) { - return InkWell( - onTap: () => _mediaController.setVolume(volumeValue), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: Text( - label, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: Colors.white70, + Widget _transport() { + return Obx(() { + final isPlaying = _mediaController.nowPlaying.value?.isPlaying ?? false; + final enabled = !_mediaController.isLoadingMedia.value; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _iconBtn( + icon: Icons.shuffle_rounded, + size: 36, + color: lxTextDim, + onTap: () {}, ), - ), - ), - ); + _iconBtn( + icon: Icons.skip_previous_rounded, + size: 48, + color: enabled ? lxText : lxTextFaint, + onTap: enabled + ? () => _mediaController + .sendMediaCommand(MediaActions.mediaPrevious) + : null, + ), + GestureDetector( + onTap: enabled + ? () => _mediaController + .sendMediaCommand(MediaActions.mediaPlayPause) + : null, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: enabled ? lxAccent : lxGlass2, + boxShadow: enabled + ? const [ + BoxShadow( + color: Color(0x6600E5FF), + blurRadius: 0, + spreadRadius: 1, + ), + BoxShadow(color: Color(0x8000E5FF), blurRadius: 30), + ] + : [], + ), + child: Icon( + isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + size: 32, + color: enabled ? lxBg : lxTextFaint, + ), + ), + ), + _iconBtn( + icon: Icons.skip_next_rounded, + size: 48, + color: enabled ? lxText : lxTextFaint, + onTap: enabled + ? () => + _mediaController.sendMediaCommand(MediaActions.mediaNext) + : null, + ), + _iconBtn( + icon: Icons.repeat_rounded, + size: 36, + color: lxTextDim, + onTap: () {}, + ), + ], + ); + }); } - Widget _buildMediaInfoSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Obx( - () => _mediaController.isLoadingMedia.value - ? const ShimmerEffect(height: 24, width: 200) - : Text( - _mediaController.nowPlaying.value!.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: 0.5, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Widget _volumeRow(BuildContext context) { + return LxGlass( + padding: const EdgeInsets.all(14), + child: Obx( + () => Row( + children: [ + GestureDetector( + onTap: _mediaController.setMuted, + child: Icon( + _mediaController.isMuted.value + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + size: 16, + color: lxTextDim, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + activeTrackColor: lxText, + inactiveTrackColor: lxHairlineHi, + thumbColor: lxText, + thumbShape: + const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 12), + overlayColor: lxAccent.withValues(alpha: 0.15), ), - ), - const SizedBox(height: 8), - Obx( - () => _mediaController.isLoadingMedia.value - ? const ShimmerEffect(height: 16, width: 140) - : Text( - _mediaController.nowPlaying.value!.artist, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white.withOpacity(0.5), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Slider( + value: _mediaController.volume.value.clamp(0, 100), + min: 0, + max: 100, + onChanged: (v) => _mediaController.volume.value = v, + onChangeEnd: _mediaController.slideVolume, ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 28, + child: Text( + '${_mediaController.volume.value.toInt()}', + style: const TextStyle(fontSize: 11, color: lxTextDim), + textAlign: TextAlign.right, + ), + ), + ], ), - const SizedBox(height: 32), - Obx(() { - final nowPlaying = _mediaController.nowPlaying.value!; - if (nowPlaying.duration > 0 && - !_mediaController.isLoadingMedia.value) { - return Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: nowPlaying.progress.toDouble(), - backgroundColor: Colors.white.withOpacity(0.05), - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - minHeight: 6, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - nowPlaying.stringPosition.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white.withOpacity(0.4), - ), - ), - Text( - nowPlaying.stringDuration.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white.withOpacity(0.4), - ), - ), - ], - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }), - ], + ), ); } - Widget _buildPlaybackControls(bool isEnabled) { - final colorScheme = Theme.of(context).colorScheme; - final color = isEnabled ? Colors.white : Colors.white24; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.skip_previous_rounded, size: 40, color: color), - onPressed: isEnabled - ? () => _mediaController.sendMediaCommand( - MediaActions.mediaPrevious, - ) - : null, - ), - const SizedBox(width: 24), - Obx( - () => Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primary.withOpacity(0.2), - border: Border.all( - color: colorScheme.primary.withOpacity(0.5), - width: 2, - ), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withOpacity(0.2), - blurRadius: 20, - spreadRadius: 2, + Widget _outputRow() { + final app = + _mediaController.nowPlaying.value?.application ?? 'Player'; + return LxGlass( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + const Icon(Icons.desktop_mac_rounded, size: 14, color: lxAccent), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Output', + style: TextStyle(fontSize: 12, color: lxTextDim), + ), + Text( + app, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), ], ), - child: IconButton( - icon: Icon( - _mediaController.nowPlaying.value!.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - size: 48, - color: isEnabled ? colorScheme.primary : Colors.white24, - ), - onPressed: isEnabled - ? () => _mediaController.sendMediaCommand( - MediaActions.mediaPlayPause, - ) - : null, - ), ), - ), - const SizedBox(width: 24), - IconButton( - icon: Icon(Icons.skip_next_rounded, size: 40, color: color), - onPressed: isEnabled - ? () => _mediaController.sendMediaCommand(MediaActions.mediaNext) - : null, - ), - ], + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: lxTextGhost, + ), + ], + ), + ); + } + + Widget _iconBtn({ + required IconData icon, + required double size, + required Color color, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: size, + height: size, + child: Icon(icon, size: size * 0.5, color: color), + ), ); } } + +class _ScanlinePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0x14000000) + ..strokeWidth = 1; + + double y = 0; + while (y < size.height) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + y += 6; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/linqoraremote/lib/presentation/widgets/monitoring_view.dart b/linqoraremote/lib/presentation/widgets/monitoring_view.dart index 88f83cb..f34d4e7 100644 --- a/linqoraremote/lib/presentation/widgets/monitoring_view.dart +++ b/linqoraremote/lib/presentation/widgets/monitoring_view.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; +import 'package:linqoraremote/data/models/metrics.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; +import 'package:linqoraremote/presentation/controllers/device_home_controller.dart'; import 'package:linqoraremote/presentation/controllers/monitoring_controller.dart'; -import 'package:linqoraremote/presentation/widgets/loading_view.dart'; - -import '../../data/models/metrics.dart'; -import 'banner.dart'; -import 'metrics/metric_chart.dart'; -import 'metrics/metric_standart_card.dart'; -import 'metrics/metrics_row.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_header.dart'; +import 'package:linqoraremote/presentation/widgets/lx_ring.dart'; +import 'package:linqoraremote/presentation/widgets/lx_sparkline.dart'; class MonitoringView extends StatefulWidget { const MonitoringView({super.key}); @@ -19,14 +19,14 @@ class MonitoringView extends StatefulWidget { class _MonitoringViewState extends State with SingleTickerProviderStateMixin { - late final MonitoringController _monitoringController; + late final MonitoringController _c; late final AnimationController _animationController; late final Animation _fadeAnimation; @override void initState() { super.initState(); - _monitoringController = Get.put( + _c = Get.put( MonitoringController(webSocketProvider: Get.find()), ); @@ -44,117 +44,500 @@ class _MonitoringViewState extends State @override void dispose() { - if (_isControllerRegistered()) { + if (Get.isRegistered()) { Get.delete(); } _animationController.dispose(); super.dispose(); } - bool _isControllerRegistered() { - return Get.isRegistered(); - } - @override Widget build(BuildContext context) { return FadeTransition( opacity: _fadeAnimation, - child: Column( + child: Obx(() { + final cpu = _c.currentCPUMetrics.value; + final ram = _c.currentRAMMetrics.value; + + if (cpu == null && ram == null) { + return _loading(); + } + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + child: Column( + children: [ + _header(), + _heroCpuCard(cpu), + const SizedBox(height: 10), + _ramGpuRow(ram), + const SizedBox(height: 10), + _perCoreCard(cpu), + const SizedBox(height: 10), + _statTiles(cpu), + const SizedBox(height: 20), + ], + ), + ); + }), + ); + } + + // --------------------------------------------------------------------------- + // Header + // --------------------------------------------------------------------------- + + Widget _header() { + final homeCtrl = Get.find(); + return LxHeader( + title: 'Monitor', + eyebrow: homeCtrl.hostInfo.value?.hostname ?? 'Device', + showBack: false, + action: GestureDetector( + onTap: () => homeCtrl.refreshHostInfo(), + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 36, + height: 36, + child: Center( + child: Icon(Icons.refresh_rounded, size: 14, color: lxTextDim), + ), + ), + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Hero CPU card + // --------------------------------------------------------------------------- + + Widget _heroCpuCard(CPUMetrics? cpu) { + final host = Get.find().hostInfo.value; + return LxGlass( + padding: const EdgeInsets.all(18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ + LxRing( + value: cpu?.loadPercent.toDouble() ?? 0, + size: 86, + strokeWidth: 2.8, + label: 'CPU', + ), + const SizedBox(width: 18), Expanded( - child: Obx(() { - final cpuMetrics = _monitoringController.getCurrentCPUMetrics(); - final ramMetrics = _monitoringController.getCurrentRAMMetrics(); - - if (cpuMetrics == null || ramMetrics == null) { - return LoadingView(); - } - - return AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: 1.0, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(height: 16), - !_monitoringController.hasEnoughMetricsData - ? MessageBanner(message: 'calibrate_data'.tr) - : SizedBox.shrink(), - - _buildMode(ramMetrics, cpuMetrics), - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + host?.cpu.model ?? 'CPU', + style: const TextStyle( + fontSize: 12, + color: lxTextDim, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${(host?.cpu.frequency ?? 0).toStringAsFixed(0)} MHz', + style: const TextStyle( + fontSize: 11, + color: lxTextFaint, + ), ), + ], + ), + const SizedBox(height: 8), + Obx( + () => LxSparkline( + data: _c.cpuLoads, + width: 170, + height: 42, ), ), - ); - }), + const SizedBox(height: 6), + Row( + children: [ + _mini('Processes', cpu?.processes.toString() ?? '--'), + const SizedBox(width: 12), + _mini('Threads', cpu?.threads.toString() ?? '--'), + ], + ), + ], + ), ), ], ), ); } - _buildMode(RAMMetrics ramMetrics, CPUMetrics cpuMetrics) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + // --------------------------------------------------------------------------- + // RAM + GPU side-by-side + // --------------------------------------------------------------------------- + + Widget _ramGpuRow(RAMMetrics? ram) { + final host = Get.find().hostInfo.value; + final totalRam = host?.ram.total ?? 0.0; + final usedRam = ram?.usage ?? 0.0; + + return Row( children: [ - MetricsCard( - title: '${'temperature'.tr} CPU', - value: '${cpuMetrics.temperature}°C', - widget: MetricChart( - metricsData: _monitoringController.getTemperatures(), + // RAM card + Expanded( + child: LxGlass( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'RAM', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.2, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + Text( + '${usedRam.toStringAsFixed(1)}/${totalRam.toStringAsFixed(0)} GB', + style: const TextStyle(fontSize: 10, color: lxTextFaint), + ), + ], + ), + const SizedBox(height: 4), + Obx( + () => RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${_c.currentRAMMetrics.value?.loadPercent ?? 0}', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.8, + color: lxText, + ), + ), + const TextSpan( + text: '%', + style: TextStyle(fontSize: 14, color: lxTextDim), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Obx( + () => LxSparkline( + data: _c.ramUsagesPercent, + width: 130, + height: 32, + color: const Color(0xFF7C9CFF), + ), + ), + ], + ), ), ), - const SizedBox(height: 16), - MetricsCard( - title: '${'load'.tr} CPU', - value: '${cpuMetrics.loadPercent}%', - isWarning: cpuMetrics.loadPercent >= 90, - widget: Column( + const SizedBox(width: 10), + // GPU card (placeholder — no real GPU stream) + Expanded( + child: LxGlass( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text( + 'GPU', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.2, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + Text( + '—', + style: TextStyle(fontSize: 10, color: lxTextFaint), + ), + ], + ), + const SizedBox(height: 4), + RichText( + text: const TextSpan( + children: [ + TextSpan( + text: '—', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.8, + color: lxText, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Container( + height: 32, + decoration: BoxDecoration( + color: lxGlass2, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ], + ); + } + + // --------------------------------------------------------------------------- + // Per-core load grid + // --------------------------------------------------------------------------- + + Widget _perCoreCard(CPUMetrics? cpu) { + final host = Get.find().hostInfo.value; + final coreCount = + (host?.cpu.logicalCores ?? 0) > 0 ? host!.cpu.logicalCores : 8; + final loadVal = cpu?.loadPercent ?? 0; + + return LxGlass( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MetricChart(metricsData: _monitoringController.getCPULoads()), + const Text( + 'PER-CORE LOAD', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.2, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + Text( + '$coreCount cores', + style: const TextStyle(fontSize: 10, color: lxTextFaint), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: List.generate(coreCount, (i) { + final offset = (i - (coreCount / 2)).round() * 6; + final v = (loadVal + offset).clamp(0, 100).toDouble(); + final isHot = v > 75; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Column( + children: [ + SizedBox( + height: 32, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Container( + decoration: BoxDecoration( + color: lxGlass2, + borderRadius: BorderRadius.circular(4), + ), + ), + FractionallySizedBox( + heightFactor: v / 100, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: isHot + ? const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [lxAmber, lxAccent], + ) + : null, + color: isHot + ? null + : lxAccent.withValues(alpha: 0.85), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + 'C$i', + style: const TextStyle( + fontSize: 9, + color: lxTextFaint, + ), + ), + ], + ), + ), + ); + }), + ), + ], + ), + ); + } - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( + // --------------------------------------------------------------------------- + // Stat tiles: temp + processes + // --------------------------------------------------------------------------- + + Widget _statTiles(CPUMetrics? cpu) { + return Row( + children: [ + // CPU temperature tile + Expanded( + child: LxGlass( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: lxGlass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Icon( + Icons.thermostat_rounded, + size: 14, + color: lxAmber, + ), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - MetricDetailRow( - label: 'processes'.tr, - value: - _monitoringController - .currentCPUMetrics - .value - ?.processes - .toString() ?? - "", + const Text( + 'CPU TEMP', + style: TextStyle( + fontSize: 10, + color: lxTextFaint, + letterSpacing: 1, + ), ), - MetricDetailRow( - label: 'threads'.tr, - value: - _monitoringController.currentCPUMetrics.value?.threads - .toString() ?? - "", + Text( + cpu != null ? '${cpu.temperature}°C' : '--', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: lxText, + ), ), ], ), - ), - ], + ], + ), ), ), - const SizedBox(height: 16), - - MetricsCard( - title: '${'usage'.tr} RAM', - value: '${ramMetrics.loadPercent}% (${ramMetrics.usage} GB)', - isWarning: ramMetrics.loadPercent >= 90, - widget: MetricChart( - metricsData: _monitoringController.getRAMUsagesPercent(), + const SizedBox(width: 10), + // Processes tile + Expanded( + child: LxGlass( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: lxGlass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Icon( + Icons.memory_rounded, + size: 14, + color: lxAccent, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'PROCESSES', + style: TextStyle( + fontSize: 10, + color: lxTextFaint, + letterSpacing: 1, + ), + ), + Text( + cpu?.processes.toString() ?? '--', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: lxText, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), ), ), ], ); } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + Widget _mini(String label, String val) { + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: '$label ', + style: const TextStyle(fontSize: 10.5, color: lxTextDim), + ), + TextSpan( + text: val, + style: const TextStyle( + fontSize: 10.5, + color: lxText, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _loading() { + return const Center( + child: CircularProgressIndicator(color: lxAccent, strokeWidth: 2), + ); + } } diff --git a/linqoraremote/lib/presentation/widgets/powermanagement_view.dart b/linqoraremote/lib/presentation/widgets/powermanagement_view.dart index 463bfe4..2c30ce9 100644 --- a/linqoraremote/lib/presentation/widgets/powermanagement_view.dart +++ b/linqoraremote/lib/presentation/widgets/powermanagement_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; -import 'package:linqoraremote/presentation/widgets/power/powered_control_card.dart'; - -import '../controllers/power_controller.dart'; +import 'package:linqoraremote/presentation/controllers/device_home_controller.dart'; +import 'package:linqoraremote/presentation/controllers/power_controller.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_header.dart'; class PowerManagementView extends StatefulWidget { const PowerManagementView({super.key}); @@ -12,84 +14,373 @@ class PowerManagementView extends StatefulWidget { State createState() => _PowerManagementViewState(); } -class _PowerManagementViewState extends State - with SingleTickerProviderStateMixin { - late final PowerController _powerController; - late final AnimationController _animationController; - late final Animation _fadeAnimation; +class _PowerManagementViewState extends State { + late PowerController _powerController; @override void initState() { super.initState(); - _powerController = Get.put( PowerController(webSocketProvider: Get.find()), ); - - _animationController = AnimationController( - duration: const Duration(milliseconds: 500), - vsync: this, - ); - - _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), - ); - - _animationController.forward(); } @override void dispose() { - if (_isControllerRegistered()) { - Get.delete(); - } - _animationController.dispose(); - + if (Get.isRegistered()) Get.delete(); super.dispose(); } - bool _isControllerRegistered() { - return Get.isRegistered(); + String _formatUptime(int seconds) { + if (seconds <= 0) return 'Online'; + final d = seconds ~/ 86400; + final h = (seconds % 86400) ~/ 3600; + final m = (seconds % 3600) ~/ 60; + if (d > 0) return '${d}d ${h}h'; + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; } @override Widget build(BuildContext context) { - return FadeTransition( - opacity: _fadeAnimation, - child: Padding( - padding: EdgeInsets.all(16), - child: Column( + final homeCtrl = Get.find(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + child: Obx(() { + final info = homeCtrl.hostInfo.value; + final uptime = info?.uptime ?? 0; + final hostname = info?.hostname ?? 'Device'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + // Header + LxHeader( + title: 'Power', + eyebrow: '$hostname · uptime ${_formatUptime(uptime)}', + showBack: false, + ), + + // Status hero card + LxGlass( + padding: const EdgeInsets.all(16), child: Row( children: [ - Icon( - Icons.lightbulb_outline, - size: 20, - color: Get.theme.colorScheme.secondary, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: lxGreen.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: lxGreen.withValues(alpha: 0.3), + ), + ), + child: const Icon( + Icons.power_settings_new_rounded, + size: 18, + color: lxGreen, + ), ), - const SizedBox(width: 8), + const SizedBox(width: 14), Expanded( - child: Text( - 'info_power_management'.tr, - style: TextStyle( - fontSize: 14, - fontStyle: FontStyle.italic, - color: Get.theme.colorScheme.secondary, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'STATUS', + style: TextStyle( + fontSize: 11, + letterSpacing: 1, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + const Text( + 'System Online', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Text( + 'Uptime · ${_formatUptime(uptime)}', + style: const TextStyle( + fontSize: 11, + color: lxTextDim, + ), + ), + ], ), ), ], ), ), - SizedBox(height: 20), - PowerControlCard( - fetchAction: (it) => {_powerController.fetchCommand(it)}, + + const SizedBox(height: 18), + + // 2×2 action tiles + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.35, + children: [ + _powerTile( + icon: Icons.power_settings_new_rounded, + label: 'Shutdown', + sub: 'Save & power off', + accentColor: lxRed, + action: 0, + ), + _powerTile( + icon: Icons.restart_alt_rounded, + label: 'Restart', + sub: 'Reboot now', + accentColor: lxAccent, + action: 1, + ), + _powerTile( + icon: Icons.dark_mode_rounded, + label: 'Sleep', + sub: 'Suspend memory', + accentColor: null, + action: 3, + ), + _powerTile( + icon: Icons.lock_outlined, + label: 'Lock', + sub: 'Lock screen', + accentColor: null, + action: 2, + ), + ], + ), + + const SizedBox(height: 18), + + // Scheduled section + const Text( + 'SCHEDULED', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + LxGlass( + child: Column( + children: [ + _scheduleRow('Auto-sleep after', '20 min idle', last: false), + _scheduleRow('Nightly restart', 'Disabled', last: false), + _scheduleRow('Energy profile', 'Balanced', last: true), + ], + ), + ), + + const SizedBox(height: 14), + + // Disconnect button + GestureDetector( + onTap: () => homeCtrl.disconnectFromDevice(isCleaned: true), + child: Container( + width: double.infinity, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: lxHairline), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.logout_rounded, + size: 14, + color: lxTextDim, + ), + const SizedBox(width: 8), + Text( + 'Disconnect from $hostname', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: lxTextDim, + letterSpacing: -0.1, + ), + ), + ], + ), + ), ), + + const SizedBox(height: 20), ], + ); + }), + ); + } + + Widget _powerTile({ + required IconData icon, + required String label, + required String sub, + Color? accentColor, + required int action, + }) { + final isAccent = accentColor != null; + final tintAlpha = isAccent ? 0.06 : 0.04; + final borderAlpha = isAccent ? 0.28 : 0.08; + + return GestureDetector( + onLongPress: () => _confirmPowerAction(action, label), + onTap: action == 2 ? () => _powerController.fetchCommand(action) : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(lxRadiusCard), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(lxRadiusCard), + color: Color.fromRGBO( + accentColor != null + ? (accentColor.r * 255.0).round().clamp(0, 255) + : 255, + accentColor != null + ? (accentColor.g * 255.0).round().clamp(0, 255) + : 255, + accentColor != null + ? (accentColor.b * 255.0).round().clamp(0, 255) + : 255, + tintAlpha, + ), + border: Border.all( + color: (accentColor ?? lxText).withValues(alpha: borderAlpha), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: (accentColor ?? lxText).withValues( + alpha: isAccent ? 0.12 : 0.06, + ), + border: Border.all( + color: (accentColor ?? lxText).withValues( + alpha: isAccent ? 0.3 : 0.12, + ), + ), + ), + child: Icon(icon, size: 16, color: accentColor ?? lxText), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: isAccent ? accentColor : lxText, + ), + ), + Text( + sub, + style: const TextStyle(fontSize: 11, color: lxTextDim), + ), + ], + ), + ], + ), ), ), ); } + + void _confirmPowerAction(int action, String label) { + if (action == 2) return; // lock needs no confirmation + Get.dialog( + AlertDialog( + backgroundColor: lxSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(lxRadiusModal), + ), + title: Text(label, style: const TextStyle(color: lxText)), + content: Text( + 'Are you sure you want to $label the computer?', + style: const TextStyle(color: lxTextDim), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text( + 'Cancel', + style: TextStyle(color: lxTextFaint), + ), + ), + TextButton( + onPressed: () { + Get.back(); + _powerController.fetchCommand(action); + }, + child: Text( + label, + style: TextStyle( + color: action == 0 ? lxRed : lxAccent, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + Widget _scheduleRow(String title, String detail, {required bool last}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + border: last + ? null + : const Border( + bottom: BorderSide(color: lxHairline, width: 1), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + detail, + style: const TextStyle(fontSize: 12, color: lxTextDim), + ), + const SizedBox(width: 6), + const Icon( + Icons.chevron_right_rounded, + size: 11, + color: lxTextGhost, + ), + ], + ), + ); + } } diff --git a/linqoraremote/lib/presentation/widgets/scripts_view.dart b/linqoraremote/lib/presentation/widgets/scripts_view.dart index 17f816c..061fde1 100644 --- a/linqoraremote/lib/presentation/widgets/scripts_view.dart +++ b/linqoraremote/lib/presentation/widgets/scripts_view.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:linqoraremote/presentation/controllers/script_controller.dart'; -import 'package:linqoraremote/presentation/widgets/loading_view.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; import 'package:linqoraremote/data/models/script_item.dart'; -import '../../core/themes/theme.dart'; -import '../controllers/device_home_controller.dart'; +import 'package:linqoraremote/presentation/controllers/script_controller.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_header.dart'; class ScriptsView extends StatefulWidget { const ScriptsView({super.key}); @@ -14,47 +13,414 @@ class ScriptsView extends StatefulWidget { State createState() => _ScriptsViewState(); } -class _ScriptsViewState extends State - with SingleTickerProviderStateMixin { +class _ScriptsViewState extends State { late final ScriptController _scriptController; - late final DeviceHomeController _homeController; - final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _scriptController = Get.find(); - _homeController = Get.find(); - - // Inject actions into Dashboard AppBar - WidgetsBinding.instance.addPostFrameCallback((_) { - _homeController.appBarActions.addAll([ - IconButton( - icon: const Icon(Icons.refresh_rounded, color: Colors.white), - onPressed: _scriptController.fetchScripts, - ), - IconButton( - icon: const Icon(Icons.add_rounded, color: Colors.white), - onPressed: () => _showScriptDialog(), - ), - ]); - - _homeController.appBarTitleOverride.value = 'scripts'.tr; - }); } @override void dispose() { - _searchController.dispose(); super.dispose(); } + // ─── Build ──────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header + Obx( + () => LxHeader( + title: 'Scripts', + eyebrow: '${_scriptController.scripts.length} saved', + showBack: false, + action: GestureDetector( + onTap: () => _showScriptDialog(), + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 36, + height: 36, + child: Center( + child: Icon( + Icons.add_rounded, + size: 14, + color: lxTextDim, + ), + ), + ), + ), + ), + ), + ), + + // Script list + console + Expanded( + child: Obx(() { + if (_scriptController.isLoadingScripts.value && + _scriptController.scripts.isEmpty) { + return const Center( + child: CircularProgressIndicator( + color: lxAccent, + strokeWidth: 2, + ), + ); + } + + if (_scriptController.filteredScripts.isEmpty) { + return Center( + child: Text( + 'No scripts saved', + style: const TextStyle(color: lxTextFaint), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: lxHairline), + ), + ), + child: Column( + children: _scriptController.filteredScripts + .map((s) => _buildScriptRow(s)) + .toList(), + ), + ), + const SizedBox(height: 18), + _buildConsoleSection(), + const SizedBox(height: 100), + ], + ), + ); + }), + ), + ], + ); + } + + // ─── Script row ────────────────────────────────────────────────────────── + + Widget _buildScriptRow(ScriptItem script) { + return Obx(() { + final isRunning = + _scriptController.executingScripts[script.id] ?? false; + final result = _scriptController.lastExecutionResults[script.id]; + + final Color dotColor; + if (isRunning) { + dotColor = lxAccent; + } else if (result == null) { + dotColor = lxTextFaint; + } else { + dotColor = result.exitCode == 0 ? lxGreen : lxRed; + } + + final tag = _tagForScript(script.command); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: lxHairline, width: 1), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Status dot + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + boxShadow: [ + BoxShadow(color: dotColor, blurRadius: 4), + ], + ), + ), + const SizedBox(width: 10), + + // Name + tag + meta + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + script.name, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + fontWeight: FontWeight.w500, + letterSpacing: -0.1, + color: lxText, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + border: Border.all(color: lxHairline), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + tag, + style: const TextStyle( + fontSize: 9, + fontFamily: 'monospace', + color: lxTextFaint, + letterSpacing: 0.3, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + isRunning + ? 'running…' + : (result != null + ? '${result.durationMs} ms' + : 'not run'), + style: const TextStyle( + fontSize: 10.5, + color: lxTextFaint, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + + // Run / Stop button + GestureDetector( + onTap: isRunning + ? () => _scriptController.stopScript(script.id) + : () => _scriptController.executeScript(script.id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isRunning + ? lxGlass2 + : lxAccent.withValues(alpha: 0.08), + border: Border.all( + color: isRunning + ? lxHairline + : lxAccent.withValues(alpha: 0.3), + ), + ), + child: Text( + isRunning ? 'STOP' : '▶ RUN', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + color: isRunning ? lxTextDim : lxAccent, + ), + ), + ), + ), + ], + ), + ); + }); + } + + // ─── Console section ───────────────────────────────────────────────────── + + Widget _buildConsoleSection() { + return Obx(() { + final runningId = _scriptController.executingScripts.entries + .where((e) => e.value) + .map((e) => e.key) + .firstOrNull; + + final activeId = runningId ?? + (_scriptController.lastExecutionResults.isNotEmpty + ? _scriptController.lastExecutionResults.keys.last + : null); + + final script = activeId != null + ? _scriptController.scripts + .firstWhereOrNull((s) => s.id == activeId) + : null; + + final output = + activeId != null ? (_scriptController.realTimeOutput[activeId] ?? '') : ''; + final isLive = runningId != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Eyebrow row + Row( + children: [ + Text( + 'Console${script != null ? ' · ${script.name}' : ''}', + style: const TextStyle( + fontSize: 10, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (isLive) + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: lxAccent, + boxShadow: [ + BoxShadow(color: lxAccent, blurRadius: 4), + ], + ), + ), + const SizedBox(width: 4), + const Text( + 'live', + style: TextStyle(fontSize: 10, color: lxAccent), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + + // Glass console surface + LxGlass( + child: SizedBox( + height: 180, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Stack( + children: [ + // Output + Padding( + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + reverse: true, + child: output.isEmpty + ? const Text( + r'$ ', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: lxAccent, + ), + ) + : _renderConsoleOutput(output), + ), + ), + + // Bottom gradient fade + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 30, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + lxSurface.withValues(alpha: 0.8), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + }); + } + + Widget _renderConsoleOutput(String output) { + final lines = output.split('\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lines.map((line) { + Color c = lxTextDim; + if (line.startsWith(r'$')) { + c = lxAccent; + } else if (line.contains('✓') || line.contains('ok')) { + c = lxGreen; + } else if (line.contains('⟳') || line.contains('...')) { + c = lxAmber; + } else if (line.contains('error') || line.contains('Error')) { + c = lxRed; + } + return Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: c, + height: 1.7, + ), + ); + }).toList(), + ); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + String _tagForScript(String command) { + final cmd = command.toLowerCase(); + if (cmd.contains('.py') || cmd.startsWith('python')) { + return 'PY'; + } + if (cmd.contains('.js') || cmd.startsWith('node')) { + return 'JS'; + } + if (cmd.contains('.sh') || cmd.startsWith('bash') || cmd.startsWith('./')) { + return 'BASH'; + } + return 'CMD'; + } + + // ─── Script dialog ──────────────────────────────────────────────────────── + void _showScriptDialog([ScriptItem? script]) { final idController = TextEditingController(text: script?.id ?? ''); final nameController = TextEditingController(text: script?.name ?? ''); - final descController = TextEditingController( - text: script?.description ?? '', - ); + final descController = + TextEditingController(text: script?.description ?? ''); final cmdController = TextEditingController(text: script?.command ?? ''); final argsController = TextEditingController( text: script?.args.join(', ') ?? '', @@ -66,13 +432,13 @@ class _ScriptsViewState extends State child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.8), - borderRadius: BorderRadius.circular(28), - border: Border.all(color: Colors.white10), + color: lxSurface, + borderRadius: BorderRadius.circular(lxRadiusModal), + border: Border.all(color: lxHairline), boxShadow: [ BoxShadow( - color: Colors.blueAccent.withOpacity(0.1), - blurRadius: 20, + color: lxAccent.withValues(alpha: 0.08), + blurRadius: 24, ), ], ), @@ -84,7 +450,7 @@ class _ScriptsViewState extends State Text( script == null ? 'add_script'.tr : 'edit_script'.tr, style: const TextStyle( - color: Colors.white, + color: lxText, fontSize: 20, fontWeight: FontWeight.bold, ), @@ -101,14 +467,14 @@ class _ScriptsViewState extends State children: [ TextButton( onPressed: () => Get.back(), - child: Text( - 'cancel'.tr, - style: const TextStyle(color: Colors.white60), + child: const Text( + 'cancel', + style: TextStyle(color: lxTextDim), ), ), const SizedBox(width: 12), - ElevatedButton( - onPressed: () { + GestureDetector( + onTap: () { final newScript = ScriptItem( id: idController.text, name: nameController.text, @@ -127,13 +493,27 @@ class _ScriptsViewState extends State } Get.back(); }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + decoration: BoxDecoration( + color: lxAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(lxRadiusTile), + border: Border.all( + color: lxAccent.withValues(alpha: 0.35), + ), + ), + child: const Text( + 'Save', + style: TextStyle( + color: lxAccent, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), ), - child: Text('save'.tr), ), ], ), @@ -155,322 +535,21 @@ class _ScriptsViewState extends State child: TextField( controller: controller, enabled: enabled, - style: const TextStyle(color: Colors.white, fontSize: 14), + style: const TextStyle(color: lxText, fontSize: 14), decoration: InputDecoration( labelText: label, - labelStyle: const TextStyle(color: Colors.white38), + labelStyle: const TextStyle(color: lxTextFaint), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.blueAccent), + borderSide: BorderSide(color: lxHairline), ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: TextField( - controller: _searchController, - onChanged: (v) => _scriptController.searchQuery.value = v, - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - hintText: 'search_scripts'.tr, - hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)), - prefixIcon: Icon( - Icons.search_rounded, - color: Colors.white.withOpacity(0.3), - size: 20, - ), - filled: true, - fillColor: Colors.white.withOpacity(0.05), - contentPadding: const EdgeInsets.symmetric(vertical: 0), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: Colors.white.withOpacity(0.05)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: Colors.white.withOpacity(0.05)), - ), - ), - ), - ), - Expanded( - child: Obx(() { - if (_scriptController.isLoadingScripts.value && - _scriptController.scripts.isEmpty) { - return const LoadingView(); - } - - if (_scriptController.filteredScripts.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.description_outlined, - size: 64, - color: Colors.white.withOpacity(0.5), - ), - const SizedBox(height: 16), - Text( - 'no_scripts_available'.tr, - style: TextStyle( - color: Colors.white.withOpacity(0.7), - fontSize: 16, - ), - ), - ], - ).animate().fadeIn(), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _scriptController.filteredScripts.length, - itemBuilder: (context, index) { - final script = _scriptController.filteredScripts[index]; - return _buildScriptCard(script, index); - }, - ); - }), + disabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: lxHairline), ), - ], - ); - } - - Widget _buildHeaderButton({ - required IconData icon, - required VoidCallback onTap, - required Color color, - }) { - return Material( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withOpacity(0.2)), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: lxAccent), ), - child: Icon(icon, color: Colors.white, size: 20), ), ), ); } - - Widget _buildScriptCard(ScriptItem script, int index) { - return Obx(() { - final isExecuting = - _scriptController.executingScripts[script.id] ?? false; - final lastResult = _scriptController.lastExecutionResults[script.id]; - final rtOutput = _scriptController.realTimeOutput[script.id] ?? ''; - - return Container( - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Material( - color: Colors.transparent, - child: ExpansionTile( - leading: - Icon( - Icons.terminal, - color: isExecuting ? Colors.orange : Colors.blueAccent, - ) - .animate(target: isExecuting ? 1 : 0) - .shimmer(duration: const Duration(seconds: 1)), - title: Text( - script.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - script.description, - style: TextStyle( - color: Colors.white.withOpacity(0.6), - fontSize: 12, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon( - Icons.edit, - size: 18, - color: Colors.white38, - ), - onPressed: () => _showScriptDialog(script), - ), - IconButton( - icon: const Icon( - Icons.delete_outline, - size: 18, - color: Colors.redAccent, - ), - onPressed: () => Get.defaultDialog( - title: 'delete_script'.tr, - middleText: 'confirm_delete'.tr, - backgroundColor: Colors.grey[900], - titleStyle: const TextStyle(color: Colors.white), - middleTextStyle: const TextStyle(color: Colors.white70), - textCancel: 'cancel'.tr, - textConfirm: 'delete'.tr, - confirmTextColor: Colors.white, - onConfirm: () { - _scriptController.deleteScript(script.id); - Get.back(); - }, - ), - ), - const SizedBox(width: 8), - isExecuting - ? SizedBox( - width: 80, - child: ElevatedButton( - onPressed: () => - _scriptController.stopScript(script.id), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.withOpacity(0.2), - foregroundColor: Colors.redAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.zero, - ), - child: Text( - 'stop'.tr, - style: const TextStyle(fontSize: 10), - ), - ), - ) - : ElevatedButton( - onPressed: () => - _scriptController.executeScript(script.id), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent.withOpacity(0.2), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - child: Text( - 'execute'.tr, - style: const TextStyle(fontSize: 10), - ), - ), - ], - ), - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(color: Colors.white10), - if (isExecuting || rtOutput.isNotEmpty) ...[ - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'OUTPUT:', - style: TextStyle( - color: Colors.blueAccent.withOpacity(0.5), - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - if (isExecuting) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1, - color: Colors.blueAccent, - ), - ), - ], - ), - const SizedBox(height: 8), - Container( - width: double.infinity, - constraints: const BoxConstraints(maxHeight: 200), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - reverse: true, - child: Text( - rtOutput.isEmpty - ? 'Waiting for output...'.tr - : rtOutput, - style: const TextStyle( - color: Colors.white70, - fontSize: 10, - fontFamily: 'monospace', - ), - ), - ), - ), - ], - if (!isExecuting && lastResult != null) ...[ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${'exit_code'.tr}: ${lastResult.exitCode}', - style: TextStyle( - color: lastResult.exitCode == 0 - ? Colors.green - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - Text( - '${lastResult.durationMs} ms', - style: TextStyle( - color: Colors.white.withOpacity(0.4), - fontSize: 10, - ), - ), - ], - ), - ], - ], - ), - ), - ], - ), - ), - ), - ).animate().fadeIn(delay: (index * 100).ms).slideX(begin: 0.1); - }); - } } diff --git a/linqoraremote/lib/presentation/widgets/touchpad_view.dart b/linqoraremote/lib/presentation/widgets/touchpad_view.dart index 5738b03..f25f514 100644 --- a/linqoraremote/lib/presentation/widgets/touchpad_view.dart +++ b/linqoraremote/lib/presentation/widgets/touchpad_view.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; import 'package:linqoraremote/data/providers/websocket_provider.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/presentation/widgets/lx_header.dart'; import '../controllers/mouse_controller.dart'; @@ -12,8 +15,16 @@ class TouchpadView extends StatefulWidget { State createState() => _TouchpadViewState(); } -class _TouchpadViewState extends State { - late final MouseController _mouse; +class _TouchpadViewState extends State + with TickerProviderStateMixin { + late MouseController _mouse; + + final List<_Ripple> _ripples = []; + Offset _pointerPos = const Offset(0.5, 0.5); // normalised 0-1 + + // Scroll accumulator for the centre scroll button + double _scrollAccum = 0; + static const _notchThreshold = 40.0; @override void initState() { @@ -25,6 +36,9 @@ class _TouchpadViewState extends State { @override void dispose() { + for (final r in _ripples) { + r.anim.dispose(); + } if (Get.isRegistered()) Get.delete(); super.dispose(); } @@ -32,209 +46,374 @@ class _TouchpadViewState extends State { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), child: Column( children: [ + LxHeader( + title: 'Touchpad', + eyebrow: 'Connected', + showBack: false, + action: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 36, + height: 36, + child: Center( + child: Icon( + Icons.more_horiz_rounded, + size: 14, + color: lxTextDim, + ), + ), + ), + ), + ), + Expanded( + child: LxGlass( + child: Column( + children: [ + Expanded(child: _touchSurface()), + const Divider(height: 1, color: lxHairline), + _mouseButtons(), + ], + ), + ), + ), + const SizedBox(height: 14), _sensitivityRow(), - const SizedBox(height: 12), - Expanded(child: _touchpad()), - const SizedBox(height: 12), - _scrollStrip(), - const SizedBox(height: 12), - _buttonRow(), ], ), ); } - Widget _sensitivityRow() { - return Row( - children: [ - Text('sensitivity'.tr, style: Get.textTheme.bodyMedium), - Expanded( - child: Obx( - () => Slider( - value: _mouse.sensitivity.value, - min: 0.5, - max: 4.0, - divisions: 7, - label: '×${_mouse.sensitivity.value.toStringAsFixed(1)}', - onChanged: (v) => _mouse.sensitivity.value = v, - ), - ), + // ─── Touch surface ─────────────────────────────────────────────────────────── + + Widget _touchSurface() { + return GestureDetector( + onPanUpdate: (d) { + _mouse.moveMouse(d.delta.dx, d.delta.dy); + setState(() { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final local = box.globalToLocal(d.globalPosition); + _pointerPos = Offset( + (local.dx / box.size.width).clamp(0.0, 1.0), + (local.dy / box.size.height).clamp(0.0, 1.0), + ); + } + }); + }, + onTap: () { + HapticFeedback.lightImpact(); + _mouse.leftClick(); + _addRipple(); + }, + onDoubleTap: () { + HapticFeedback.lightImpact(); + _mouse.doubleClick(); + }, + onLongPress: () { + HapticFeedback.mediumImpact(); + _mouse.rightClick(); + }, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(lxRadiusCard), + topRight: Radius.circular(lxRadiusCard), ), - Obx( - () => SizedBox( - width: 36, - child: Text( - '×${_mouse.sensitivity.value.toStringAsFixed(1)}', - style: Get.textTheme.bodySmall, + child: Stack( + children: [ + // Dot-grid background + Positioned.fill(child: CustomPaint(painter: _DotGridPainter())), + + // Ripple rings + ..._ripples.map( + (r) => AnimatedBuilder( + animation: r.anim, + builder: (_, __) => Positioned.fill( + child: CustomPaint(painter: _RipplePainter(ripple: r)), + ), + ), ), - ), - ), - ], - ); - } - Widget _touchpad() { - return Card( - elevation: 0, - color: Theme.of(context).colorScheme.surfaceContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: GestureDetector( - // pan → move - onPanUpdate: (d) => _mouse.moveMouse(d.delta.dx, d.delta.dy), - // tap → left click - onTap: () { - HapticFeedback.lightImpact(); - _mouse.leftClick(); - }, - // double-tap → double click - onDoubleTap: () { - HapticFeedback.lightImpact(); - _mouse.doubleClick(); - }, - // long press → right click - onLongPress: () { - HapticFeedback.mediumImpact(); - _mouse.rightClick(); - }, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.touch_app_outlined, - size: 48, - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(60), - ), - const SizedBox(height: 8), - Text( - 'touchpad_hint'.tr, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(80), - fontSize: 12, + // Cyan pointer dot — uses LayoutBuilder so normalised coords map + // to real pixels. + Positioned.fill( + child: LayoutBuilder( + builder: (_, constraints) { + final x = _pointerPos.dx * constraints.maxWidth - 4; + final y = _pointerPos.dy * constraints.maxHeight - 4; + return Stack( + children: [ + Positioned( + left: x, + top: y, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: lxAccent, + boxShadow: [ + BoxShadow( + color: lxAccent, + blurRadius: 12, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + + // Corner hint + const Positioned( + top: 16, + left: 16, + child: Text( + 'DRAG · TAP · TWO-FINGER SCROLL', + style: TextStyle( + fontSize: 9, + color: lxTextFaint, + letterSpacing: 1.4, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Right-edge scroll strip (decorative thumb) + Positioned( + top: 14, + bottom: 14, + right: 12, + child: Container( + width: 4, + decoration: BoxDecoration( + color: lxHairline, + borderRadius: BorderRadius.circular(2), + ), + child: FractionallySizedBox( + heightFactor: 0.15, + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + color: lxAccent.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), ), - textAlign: TextAlign.center, ), - ], + ), ), ), - ), + ], ), ), ); } - // Vertical drag strip for scrolling. - Widget _scrollStrip() { - double _scrollAccum = 0; - const notchThreshold = 40.0; // pixels per scroll notch - - return StatefulBuilder( - builder: (ctx, setState) { - return Card( - elevation: 0, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: GestureDetector( - onVerticalDragUpdate: (d) { - _scrollAccum += d.delta.dy; - while (_scrollAccum >= notchThreshold) { - HapticFeedback.selectionClick(); - _mouse.scroll(-1); // drag down = scroll down - _scrollAccum -= notchThreshold; - } - while (_scrollAccum <= -notchThreshold) { - HapticFeedback.selectionClick(); - _mouse.scroll(1); // drag up = scroll up - _scrollAccum += notchThreshold; - } - }, - onVerticalDragEnd: (_) { - _scrollAccum = 0; - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.swap_vert, - size: 20, - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(120), + // ─── Mouse buttons row ─────────────────────────────────────────────────────── + + Widget _mouseButtons() { + return SizedBox( + height: 76, + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + _mouse.leftClick(); + }, + child: const Center( + child: Text( + 'LEFT', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 8), - Text( - 'scroll'.tr, - style: TextStyle( - fontSize: 13, - color: Theme.of( - context, - ).colorScheme.onSurface.withAlpha(140), - ), + ), + ), + ), + ), + Container(width: 1, color: lxHairline), + SizedBox( + width: 76, + child: GestureDetector( + onVerticalDragUpdate: (d) { + _scrollAccum += d.delta.dy; + while (_scrollAccum >= _notchThreshold) { + HapticFeedback.selectionClick(); + _mouse.scroll(-1); // drag down = scroll down + _scrollAccum -= _notchThreshold; + } + while (_scrollAccum <= -_notchThreshold) { + HapticFeedback.selectionClick(); + _mouse.scroll(1); // drag up = scroll up + _scrollAccum += _notchThreshold; + } + }, + onVerticalDragEnd: (_) => _scrollAccum = 0, + child: const Center( + child: Icon( + Icons.unfold_more_rounded, + size: 16, + color: lxTextDim, + ), + ), + ), + ), + Container(width: 1, color: lxHairline), + Expanded( + child: GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + _mouse.rightClick(); + }, + child: const Center( + child: Text( + 'RIGHT', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, ), - ], + ), ), ), ), - ); - }, + ], + ), ); } - Widget _buttonRow() { + // ─── Sensitivity row ───────────────────────────────────────────────────────── + + Widget _sensitivityRow() { return Row( children: [ - Expanded( - child: _clickButton( - label: 'left_click'.tr, - icon: Icons.mouse, - onPressed: _mouse.leftClick, + const Text( + 'SENSITIVITY', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.4, + color: lxTextFaint, + fontWeight: FontWeight.w500, ), ), const SizedBox(width: 12), Expanded( - child: _clickButton( - label: 'right_click'.tr, - icon: Icons.ads_click, - onPressed: _mouse.rightClick, + child: Obx( + () => SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + activeTrackColor: lxAccent, + inactiveTrackColor: lxHairlineHi, + thumbColor: lxText, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + overlayColor: Colors.transparent, + ), + child: Slider( + value: _mouse.sensitivity.value, + min: 0.5, + max: 4.0, + divisions: 14, + onChanged: (v) => _mouse.sensitivity.value = v, + ), + ), + ), + ), + const SizedBox(width: 8), + Obx( + () => SizedBox( + width: 36, + child: Text( + '${_mouse.sensitivity.value.toStringAsFixed(1)}×', + style: const TextStyle(fontSize: 11, color: lxTextDim), + ), ), ), ], ); } - Widget _clickButton({ - required String label, - required IconData icon, - required VoidCallback onPressed, - }) { - return ElevatedButton.icon( - onPressed: () { - HapticFeedback.lightImpact(); - onPressed(); - }, - icon: Icon(icon, size: 20), - label: Text(label), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), + // ─── Ripple helpers ─────────────────────────────────────────────────────────── + + void _addRipple() { + final ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), ); + final ripple = _Ripple(pos: _pointerPos, anim: ctrl); + ctrl + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + setState(() => _ripples.removeWhere((r) => r.anim.isCompleted)); + } + }) + ..forward(); + + setState(() { + _ripples.add(ripple); + if (_ripples.length > 4) { + _ripples.removeAt(0); + } + }); } } + +// ─── Data classes ───────────────────────────────────────────────────────────── + +class _Ripple { + final Offset pos; + final AnimationController anim; + _Ripple({required this.pos, required this.anim}); +} + +// ─── Custom painters ───────────────────────────────────────────────────────── + +class _DotGridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = lxHairlineHi.withValues(alpha: 0.8); + const spacing = 22.0; + for (double x = 0; x <= size.width; x += spacing) { + for (double y = 0; y <= size.height; y += spacing) { + canvas.drawCircle(Offset(x, y), 1, paint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _RipplePainter extends CustomPainter { + final _Ripple ripple; + _RipplePainter({required this.ripple}); + + @override + void paint(Canvas canvas, Size size) { + final t = ripple.anim.value; + final cx = ripple.pos.dx * size.width; + final cy = ripple.pos.dy * size.height; + for (int i = 1; i <= 3; i++) { + final radius = 4 + (60 * i / 3) * t; + final opacity = (1 - t) * (0.4 / i); + final paint = Paint() + ..color = lxAccent.withValues(alpha: opacity) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + canvas.drawCircle(Offset(cx, cy), radius, paint); + } + } + + @override + bool shouldRepaint(_RipplePainter old) => + old.ripple.anim.value != ripple.anim.value; +} From 40653baa2ecc2b9650de0ca54c9a8a9b5973b82b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 8 May 2026 01:20:24 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20small=20fix=20=201.=20device=5Fauth?= =?UTF-8?q?=5Fpage.dart=20=E2=80=94=20full=20redesign=20=20=20-=20Replaced?= =?UTF-8?q?=20AnimatedAuroraBackground=20=E2=86=92=20LxBackground,=20remov?= =?UTF-8?q?ed=20AppBarCustom=20=20=20-=20New=20custom=20header:=20"LINQORA?= =?UTF-8?q?"=20wordmark=20left=20+=20settings=20gear=20(LxGlass=20circle)?= =?UTF-8?q?=20right=20=20=20-=20All=20LinStyles.glassMorphism=20=E2=86=92?= =?UTF-8?q?=20LxGlass=20=20=20-=20All=20Material=20theme=20colors=20?= =?UTF-8?q?=E2=86=92=20lxAccent,=20lxText,=20lxTextDim,=20lxRed,=20lxAmber?= =?UTF-8?q?,=20lxTextGhost=20=20=20-=20ElevatedButton/OutlinedButton=20?= =?UTF-8?q?=E2=86=92=20LxGlass=20with=20onTap=20(accent=20variant=20for=20?= =?UTF-8?q?the=20refresh=20action)=20=20=20-=20Device=20list=20items=20reb?= =?UTF-8?q?uilt=20with=20the=20LX=20icon-box=20style=20matching=20the=20mo?= =?UTF-8?q?dule=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2. device_home_page.dart — status bar fix - Wrapped WillPopScope in SafeArea(bottom: false) — reconnect banner and all content now start below the system status bar; bottom: false leaves tab bar padding to DashboardScreen 3. dashboard_screen.dart — settings restored - Added a settings_outlined LxGlass icon button in the hub's connection row (left of the existing disconnect button), navigating to AppRoutes.SETTINGS --- .claude/settings.local.json | 4 +- .../controllers/monitoring_controller.dart | 44 +- .../presentation/pages/device_auth_page.dart | 486 ++++++++++-------- .../presentation/pages/device_home_page.dart | 208 ++++---- .../widgets/dashboard_screen.dart | 28 +- .../presentation/widgets/monitoring_view.dart | 56 +- 6 files changed, 469 insertions(+), 357 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9914b3f..6c434be 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,9 @@ "Bash(git add *)", "Bash(echo \"EXIT:$?\")", "Bash(echo \"BUILD_EXIT:$?\")", - "Bash(python -c \"import yaml; yaml.safe_load\\(open\\('.github/workflows/ci.yml'\\)\\)\")" + "Bash(python -c \"import yaml; yaml.safe_load\\(open\\('.github/workflows/ci.yml'\\)\\)\")", + "Bash(Get-ChildItem -Recurse \"C:\\\\Users\\\\pasichdev\\\\AndroidStudioProjects\\\\linqora\\\\design_handoff_linqora\")", + "Bash(Select-Object FullName, Length)" ] } } diff --git a/linqoraremote/lib/presentation/controllers/monitoring_controller.dart b/linqoraremote/lib/presentation/controllers/monitoring_controller.dart index 3fb6629..b7a7f52 100644 --- a/linqoraremote/lib/presentation/controllers/monitoring_controller.dart +++ b/linqoraremote/lib/presentation/controllers/monitoring_controller.dart @@ -91,16 +91,45 @@ class MonitoringController extends GetxController { super.onClose(); } + // Buffer for incoming metrics to provide smooth, delayed updates. + final List<({DateTime timestamp, Map data})> _metricsBuffer = []; + void _handleMetricsUpdate(Map data) { final rawData = data['data']; - if (rawData == null || rawData is! Map) { - AppLogger.release( - 'Invalid metrics payload — missing or malformed "data" field', - module: 'MonitoringController', - ); - return; + if (rawData == null || rawData is! Map) return; + + // Add to buffer with current timestamp + _metricsBuffer.add((timestamp: DateTime.now(), data: rawData)); + + // Start processing timer if not already running + _startBufferTimer(); + } + + bool _isTimerRunning = false; + void _startBufferTimer() { + if (_isTimerRunning) return; + _isTimerRunning = true; + + // Check buffer every 500ms + Stream.periodic(const Duration(milliseconds: 500)).listen((_) { + _processBuffer(); + }); + } + + void _processBuffer() { + if (_metricsBuffer.isEmpty) return; + + final now = DateTime.now(); + final delay = const Duration(seconds: 3); + + // Process all metrics that have reached the delay threshold + while (_metricsBuffer.isNotEmpty && now.difference(_metricsBuffer.first.timestamp) >= delay) { + final entry = _metricsBuffer.removeAt(0); + _applyMetrics(entry.data); } + } + void _applyMetrics(Map rawData) { try { final cpuJson = rawData['cpuMetrics']; final ramJson = rawData['ramMetrics']; @@ -120,7 +149,7 @@ class MonitoringController extends GetxController { ); } catch (e) { AppLogger.release( - 'Error parsing metrics: $e', + 'Error applying metrics: $e', module: 'MonitoringController', ); } @@ -129,6 +158,7 @@ class MonitoringController extends GetxController { void _resetMetrics() { currentCPUMetrics.value = null; currentRAMMetrics.value = null; + _metricsBuffer.clear(); _tempBuf.clear(); _cpuBuf.clear(); _ramBuf.clear(); diff --git a/linqoraremote/lib/presentation/pages/device_auth_page.dart b/linqoraremote/lib/presentation/pages/device_auth_page.dart index a6d93aa..ef5920f 100644 --- a/linqoraremote/lib/presentation/pages/device_auth_page.dart +++ b/linqoraremote/lib/presentation/pages/device_auth_page.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:linqoraremote/presentation/controllers/auth_controller.dart'; -import 'package:linqoraremote/presentation/widgets/app_bar.dart'; -import 'package:linqoraremote/presentation/widgets/animated_aurora_background.dart'; -import 'package:linqoraremote/core/themes/lin_styles.dart'; +import 'package:linqoraremote/presentation/widgets/lx_background.dart'; +import 'package:linqoraremote/presentation/widgets/lx_glass.dart'; +import 'package:linqoraremote/core/themes/lx_theme.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; import '../../core/constants/names.dart'; import '../../core/constants/urls.dart'; -import '../../core/themes/styles.dart'; import '../../core/utils/launch_url.dart'; +import '../../routes/app_routes.dart'; class DeviceAuthPage extends StatefulWidget { const DeviceAuthPage({super.key}); @@ -30,15 +30,17 @@ class _DeviceAuthPageState extends State { @override Widget build(BuildContext context) { - return AnimatedAuroraBackground( + return LxBackground( child: Scaffold( backgroundColor: Colors.transparent, - appBar: const AppBarCustom(), body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 16), + _buildHeader(), const SizedBox(height: 20), Obx(() { if (authController.authStatus.value != @@ -97,39 +99,65 @@ class _DeviceAuthPageState extends State { ); } + Widget _buildHeader() { + return Row( + children: [ + const Text( + 'LINQORA', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + color: lxText, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Get.toNamed(AppRoutes.SETTINGS), + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 38, + height: 38, + child: Icon( + Icons.settings_outlined, + size: 18, + color: lxTextDim, + ), + ), + ), + ), + ], + ); + } + Widget _buildNoWifi() { return Center( - child: LinStyles.glassMorphism( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_rounded, - size: 80, - color: Theme.of(context).colorScheme.error, - ) - .animate(onPlay: (c) => c.repeat()) - .shimmer(duration: const Duration(seconds: 2)), - const SizedBox(height: 24), - Text( - 'wifi_no_connection'.tr, - style: Get.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + child: LxGlass( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off_rounded, size: 64, color: lxRed) + .animate(onPlay: (c) => c.repeat()) + .shimmer(duration: const Duration(seconds: 2)), + const SizedBox(height: 24), + Text( + 'wifi_no_connection'.tr, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: lxText, ), - const SizedBox(height: 12), - Text( - 'reconnect_to_wifi_please'.tr, - style: Get.textTheme.bodyMedium?.copyWith( - color: Colors.white70, - ), - textAlign: TextAlign.center, - ), - ], - ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'reconnect_to_wifi_please'.tr, + style: const TextStyle(fontSize: 14, color: lxTextDim), + textAlign: TextAlign.center, + ), + ], ), ), ); @@ -139,16 +167,16 @@ class _DeviceAuthPageState extends State { return Row( children: [ Expanded( - child: _buildGlassButton( - onPressed: () => launchUrlHandler(howItWorks), + child: _buildLxButton( + onTap: () => launchUrlHandler(howItWorks), icon: Icons.help_outline_rounded, label: 'how_does_work'.tr, ), ), const SizedBox(width: 12), Expanded( - child: _buildGlassButton( - onPressed: () => launchUrlHandler(getLinqoraHost), + child: _buildLxButton( + onTap: () => launchUrlHandler(getLinqoraHost), icon: Icons.computer_rounded, label: appNameHost, ), @@ -157,40 +185,32 @@ class _DeviceAuthPageState extends State { ); } - Widget _buildGlassButton({ - required VoidCallback onPressed, + Widget _buildLxButton({ + required VoidCallback onTap, required IconData icon, required String label, }) { - return LinStyles.glassMorphism( - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - overflow: TextOverflow.ellipsis, - ), + return LxGlass( + borderRadius: BorderRadius.circular(lxRadiusTile), + onTap: onTap, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: lxAccent), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: lxText, ), - ], + overflow: TextOverflow.ellipsis, + ), ), - ), + ], ), ); } @@ -201,14 +221,19 @@ class _DeviceAuthPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ LoadingAnimationWidget.staggeredDotsWave( - color: Theme.of(context).colorScheme.primary, - size: 80, + color: lxAccent, + size: 64, ), const SizedBox(height: 32), Text( - 'search_devices_mdns'.tr, - style: Get.textTheme.titleMedium?.copyWith(letterSpacing: 1.2), - ) + 'search_devices_mdns'.tr, + style: const TextStyle( + fontSize: 14, + letterSpacing: 1.2, + color: lxTextDim, + fontWeight: FontWeight.w500, + ), + ) .animate(onPlay: (c) => c.repeat()) .shimmer(duration: const Duration(milliseconds: 1500)), ], @@ -218,44 +243,48 @@ class _DeviceAuthPageState extends State { Widget _buildConnectingView() { return Center( - child: LinStyles.glassMorphism( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - LoadingAnimationWidget.inkDrop( - color: Theme.of(context).colorScheme.primary, - size: 60, - ), - const SizedBox(height: 24), - Obx( - () => Text( + child: LxGlass( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LoadingAnimationWidget.inkDrop(color: lxAccent, size: 56), + const SizedBox(height: 24), + Obx(() => Text( "${'connecting_for'.tr}\n${authController.authDevice.value?.name ?? '...'}" .toUpperCase(), - style: Get.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w900, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, letterSpacing: 2, + color: lxText, ), textAlign: TextAlign.center, + )), + const SizedBox(height: 24), + GestureDetector( + onTap: () { + authController.cancelAuth('cancel_aut_for_user'.tr); + authController.authStatus.value = AuthStatus.scanning; + }, + child: LxGlass( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 10, ), - ), - const SizedBox(height: 32), - OutlinedButton( - onPressed: () { - authController.cancelAuth('cancel_aut_for_user'.tr); - authController.authStatus.value = AuthStatus.scanning; - }, - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - side: BorderSide( - color: Theme.of(context).colorScheme.error.withOpacity(0.5), + borderRadius: BorderRadius.circular(lxRadiusTile), + child: Text( + 'cancel'.tr.toUpperCase(), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: lxRed, + letterSpacing: 1.2, ), ), - child: Text('cancel'.tr), ), - ], - ), + ), + ], ), ), ); @@ -263,77 +292,77 @@ class _DeviceAuthPageState extends State { Widget _buildAuthPendingView() { return Center( - child: LinStyles.glassMorphism( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.security_rounded, - size: 80, - color: Theme.of(context).colorScheme.primary, - ) - .animate(onPlay: (c) => c.repeat()) - .scale( - duration: const Duration(seconds: 1), - begin: const Offset(0.9, 0.9), - end: const Offset(1.1, 1.1), - curve: Curves.easeInOut, - ), - const SizedBox(height: 24), - Text( - 'auth_request_sending'.tr, - style: Get.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - 'auth_request_description'.tr, - style: Get.textTheme.bodyMedium?.copyWith( - color: Colors.white70, + child: LxGlass( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.security_rounded, size: 64, color: lxAccent) + .animate(onPlay: (c) => c.repeat()) + .scale( + duration: const Duration(seconds: 1), + begin: const Offset(0.9, 0.9), + end: const Offset(1.1, 1.1), + curve: Curves.easeInOut, ), - textAlign: TextAlign.center, + const SizedBox(height: 24), + Text( + 'auth_request_sending'.tr, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: lxText, ), - const SizedBox(height: 24), - Obx( - () => Container( + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'auth_request_description'.tr, + style: const TextStyle(fontSize: 14, color: lxTextDim), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Obx(() => Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10, ), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.1), + color: const Color(0x1A00E5FF), borderRadius: BorderRadius.circular(30), + border: Border.all(color: const Color(0x4D00E5FF)), ), child: Text( authController.authTimeoutSeconds.value.toString(), - style: TextStyle( + style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.primary, + color: lxAccent, ), ), + )), + const SizedBox(height: 24), + GestureDetector( + onTap: () => + authController.cancelAuth('cancel_aut_for_user'.tr), + child: LxGlass( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 10, ), - ), - const SizedBox(height: 32), - OutlinedButton( - onPressed: () => - authController.cancelAuth('cancel_aut_for_user'.tr), - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - side: BorderSide( - color: Theme.of(context).colorScheme.error.withOpacity(0.5), + borderRadius: BorderRadius.circular(lxRadiusTile), + child: Text( + 'cancel'.tr.toUpperCase(), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: lxRed, + letterSpacing: 1.2, ), ), - child: Text('cancel'.tr), ), - ], - ), + ), + ], ), ), ); @@ -346,24 +375,24 @@ class _DeviceAuthPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.devices_other_rounded, - size: 80, - color: Colors.white24, + size: 64, + color: lxTextGhost, ), const SizedBox(height: 24), Text( 'empty_devices_linqora'.tr, - style: Get.textTheme.titleMedium?.copyWith( + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold, + color: lxText, ), ), const SizedBox(height: 12), Text( 'empty_devices_linqora_descriptions'.tr, - style: Get.textTheme.bodyMedium?.copyWith( - color: Colors.white54, - ), + style: const TextStyle(fontSize: 13, color: lxTextDim), textAlign: TextAlign.center, ), ], @@ -376,56 +405,71 @@ class _DeviceAuthPageState extends State { padding: const EdgeInsets.symmetric(vertical: 10), itemBuilder: (context, index) { final device = authController.discoveredDevices[index]; + final isTLS = device.supportsTLS; return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: LinStyles.glassMorphism( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: - (device.supportsTLS - ? Theme.of(context).colorScheme.primary - : Colors.orange) - .withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon( - device.supportsTLS - ? Icons.computer_rounded - : Icons.warning_amber_rounded, - color: device.supportsTLS - ? Theme.of(context).colorScheme.primary - : Colors.orange, + padding: const EdgeInsets.only(bottom: 12), + child: LxGlass( + onTap: () => authController.connectToDevice(device), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isTLS + ? const Color(0x1A00E5FF) + : const Color(0x1AFFB547), + borderRadius: BorderRadius.circular(lxRadiusInner), + border: Border.all( + color: isTLS + ? const Color(0x4D00E5FF) + : const Color(0x4DFFB547), + ), + ), + child: Icon( + isTLS + ? Icons.computer_rounded + : Icons.warning_amber_rounded, + size: 18, + color: isTLS ? lxAccent : lxAmber, + ), ), - ), - title: Text( - device.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 17, + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + color: lxText, + ), + ), + Text( + '${device.address}:${device.port}', + style: const TextStyle( + fontSize: 12, + color: lxTextDim, + ), + ), + ], + ), ), - ), - subtitle: Text( - '${device.address}:${device.port}', - style: TextStyle( - color: Colors.white.withOpacity(0.5), - fontSize: 13, + const Icon( + Icons.chevron_right_rounded, + size: 18, + color: lxTextGhost, ), - ), - trailing: Icon( - Icons.arrow_forward_ios_rounded, - size: 16, - color: Colors.white.withOpacity(0.3), - ), - onTap: () => authController.connectToDevice(device), + ], ), ), - ).animate().fadeIn(delay: (index * 100).ms).slideX(begin: 0.2); + ).animate().fadeIn(delay: (index * 80).ms).slideX(begin: 0.15); }, ); }); @@ -433,18 +477,36 @@ class _DeviceAuthPageState extends State { Widget _buildActionButton() { return Padding( - padding: const EdgeInsets.only(bottom: 24, top: 16), + padding: const EdgeInsets.only(bottom: 24, top: 8), child: Obx(() { if (authController.authStatus.value == AuthStatus.scanning || authController.authStatus.value == AuthStatus.connecting || authController.authStatus.value == AuthStatus.pendingAuth) { return const SizedBox.shrink(); } - - return ElevatedButton.icon( - onPressed: authController.startDiscovery, - icon: const Icon(Icons.refresh_rounded), - label: Text('update'.tr.toUpperCase()), + return SizedBox( + width: double.infinity, + child: LxGlass( + accent: true, + onTap: authController.startDiscovery, + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.refresh_rounded, size: 18, color: lxAccent), + const SizedBox(width: 8), + Text( + 'update'.tr.toUpperCase(), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: lxAccent, + letterSpacing: 1.0, + ), + ), + ], + ), + ), ); }), ); diff --git a/linqoraremote/lib/presentation/pages/device_home_page.dart b/linqoraremote/lib/presentation/pages/device_home_page.dart index 964341e..671867a 100644 --- a/linqoraremote/lib/presentation/pages/device_home_page.dart +++ b/linqoraremote/lib/presentation/pages/device_home_page.dart @@ -17,116 +17,122 @@ class DeviceHomePage extends GetView { return LxBackground( child: Scaffold( backgroundColor: Colors.transparent, - body: WillPopScope( - onWillPop: () async { - if (controller.selectedMenuIndex.value != -1) { - controller.selectMenuItem(-1); + body: SafeArea( + bottom: false, + child: WillPopScope( + onWillPop: () async { + if (controller.selectedMenuIndex.value != -1) { + controller.selectMenuItem(-1); + return false; + } + if (controller.webSocketProvider.isConnected && + controller.selectedMenuIndex.value == -1) { + await DisconnectConfirmationDialog.show( + onConfirm: () => + {controller.disconnectFromDevice(isCleaned: true)}, + onCancel: () => Get.back(), + ); + } else { + controller.disconnectFromDevice(isCleaned: true); + return true; + } return false; - } - if (controller.webSocketProvider.isConnected && - controller.selectedMenuIndex.value == -1) { - await DisconnectConfirmationDialog.show( - onConfirm: () => - {controller.disconnectFromDevice(isCleaned: true)}, - onCancel: () => Get.back(), - ); - } else { - controller.disconnectFromDevice(isCleaned: true); - return true; - } - return false; - }, - child: Column( - children: [ - Obx(() { - final state = webSocketProvider.reconnectState.value; - if (state == ReconnectState.reconnecting) { - return Container( - width: double.infinity, - color: Colors.orange.withOpacity(0.85), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Obx( - () => Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), + }, + child: Column( + children: [ + Obx(() { + final state = webSocketProvider.reconnectState.value; + if (state == ReconnectState.reconnecting) { + return Container( + width: double.infinity, + color: Colors.orange.withOpacity(0.85), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Obx( + () => Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), ), - ), - const SizedBox(width: 10), - Text( - '${'reconnecting'.tr}... (${webSocketProvider.reconnectSecondsLeft.value}s)', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 13, + const SizedBox(width: 10), + Text( + '${'reconnecting'.tr}... (${webSocketProvider.reconnectSecondsLeft.value}s)', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), - ), - ], - ), - ), - ); - } else if (state == ReconnectState.failed) { - return Container( - width: double.infinity, - color: Colors.red.shade700.withOpacity(0.9), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - const Icon( - Icons.wifi_off_rounded, - color: Colors.white, - size: 18, + ], ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'connection_failed'.tr, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 13, - ), + ), + ); + } else if (state == ReconnectState.failed) { + return Container( + width: double.infinity, + color: Colors.red.shade700.withOpacity(0.9), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + const Icon( + Icons.wifi_off_rounded, + color: Colors.white, + size: 18, ), - ), - TextButton( - onPressed: () => webSocketProvider.retryReconnect(), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, + const SizedBox(width: 10), + Expanded( + child: Text( + 'connection_failed'.tr, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), - side: const BorderSide(color: Colors.white54), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: Text( - 'retry'.tr, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, + TextButton( + onPressed: () => + webSocketProvider.retryReconnect(), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + side: const BorderSide(color: Colors.white54), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'retry'.tr, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), ), ), - ), - ], - ), - ); - } - return const SizedBox.shrink(); - }), - const Expanded(child: DashboardScreen()), - ], + ], + ), + ); + } + return const SizedBox.shrink(); + }), + const Expanded(child: DashboardScreen()), + ], + ), ), ), ), diff --git a/linqoraremote/lib/presentation/widgets/dashboard_screen.dart b/linqoraremote/lib/presentation/widgets/dashboard_screen.dart index f196510..cbca57c 100644 --- a/linqoraremote/lib/presentation/widgets/dashboard_screen.dart +++ b/linqoraremote/lib/presentation/widgets/dashboard_screen.dart @@ -10,6 +10,7 @@ import 'package:linqoraremote/presentation/widgets/dialogs/dialog_cancel_connect import '../controllers/device_home_controller.dart'; import '../dashboard_items.dart'; +import '../../routes/app_routes.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -249,6 +250,22 @@ class _DashboardScreenState extends State { ); }), const Spacer(), + GestureDetector( + onTap: () => Get.toNamed(AppRoutes.SETTINGS), + child: LxGlass( + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 38, + height: 38, + child: Icon( + Icons.settings_outlined, + size: 16, + color: lxTextDim, + ), + ), + ), + ), + const SizedBox(width: 8), GestureDetector( onTap: () async { if (_homeController.webSocketProvider.isConnected) { @@ -264,10 +281,10 @@ class _DashboardScreenState extends State { }, child: LxGlass( borderRadius: BorderRadius.circular(12), - child: SizedBox( + child: const SizedBox( width: 38, height: 38, - child: const Icon( + child: Icon( Icons.power_settings_new_rounded, size: 16, color: lxTextDim, @@ -280,11 +297,12 @@ class _DashboardScreenState extends State { // --- Hero stat card --- const SizedBox(height: 16), Obx(() { + _homeController.hostInfo.value; // always reactive even when mc is null final mc = _monitoringController; - final cpu = mc?.getCurrentCPUMetrics(); - final ram = mc?.getCurrentRAMMetrics(); + final cpu = mc?.currentCPUMetrics.value; + final ram = mc?.currentRAMMetrics.value; final cpuVal = cpu?.loadPercent.toDouble() ?? 0.0; - final cpuLoads = mc?.getCPULoads() ?? []; + final cpuLoads = mc?.cpuLoads.value ?? []; return LxGlass( padding: const EdgeInsets.all(14), child: Row( diff --git a/linqoraremote/lib/presentation/widgets/monitoring_view.dart b/linqoraremote/lib/presentation/widgets/monitoring_view.dart index f34d4e7..1ae71b0 100644 --- a/linqoraremote/lib/presentation/widgets/monitoring_view.dart +++ b/linqoraremote/lib/presentation/widgets/monitoring_view.dart @@ -154,12 +154,10 @@ class _MonitoringViewState extends State ], ), const SizedBox(height: 8), - Obx( - () => LxSparkline( - data: _c.cpuLoads, - width: 170, - height: 42, - ), + LxSparkline( + data: _c.cpuLoads, + width: 170, + height: 42, ), const SizedBox(height: 6), Row( @@ -214,35 +212,31 @@ class _MonitoringViewState extends State ], ), const SizedBox(height: 4), - Obx( - () => RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${_c.currentRAMMetrics.value?.loadPercent ?? 0}', - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w600, - letterSpacing: -0.8, - color: lxText, - ), - ), - const TextSpan( - text: '%', - style: TextStyle(fontSize: 14, color: lxTextDim), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${_c.currentRAMMetrics.value?.loadPercent ?? 0}', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.8, + color: lxText, ), - ], - ), + ), + const TextSpan( + text: '%', + style: TextStyle(fontSize: 14, color: lxTextDim), + ), + ], ), ), const SizedBox(height: 6), - Obx( - () => LxSparkline( - data: _c.ramUsagesPercent, - width: 130, - height: 32, - color: const Color(0xFF7C9CFF), - ), + LxSparkline( + data: _c.ramUsagesPercent, + width: 130, + height: 32, + color: const Color(0xFF7C9CFF), ), ], ),