diff --git a/assets/images/COPYRIGHT_NOTICE b/assets/images/COPYRIGHT_NOTICE new file mode 100644 index 0000000..dab975d --- /dev/null +++ b/assets/images/COPYRIGHT_NOTICE @@ -0,0 +1,12 @@ +ORA Logos Copyright Notice + +The ORA logos (including, but not limited to, "open_resin_alliance_logo_darkmode.png") are NOT covered by the Apache License, Version 2.0 that applies to this project. The logos are protected by copyright and may also be protected as trademarks. All rights are reserved by Ada Phillips. + +You MAY NOT copy, distribute, modify, publish, display, or otherwise use the logos except as explicitly permitted in writing by the copyright/trademark owner. + +Repository source code and other content are licensed under the Apache License, Version 2.0 unless otherwise stated. This notice applies only to the logo artwork referenced above. + +To request permission to use the ORA logos, contact the project maintainers or: +contact@openresin.org + +© 2025 Open Resin Alliance. All rights reserved. diff --git a/assets/images/open_resin_alliance_logo_darkmode.png b/assets/images/open_resin_alliance_logo_darkmode.png new file mode 100644 index 0000000..af00aa2 Binary files /dev/null and b/assets/images/open_resin_alliance_logo_darkmode.png differ diff --git a/lib/backend_service/backend_client.dart b/lib/backend_service/backend_client.dart new file mode 100644 index 0000000..7e1fe97 --- /dev/null +++ b/lib/backend_service/backend_client.dart @@ -0,0 +1,105 @@ +/* +* Orion - Backend Client +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:typed_data'; +import 'dart:async'; + +/// Minimal abstraction over the Backend API used by providers. This allows +/// swapping implementations (real HTTP client, mock, or unit-test doubles) +/// while keeping providers free from direct dependency on ApiService. +abstract class BackendClient { + Future> listItems( + String location, int pageSize, int pageIndex, String subdirectory); + + Future usbAvailable(); + + Future> getFileMetadata( + String location, String filePath); + + Future> getConfig(); + + Future getBackendVersion(); + + Future getFileThumbnail( + String location, String filePath, String size); + + Future startPrint(String location, String filePath); + + Future> deleteFile(String location, String filePath); + + // Status-related + Future> getStatus(); + + /// Stream of status updates from the server. Implementations may expose + /// an SSE / streaming endpoint. Each emitted Map corresponds to a JSON + /// object parsed from the stream's data payloads. + Stream> getStatusStream(); + + /// Fetch recent notifications from the backend. Returns a list of JSON + /// objects representing notifications. Some backends (e.g. NanoDLP) + /// expose a `/notification` endpoint that returns an array. + Future>> getNotifications(); + + /// Disable / acknowledge a notification on the backend when supported. + /// The timestamp argument is the numeric timestamp provided by the + /// notification payload (e.g. NanoDLP uses an integer timestamp). + Future disableNotification(int timestamp); + + // Print control + Future cancelPrint(); + Future pausePrint(); + Future resumePrint(); + + // Manual controls and hardware commands + Future> move(double height); + + /// Send a relative Z move in millimeters (positive = up, negative = down). + /// This maps directly to the device's relative move endpoints when + /// available (e.g. NanoDLP /z-axis/move/.../micron/...). + Future> moveDelta(double deltaMm); + + /// Whether the client supports a direct "move to top limit" command. + Future canMoveToTop(); + Future canMoveToFloor(); + + /// Move the Z axis directly to the device's top limit if supported. + Future> moveToTop(); + Future> moveToFloor(); + Future> manualCure(bool cure); + Future> manualHome(); + Future> manualCommand(String command); + Future> emergencyStop(); + Future displayTest(String test); + + /// Fetch a specific 2D layer PNG from a NanoDLP-style plates endpoint. + /// plateId is the numeric plate identifier and layer is the layer index + /// (as reported by the backend). Implementations that don't support this + /// may return a placeholder image or empty bytes. + Future getPlateLayerImage(int plateId, int layer); + + /// Fetch recent analytics entries. `n` requests the last N entries. + /// Returns a list of JSON objects with keys like 'ID', 'T', 'V'. + Future>> getAnalytics(int n); + + /// Fetch a single analytic value by metric id (e.g. /analytic/value/6). + /// Returns the raw value (number or string) or null on failure. + Future getAnalyticValue(int id); + + /// Tare the force sensor if supported by the backend. + /// Returns a boolean indicating success or failure. + Future tareForceSensor(); +} diff --git a/lib/backend_service/backend_service.dart b/lib/backend_service/backend_service.dart index e6dd2a3..19bb8e8 100644 --- a/lib/backend_service/backend_service.dart +++ b/lib/backend_service/backend_service.dart @@ -1,29 +1,49 @@ +/* +* Orion - Backend Service +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + import 'dart:typed_data'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/odyssey/odyssey_http_client.dart'; import 'package:orion/backend_service/nanodlp/nanodlp_http_client.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_simulated_client.dart'; import 'package:orion/util/orion_config.dart'; /// BackendService is a small façade that selects a concrete -/// `OdysseyClient` implementation at runtime. This centralizes the +/// `BackendClient` implementation at runtime. This centralizes the /// point where an alternative backend implementation (different API) /// can be swapped in without changing providers or UI code. -class BackendService implements OdysseyClient { - final OdysseyClient _delegate; +class BackendService implements BackendClient { + final BackendClient _delegate; /// Default constructor: picks the concrete implementation based on /// configuration (or defaults to the HTTP adapter). - BackendService({OdysseyClient? delegate}) + BackendService({BackendClient? delegate}) : _delegate = delegate ?? _chooseFromConfig(); - static OdysseyClient _chooseFromConfig() { + static BackendClient _chooseFromConfig() { try { final cfg = OrionConfig(); - final backend = cfg.getString('backend', category: 'advanced'); - if (backend == 'nanodlp') { + // Developer-mode simulated backend flag (developer.simulated = true) + final simulated = cfg.getFlag('simulated', category: 'developer'); + if (simulated) { + return NanoDlpSimulatedClient(); + } + if (cfg.isNanoDlpMode()) { // Return the NanoDLP adapter when explicitly requested in config. - // Add a small log to aid debugging in cases where config isn't applied. - // Note: avoid bringing logging package into this file if not used return NanoDlpHttpClient(); } } catch (_) { @@ -32,7 +52,7 @@ class BackendService implements OdysseyClient { return OdysseyHttpClient(); } - // Forward all OdysseyClient methods to the selected delegate. + // Forward all BackendClient methods to the selected delegate. @override Future> listItems( String location, int pageSize, int pageIndex, String subdirectory) => @@ -71,6 +91,14 @@ class BackendService implements OdysseyClient { @override Stream> getStatusStream() => _delegate.getStatusStream(); + @override + Future>> getNotifications() => + _delegate.getNotifications(); + + @override + Future disableNotification(int timestamp) => + _delegate.disableNotification(timestamp); + @override Future cancelPrint() => _delegate.cancelPrint(); @@ -115,4 +143,18 @@ class BackendService implements OdysseyClient { @override Future displayTest(String test) => _delegate.displayTest(test); + + @override + Future getPlateLayerImage(int plateId, int layer) => + _delegate.getPlateLayerImage(plateId, layer); + + @override + Future>> getAnalytics(int n) => + _delegate.getAnalytics(n); + + @override + Future getAnalyticValue(int id) => _delegate.getAnalyticValue(id); + + @override + Future tareForceSensor() => _delegate.tareForceSensor(); } diff --git a/lib/backend_service/nanodlp/helpers/nano_analytics_config.dart b/lib/backend_service/nanodlp/helpers/nano_analytics_config.dart new file mode 100644 index 0000000..a8eaa60 --- /dev/null +++ b/lib/backend_service/nanodlp/helpers/nano_analytics_config.dart @@ -0,0 +1,39 @@ +// Mapping of NanoDLP analytic metric ids (T) to canonical keys used by the UI. +const List> allChartConfig = [ + {'key': 'LayerHeight', 'id': 0}, + {'key': 'SolidArea', 'id': 1}, + {'key': 'AreaCount', 'id': 2}, + {'key': 'LargestArea', 'id': 3}, + {'key': 'Speed', 'id': 4}, + {'key': 'Cure', 'id': 5}, + {'key': 'Pressure', 'id': 6}, + {'key': 'TemperatureInside', 'id': 7}, + {'key': 'TemperatureOutside', 'id': 8}, + {'key': 'LayerTime', 'id': 9}, + {'key': 'LiftHeight', 'id': 10}, + {'key': 'TemperatureMCU', 'id': 11}, + {'key': 'TemperatureInsideTarget', 'id': 12}, + {'key': 'TemperatureOutsideTarget', 'id': 13}, + {'key': 'TemperatureMCUTarget', 'id': 14}, + {'key': 'MCUFanRPM', 'id': 15}, + {'key': 'UVFanRPM', 'id': 16}, + {'key': 'DynamicWait', 'id': 17}, + {'key': 'TemperatureVat', 'id': 18}, + {'key': 'TemperatureVatTarget', 'id': 19}, + {'key': 'PTCFanRPM', 'id': 20}, + {'key': 'AEGISFanRPM', 'id': 21}, + {'key': 'TemperatureChamber', 'id': 22}, + {'key': 'TemperatureChamberTarget', 'id': 23}, + {'key': 'TemperaturePTC', 'id': 24}, + {'key': 'TemperaturePTCTarget', 'id': 25}, + {'key': 'VOCInlet', 'id': 26}, + {'key': 'VOCOutlet', 'id': 27}, +]; + +String? idToKey(int id) { + for (final e in allChartConfig) { + final val = e['id']; + if (val is int && val == id) return e['key'] as String?; + } + return null; +} diff --git a/lib/backend_service/nanodlp/helpers/nano_simulated_client.dart b/lib/backend_service/nanodlp/helpers/nano_simulated_client.dart new file mode 100644 index 0000000..e9ee9ef --- /dev/null +++ b/lib/backend_service/nanodlp/helpers/nano_simulated_client.dart @@ -0,0 +1,294 @@ +/* +* Orion - NanoDLP Simulated Client +* Provides a simple in-memory simulated NanoDLP backend for development +* without a physical printer. Behavior is intentionally simple: it +* simulates a print job advancing layers over time and responds to +* control commands (start/pause/resume/cancel). This implementation +* implements the BackendClient interface used by the app. +*/ + +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:math' as math; + +import 'package:orion/backend_service/backend_client.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; +import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; + +class NanoDlpSimulatedClient implements BackendClient { + // Simulated job state + bool _printing = false; + bool _paused = false; + bool _cancelLatched = false; + int _currentLayer = 0; + final int _totalLayers = 200; + + // Status stream + final StreamController> _statusController = + StreamController.broadcast(); + Timer? _tickTimer; + + NanoDlpSimulatedClient() { + // start periodic tick to update status stream + _tickTimer = Timer.periodic(Duration(seconds: 1), (_) => _tick()); + } + + void _tick() { + if (_printing && !_paused && !_cancelLatched) { + _currentLayer = math.min(_totalLayers, _currentLayer + 1); + if (_currentLayer >= _totalLayers) { + // job finished + _printing = false; + } + } + + final nanoJson = _makeStatusMap(); + try { + final ns = NanoStatus.fromJson(Map.from(nanoJson)); + final odyssey = nanoStatusToOdysseyMap(ns); + if (!_statusController.isClosed) _statusController.add(odyssey); + } catch (_) { + // Fallback: emit the raw nano map if mapping fails + if (!_statusController.isClosed) _statusController.add(nanoJson); + } + } + + Map _makeStatusMap() { + return { + 'Printing': _printing, + 'Paused': _paused, + 'State': _printing ? 5 : 0, + 'LayerID': _printing ? _currentLayer : null, + 'LayersCount': _totalLayers, + 'Status': _printing ? 'Printing' : 'Idle', + // Minimal file metadata when a job is active + if (_printing) + 'file': { + 'name': 'simulated_print.gcode', + 'path': '/sim/simulated_print.gcode', + 'layer_count': _totalLayers, + } + }; + } + + @override + Future cancelPrint() async { + if (!_printing) return; + _cancelLatched = true; + // simulate immediate stop + _printing = false; + _paused = false; + _currentLayer = 0; + _statusController.add(_makeStatusMap()); + } + + @override + Future pausePrint() async { + if (!_printing || _paused) return; + _paused = true; + _statusController.add(_makeStatusMap()); + } + + @override + Future resumePrint() async { + if (!_printing || !_paused) return; + _paused = false; + _statusController.add(_makeStatusMap()); + } + + @override + Future> deleteFile( + String location, String filePath) async { + return {'deleted': true}; + } + + @override + Future> displayTest(String test) async { + return {'ok': true}; + } + + @override + Future startPrint(String location, String filePath) async { + _printing = true; + _paused = false; + _cancelLatched = false; + _currentLayer = 0; + _statusController.add(_makeStatusMap()); + } + + @override + Future> getConfig() async { + return { + 'general': {'hostname': 'sim-nanodlp'}, + 'advanced': {'backend': 'nanodlp'} + }; + } + + @override + Future getBackendVersion() async => 'NanoDLP-sim-1.0'; + + @override + Future getFileThumbnail( + String location, String filePath, String size) async { + final dims = _parseSize(size); + return NanoDlpThumbnailGenerator.generatePlaceholder(dims[0], dims[1]); + } + + List _parseSize(String size) { + // expected like 'thumb' or 'large' - default to large + if (size == 'thumb') return [160, 96]; + return [ + NanoDlpThumbnailGenerator.largeWidth, + NanoDlpThumbnailGenerator.largeHeight + ]; + } + + @override + Future> getFileMetadata( + String location, String filePath) async { + return { + 'file_data': { + 'path': filePath, + 'name': filePath.split('/').last, + 'last_modified': DateTime.now().millisecondsSinceEpoch, + 'parent_path': '/sim' + } + }; + } + + @override + Future> listItems( + String location, int pageSize, int pageIndex, String subdirectory) async { + // Return a small simulated file list for Local location. + final files = List.generate(5, (i) { + return { + 'name': 'sim_model_${i + 1}.stl', + 'path': '/sim/sim_model_${i + 1}.stl', + 'last_modified': DateTime.now().millisecondsSinceEpoch - i * 1000, + }; + }); + return { + 'files': files, + 'dirs': >[], + 'page_index': pageIndex, + 'page_size': pageSize, + }; + } + + @override + Future disableNotification(int timestamp) async { + // no-op for simulated backend + return; + } + + @override + Future>> getNotifications() async => []; + + @override + Future> getStatus() async { + final nanoJson = _makeStatusMap(); + final ns = NanoStatus.fromJson(Map.from(nanoJson)); + return nanoStatusToOdysseyMap(ns); + } + + @override + Stream> getStatusStream() => _statusController.stream; + + @override + Future>> getAnalytics(int n) async { + // Simulated client has no analytics; return empty list. + return []; + } + + @override + Future getAnalyticValue(int id) async { + // Smoothly ramp to a large peak after restart instead of an instant jump. + final now = DateTime.now().millisecondsSinceEpoch / 1000.0; + const period = 60.0; // restart every 40 seconds + final elapsed = now % period; + + const maxAmp = 6000.0; // target peak amplitude + const decayTime = 30.0; // seconds to decay back down + final decay = math.log(30.0) / decayTime; + final envelope = math.exp(-decay * elapsed); + + // ramp up over the first few seconds after a restart to avoid an immediate jump + const rampUpTime = 2.0; // seconds to reach full amplitude + final ramp = (elapsed >= rampUpTime) ? 1.0 : (elapsed / rampUpTime); + + // use cosine for oscillation, scaled by ramp and decay envelope + final raw = + math.cos(2 * math.pi * (elapsed / 3.0)) * maxAmp * ramp * envelope; + + // small random noise in [-5,5] + final noise = (Random().nextDouble() * 10.0) - 5.0; + + // once the oscillation has decayed below ±5, return only the small random noise + if (raw.abs() < 5.0) return noise; + + // otherwise return the oscillation with a little jitter + return raw + noise * 0.2; + } + + @override + Future usbAvailable() async => false; + + @override + Future> manualCommand(String command) async => + {'ok': true}; + + @override + Future> manualCure(bool cure) async => {'ok': true}; + + @override + Future> manualHome() async => {'ok': true}; + + @override + Future> move(double height) async => {'ok': true}; + + @override + Future> moveDelta(double deltaMm) async => {'ok': true}; + + @override + Future canMoveToFloor() async => false; + + @override + Future canMoveToTop() async => false; + + @override + Future> moveToFloor() async => {'ok': true}; + + @override + Future> moveToTop() async => {'ok': true}; + + @override + Future> emergencyStop() async { + _printing = false; + _paused = false; + _statusController.add(_makeStatusMap()); + return {'stopped': true}; + } + + @override + Future getPlateLayerImage(int plateId, int layer) async { + // Generate a simple placeholder image for the requested layer. + // We'll encode a tiny image with a band indicating the layer number. + final bytes = NanoDlpThumbnailGenerator.resizeLayer2D(Uint8List.fromList([ + // empty source triggers placeholder + ])); + return bytes; + } + + void dispose() { + _tickTimer?.cancel(); + _statusController.close(); + } + + @override + Future tareForceSensor() { + // TODO: implement tareForceSensor + throw UnimplementedError(); + } +} diff --git a/lib/backend_service/nanodlp/nanodlp_state_handler.dart b/lib/backend_service/nanodlp/helpers/nano_state_handler.dart similarity index 95% rename from lib/backend_service/nanodlp/nanodlp_state_handler.dart rename to lib/backend_service/nanodlp/helpers/nano_state_handler.dart index 12824ea..c7f07bb 100644 --- a/lib/backend_service/nanodlp/nanodlp_state_handler.dart +++ b/lib/backend_service/nanodlp/helpers/nano_state_handler.dart @@ -156,7 +156,12 @@ class NanoDlpStateHandler { return result; } - if (stateCode == 1 || stateCode == 5) { + // Treat state==1 as a transient printing/start state. For state==5 + // (active print) require that the status payload's `Printing` flag is + // actually true before we consider the device printing. Some NanoDLP + // installs emit State==5 but have Printing==false in corner cases; + // requiring both helps avoid false-positive printing reports. + if (stateCode == 1 || (stateCode == 5 && ns.printing == true)) { final result = { 'status': 'Printing', 'paused': false, diff --git a/lib/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart b/lib/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart new file mode 100644 index 0000000..4e89462 --- /dev/null +++ b/lib/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart @@ -0,0 +1,164 @@ +/* +* Orion - NanoDLP Thumbnail Generator +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:typed_data'; +import 'dart:math' as math; + +import 'package:image/image.dart' as img; +import 'package:flutter/foundation.dart'; + +class NanoDlpThumbnailGenerator { + const NanoDlpThumbnailGenerator._(); + + // Canonical large thumbnail size used in DetailsScreen. + static const int largeWidth = 800; + static const int largeHeight = 480; + + static Uint8List generatePlaceholder(int width, int height) { + final image = img.Image(width: width, height: height); + final background = img.ColorRgb8(32, 36, 43); + final accent = img.ColorRgb8(63, 74, 88); + final highlight = img.ColorRgb8(90, 104, 122); + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final band = ((x ~/ 16) + (y ~/ 16)) % 3; + switch (band) { + case 0: + image.setPixel(x, y, background); + break; + case 1: + image.setPixel(x, y, accent); + break; + default: + image.setPixel(x, y, highlight); + } + } + } + + for (var x = width ~/ 4; x < (width * 3) ~/ 4; x++) { + final y1 = height ~/ 4; + final y2 = (height * 3) ~/ 4; + image.setPixel(x, y1, highlight); + image.setPixel(x, y2, highlight); + } + + for (var y = height ~/ 4; y < (height * 3) ~/ 4; y++) { + final x1 = width ~/ 4; + final x2 = (width * 3) ~/ 4; + image.setPixel(x1, y, highlight); + image.setPixel(x2, y, highlight); + } + + return Uint8List.fromList(img.encodePng(image)); + } + + static Uint8List resizeOrPlaceholder( + Uint8List? sourceBytes, int width, int height) { + if (sourceBytes != null && sourceBytes.isNotEmpty) { + try { + final decoded = img.decodeImage(sourceBytes); + if (decoded != null) { + // If the image already matches the target size, return it as-is. + if (decoded.width == width && decoded.height == height) { + return Uint8List.fromList(img.encodePng(decoded)); + } + + // Apply a small internal padding so thumbnails don't touch the + // canvas edges. The padded area (innerWidth x innerHeight) is the + // space available for the aspect-fit image. + const int pad = 8; + final innerWidth = math.max(1, width - (pad * 2)); + final innerHeight = math.max(1, height - (pad * 2)); + + // Compute aspect-fit scale to preserve aspect ratio inside the + // inner padded box. + final scale = math.min( + innerWidth / decoded.width, innerHeight / decoded.height); + final targetW = math.max(1, (decoded.width * scale).round()); + final targetH = math.max(1, (decoded.height * scale).round()); + + final resized = img.copyResize(decoded, + width: targetW, + height: targetH, + interpolation: img.Interpolation.cubic); + + // Create a transparent canvas of the requested size and center the + // resized image on it so we don't distort the aspect ratio. This + // results in translucent padding when the aspect ratios differ. + // Ensure canvas has an alpha channel so transparent padding stays + // transparent when encoded to PNG. + final canvas = + img.Image(width: width, height: height, numChannels: 4); + // Ensure fully transparent background (ARGB = 0). + final transparent = img.ColorRgba8(0, 0, 0, 0); + img.fill(canvas, color: transparent); + + final dx = pad + ((innerWidth - targetW) / 2).round(); + final dy = pad + ((innerHeight - targetH) / 2).round(); + + // Blit the resized image into the centered position on the + // transparent canvas by copying pixels — this works across + // image package versions without relying on draw helpers. + for (var y = 0; y < targetH; y++) { + for (var x = 0; x < targetW; x++) { + final px = resized.getPixel(x, y); + canvas.setPixel(dx + x, dy + y, px); + } + } + + return Uint8List.fromList(img.encodePng(canvas)); + } + } catch (_) { + // fall back to placeholder below + } + } + return generatePlaceholder(width, height); + } + + /// Convenience helper to force the canonical NanoDLP large size. + static Uint8List resizeToLarge(Uint8List? sourceBytes) => + resizeOrPlaceholder(sourceBytes, largeWidth, largeHeight); + + /// Resize a 2D layer PNG (from /static/plates/`/N.png) to the + /// canonical large size, ignoring aspect ratio (force resize). This is + /// intentionally different from `resizeOrPlaceholder` which preserves + /// aspect ratio and adds transparent padding. + static Uint8List resizeLayer2D(Uint8List? sourceBytes) { + if (sourceBytes != null && sourceBytes.isNotEmpty) { + try { + final decoded = img.decodeImage(sourceBytes); + if (decoded != null) { + final resized = img.copyResize(decoded, + width: largeWidth, + height: largeHeight, + interpolation: img.Interpolation.cubic); + return Uint8List.fromList(img.encodePng(resized)); + } + } catch (_) { + // fall-through to placeholder + } + } + return generatePlaceholder(largeWidth, largeHeight); + } +} + +// Public top-level entrypoint for compute() so other libraries can call +// compute(resizeLayer2DCompute, bytes). Must be public (non-underscore) +// so it is available across library boundaries when spawning an isolate. +Uint8List resizeLayer2DCompute(Uint8List bytes) => + NanoDlpThumbnailGenerator.resizeLayer2D(bytes); diff --git a/lib/backend_service/nanodlp/models/nano_file.dart b/lib/backend_service/nanodlp/models/nano_file.dart index f9292c7..431aeca 100644 --- a/lib/backend_service/nanodlp/models/nano_file.dart +++ b/lib/backend_service/nanodlp/models/nano_file.dart @@ -24,6 +24,7 @@ class NanoFile { final String? name; final int? layerCount; final double? printTime; // seconds + final int? resinTemperature; // Extended metadata commonly returned by /plates/list/json or similar final int? lastModified; @@ -44,6 +45,7 @@ class NanoFile { this.name, this.layerCount, this.printTime, + this.resinTemperature, this.lastModified, this.parentPath, this.fileSize, @@ -131,6 +133,24 @@ class NanoFile { return numeric; } + int? parseResinTemperature(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) { + var s = v.trim().toLowerCase(); + // Remove degree symbol and common suffixes like 'c' or '°c' + s = s.replaceAll('°', '').replaceAll('c', '').trim(); + // Strip any non-number characters except dot, sign and exponent + final numStr = s.replaceAll(RegExp(r'[^0-9+\-.eE]'), ''); + if (numStr.isEmpty) return null; + final d = double.tryParse(numStr); + if (d != null) return d.round(); + return int.tryParse(numStr); + } + return null; + } + String? path = json['path']?.toString() ?? json['Path']?.toString() ?? json['file_path']?.toString() ?? @@ -269,11 +289,18 @@ class NanoFile { parentPath = resolvedPath.substring(0, resolvedPath.lastIndexOf('/')); } + // Try several common keys for resin temperature returned by NanoDLP/status + final resinTemp = parseResinTemperature(json['resin'] ?? + json['Resin'] ?? + json['resin_temperature'] ?? + json['ResinTemperature']); + return NanoFile( path: resolvedPath, name: resolvedName, layerCount: layerCount, printTime: printTime, + resinTemperature: resinTemp, lastModified: finalLastModified, parentPath: parentPath ?? '', fileSize: fileSize, diff --git a/lib/backend_service/nanodlp/models/nano_notification_types.dart b/lib/backend_service/nanodlp/models/nano_notification_types.dart new file mode 100644 index 0000000..4ad12c3 --- /dev/null +++ b/lib/backend_service/nanodlp/models/nano_notification_types.dart @@ -0,0 +1,132 @@ +/* +* Orion - NanoDLP Notification Types +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +class NanoDlpNotificationType { + final String type; + final List actions; + final int priority; + + const NanoDlpNotificationType( + {required this.type, required this.actions, required this.priority}); +} + +class NanoDlpNotificationTypes { + static const List _table = [ + NanoDlpNotificationType( + type: 'error', actions: ['continue', 'stop'], priority: 1), + NanoDlpNotificationType( + type: 'warn', actions: ['continue', 'stop'], priority: 2), + NanoDlpNotificationType( + type: 'klipper-error', actions: ['confirm'], priority: 3), + NanoDlpNotificationType( + type: 'aegis-error', actions: ['confirm'], priority: 4), + NanoDlpNotificationType( + type: 'aegis-info', actions: ['confirm'], priority: 5), + NanoDlpNotificationType(type: 'default', actions: ['confirm'], priority: 6), + ]; + + static NanoDlpNotificationType lookup(String? type) { + if (type == null) return _table.last; + try { + return _table.firstWhere((e) => e.type == type, + orElse: () => _table.last); + } catch (_) { + return _table.last; + } + } + + static int priorityOf(String? type) => lookup(type).priority; +} + +// Backwards-compatible functional helpers +const List> _defaultNanoNotificationTypes = [ + { + 'type': 'error', + 'actions': ['continue', 'stop'], + 'priority': 1 + }, + { + 'type': 'warn', + 'actions': ['continue', 'stop'], + 'priority': 2 + }, + { + 'type': 'klipper-error', + 'actions': ['close'], + 'priority': 3 + }, + { + 'type': 'aegis-error', + 'actions': ['close'], + 'priority': 4 + }, + { + 'type': 'aegis-info', + 'actions': ['close'], + 'priority': 5 + }, + { + 'type': 'default', + 'actions': ['close'], + 'priority': 6 + }, +]; + +const List> _notificationTypeTitles = [ + {'type': 'error', 'title': 'Error'}, + {'type': 'warn', 'title': 'Warning'}, + {'type': 'klipper-error', 'title': 'Klipper Error'}, + {'type': 'aegis-error', 'title': 'AEGIS Error'}, + {'type': 'aegis-info', 'title': 'AEGIS Info'}, + {'type': 'default', 'title': 'Notification'}, +]; + +Map> _indexByType( + List> list) { + final map = >{}; + for (final e in list) { + final t = (e['type'] ?? 'default').toString(); + map[t] = e; + } + return map; +} + +/// Returns the default lookup table as a type -> config map. +Map> getDefaultNanoNotificationLookup() { + return _indexByType(_defaultNanoNotificationTypes); +} + +/// Convenience: get priority for a type. Lower = higher priority. +int getNanoTypePriority(String? type) { + if (type == null) return 999; + final lookup = getDefaultNanoNotificationLookup(); + final entry = lookup[type] ?? lookup['default']; + return (entry?['priority'] as int?) ?? 999; +} + +/// Convenience: get config for a type (actions + priority). Returns default if missing. +Map getNanoTypeConfig(String? type) { + final lookup = getDefaultNanoNotificationLookup(); + return lookup[type] ?? lookup['default']!; +} + +/// Convenience: get a human-friendly title for the notification type. +String getNanoTypeTitle(String? type) { + final lookup = _indexByType(_notificationTypeTitles); + final entry = lookup[type] ?? lookup['default']; + return (entry?['title'] as String?) ?? 'Notification'; +} diff --git a/lib/backend_service/nanodlp/nanodlp_http_client.dart b/lib/backend_service/nanodlp/nanodlp_http_client.dart index a54da0b..0e3fdb9 100644 --- a/lib/backend_service/nanodlp/nanodlp_http_client.dart +++ b/lib/backend_service/nanodlp/nanodlp_http_client.dart @@ -16,7 +16,6 @@ */ import 'dart:async'; -import 'dart:typed_data'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -25,16 +24,17 @@ import 'package:orion/backend_service/nanodlp/models/nano_file.dart'; import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; import 'package:orion/backend_service/nanodlp/models/nano_manual.dart'; import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; -import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; +import 'package:flutter/foundation.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/util/orion_config.dart'; /// NanoDLP adapter (initial implementation) /// -/// Implements a small subset of the `OdysseyClient` contract needed for +/// Implements a small subset of the `BackendClient` contract needed for /// StatusProvider and thumbnail fetching. Other methods remain unimplemented /// and should be added as needed. -class NanoDlpHttpClient implements OdysseyClient { +class NanoDlpHttpClient implements BackendClient { late final String apiUrl; final _log = Logger('NanoDlpHttpClient'); final http.Client Function() _clientFactory; @@ -77,7 +77,7 @@ class NanoDlpHttpClient implements OdysseyClient { } catch (e) { apiUrl = 'http://localhost'; } - _log.info('constructed NanoDlpHttpClient apiUrl=$apiUrl'); + // _log.info('constructed NanoDlpHttpClient apiUrl=$apiUrl'); // commented out to reduce noise } http.Client _createClient() { @@ -221,6 +221,29 @@ class NanoDlpHttpClient implements OdysseyClient { } } + @override + Future>> getNotifications() async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/notification'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) return []; + final decoded = json.decode(resp.body); + if (decoded is List) { + return decoded + .whereType>() + .toList(growable: false); + } + return []; + } catch (e, st) { + _log.fine('NanoDLP getNotifications failed', e, st); + return []; + } finally { + client.close(); + } + } + @override Future usbAvailable() async { // NanoDLP runs on a networked device, USB availability is not applicable. @@ -241,6 +264,56 @@ class NanoDlpHttpClient implements OdysseyClient { } } + @override + Future>> getAnalytics(int n) async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/analytic/data/$n'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) return []; + final decoded = json.decode(resp.body); + if (decoded is List) { + return decoded + .whereType>() + .toList(growable: false); + } + return []; + } catch (e, st) { + _log.fine('NanoDLP getAnalytics failed', e, st); + return []; + } finally { + client.close(); + } + } + + @override + Future getAnalyticValue(int id) async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/analytic/value/$id'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) return null; + final body = resp.body.trim(); + // NanoDLP returns a plain numeric value like "-3.719..." so try parsing + final v = double.tryParse(body); + if (v != null) return v; + // Fallback: try JSON decode (in case the server returns JSON) + try { + final decoded = json.decode(body); + return decoded; + } catch (_) { + return body; + } + } catch (e, st) { + _log.fine('NanoDLP getAnalyticValue failed', e, st); + return null; + } finally { + client.close(); + } + } + @override Future getFileThumbnail( String location, String filePath, String size) async { @@ -683,12 +756,68 @@ class NanoDlpHttpClient implements OdysseyClient { } @override - Future> manualCommand(String command) async => - throw UnimplementedError('NanoDLP manualCommand not implemented'); + Future> manualCommand(String command) async { + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/gcode'); + _log.info('NanoDLP manualCommand($command) request -> POST /gcode: $uri'); + final client = _createClient(); + try { + final resp = await client.post(uri, body: {'gcode': command}); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP manualCommand($command) failed: ${resp.statusCode} ${resp.body}'); + throw Exception( + 'NanoDLP manualCommand($command) failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP manualCommand($command) error', e, st); + throw Exception('NanoDLP manualCommand($command) failed: $e'); + } + } @override - Future> manualCure(bool cure) async => - throw UnimplementedError('NanoDLP manualCure not implemented'); + Future> manualCure(bool cure) async { + try { + final action = cure ? 'on' : 'blank'; + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/projector/$action'); + _log.info( + 'NanoDLP manualCure($cure) request -> /projector/$action: $uri'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP manualCure($cure) failed: ${resp.statusCode} ${resp.body}'); + throw Exception( + 'NanoDLP manualCure($cure) failed: ${resp.statusCode}'); + } + try { + final decoded = json.decode(resp.body); + final nm = NanoManualResult.fromDynamic(decoded); + return nm.toMap(); + } catch (_) { + return NanoManualResult(ok: true).toMap(); + } + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP manualCure($cure) error', e, st); + throw Exception('NanoDLP manualCure($cure) failed: $e'); + } + } @override Future> emergencyStop() async { @@ -743,6 +872,31 @@ class NanoDlpHttpClient implements OdysseyClient { } }(); + @override + Future disableNotification(int timestamp) async { + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/notification/disable/$timestamp'); + _log.info('NanoDLP disableNotification: $uri'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP disableNotification returned ${resp.statusCode}: ${resp.body}'); + throw Exception( + 'NanoDLP disableNotification failed: ${resp.statusCode}'); + } + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP disableNotification error', e, st); + rethrow; + } + } + @override Future pausePrint() async { try { @@ -871,8 +1025,43 @@ class NanoDlpHttpClient implements OdysseyClient { } @override - Future displayTest(String test) async => - throw UnimplementedError('NanoDLP displayTest not implemented'); + Future displayTest(String test) async { + // Map test names to real NanoDLP test endpoints. + const Map testMappings = { + 'Grid': '/projector/generate/calibration', + // So far Athena is the only printer with NanoDLP that we support + 'Logo': '/projector/display/general***athena.png', + 'Measure': '/projector/generate/boundaries', + 'White': '/projector/generate/white', + }; + + try { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + // Resolve mapped command; fall back to legacy /display/test/ if unknown. + final mapped = testMappings[test]; + final command = mapped ?? '/display/test/$test'; + final cmdWithSlash = command.startsWith('/') ? command : '/$command'; + final uri = Uri.parse('$baseNoSlash$cmdWithSlash'); + + _log.info('NanoDLP displayTest request for "$test": $uri'); + final client = _createClient(); + try { + final resp = await client.get(uri); + if (resp.statusCode != 200) { + _log.warning( + 'NanoDLP displayTest failed: ${resp.statusCode} ${resp.body}'); + throw Exception('NanoDLP displayTest failed: ${resp.statusCode}'); + } + // Some installs return JSON or plain text; ignore body and treat 200 as success + return; + } finally { + client.close(); + } + } catch (e, st) { + _log.warning('NanoDLP displayTest error', e, st); + rethrow; + } + } bool _matchesPath(String? lhs, String rhs) { if (lhs == null) return false; @@ -938,6 +1127,33 @@ class NanoDlpHttpClient implements OdysseyClient { } } + /// Fetch a 2D layer PNG for a given plate and layer index. This will + /// return a canonical large-sized PNG (800x480) by force-resizing the + /// downloaded image. On error, a generated placeholder is returned. + @override + Future getPlateLayerImage(int plateId, int layer) async { + final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$baseNoSlash/static/plates/$plateId/$layer.png'); + final entry = await _downloadThumbnail(uri, 'plate $plateId layer $layer'); + if (entry.bytes.isNotEmpty) { + try { + // Resize on a background isolate to avoid blocking the UI thread. + try { + return await compute(resizeLayer2DCompute, entry.bytes); + } catch (_) { + // If compute fails (e.g., in test environment), fall back to + // synchronous resize. + return NanoDlpThumbnailGenerator.resizeLayer2D(entry.bytes); + } + } catch (_) { + // fall through to placeholder + } + } + return NanoDlpThumbnailGenerator.generatePlaceholder( + NanoDlpThumbnailGenerator.largeWidth, + NanoDlpThumbnailGenerator.largeHeight); + } + Future> _fetchPlates({bool forceRefresh = false}) { if (!forceRefresh) { final cached = _platesCacheData; @@ -976,7 +1192,7 @@ class NanoDlpHttpClient implements OdysseyClient { Future> _loadPlatesFromNetwork() async { final baseNoSlash = apiUrl.replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$baseNoSlash/plates/list/json'); - _log.fine('Requesting NanoDLP plates list (no query params): $uri'); + // _log.fine('Requesting NanoDLP plates list (no query params): $uri'); // commented out to reduce noise final client = _createClient(); try { @@ -1071,6 +1287,16 @@ class NanoDlpHttpClient implements OdysseyClient { return (400, 400); } } + + @override + Future tareForceSensor() async { + try { + manualCommand('[[PressureWrite 1]]'); + } catch (e, st) { + _log.warning('NanoDLP tareForceSensor error', e, st); + rethrow; + } + } } class _TimeoutHttpClient extends http.BaseClient { diff --git a/lib/backend_service/nanodlp/nanodlp_mappers.dart b/lib/backend_service/nanodlp/nanodlp_mappers.dart index 574b4ca..6d47a79 100644 --- a/lib/backend_service/nanodlp/nanodlp_mappers.dart +++ b/lib/backend_service/nanodlp/nanodlp_mappers.dart @@ -16,7 +16,7 @@ */ import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; -import 'package:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_state_handler.dart'; /// Map NanoDLP DTOs into Odyssey-shaped maps expected by StatusModel.fromJson Map nanoStatusToOdysseyMap(NanoStatus ns) { @@ -109,5 +109,13 @@ Map nanoStatusToOdysseyMap(NanoStatus ns) { 'finished': finished, }; + // Preserve a few original numeric fields from the NanoDLP payload so + // consumers that expect the raw keys (e.g., StatusProvider parsing logic) + // can access them. These are derived from the parsed NanoStatus fields. + // Keep keys lowercase to match common NanoDLP payloads. + result['resin'] = ns.resinLevel; + result['temp'] = ns.temp; + result['mcu'] = ns.mcuTemp; + return result; } diff --git a/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart b/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart deleted file mode 100644 index a687f69..0000000 --- a/lib/backend_service/nanodlp/nanodlp_thumbnail_generator.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:typed_data'; - -import 'package:image/image.dart' as img; - -class NanoDlpThumbnailGenerator { - const NanoDlpThumbnailGenerator._(); - - // Canonical large thumbnail size used in DetailsScreen. - static const int largeWidth = 800; - static const int largeHeight = 480; - - static Uint8List generatePlaceholder(int width, int height) { - final image = img.Image(width: width, height: height); - final background = img.ColorRgb8(32, 36, 43); - final accent = img.ColorRgb8(63, 74, 88); - final highlight = img.ColorRgb8(90, 104, 122); - - for (var y = 0; y < height; y++) { - for (var x = 0; x < width; x++) { - final band = ((x ~/ 16) + (y ~/ 16)) % 3; - switch (band) { - case 0: - image.setPixel(x, y, background); - break; - case 1: - image.setPixel(x, y, accent); - break; - default: - image.setPixel(x, y, highlight); - } - } - } - - for (var x = width ~/ 4; x < (width * 3) ~/ 4; x++) { - final y1 = height ~/ 4; - final y2 = (height * 3) ~/ 4; - image.setPixel(x, y1, highlight); - image.setPixel(x, y2, highlight); - } - - for (var y = height ~/ 4; y < (height * 3) ~/ 4; y++) { - final x1 = width ~/ 4; - final x2 = (width * 3) ~/ 4; - image.setPixel(x1, y, highlight); - image.setPixel(x2, y, highlight); - } - - return Uint8List.fromList(img.encodePng(image)); - } - - static Uint8List resizeOrPlaceholder( - Uint8List? sourceBytes, int width, int height) { - if (sourceBytes != null && sourceBytes.isNotEmpty) { - try { - final decoded = img.decodeImage(sourceBytes); - if (decoded != null) { - img.Image resized; - if (decoded.width == width && decoded.height == height) { - resized = decoded; - } else { - resized = img.copyResize(decoded, - width: width, - height: height, - interpolation: img.Interpolation.cubic); - } - return Uint8List.fromList(img.encodePng(resized)); - } - } catch (_) { - // fall back to placeholder below - } - } - return generatePlaceholder(width, height); - } - - /// Convenience helper to force the canonical NanoDLP large size. - static Uint8List resizeToLarge(Uint8List? sourceBytes) => - resizeOrPlaceholder(sourceBytes, largeWidth, largeHeight); -} diff --git a/lib/backend_service/odyssey/odyssey_client.dart b/lib/backend_service/odyssey/odyssey_client.dart deleted file mode 100644 index cad757b..0000000 --- a/lib/backend_service/odyssey/odyssey_client.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:typed_data'; -import 'dart:async'; - -/// Minimal abstraction over the Odyssey API used by providers. This allows -/// swapping implementations (real HTTP client, mock, or unit-test doubles) -/// while keeping providers free from direct dependency on ApiService. -abstract class OdysseyClient { - Future> listItems( - String location, int pageSize, int pageIndex, String subdirectory); - - Future usbAvailable(); - - Future> getFileMetadata( - String location, String filePath); - - Future> getConfig(); - - Future getBackendVersion(); - - Future getFileThumbnail( - String location, String filePath, String size); - - Future startPrint(String location, String filePath); - - Future> deleteFile(String location, String filePath); - - // Status-related - Future> getStatus(); - - /// Stream of status updates from the server. Implementations may expose - /// an SSE / streaming endpoint. Each emitted Map corresponds to a JSON - /// object parsed from the stream's data payloads. - Stream> getStatusStream(); - - // Print control - Future cancelPrint(); - Future pausePrint(); - Future resumePrint(); - - // Manual controls and hardware commands - Future> move(double height); - - /// Send a relative Z move in millimeters (positive = up, negative = down). - /// This maps directly to the device's relative move endpoints when - /// available (e.g. NanoDLP /z-axis/move/.../micron/...). - Future> moveDelta(double deltaMm); - - /// Whether the client supports a direct "move to top limit" command. - Future canMoveToTop(); - Future canMoveToFloor(); - - /// Move the Z axis directly to the device's top limit if supported. - Future> moveToTop(); - Future> moveToFloor(); - Future> manualCure(bool cure); - Future> manualHome(); - Future> manualCommand(String command); - Future> emergencyStop(); - Future displayTest(String test); -} diff --git a/lib/backend_service/odyssey/odyssey_http_client.dart b/lib/backend_service/odyssey/odyssey_http_client.dart index f5fffa0..260e13a 100644 --- a/lib/backend_service/odyssey/odyssey_http_client.dart +++ b/lib/backend_service/odyssey/odyssey_http_client.dart @@ -20,10 +20,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/util/orion_config.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; -class OdysseyHttpClient implements OdysseyClient { +class OdysseyHttpClient implements BackendClient { late final String apiUrl; final _log = Logger('OdysseyHttpClient'); final http.Client Function() _clientFactory; @@ -102,6 +103,49 @@ class OdysseyHttpClient implements OdysseyClient { return json.decode(resp.body) as Map; } + @override + Future>> getAnalytics(int n) async { + // Odyssey doesn't currently define a standard analytics endpoint. As a + // best-effort, try '/analytic/data/' on Odyssey host; otherwise return + // an empty list. + try { + final uri = _dynUri(apiUrl, '/analytic/data/$n', {}); + final client = _createClient(); + final resp = await client.get(uri); + client.close(); + if (resp.statusCode != 200) return []; + final decoded = json.decode(resp.body); + if (decoded is List) + return decoded + .whereType>() + .toList(growable: false); + } catch (_) {} + return []; + } + + @override + Future getAnalyticValue(int id) async { + // Odyssey doesn't support the scalar NanoDLP analytic/value endpoint. + // Return null to indicate unsupported / no-value. + return null; + } + + @override + Future>> getNotifications() async { + try { + final resp = await _odysseyGet('/notification', {}); + final decoded = json.decode(resp.body); + if (decoded is List) { + return decoded + .whereType>() + .toList(growable: false); + } + return []; + } catch (e) { + return []; + } + } + @override Stream> getStatusStream() async* { final uri = _dynUri(apiUrl, '/status/stream', {}); @@ -218,6 +262,26 @@ class OdysseyHttpClient implements OdysseyClient { return resp.bodyBytes; } + @override + Future getPlateLayerImage(int plateId, int layer) async { + // Odyssey backend does not generally provide NanoDLP-style plate layer + // images. Return a placeholder matching the canonical NanoDLP large + // size so callers can display a consistent image. + try { + // Use the NanoDLP thumbnail generator's placeholder if available. + // Importing here avoids adding a package-level dependency at the + // top-level of this file which may not be desired for all builds. + // However, NanoDlpThumbnailGenerator is a light-weight helper already + // present in the project. + // ignore: avoid_dynamic_calls + return Future.value(NanoDlpThumbnailGenerator.generatePlaceholder( + NanoDlpThumbnailGenerator.largeWidth, + NanoDlpThumbnailGenerator.largeHeight)); + } catch (_) { + return Future.value(Uint8List(0)); + } + } + @override Future startPrint(String location, String filePath) async { await _odysseyPost( @@ -289,6 +353,18 @@ class OdysseyHttpClient implements OdysseyClient { client.close(); } } + + @override + Future disableNotification(int timestamp) { + // TODO: implement disableNotification + throw UnimplementedError(); + } + + @override + Future tareForceSensor() { + // TODO: implement tareForceSensor + throw UnimplementedError(); + } } class _TimeoutHttpClient extends http.BaseClient { diff --git a/lib/backend_service/providers/analytics_provider.dart b/lib/backend_service/providers/analytics_provider.dart new file mode 100644 index 0000000..8955337 --- /dev/null +++ b/lib/backend_service/providers/analytics_provider.dart @@ -0,0 +1,189 @@ +// Clean AnalyticsProvider implementation +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:orion/backend_service/backend_client.dart'; +import 'package:orion/backend_service/backend_service.dart'; +import 'package:orion/util/orion_config.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_analytics_config.dart'; + +class AnalyticsProvider extends ChangeNotifier { + final BackendClient _client; + final Logger _log = Logger('AnalyticsProvider'); + + AnalyticsProvider({BackendClient? client}) + : _client = client ?? BackendService() { + _start(); + } + + Map? _analytics; + Map? get analytics => _analytics; + + bool _loading = true; + bool get isLoading => _loading; + + Object? _error; + Object? get error => _error; + + static const int _pressureId = 6; + final List> _pressureSeries = []; + List> get pressureSeries => + List.unmodifiable(_pressureSeries); + + int pollIntervalHertz = 15; // 15 Hz + double get pollIntervalMilliseonds => 1000.0 / pollIntervalHertz; + int get _maxPressureSamples => (60 * pollIntervalHertz); // 1 minute + + bool _disposed = false; + bool _polling = false; + + void _start() { + if (_polling || _disposed) return; + _polling = true; + () async { + while (!_disposed && _polling) { + final before = DateTime.now(); + await _pollOnce(); + final took = DateTime.now().difference(before).inMilliseconds; + final wait = pollIntervalMilliseonds - took; + if (wait > 0) { + try { + await Future.delayed(Duration(milliseconds: wait.toInt())); + } catch (_) {} + } + } + _polling = false; + }(); + } + + /// Public trigger to immediately refresh analytics once. + Future refresh() async { + if (_disposed) return; + await _pollOnce(); + } + + Future _pollOnce() async { + if (_disposed) return; + _loading = true; + try { + final cfg = OrionConfig(); + if (cfg.isNanoDlpMode()) { + // Fast path: scalar endpoint + try { + final val = await _client.getAnalyticValue(_pressureId); + if (val != null) { + final num? v = (val is num) ? val : double.tryParse(val.toString()); + if (v != null) { + final id = DateTime.now().millisecondsSinceEpoch; + _pressureSeries.add({'id': id, 'v': v}); + if (_pressureSeries.length > _maxPressureSamples) { + _pressureSeries.removeRange( + 0, _pressureSeries.length - _maxPressureSamples); + } + _analytics = { + 'nano_analytics': { + _pressureId: List>.from(_pressureSeries) + } + }; + _error = null; + _loading = false; + if (!_disposed) notifyListeners(); + return; + } + } + } catch (e, st) { + _log.fine('getAnalyticValue failed', e, st); + } + + // Fallback: batch analytics + try { + final list = await _client.getAnalytics(20); + final newPressure = >[]; + for (final item in list) { + final tRaw = item['T']; + final tid = + tRaw is int ? tRaw : int.tryParse(tRaw?.toString() ?? ''); + if (tid != _pressureId) continue; + final idRaw = item['ID']; + final vRaw = item['V']; + final id = idRaw is int + ? idRaw + : int.tryParse(idRaw?.toString() ?? '') ?? + DateTime.now().millisecondsSinceEpoch; + final num? v = + vRaw is num ? vRaw : double.tryParse(vRaw?.toString() ?? ''); + if (v == null) continue; + newPressure.add({'id': id, 'v': v}); + } + if (newPressure.isNotEmpty) { + _pressureSeries.clear(); + _pressureSeries.addAll(newPressure); + _analytics = { + 'nano_analytics': { + _pressureId: List>.from(_pressureSeries) + } + }; + _error = null; + _loading = false; + if (!_disposed) notifyListeners(); + return; + } + } catch (e, st) { + _log.fine('getAnalytics fallback failed', e, st); + } + + _loading = false; + return; + } else { + final raw = await _client.getStatus(); + _analytics = raw; + _error = null; + _loading = false; + if (!_disposed) notifyListeners(); + return; + } + } catch (e, st) { + _log.fine('Analytics poll failed', e, st); + _error = e; + _loading = false; + if (!_disposed) notifyListeners(); + } + } + + Map>> get analyticsByKey { + final a = _analytics; + if (a == null) return {}; + if (a.containsKey('nano_analytics')) { + try { + final Map>> byId = + Map>>.from(a['nano_analytics']); + final mapped = >>{}; + byId.forEach((id, list) { + final key = idToKey(id) ?? id.toString(); + mapped[key] = List>.from(list); + }); + return mapped; + } catch (_) { + return {}; + } + } + return {}; + } + + List> getSeriesForKey(String key) => + analyticsByKey[key] ?? []; + + dynamic getLatestForKey(String key) { + final s = getSeriesForKey(key); + if (s.isEmpty) return null; + return s.last['v']; + } + + @override + void dispose() { + _disposed = true; + _polling = false; + super.dispose(); + } +} diff --git a/lib/backend_service/providers/config_provider.dart b/lib/backend_service/providers/config_provider.dart index a61c744..a799249 100644 --- a/lib/backend_service/providers/config_provider.dart +++ b/lib/backend_service/providers/config_provider.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/backend_service.dart'; import 'package:orion/backend_service/odyssey/models/config_models.dart'; class ConfigProvider extends ChangeNotifier { - final OdysseyClient _client; + final BackendClient _client; final _log = Logger('ConfigProvider'); ConfigModel? _config; @@ -18,7 +18,7 @@ class ConfigProvider extends ChangeNotifier { Object? _error; Object? get error => _error; - ConfigProvider({OdysseyClient? client}) + ConfigProvider({BackendClient? client}) : _client = client ?? BackendService() { // Don't call refresh synchronously during construction — when the // provider is created inside widget build (e.g. `create: (_) => diff --git a/lib/backend_service/providers/files_provider.dart b/lib/backend_service/providers/files_provider.dart index 34e893c..680ec3d 100644 --- a/lib/backend_service/providers/files_provider.dart +++ b/lib/backend_service/providers/files_provider.dart @@ -8,7 +8,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/backend_service.dart'; import 'package:orion/backend_service/odyssey/models/files_models.dart'; import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; @@ -22,7 +22,7 @@ import 'package:orion/util/orion_api_filesystem/orion_api_item.dart'; /// * Expose current directory, loading and error state /// * Perform file actions: delete, start print, refresh class FilesProvider extends ChangeNotifier { - final OdysseyClient _client; + final BackendClient _client; final _log = Logger('FilesProvider'); FilesListModel? _listing; @@ -43,7 +43,7 @@ class FilesProvider extends ChangeNotifier { String _subdirectory = ''; String get subdirectory => _subdirectory; - FilesProvider({OdysseyClient? client}) : _client = client ?? BackendService(); + FilesProvider({BackendClient? client}) : _client = client ?? BackendService(); /// Load items into provider state (convenience wrapper around listItemsAsOrionApiItems) Future loadItems(String location, String subdirectory, @@ -109,7 +109,7 @@ class FilesProvider extends ChangeNotifier { const ttl = Duration(seconds: 30); if (_usbAvailableCache != null && _usbAvailableCachedAt != null) { if (now.difference(_usbAvailableCachedAt!) < ttl) { - _log.fine('usbAvailable: returning cached=${_usbAvailableCache}'); + _log.fine('usbAvailable: returning cached=$_usbAvailableCache'); return _usbAvailableCache!; } } diff --git a/lib/backend_service/providers/manual_provider.dart b/lib/backend_service/providers/manual_provider.dart index 7bef35b..7920e1a 100644 --- a/lib/backend_service/providers/manual_provider.dart +++ b/lib/backend_service/providers/manual_provider.dart @@ -17,14 +17,14 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/backend_service.dart'; /// Provider that exposes manual hardware controls (move, home, cure, commands, -/// and display test) using the `OdysseyClient` abstraction so screens do not +/// and display test) using the `BackendClient` abstraction so screens do not /// depend on the concrete ApiService implementation. class ManualProvider extends ChangeNotifier { - final OdysseyClient _client; + final BackendClient _client; final _log = Logger('ManualProvider'); bool _busy = false; @@ -33,7 +33,7 @@ class ManualProvider extends ChangeNotifier { Object? _error; Object? get error => _error; - ManualProvider({OdysseyClient? client}) + ManualProvider({BackendClient? client}) : _client = client ?? BackendService(); Future move(double height) async { @@ -233,4 +233,24 @@ class ManualProvider extends ChangeNotifier { return false; } } + + Future manualTareForceSensor() async { + _log.info('tareForceSensor'); + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await _client.tareForceSensor(); + _busy = false; + notifyListeners(); + return true; + } catch (e, st) { + _log.severe('tareForceSensor failed', e, st); + _error = e; + _busy = false; + notifyListeners(); + return true; // Return true even on error to avoid blocking UI + } + } } diff --git a/lib/backend_service/providers/notification_provider.dart b/lib/backend_service/providers/notification_provider.dart new file mode 100644 index 0000000..30d50bf --- /dev/null +++ b/lib/backend_service/providers/notification_provider.dart @@ -0,0 +1,179 @@ +/* +* Orion - Notification Provider +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:orion/backend_service/backend_service.dart'; +import 'package:orion/backend_service/backend_client.dart'; +import 'package:orion/util/orion_config.dart'; + +/// A simple notification model used locally. +class NotificationItem { + final int? timestamp; + final String? type; + final int? duration; + final String? text; + + NotificationItem.fromJson(Map j) + : timestamp = j['Timestamp'] is int + ? j['Timestamp'] + : (j['timestamp'] is int ? j['timestamp'] : null), + type = j['Type']?.toString() ?? j['type']?.toString(), + duration = j['Duration'] is int + ? j['Duration'] + : (j['duration'] is int ? j['duration'] : null), + text = j['Text']?.toString() ?? j['text']?.toString(); +} + +class NotificationProvider extends ChangeNotifier { + final BackendClient _client; + final _log = Logger('NotificationProvider'); + + Timer? _timer; + bool _disposed = false; + final int _pollIntervalSeconds = 1; + final Set _seen = {}; // track seen notifications by text+ts + final List _pending = []; + // Keys present on the server in the most recent poll. Each key is + // '::'. Watchers can read this to determine if a + // currently-shown notification still exists server-side. + Set _lastServerKeys = {}; + + /// Pending notifications that have not yet been handled by a watcher. + List get pendingNotifications => + List.unmodifiable(_pending); + + /// Consume and return pending notifications, clearing the pending list. + List popPendingNotifications() { + final copy = List.from(_pending); + _pending.clear(); + return copy; + } + + NotificationProvider({BackendClient? client}) + : _client = client ?? BackendService() { + _start(); + } + + void _start() { + try { + final cfg = OrionConfig(); + final isNano = cfg.isNanoDlpMode(); + if (!isNano) return; // placeholder: only NanoDLP implemented + } catch (_) { + // if config read fails, default to not starting + return; + } + + // Start a periodic poller + _timer = Timer.periodic(Duration(seconds: _pollIntervalSeconds), (_) { + _pollOnce(); + }); + // run an initial poll + _pollOnce(); + } + + Future _pollOnce() async { + if (_disposed) return; + try { + final raw = await _client.getNotifications(); + if (raw.isEmpty) { + // If server returned no notifications, clear any pending items and + // forget seen non-timestamped entries that cannot be reconciled. + // Also, remove any seen keys that referenced timestamped items which + // are no longer present on the server (they were acked elsewhere). + // Build an empty server set and prune below. + } + + // Parse server items into NotificationItem so we can build consistent keys + final serverItems = []; + for (final r in raw) { + try { + serverItems.add(NotificationItem.fromJson(r)); + } catch (_) {} + } + + // Build set of keys present on server + final serverKeys = {}; + for (final item in serverItems) { + final k = '${item.timestamp}:${item.type}:${item.text}'; + serverKeys.add(k); + } + + // Publish last seen server keys for watchers. + _lastServerKeys = serverKeys; + + // Prune _seen entries that are for timestamped notifications no longer + // present on server: they were likely acknowledged elsewhere. + final toRemove = []; + for (final s in _seen) { + // Only consider keys which include a timestamp (non-null prefix) + final parts = s.split(':'); + if (parts.isEmpty) continue; + final tsPart = parts[0]; + if (tsPart == 'null' || tsPart.isEmpty) continue; + if (!serverKeys.contains(s)) { + toRemove.add(s); + } + } + if (toRemove.isNotEmpty) { + for (final r in toRemove) { + _seen.remove(r); + } + // Also drop any pending NotificationItems which match the removed keys + _pending.removeWhere((p) { + final k = '${p.timestamp}:${p.type}:${p.text}'; + return toRemove.contains(k); + }); + } + + // Add new items that we haven't seen yet + var added = false; + for (final item in serverItems) { + final key = '${item.timestamp}:${item.type}:${item.text}'; + if (_seen.contains(key)) continue; + _seen.add(key); + _pending.add(item); + added = true; + } + + if (added) notifyListeners(); + } catch (e, st) { + _log.fine('Notification poll failed', e, st); + } + } + + /// Returns an unmodifiable view of the last-known server notification keys. + Set get serverKeys => Set.unmodifiable(_lastServerKeys); + + // Attempt to find a top-level context. This simple approach relies on + // WidgetsBinding having a current root view; it mirrors other usages in + // main.dart where a nav context is used. If unavailable, notification + // dialogs will be skipped. + // No direct UI responsibilities: watchers should listen to this provider + // and display dialogs using an appropriate BuildContext. + + @override + void dispose() { + _disposed = true; + try { + _timer?.cancel(); + } catch (_) {} + super.dispose(); + } +} diff --git a/lib/backend_service/providers/print_provider.dart b/lib/backend_service/providers/print_provider.dart index 6c6158e..440e3f6 100644 --- a/lib/backend_service/providers/print_provider.dart +++ b/lib/backend_service/providers/print_provider.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/backend_service.dart'; /// Provider to encapsulate print-related actions (start, cancel, pause, resume) /// and expose simple busy/error state for UI wiring. class PrintProvider extends ChangeNotifier { - final OdysseyClient _client; + final BackendClient _client; final _log = Logger('PrintProvider'); bool _busy = false; @@ -16,7 +16,7 @@ class PrintProvider extends ChangeNotifier { Object? _error; Object? get error => _error; - PrintProvider({OdysseyClient? client}) : _client = client ?? BackendService(); + PrintProvider({BackendClient? client}) : _client = client ?? BackendService(); Future startPrint(String location, String filePath) async { _log.info('startPrint: $location/$filePath'); diff --git a/lib/backend_service/providers/status_provider.dart b/lib/backend_service/providers/status_provider.dart index 72e5b01..cd9b6bc 100644 --- a/lib/backend_service/providers/status_provider.dart +++ b/lib/backend_service/providers/status_provider.dart @@ -21,13 +21,13 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:orion/backend_service/backend_service.dart'; import 'package:orion/util/orion_config.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; import 'dart:typed_data'; import 'package:orion/util/thumbnail_cache.dart'; -import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; /// Polls the backend `/status` endpoint and exposes a typed [StatusModel]. @@ -38,11 +38,12 @@ import 'package:orion/util/orion_api_filesystem/orion_api_file.dart'; /// * Lazy thumbnail fetching and caching /// * Convenience accessors for the UI class StatusProvider extends ChangeNotifier { - final OdysseyClient _client; + final BackendClient _client; final _log = Logger('StatusProvider'); StatusModel? _status; String? _deviceStatusMessage; + int? _resinTemperature; /// Optional raw device-provided status message (e.g. NanoDLP "Status" /// field). When present this may be used to override the app bar title @@ -51,6 +52,9 @@ class StatusProvider extends ChangeNotifier { String? get deviceStatusMessage => _deviceStatusMessage; StatusModel? get status => _status; + /// Current resin temperature reported by the backend (degrees Celsius). + int? get resinTemperature => _resinTemperature; + bool _loading = true; bool get isLoading => _loading; @@ -131,6 +135,19 @@ class StatusProvider extends ChangeNotifier { // int _sseConsecutiveErrors = 0; // reserved for future use bool _fetchInFlight = false; bool _disposed = false; + // True while the provider is attempting the first initial connection. + // This is used by the UI to show a startup/splash screen until the + // first status refresh completes (success or failure). + bool _initialAttemptInProgress = true; + + bool get initialAttemptInProgress => _initialAttemptInProgress; + + // True once we've received at least one successful status response. Used + // by the startup gate and connection error handling logic to differentiate + // between initial startup and post-startup connection loss. + bool _everHadSuccessfulStatus = false; + + bool get hasEverConnected => _everHadSuccessfulStatus; // SSE (Server-Sent Events) client state. When streaming is active we // rely on incoming events instead of the periodic polling loop. @@ -154,7 +171,7 @@ class StatusProvider extends ChangeNotifier { bool? get sseSupported => _sseSupported; - StatusProvider({OdysseyClient? client}) + StatusProvider({BackendClient? client}) : _client = client ?? BackendService() { // Prefer an SSE subscription when the backend supports it. If the // connection fails we fall back to the existing polling loop. See @@ -202,9 +219,8 @@ class StatusProvider extends ChangeNotifier { // attempting to establish an SSE subscription in that case. try { final cfg = OrionConfig(); - final backend = cfg.getString('backend', category: 'advanced'); - final devNano = cfg.getFlag('nanoDLPmode', category: 'developer'); - if (backend == 'nanodlp' || devNano) { + final isNano = cfg.isNanoDlpMode(); + if (isNano) { _log.info( 'Backend is NanoDLP; skipping SSE subscription and using polling'); _startPolling(); @@ -223,7 +239,7 @@ class StatusProvider extends ChangeNotifier { // If config read fails, proceed to attempt SSE as a best-effort. } - _log.info('Attempting SSE subscription via OdysseyClient.getStatusStream'); + _log.info('Attempting SSE subscription via BackendClient.getStatusStream'); try { final stream = _client.getStatusStream(); // When SSE becomes active, cancel any existing polling loop so we rely @@ -245,6 +261,33 @@ class StatusProvider extends ChangeNotifier { } catch (_) { _deviceStatusMessage = null; } + // Capture resin temperature if the backend provides it (common NanoDLP fields) + try { + final maybeTemp = raw['resin'] ?? + raw['Resin'] ?? + raw['resin_temperature'] ?? + raw['ResinTemperature']; + int? parsedTemp; + if (maybeTemp == null) { + parsedTemp = null; + } else if (maybeTemp is num) { + parsedTemp = maybeTemp.toInt(); + } else { + final s = maybeTemp + .toString() + .trim() + .toLowerCase() + .replaceAll('°', '') + .replaceAll('c', '') + .trim(); + final numStr = s.replaceAll(RegExp(r'[^0-9+\-.eE]'), ''); + parsedTemp = + double.tryParse(numStr)?.round() ?? int.tryParse(numStr); + } + _resinTemperature = parsedTemp; + } catch (_) { + // ignore parsing errors and leave _resinTemperature unchanged + } final parsed = StatusModel.fromJson(raw); // (previous snapshot removed) transitional clears now rely on the // parsed payload directly. @@ -482,6 +525,32 @@ class StatusProvider extends ChangeNotifier { } catch (_) { _deviceStatusMessage = null; } + // Capture resin temperature if available + try { + final maybeTemp = raw['resin'] ?? + raw['Resin'] ?? + raw['resin_temperature'] ?? + raw['ResinTemperature']; + int? parsedTemp; + if (maybeTemp == null) { + parsedTemp = null; + } else if (maybeTemp is num) { + parsedTemp = maybeTemp.toInt(); + } else { + final s = maybeTemp + .toString() + .trim() + .toLowerCase() + .replaceAll('°', '') + .replaceAll('c', '') + .trim(); + final numStr = s.replaceAll(RegExp(r'[^0-9+\-.eE]'), ''); + parsedTemp = double.tryParse(numStr)?.round() ?? int.tryParse(numStr); + } + _resinTemperature = parsedTemp; + } catch (_) { + // ignore + } final parsed = StatusModel.fromJson(raw); // Compute fingerprint difference to detect meaningful changes that // should update the UI (z/layer/progress/etc.). This allows the @@ -539,6 +608,7 @@ class StatusProvider extends ChangeNotifier { _status = parsed; _error = null; _loading = false; + _everHadSuccessfulStatus = true; // Successful refresh -> shorten polling interval _pollIntervalSeconds = _minPollIntervalSeconds; @@ -611,8 +681,12 @@ class StatusProvider extends ChangeNotifier { _loading = false; // On failure, increase consecutive error count and back off polling _consecutiveErrors = min(_consecutiveErrors + 1, _maxReconnectAttempts); + // Keep backoff short until we've ever seen a successful status so the + // startup experience remains responsive. After the first success we + // revert to the normal ramp-to-60s behavior for subsequent failures. + final maxBackoff = _everHadSuccessfulStatus ? _maxPollIntervalSeconds : 5; final backoff = _computeBackoff(_consecutiveErrors, - base: _minPollIntervalSeconds, max: _maxPollIntervalSeconds); + base: _minPollIntervalSeconds, max: maxBackoff); _pollIntervalSeconds = backoff; _nextPollRetryAt = DateTime.now().add(Duration(seconds: backoff)); // Clear timestamp when timer expires @@ -628,6 +702,12 @@ class StatusProvider extends ChangeNotifier { 'Status refresh failed after $elapsedStr; backing off polling for ${_pollIntervalSeconds}s (attempt $_consecutiveErrors)'); } finally { _fetchInFlight = false; + // The initial attempt is finished once we've done at least one fetch + // (success or failure). Clear the flag so UI can dismiss any startup + // overlay. + if (_initialAttemptInProgress) { + _initialAttemptInProgress = false; + } if (!_disposed) { // Only notify listeners if one of the meaningful observable fields // actually changed. This avoids spamming watchers with identical @@ -669,9 +749,7 @@ class StatusProvider extends ChangeNotifier { // visually responsive. try { final cfg = OrionConfig(); - final backend = cfg.getString('backend', category: 'advanced'); - final devNano = cfg.getFlag('nanoDLPmode', category: 'developer'); - final isNano = backend == 'nanodlp' || devNano; + final isNano = cfg.isNanoDlpMode(); if (!shouldNotify && isNano && (_status?.isPrinting == true || _status?.isPaused == true)) { diff --git a/lib/files/details_screen.dart b/lib/files/details_screen.dart index 662db4d..b515ed2 100644 --- a/lib/files/details_screen.dart +++ b/lib/files/details_screen.dart @@ -143,8 +143,76 @@ class DetailScreenState extends State { return GlassApp( child: Scaffold( appBar: AppBar( - title: Text('Print Details'), centerTitle: true, + title: Builder(builder: (context) { + // Use a single base font size for both title lines so they appear + // visually consistent. If the AppBar theme provides a title + // fontSize, use that as the base; otherwise default to 14 and + // reduce slightly. + final baseFontSize = + (Theme.of(context).appBarTheme.titleTextStyle?.fontSize ?? 14) - + 10; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.fileName.isNotEmpty ? widget.fileName : 'No file', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).appBarTheme.titleTextStyle?.copyWith( + fontSize: baseFontSize, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.95), + ), + ), + const SizedBox(height: 2), + Text( + _meta != null + ? DateTime.fromMillisecondsSinceEpoch( + _meta!.fileData.lastModified * 1000) + .toString() + .split('.') + .first + : '', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.merge(TextStyle( + fontWeight: FontWeight.normal, + fontSize: baseFontSize, + )) + .copyWith( + // Make status less visually dominant by lowering + // its alpha relative to the AppBar title color. + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.65), + ) ?? + TextStyle( + fontSize: baseFontSize, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.65), + ), + ), + ], + ); + }), ), body: Center( child: loading @@ -182,11 +250,10 @@ class DetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildNameCard(_meta?.fileData.name ?? widget.fileName), - const SizedBox(height: 16), Expanded( child: Column( children: [ + const SizedBox(height: 16), buildThumbnailView(context), const Spacer(), Row( @@ -228,14 +295,6 @@ class DetailScreenState extends State { ), ]), const SizedBox(height: 5), - buildInfoCard( - 'Modified Date', - _meta != null - ? DateTime.fromMillisecondsSinceEpoch( - _meta!.fileData.lastModified * 1000) - .toString() - : '-', - ), const Spacer(), buildPrintButtons(), ], @@ -256,9 +315,9 @@ class DetailScreenState extends State { children: [ Expanded( flex: 1, - child: ListView( + child: Column( children: [ - buildNameCard(_meta?.fileData.name ?? widget.fileName), + Spacer(), buildInfoCard( 'Layer Height', _meta?.layerHeight != null @@ -275,19 +334,12 @@ class DetailScreenState extends State { ? _meta!.formattedPrintTime : '-', ), - buildInfoCard( - 'Modified Date', - _meta != null - ? DateTime.fromMillisecondsSinceEpoch( - _meta!.fileData.lastModified * 1000) - .toString() - : '-', - ), buildInfoCard( 'File Size', _meta?.fileData.fileSize != null ? '${(_meta!.fileData.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : '-'), + Spacer(), ], ), ), @@ -310,8 +362,13 @@ class DetailScreenState extends State { Widget buildInfoCard(String title, String subtitle) { final cardContent = ListTile( - title: Text(title), - subtitle: Text(subtitle), + title: AutoSizeText(title), + subtitle: AutoSizeText( + subtitle, + maxLines: 1, + minFontSize: 18, + overflow: TextOverflow.ellipsis, + ), ); return GlassCard( @@ -462,6 +519,7 @@ class DetailScreenState extends State { return Row( children: [ GlassButton( + tint: GlassButtonTint.negative, wantIcon: false, onPressed: () { launchDeleteDialog(); @@ -481,6 +539,7 @@ class DetailScreenState extends State { const SizedBox(width: 20), Expanded( child: GlassButton( + tint: GlassButtonTint.neutral, onPressed: () async { try { final provider = diff --git a/lib/glasser/README.md b/lib/glasser/README.md index 1df1d0f..bffcb61 100644 --- a/lib/glasser/README.md +++ b/lib/glasser/README.md @@ -61,8 +61,28 @@ showDialog( ], ), ); + +// Apply semantic tint accents that work in both themes +GlassButton( + onPressed: () {}, + tint: GlassButtonTint.negative, + child: Text('Stop'), +) ``` +### Button Tints + +`GlassButton` supports semantic tint accents via the `tint` property. The available options are: + +- `GlassButtonTint.none` (default) +- `GlassButtonTint.none` (default) +- `GlassButtonTint.positive` (green accent) +- `GlassButtonTint.off` (orange/off-accent) +- `GlassButtonTint.neutral` (uses theme primary color) +- `GlassButtonTint.negative` (red accent) + +Tint accents automatically adapt their styling for glass and non-glass themes, ensuring consistent semantics without extra configuration. + ### Theme Detection Widgets automatically detect the current theme - no manual checking required: diff --git a/lib/glasser/src/glass_effect.dart b/lib/glasser/src/glass_effect.dart index cead680..3aad2cb 100644 --- a/lib/glasser/src/glass_effect.dart +++ b/lib/glasser/src/glass_effect.dart @@ -30,10 +30,12 @@ class GlassEffect extends StatelessWidget { final BorderRadiusGeometry? borderRadius; final double borderWidth; final bool emphasizeBorder; + final Color? borderColor; final double borderAlpha; final bool useRawOpacity; final bool useRawBorderAlpha; final bool interactiveSurface; + final bool floatingSurface; final bool disableBlur; final bool forceBlur; @@ -47,10 +49,12 @@ class GlassEffect extends StatelessWidget { this.borderRadius, this.borderWidth = 1.0, this.emphasizeBorder = false, + this.borderColor, this.borderAlpha = 0.2, this.useRawOpacity = false, this.useRawBorderAlpha = false, this.interactiveSurface = false, + this.floatingSurface = false, this.disableBlur = false, this.forceBlur = false, }); @@ -63,17 +67,22 @@ class GlassEffect extends StatelessWidget { final effectiveSigma = GlassPlatformConfig.blurSigma(sigma); final effectiveOpacity = useRawOpacity ? opacity : GlassPlatformConfig.surfaceOpacity(opacity); + // Only enable a backdrop blur for explicitly floating surfaces (dialogs, + // floating action buttons, etc.) or when [forceBlur] is true. Interactive + // surfaces (buttons, chips) skip blur by default for performance. final enableBlur = !disableBlur && - GlassPlatformConfig.shouldBlur( - interactiveSurface: interactiveSurface, - force: forceBlur, - ); + (forceBlur || + (floatingSurface && + GlassPlatformConfig.shouldBlur( + interactiveSurface: interactiveSurface, + ))); Widget decoratedChild = DecoratedBox( decoration: createGlassDecoration( opacity: effectiveOpacity, borderRadius: resolvedBorderRadius, color: color, + borderColor: borderColor, borderWidth: borderWidth, emphasizeBorder: emphasizeBorder, borderAlpha: borderAlpha, @@ -107,6 +116,7 @@ BoxDecoration createGlassDecoration({ BorderRadiusGeometry borderRadius = const BorderRadius.all(Radius.circular(glassCornerRadius)), Color? color, + Color? borderColor, double borderWidth = 1.0, bool emphasizeBorder = false, double borderAlpha = 0.2, @@ -119,11 +129,13 @@ BoxDecoration createGlassDecoration({ emphasize: emphasizeBorder, ); + // Default base is white (frosted). Callers can override the base color if + // a different aesthetic is desired. return BoxDecoration( - color: (color ?? Colors.white).withValues(alpha: opacity), + color: (color ?? Colors.grey).withValues(alpha: opacity), borderRadius: borderRadius, border: Border.all( - color: Colors.white.withValues( + color: (borderColor ?? Colors.white).withValues( alpha: effectiveBorderAlpha, ), width: borderWidth, diff --git a/lib/glasser/src/platform_config.dart b/lib/glasser/src/platform_config.dart index 8f3c8b2..4a19ad7 100644 --- a/lib/glasser/src/platform_config.dart +++ b/lib/glasser/src/platform_config.dart @@ -18,62 +18,33 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -/// Provides adaptive configuration knobs for the glass widgets so that -/// expensive visual effects can be tuned per platform. The Linux desktop -/// renderer in particular struggles with multiple backdrop filters, so we -/// dampen the blur radius while slightly boosting opacity to preserve the -/// perceived look. +/// Platform-adaptive configuration for glass widgets. class GlassPlatformConfig { const GlassPlatformConfig._(); - /// Returns true when we are running on Flutter's Linux desktop target. static bool get isLinuxDesktop => !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; - /// Determines whether applying a live blur is acceptable for the current - /// surface. Small interactive elements on Linux tend to look fine without a - /// full backdrop blur and skipping it saves a costly render pass. - static bool shouldBlur({ - bool interactiveSurface = false, - bool force = false, - }) { - if (force) { - return true; - } - - if (!isLinuxDesktop) { - return true; - } - - // Prioritise blur for larger structural surfaces like dialogs/cards, but - // skip it for small interactive controls by default. - if (interactiveSurface) { - return false; - } - - return true; + /// Whether to apply a backdrop blur for this surface. + /// Small interactive controls (interactiveSurface=true) skip blur by + /// default to improve performance; pass [force] to override. + static bool shouldBlur( + {bool interactiveSurface = false, bool force = false}) { + if (force) return true; + return !interactiveSurface; } - /// Returns a platform-tuned blur sigma. + /// Platform-tuned blur sigma. static double blurSigma(double base) { - if (!isLinuxDesktop) { - return base; - } - - // Linux performs significantly better with a lower sigma. Adding a small - // constant keeps the blur from looking too sharp on large surfaces. + if (!isLinuxDesktop) return base; final adjusted = (base * 0.65) + 1.5; return adjusted.clamp(0.0, base).toDouble(); } - /// Normalises surface opacity for translucent fills. On Linux we boost the - /// opacity slightly to compensate for the reduced blur strength while trying - /// to maintain the darker glass aesthetic. + /// Surface opacity normalization. Slightly boosts opacity for better + /// readability; [emphasize] increases the boost. static double surfaceOpacity(double base, {bool emphasize = false}) { final reducedBase = (base - 0.02).clamp(0.0, 1.0); - - if (!isLinuxDesktop) return reducedBase; - final boost = emphasize ? 0.06 : 0.04; final maxOpacity = emphasize ? 0.28 : 0.22; final targetCap = (reducedBase + boost).clamp(0.0, maxOpacity); @@ -81,20 +52,15 @@ class GlassPlatformConfig { return adjusted > targetCap ? targetCap : adjusted; } - /// Adjusts border opacity so that edge highlights remain visible when we - /// increase the surface opacity on Linux. + /// Border opacity adjustment so edge highlights remain visible with + /// increased surface opacity. static double borderOpacity(double base, {bool emphasize = false}) { - if (!isLinuxDesktop) { - return base; - } - final boost = emphasize ? 0.07 : 0.05; final maxOpacity = emphasize ? 0.38 : 0.3; final adjusted = base + boost; return adjusted > maxOpacity ? maxOpacity : adjusted; } - /// Returns a shadow list suitable for surface elements such as cards. static List surfaceShadow({ double blurRadius = 15.0, double yOffset = 4.0, @@ -109,7 +75,6 @@ class GlassPlatformConfig { ), ]; } - return [ BoxShadow( color: Colors.black.withValues(alpha: alpha), @@ -119,17 +84,13 @@ class GlassPlatformConfig { ]; } - /// Returns shadow settings for elevated interactive elements (FABs, buttons). static List? interactiveShadow({ bool enabled = true, double blurRadius = 20.0, double yOffset = 4.0, double alpha = 0.1, }) { - if (!enabled) { - return null; - } - + if (!enabled) return null; if (isLinuxDesktop) { return [ BoxShadow( @@ -139,7 +100,6 @@ class GlassPlatformConfig { ), ]; } - return [ BoxShadow( color: Colors.black.withValues(alpha: alpha), @@ -149,7 +109,6 @@ class GlassPlatformConfig { ]; } - /// Returns a soft glow for selected controls (chips, toggles, etc.). static List selectionGlow({ double blurRadius = 12.0, double alpha = 0.3, @@ -163,7 +122,6 @@ class GlassPlatformConfig { ), ]; } - return [ BoxShadow( color: Colors.white.withValues(alpha: alpha), @@ -173,11 +131,6 @@ class GlassPlatformConfig { ]; } - /// Tile mode used by blur filters. Using [TileMode.decal] prevents sampling - /// beyond the clipped region which slightly improves performance. static TileMode get blurTileMode => TileMode.decal; - - /// Clip behaviour used by our glass surfaces. Keeping anti aliasing on - /// avoids jagged edges without incurring the cost of saveLayer operations. static Clip get clipBehavior => Clip.antiAlias; } diff --git a/lib/glasser/src/widgets/glass_alert_dialog.dart b/lib/glasser/src/widgets/glass_alert_dialog.dart index bc8d4b4..ddfaf22 100644 --- a/lib/glasser/src/widgets/glass_alert_dialog.dart +++ b/lib/glasser/src/widgets/glass_alert_dialog.dart @@ -106,6 +106,7 @@ class GlassAlertDialog extends StatelessWidget { borderRadius: borderRadius, sigma: glassBlurSigma, opacity: fillOpacity, + floatingSurface: true, borderWidth: 1.6, emphasizeBorder: true, interactiveSurface: false, diff --git a/lib/glasser/src/widgets/glass_button.dart b/lib/glasser/src/widgets/glass_button.dart index 53a9295..abeb55e 100644 --- a/lib/glasser/src/widgets/glass_button.dart +++ b/lib/glasser/src/widgets/glass_button.dart @@ -22,6 +22,24 @@ import '../constants.dart'; import '../glass_effect.dart'; import '../platform_config.dart'; +/// Available tint accents for [GlassButton]. +enum GlassButtonTint { + /// No tint, keeps the default styling for both glass and non-glass themes. + none, + + /// Positive accent, rendered with a green emphasis. + positive, + + /// Neutral accent that uses the current theme primary color. + neutral, + + /// Warning accent, rendered with an orange emphasis. + warn, + + /// Negative accent, rendered with a red emphasis. + negative, +} + /// A button that automatically becomes glassmorphic when the glass theme is active. /// /// This widget is a drop-in replacement for [ElevatedButton]. When the glass theme is enabled, @@ -45,6 +63,8 @@ class GlassButton extends StatelessWidget { final VoidCallback? onPressed; final ButtonStyle? style; final bool wantIcon; + final GlassButtonTint tint; + final EdgeInsetsGeometry? margin; const GlassButton({ super.key, @@ -52,16 +72,25 @@ class GlassButton extends StatelessWidget { required this.onPressed, this.style, this.wantIcon = false, + this.tint = GlassButtonTint.none, + this.margin, }); @override Widget build(BuildContext context) { final themeProvider = Provider.of(context); + // If the button is disabled, force no tint so disabled buttons keep + // the neutral / muted appearance. + final effectiveTint = (onPressed == null) ? GlassButtonTint.none : tint; + // Resolve palette; neutral needs the theme primary so use context-aware resolver. + final tintPalette = _resolveTintPaletteWithContext(effectiveTint, context); + final resolvedMaterialStyle = + tintPalette == null ? style : tintPalette.toButtonStyle().merge(style); if (!themeProvider.isGlassTheme) { return ElevatedButton( onPressed: onPressed, - style: style, + style: resolvedMaterialStyle, child: child, ); } @@ -70,7 +99,9 @@ class GlassButton extends StatelessWidget { onPressed: onPressed, style: style, wantIcon: wantIcon, - child: child, // Pass the wantIcon parameter + tintPalette: tintPalette, // Pass the wantIcon parameter + margin: margin, + child: child, ); } } @@ -81,12 +112,16 @@ class _GlassmorphicButton extends StatelessWidget { final VoidCallback? onPressed; final ButtonStyle? style; final bool wantIcon; + final _GlassButtonTintPalette? tintPalette; + final EdgeInsetsGeometry? margin; const _GlassmorphicButton({ required this.child, required this.onPressed, this.style, this.wantIcon = true, + this.tintPalette, + this.margin, }); @override @@ -106,10 +141,23 @@ class _GlassmorphicButton extends StatelessWidget { final isCircle = style?.shape?.resolve({}) is CircleBorder; final borderRadius = BorderRadius.circular(isCircle ? 30 : glassCornerRadius); + final palette = tintPalette; + final hasTint = palette != null; + final tintColor = palette?.color; + final fillOpacity = GlassPlatformConfig.surfaceOpacity( isEnabled ? 0.14 : 0.1, emphasize: isEnabled, ); + + Color? blendedFillColor; + if (hasTint) { + // Blend a low-opacity tint over white. Use alphaBlend so the result + // keeps white highlights while adding color. + blendedFillColor = + Color.alphaBlend(tintColor!.withValues(alpha: 0.75), Colors.white); + } + final shadow = GlassPlatformConfig.interactiveShadow( enabled: isEnabled, blurRadius: isCircle ? 18 : 16, @@ -118,6 +166,7 @@ class _GlassmorphicButton extends StatelessWidget { ); Widget buttonChild = Container( + margin: margin ?? const EdgeInsets.all(0.0), decoration: BoxDecoration( borderRadius: borderRadius, boxShadow: shadow, @@ -125,10 +174,23 @@ class _GlassmorphicButton extends StatelessWidget { child: GlassEffect( borderRadius: borderRadius, sigma: glassBlurSigma, - opacity: fillOpacity, + opacity: GlassPlatformConfig.surfaceOpacity( + 0.12, + emphasize: isEnabled, + ), + // Provide a subtle tinted white base when a tint is requested so the + // control remains frosted but carries semantic color. + color: blendedFillColor, + // Tone down the outline brightness so tinted buttons aren't too + // aggressive. We still bypass platform adjustments for tinted buttons + // but use a reduced alpha for a softer outline. borderWidth: 1.5, emphasizeBorder: isEnabled, + borderColor: hasTint ? tintColor : null, + borderAlpha: hasTint ? 0.45 : 0.2, + useRawBorderAlpha: hasTint, interactiveSurface: true, + floatingSurface: false, child: Material( color: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: borderRadius), @@ -136,18 +198,28 @@ class _GlassmorphicButton extends StatelessWidget { child: InkWell( borderRadius: borderRadius, onTap: onPressed, - splashColor: isEnabled ? Colors.white.withValues(alpha: 0.2) : null, - highlightColor: - isEnabled ? Colors.white.withValues(alpha: 0.1) : null, + splashColor: isEnabled + ? (hasTint + ? tintColor!.withValues(alpha: 0.28) + : Colors.white.withValues(alpha: 0.2)) + : null, + highlightColor: isEnabled + ? (hasTint + ? tintColor!.withValues(alpha: 0.18) + : Colors.white.withValues(alpha: 0.1)) + : null, child: Opacity( - opacity: isEnabled ? 1.0 : 0.6, + opacity: isEnabled ? 1.0 : 0.4, child: Padding( padding: isCircle ? const EdgeInsets.all(0) : const EdgeInsets.symmetric( horizontal: 24.0, vertical: 8.0), child: Center( - child: _buildButtonContentWithIcon(child, wantIcon: wantIcon), + child: _buildTintAwareContent( + _buildButtonContentWithIcon(child, wantIcon: wantIcon), + palette, + ), ), ), ), @@ -213,7 +285,6 @@ Widget _buildButtonContentWithIcon(Widget originalChild, Icon( icon, size: 18, - color: Colors.white, ), const SizedBox(width: 8), Text(originalChild.data ?? ''), @@ -224,3 +295,136 @@ Widget _buildButtonContentWithIcon(Widget originalChild, return originalChild; } + +Widget _buildTintAwareContent( + Widget content, + _GlassButtonTintPalette? palette, +) { + if (palette == null) { + return content; + } + + return IconTheme( + data: IconThemeData(color: palette.glassForeground), + child: DefaultTextStyle.merge( + style: TextStyle(color: palette.glassForeground), + child: content, + ), + ); +} + +_GlassButtonTintPalette? _resolveTintPalette(GlassButtonTint tint) { + switch (tint) { + case GlassButtonTint.none: + return null; + case GlassButtonTint.positive: + return const _GlassButtonTintPalette( + color: Colors.greenAccent, + materialForeground: Colors.white, + glassForeground: Colors.greenAccent, + ); + case GlassButtonTint.warn: + return const _GlassButtonTintPalette( + color: Colors.orangeAccent, + materialForeground: Colors.white, + glassForeground: Colors.orangeAccent, + ); + case GlassButtonTint.neutral: + // neutral uses theme primary; we'll resolve a placeholder here but + // callers should call the context-aware resolver below. + return const _GlassButtonTintPalette( + color: Colors.black, + materialForeground: Colors.white, + glassForeground: Colors.black, + ); + case GlassButtonTint.negative: + return const _GlassButtonTintPalette( + color: Colors.redAccent, + materialForeground: Colors.white, + glassForeground: Colors.redAccent, + ); + } +} + +_GlassButtonTintPalette? _resolveTintPaletteWithContext( + GlassButtonTint tint, BuildContext context) { + if (tint == GlassButtonTint.neutral) { + final primary = Theme.of(context).colorScheme.primary; + return _GlassButtonTintPalette( + color: primary, + materialForeground: Colors.white, + glassForeground: primary, + ); + } + + return _resolveTintPalette(tint); +} + +class _GlassButtonTintPalette { + final Color color; + + /// Foreground color to use for non-glass (material) buttons - usually a + /// high-contrast value like white. + final Color materialForeground; + + /// Foreground color to use for glass buttons: full tint color so the text + /// and icons match the outline. + final Color glassForeground; + + const _GlassButtonTintPalette({ + required this.color, + required this.materialForeground, + required this.glassForeground, + }); + + ButtonStyle toButtonStyle() { + return ButtonStyle( + // Use a light inner tint with a strong outline for material buttons so + // they visually match the glass variant (light fill + strong outline). + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return color.withValues(alpha: 0.08); + } + // Light inner tint + return color.withValues(alpha: 0.10); + }), + // Foreground (text/icon) should be full tint color for punchiness. + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return color.withValues(alpha: 0.6); + } + return color; + }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return color.withValues(alpha: 0.6); + } + return color; + }), + // Strong outline using side, and overlay uses a slightly stronger tint. + side: WidgetStateProperty.resolveWith((states) { + final c = states.contains(WidgetState.disabled) + ? color.withValues(alpha: 0.45) + : color.withValues(alpha: 0.75); + return BorderSide(color: c, width: 1.4); + }), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return color.withValues(alpha: 0.22); + } + if (states.contains(WidgetState.focused) || + states.contains(WidgetState.hovered)) { + return color.withValues(alpha: 0.12); + } + return null; + }), + surfaceTintColor: WidgetStateProperty.all(Colors.transparent), + shadowColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return Colors.black.withValues(alpha: 0.08); + } + return Colors.black.withValues(alpha: 0.18); + }), + ); + } +} diff --git a/lib/glasser/src/widgets/glass_card.dart b/lib/glasser/src/widgets/glass_card.dart index 90cc5bc..110c8ef 100644 --- a/lib/glasser/src/widgets/glass_card.dart +++ b/lib/glasser/src/widgets/glass_card.dart @@ -106,6 +106,7 @@ class GlassCard extends StatelessWidget { ), sigma: glassBlurSigma, borderWidth: outlined ? 1.4 : 1.0, + floatingSurface: false, child: Material( type: MaterialType.transparency, shape: RoundedRectangleBorder(borderRadius: borderRadius), diff --git a/lib/glasser/src/widgets/glass_choice_chip.dart b/lib/glasser/src/widgets/glass_choice_chip.dart index 97b3df0..9ac9abc 100644 --- a/lib/glasser/src/widgets/glass_choice_chip.dart +++ b/lib/glasser/src/widgets/glass_choice_chip.dart @@ -94,6 +94,7 @@ class GlassChoiceChip extends StatelessWidget { borderWidth: borderWidth, emphasizeBorder: selected, interactiveSurface: true, + floatingSurface: false, child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/glasser/src/widgets/glass_dialog.dart b/lib/glasser/src/widgets/glass_dialog.dart index d8e8184..cd2680f 100644 --- a/lib/glasser/src/widgets/glass_dialog.dart +++ b/lib/glasser/src/widgets/glass_dialog.dart @@ -18,7 +18,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../util/providers/theme_provider.dart'; +import '../constants.dart'; import '../glass_effect.dart'; +import '../platform_config.dart'; /// A dialog that automatically becomes glassmorphic when the glass theme is active. /// @@ -60,14 +62,33 @@ class GlassDialog extends StatelessWidget { ); } + final borderRadius = BorderRadius.circular(glassCornerRadius); + final shadow = GlassPlatformConfig.surfaceShadow( + blurRadius: 26, + yOffset: 12, + alpha: 0.24, + ); + return Dialog( backgroundColor: Colors.transparent, elevation: 0, - child: GlassEffect( - opacity: 0.1, // Use dialog-specific opacity - child: Padding( - padding: padding, - child: child, + child: Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: shadow, + ), + child: GlassEffect( + borderRadius: borderRadius, + sigma: glassBlurSigma, + opacity: 0.1, // Use dialog-specific opacity + floatingSurface: true, + borderWidth: 1.6, + emphasizeBorder: true, + interactiveSurface: false, + child: Padding( + padding: padding, + child: child, + ), ), ), ); diff --git a/lib/glasser/src/widgets/glass_floating_action_button.dart b/lib/glasser/src/widgets/glass_floating_action_button.dart index 1e384f9..0d5d605 100644 --- a/lib/glasser/src/widgets/glass_floating_action_button.dart +++ b/lib/glasser/src/widgets/glass_floating_action_button.dart @@ -181,6 +181,7 @@ class GlassFloatingActionButton extends StatelessWidget { borderRadius: borderRadius, sigma: glassBlurSigma, opacity: fillOpacity, + floatingSurface: true, borderWidth: 1.6, emphasizeBorder: true, child: Material( @@ -281,6 +282,7 @@ class GlassFloatingActionButton extends StatelessWidget { borderRadius: borderRadius, sigma: glassBlurSigma, opacity: fillOpacity, + floatingSurface: true, borderWidth: 1.6, emphasizeBorder: true, child: Center( diff --git a/lib/home/home_screen.dart b/lib/home/home_screen.dart index d1d165f..08e20e9 100644 --- a/lib/home/home_screen.dart +++ b/lib/home/home_screen.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:orion/backend_service/providers/manual_provider.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:provider/provider.dart'; import 'package:orion/glasser/glasser.dart'; import 'package:orion/l10n/generated/app_localizations.dart'; @@ -181,7 +182,7 @@ class HomeScreenState extends State { }, ); }, - child: const Icon(Icons.power_settings_new_outlined, size: 38), + child: PhosphorIcon(PhosphorIcons.power(), size: 42), ), ), ], @@ -205,7 +206,7 @@ class HomeScreenState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.print_outlined, size: 52), + PhosphorIcon(PhosphorIcons.printer(), size: 52), Text( l10n.homeBtnPrint, style: const TextStyle(fontSize: 28), @@ -215,6 +216,27 @@ class HomeScreenState extends State { ), ), const SizedBox(width: 20), + if (_config.enableResinProfiles()) ...[ + Expanded( + child: GlassButton( + style: theme.elevatedButtonTheme.style, + onPressed: + null, // () => context.go('/materials'), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + PhosphorIcon(PhosphorIcons.flask(), size: 52), + Text( + 'Materials', + style: const TextStyle(fontSize: 28), + ), + ], + ), + ), + ), + const SizedBox(width: 20), + ], ], ), ), @@ -231,7 +253,7 @@ class HomeScreenState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.handyman_outlined, size: 52), + PhosphorIcon(PhosphorIcons.toolbox(), size: 52), Text( l10n.homeBtnTools, style: const TextStyle(fontSize: 28), @@ -249,7 +271,7 @@ class HomeScreenState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.settings_outlined, size: 52), + PhosphorIcon(PhosphorIcons.gear(), size: 52), Text( l10n.homeBtnSettings, style: const TextStyle(fontSize: 28), diff --git a/lib/home/startup_gate.dart b/lib/home/startup_gate.dart new file mode 100644 index 0000000..6e158f1 --- /dev/null +++ b/lib/home/startup_gate.dart @@ -0,0 +1,87 @@ +/* +* Orion - Startup Gate +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:orion/backend_service/providers/status_provider.dart'; +import 'package:orion/home/onboarding_screen.dart'; +import 'package:orion/home/home_screen.dart'; +import 'package:orion/home/startup_screen.dart'; +import 'package:orion/util/orion_config.dart'; + +/// Blocks initial app content until the backend reports a successful +/// initial connection. While waiting, shows the branded [StartupScreen]. +class StartupGate extends StatefulWidget { + const StartupGate({super.key}); + + @override + State createState() => _StartupGateState(); +} + +class _StartupGateState extends State { + late StatusProvider _statusProv; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _statusProv = Provider.of(context, listen: false); + // Listen for changes so we can rebuild when status becomes available. + _statusProv.addListener(_onStatusChange); + } + + void _onStatusChange() { + // Rebuild whenever provider updates + if (mounted) setState(() {}); + } + + @override + void dispose() { + try { + _statusProv.removeListener(_onStatusChange); + } catch (_) {} + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final prov = Provider.of(context, listen: false); + // Choose the child widget depending on whether we've ever connected. + // We use keys so AnimatedSwitcher can correctly cross-fade between + // the startup overlay and the main app content. + final Widget child; + if (!prov.hasEverConnected) { + child = const StartupScreen(key: ValueKey('startup')); + } else { + final cfg = OrionConfig(); + final showOnboarding = cfg.getFlag('firstRun', category: 'machine'); + child = showOnboarding + ? const OnboardingScreen(key: ValueKey('onboarding')) + : const HomeScreen(key: ValueKey('home')); + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 600), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (widget, animation) => FadeTransition( + opacity: animation, + child: widget, + ), + child: child, + ); + } +} diff --git a/lib/home/startup_screen.dart b/lib/home/startup_screen.dart new file mode 100644 index 0000000..73be558 --- /dev/null +++ b/lib/home/startup_screen.dart @@ -0,0 +1,295 @@ +/* +* Orion - Startup Screen +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:orion/backend_service/providers/status_provider.dart'; +import 'package:orion/glasser/src/gradient_utils.dart'; +import 'package:orion/util/providers/theme_provider.dart'; +import 'package:orion/util/orion_config.dart'; +import 'package:provider/provider.dart'; + +/// Blocking startup overlay shown while the app awaits the initial backend +/// connection. Displayed by [StartupGate] until +/// [StatusProvider.hasEverConnected] becomes true. +class StartupScreen extends StatefulWidget { + const StartupScreen({super.key}); + + @override + State createState() => _StartupScreenState(); +} + +class _StartupScreenState extends State + with TickerProviderStateMixin { + late final AnimationController _logoController; + late final AnimationController _loaderController; + late final AnimationController _logoMoveController; + late final AnimationController _backgroundController; + late final Animation _logoOpacity; + late final Animation _logoMove; + late final Animation _loaderOpacity; + late final Animation _backgroundOpacity; + late final String _printerName; + List _gradientColors = const []; + Color _backgroundColor = Colors.black; + + @override + void initState() { + super.initState(); + + _logoController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _logoMoveController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + final config = OrionConfig(); + final rawPrinterName = + config.getString('machineName', category: 'machine').trim(); + _printerName = rawPrinterName.isEmpty ? '3D Printer' : rawPrinterName; + _loaderController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _backgroundController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + _logoOpacity = + CurvedAnimation(parent: _logoController, curve: Curves.easeInOut); + _logoMove = CurvedAnimation( + parent: _logoMoveController, curve: Curves.easeInOutSine); + _loaderOpacity = + CurvedAnimation(parent: _loaderController, curve: Curves.easeInOut); + _backgroundOpacity = + CurvedAnimation(parent: _backgroundController, curve: Curves.easeInOut); + + // Stage animations: logo after 1s, background after 4s with a slower fade. + Future.delayed(const Duration(seconds: 1), () { + if (mounted) _logoController.forward(); + }); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) _logoMoveController.forward(); + }); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) _loaderController.forward(); + }); + Future.delayed(const Duration(seconds: 4), () { + if (mounted) _backgroundController.forward(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final baseBackground = Theme.of(context).scaffoldBackgroundColor; + _backgroundColor = + Color.lerp(baseBackground, Colors.black, 0.6) ?? Colors.black; + try { + final themeProvider = Provider.of(context, listen: false); + if (themeProvider.isGlassTheme) { + // Use the same gradient resolution as the rest of the app so + // brightness and color stops match GlassApp. + final gradient = GlassGradientUtils.resolveGradient( + themeProvider: themeProvider, + ); + _gradientColors = gradient.isNotEmpty ? gradient : const []; + } else { + // For non-glass themes, prefer the theme's scaffold background color + // as a solid background. We already computed a blended _backgroundColor + // above; slightly darken it so the startup overlay reads well over + // light/dark backgrounds. + _gradientColors = const []; + _backgroundColor = Color.lerp(Theme.of(context).scaffoldBackgroundColor, + Colors.black, 0.35) ?? + _backgroundColor; + } + } catch (_) { + _gradientColors = const []; + } + } + + @override + void dispose() { + _logoController.dispose(); + _loaderController.dispose(); + _logoMoveController.dispose(); + _backgroundController.dispose(); + super.dispose(); + } + + // Interpolates between a greyscale color matrix (t=0) and the identity + // color matrix (t=1). We drive this with the loader animation so the + // logo is greyscale initially and transitions back to color as the + // loader/text are revealed. + List _colorMatrixFor(double t) { + const List grey = [ + 0.2126, 0.7152, 0.0722, 0, 0, // R + 0.2126, 0.7152, 0.0722, 0, 0, // G + 0.2126, 0.7152, 0.0722, 0, 0, // B + 0, 0, 0, 1, 0, // A + ]; + const List identity = [ + 1, 0, 0, 0, 0, // R + 0, 1, 0, 0, 0, // G + 0, 0, 1, 0, 0, // B + 0, 0, 0, 1, 0, // A + ]; + // t==0 => grey, t==1 => identity + return List.generate( + 20, + (i) => grey[i] + (identity[i] - grey[i]) * t, + growable: false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + Container(color: Colors.black), + FadeTransition( + opacity: _backgroundOpacity, + child: _gradientColors.length >= 2 + ? Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _gradientColors, + ), + ), + // Match GlassApp: overlay a semi-transparent black layer on + // top of the gradient to achieve the same perceived + // brightness. + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + ), + ), + ) + : DecoratedBox( + decoration: BoxDecoration( + color: _backgroundColor.withValues(alpha: 1.0), + ), + ), + ), + Center( + child: FadeTransition( + opacity: _logoOpacity, + child: AnimatedBuilder( + animation: _logoMove, + builder: (context, _) { + final dy = -30.0 * _logoMove.value; + final matrix = _colorMatrixFor(_logoMove.value); + return Transform.translate( + offset: Offset(0, dy - 10), + child: Stack( + alignment: Alignment.center, + children: [ + // Slightly upscaled blurred black copy (halo) + Transform.scale( + scale: 1.07, + child: ImageFiltered( + imageFilter: + ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.5), + BlendMode.srcIn), + child: Padding( + padding: const EdgeInsets.all(8), + child: Image.asset( + 'assets/images/open_resin_alliance_logo_darkmode.png', + width: 220, + height: 220, + ), + ), + ), + ), + ), + // Foreground logo which receives the greyscale->color + // matrix animation. + Padding( + padding: const EdgeInsets.all(8), + child: ColorFiltered( + colorFilter: ColorFilter.matrix(matrix), + child: Image.asset( + 'assets/images/open_resin_alliance_logo_darkmode.png', + width: 220, + height: 220, + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + // Full-width indeterminate progress bar at the bottom edge. Kept + // separate from the centered content so it doesn't affect layout. + Positioned( + left: 20, + right: 20, + bottom: 30, + child: FadeTransition( + opacity: _loaderOpacity, + child: Text( + 'Starting up $_printerName', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'AtkinsonHyperlegible', + fontSize: 24, + color: Colors.white70, + ), + ), + ), + ), + Positioned( + left: 40, + right: 40, + bottom: 90, + child: FadeTransition( + opacity: _loaderOpacity, + child: SizedBox( + height: 14, + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: LinearProgressIndicator( + // Use theme primary color for the indicator to match app theming. + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary), + backgroundColor: Colors.black.withValues(alpha: 0.3), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 9da7266..a3f8048 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,28 +33,32 @@ import 'package:orion/files/files_screen.dart'; import 'package:orion/files/grid_files_screen.dart'; import 'package:orion/glasser/glasser.dart'; import 'package:orion/home/home_screen.dart'; -import 'package:orion/home/onboarding_screen.dart'; +import 'package:orion/home/startup_gate.dart'; import 'package:orion/l10n/generated/app_localizations.dart'; import 'package:orion/settings/about_screen.dart'; import 'package:orion/settings/settings_screen.dart'; import 'package:orion/status/status_screen.dart'; +import 'package:orion/materials/materials_screen.dart'; import 'package:orion/backend_service/providers/status_provider.dart'; import 'package:orion/backend_service/providers/files_provider.dart'; import 'package:orion/backend_service/providers/config_provider.dart'; import 'package:orion/backend_service/providers/print_provider.dart'; +import 'package:orion/backend_service/providers/notification_provider.dart'; import 'package:orion/backend_service/providers/manual_provider.dart'; +import 'package:orion/backend_service/providers/analytics_provider.dart'; import 'package:orion/tools/tools_screen.dart'; import 'package:orion/util/error_handling/error_handler.dart'; import 'package:orion/util/providers/locale_provider.dart'; import 'package:orion/util/providers/theme_provider.dart'; import 'package:orion/util/error_handling/connection_error_watcher.dart'; +import 'package:orion/util/error_handling/notification_watcher.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { setWindowTitle('Orion - Open Resin Alliance'); - setWindowMinSize(const Size(480, 480)); + setWindowMinSize(const Size(480, 480 + 28)); // account for title bar if (kDebugMode) { setWindowMaxSize(const Size(800, 800)); } @@ -144,6 +148,10 @@ class OrionRoot extends StatelessWidget { create: (_) => StatusProvider(), lazy: false, ), + ChangeNotifierProvider( + create: (_) => NotificationProvider(), + lazy: true, + ), ChangeNotifierProvider( create: (_) => ConfigProvider(), lazy: false, @@ -156,6 +164,10 @@ class OrionRoot extends StatelessWidget { create: (_) => PrintProvider(), lazy: true, ), + ChangeNotifierProvider( + create: (_) => AnalyticsProvider(), + lazy: false, + ), ChangeNotifierProvider( create: (_) => ManualProvider(), lazy: true, @@ -176,6 +188,7 @@ class OrionMainApp extends StatefulWidget { class OrionMainAppState extends State { late final GoRouter _router; ConnectionErrorWatcher? _connWatcher; + NotificationWatcher? _notifWatcher; final GlobalKey _navKey = GlobalKey(); bool _statusListenerAttached = false; bool _wasPrinting = false; @@ -191,6 +204,7 @@ class OrionMainAppState extends State { @override void dispose() { _connWatcher?.dispose(); + _notifWatcher?.dispose(); super.dispose(); } @@ -201,9 +215,10 @@ class OrionMainAppState extends State { GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) { - return initialSetupTrigger() - ? const OnboardingScreen() - : const HomeScreen(); + // Let the StartupGate decide whether to show the startup overlay + // while the initial backend connection attempt completes. It will + // render the onboarding screen or the HomeScreen once connected. + return const StartupGate(); }, routes: [ GoRoute( @@ -224,6 +239,12 @@ class OrionMainAppState extends State { return const GridFilesScreen(); }, ), + GoRoute( + path: 'materials', + builder: (BuildContext context, GoRouterState state) { + return const MaterialsScreen(); + }, + ), GoRoute( path: 'settings', builder: (BuildContext context, GoRouterState state) { @@ -285,9 +306,13 @@ class OrionMainAppState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { try { final navCtx = _navKey.currentContext; + // Startup gating is handled by the root route's StartupGate. if (_connWatcher == null && navCtx != null) { _connWatcher = ConnectionErrorWatcher.install(navCtx); } + if (_notifWatcher == null && navCtx != null) { + _notifWatcher = NotificationWatcher.install(navCtx); + } // Attach a listener to StatusProvider so we can auto-open // the StatusScreen when a print becomes active (remote start). try { @@ -300,8 +325,8 @@ class OrionMainAppState extends State { final active = (s?.isPrinting == true) || (s?.isPaused == true); if (active && !_wasPrinting) { - // Only navigate if not already on /status - // Navigate to status on transition to active print. + // Only navigate if not already on /status. Navigate + // to status on transition to active print. try { final navState = _navKey.currentState; final sModel = statusProv.status; diff --git a/lib/materials/materials_screen.dart b/lib/materials/materials_screen.dart new file mode 100644 index 0000000..ef7e57b --- /dev/null +++ b/lib/materials/materials_screen.dart @@ -0,0 +1,80 @@ +/* +* Orion - Materials Screen +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'package:flutter/material.dart'; + +import 'package:orion/glasser/glasser.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +class MaterialsScreen extends StatefulWidget { + const MaterialsScreen({super.key}); + + @override + MaterialsScreenState createState() => MaterialsScreenState(); +} + +class MaterialsScreenState extends State { + int _selectedIndex = 1; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GlassApp( + child: Scaffold( + appBar: AppBar( + title: const Text('Materials'), + ), + body: _selectedIndex == 0 + ? const Center(child: Text('To Be Implemented Soon!')) + : _selectedIndex == 1 + ? const Center(child: Text('To Be Implemented Soon!')) + : const Center(child: Text('To Be Implemented Soon!')), + bottomNavigationBar: GlassBottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: PhosphorIcon(PhosphorIcons.flask()), + label: 'Resins', + ), + BottomNavigationBarItem( + icon: PhosphorIcon(PhosphorIcons.scales()), + label: 'Calibration', + ), + BottomNavigationBarItem( + icon: PhosphorIcon(PhosphorIcons.thermometer()), + label: 'Heaters', + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Theme.of(context).colorScheme.primary, + onTap: _onItemTapped, + unselectedItemColor: Theme.of(context).colorScheme.secondary, + ), + ), + ); + } +} diff --git a/lib/settings/general_screen.dart b/lib/settings/general_screen.dart index c60029a..f95418b 100644 --- a/lib/settings/general_screen.dart +++ b/lib/settings/general_screen.dart @@ -51,6 +51,7 @@ class GeneralCfgScreenState extends State { late bool developerMode; late bool releaseOverride; late bool overrideUpdateCheck; + late bool overrideRawForceSensorValues; late String overrideRelease; late bool verboseLogging; late bool selfDestructMode; @@ -91,6 +92,8 @@ class GeneralCfgScreenState extends State { releaseOverride = config.getFlag('releaseOverride', category: 'developer'); overrideUpdateCheck = config.getFlag('overrideUpdateCheck', category: 'developer'); + overrideRawForceSensorValues = + config.getFlag('overrideRawForceSensorValues', category: 'developer'); overrideRelease = config.getString('overrideRelease', category: 'developer'); verboseLogging = config.getFlag('verboseLogging', category: 'developer'); @@ -102,77 +105,6 @@ class GeneralCfgScreenState extends State { machineName = config.getString('machineName', category: 'machine'); } - GlassCard _buildBetaTestingSection() { - return GlassCard( - outlined: true, - elevation: 1, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Beta Testing', - style: TextStyle( - fontSize: 28.0, - ), - ), - const SizedBox(height: 12.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: SizedBox( - height: 48, - child: GlassButton( - style: ElevatedButton.styleFrom(elevation: 2), - onPressed: _isReverting - ? null - : () async { - final confirm = await showDialog( - context: context, - builder: (context) => GlassAlertDialog( - title: const Text('Confirm NanoDLP Revert'), - content: const Text( - 'This will revert from the Orion Beta to the NanoDLP HMI. Do you want to continue?\n\nRe-activating Orion later will require SSH access.\nRun "activate_orion" to re-enable Orion.'), - actions: [ - GlassButton( - onPressed: () => - Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - GlassButton( - onPressed: () => - Navigator.of(context).pop(true), - child: const Text('Run'), - ), - ], - ), - ); - if (confirm != true) return; - _runRevertOrion(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.restore, size: 20), - const SizedBox(width: 8), - _isReverting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Revert to NanoDLP HMI'), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -592,8 +524,6 @@ class GeneralCfgScreenState extends State { /// Developer Section for build overrides. if (developerMode) _buildDeveloperSection(), - if (developerMode) const SizedBox(height: 12.0), - if (developerMode) _buildBetaTestingSection(), ], ), ), @@ -738,6 +668,20 @@ class GeneralCfgScreenState extends State { }); }, ), + const SizedBox(height: 20.0), + OrionListTile( + title: 'Raw Force Sensor Values', + icon: PhosphorIcons.scales(), + value: overrideRawForceSensorValues, + onChanged: (bool value) { + setState(() { + overrideRawForceSensorValues = value; + config.setFlag('overrideRawForceSensorValues', + overrideRawForceSensorValues, + category: 'developer'); + }); + }, + ), ], ), ), diff --git a/lib/settings/ui_screen.dart b/lib/settings/ui_screen.dart index 8a43e59..22084e4 100644 --- a/lib/settings/ui_screen.dart +++ b/lib/settings/ui_screen.dart @@ -53,7 +53,8 @@ class _UIScreenState extends State { ), body: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), child: Column( children: [ // Theme Mode Selector Card diff --git a/lib/status/status_screen.dart b/lib/status/status_screen.dart index dd8ad32..aa4458d 100644 --- a/lib/status/status_screen.dart +++ b/lib/status/status_screen.dart @@ -19,9 +19,10 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'dart:typed_data'; -import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; import 'package:orion/files/grid_files_screen.dart'; import 'package:orion/glasser/glasser.dart'; @@ -31,6 +32,8 @@ import 'package:orion/util/providers/theme_provider.dart'; import 'package:orion/util/status_card.dart'; import 'package:orion/backend_service/providers/status_provider.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; +import 'package:orion/backend_service/backend_service.dart'; +import 'package:orion/util/layer_preview_cache.dart'; class StatusScreen extends StatefulWidget { final bool newPrint; @@ -61,11 +64,6 @@ class StatusScreenState extends State { // Presentation-local state (derived values computed per build instead of storing) bool get _isLandscape => MediaQuery.of(context).orientation == Orientation.landscape; - int get _maxNameLength => _isLandscape ? 12 : 24; - - String _truncateFileName(String name) => name.length >= _maxNameLength - ? '${name.substring(0, _maxNameLength)}...' - : name; // Duration formatting moved to StatusModel.formattedElapsedPrintTime @@ -86,6 +84,17 @@ class StatusScreenState extends State { } } + // Local UI state for toggling 2D layer preview + bool _showLayer2D = false; + Uint8List? _layer2DBytes; + ImageProvider? _layer2DImageProvider; + bool _layer2DLoading = false; + DateTime? _lastLayerToggleTime; + bool _prefetched = false; + int? _lastPrefetchedLayer; + int? _resolvedPlateIdForPrefetch; + String? _resolvedFilePathForPrefetch; + bool _bytesEqual(Uint8List a, Uint8List b) { if (identical(a, b)) return true; if (a.lengthInBytes != b.lengthInBytes) return false; @@ -240,6 +249,33 @@ class StatusScreenState extends State { } final elapsedStr = status.formattedElapsedPrintTime; + // Trigger a one-time prefetch of 3D and current 2D layer thumbnails + // when we first observe a valid status with file metadata. + if (!_prefetched && status.printData?.fileData != null) { + _prefetched = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _prefetchThumbnails(status); + }); + } + + // Proactively preload next layers when the reported layer changes. + // Use a post-frame callback to perform async work outside build. + if (status.layer != null && + status.printData?.fileData != null && + status.printData?.fileData?.path != _resolvedFilePathForPrefetch) { + // File changed; clear previously-resolved plate id so we'll re-resolve. + _resolvedFilePathForPrefetch = status.printData?.fileData?.path; + _resolvedPlateIdForPrefetch = null; + _lastPrefetchedLayer = null; + } + + if (status.layer != null && status.layer != _lastPrefetchedLayer) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _maybePreloadNextLayers(status); + }); + } final fileName = _frozenFileName ?? status.printData?.fileData?.name ?? ''; @@ -247,15 +283,104 @@ class StatusScreenState extends State { child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, + centerTitle: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Builder(builder: (context) { + final provider = Provider.of(context); + final int? temp = provider.resinTemperature; + return GlassCard( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon(Icons.thermostat, + size: 20, + color: + Theme.of(context).colorScheme.primary), + const SizedBox(width: 6), + Text( + '$temp\u00B0C', + style: TextStyle(fontSize: 18), + ), + const SizedBox(width: 8), + ], + ))); + }), + ), + ], title: Builder(builder: (context) { final deviceMsg = provider.deviceStatusMessage; final statusText = (deviceMsg != null && deviceMsg.trim().isNotEmpty) ? deviceMsg : provider.displayStatus; - return Text( - statusText, - style: Theme.of(context).appBarTheme.titleTextStyle, + // Use a single base font size for both title lines so they appear + // visually consistent. If the AppBar theme provides a title + // fontSize, use that as the base; otherwise default to 14 and + // reduce slightly. + final baseFontSize = + (Theme.of(context).appBarTheme.titleTextStyle?.fontSize ?? + 14) - + 10; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + fileName.isNotEmpty ? fileName : 'No file', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.copyWith( + fontSize: baseFontSize, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.95), + ), + ), + const SizedBox(height: 2), + Text( + statusText, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.merge(TextStyle( + fontWeight: FontWeight.normal, + fontSize: baseFontSize, + )) + .copyWith( + // Make status less visually dominant by lowering + // its alpha relative to the AppBar title color. + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.65), + ) ?? + TextStyle( + fontSize: baseFontSize, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.color + ?.withValues(alpha: 0.65), + ), + ), + ], ); }), ), @@ -311,7 +436,6 @@ class StatusScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildNameCard(fileName, provider), const SizedBox(height: 16), Expanded( child: Column( @@ -365,8 +489,8 @@ class StatusScreenState extends State { child: Row(children: [ Expanded( flex: 1, - child: ListView(children: [ - _buildNameCard(fileName, provider), + child: Column(children: [ + Spacer(), _buildInfoCard( 'Current Z Position', '${statusModel?.physicalState.z.toStringAsFixed(3) ?? '-'} mm', @@ -384,6 +508,7 @@ class StatusScreenState extends State { ? '-' : '${usedMaterial.toStringAsFixed(2)} mL', ), + Spacer(), ]), ), const SizedBox(width: 16.0), @@ -413,37 +538,6 @@ class StatusScreenState extends State { ); } - Widget _buildNameCard(String fileName, StatusProvider provider) { - final truncated = _truncateFileName(fileName); - final statusModel = provider.status; - final finishedSnapshot = - statusModel?.isIdle == true && statusModel?.layer != null; - // Prefer canonical 'finished' hint from the mapper. - final effectivelyFinished = statusModel?.finished == true; - final color = (finishedSnapshot && !effectivelyFinished) - ? Theme.of(context).colorScheme.error - : provider.statusColor(context); - return GlassCard( - outlined: true, - child: ListTile( - title: AutoSizeText.rich( - maxLines: 1, - minFontSize: 16, - TextSpan(children: [ - TextSpan( - text: truncated, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ]), - ), - ), - ); - } - Widget _buildThumbnailView( BuildContext context, StatusProvider provider, StatusModel? status) { // Prefer provider's thumbnail bytes. If none yet, consider the @@ -483,132 +577,401 @@ class StatusScreenState extends State { ? Theme.of(context).colorScheme.error : statusColor; return Center( - child: Stack( - children: [ - GlassCard( - outlined: true, - elevation: 1.0, - child: Padding( - padding: const EdgeInsets.all(4.5), - child: ClipRRect( - borderRadius: themeProvider.isGlassTheme - ? BorderRadius.circular(10.5) - : BorderRadius.circular(7.75), - child: Stack(children: [ - ColorFiltered( - colorFilter: const ColorFilter.matrix([ - 0.2126, 0.7152, 0.0722, 0, 0, // grayscale matrix - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, - ]), - child: thumbnail != null && thumbnail.isNotEmpty - ? Image.memory( - thumbnail, - fit: BoxFit.cover, - ) - : Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - effectiveStatusColor), + child: GestureDetector( + onTap: () { + // Debounce toggles: ignore taps that occur within 500ms of the + // previous toggle, and ignore while a layer load is in progress. + final now = DateTime.now(); + if (_layer2DLoading) return; + if (_lastLayerToggleTime != null && + now.difference(_lastLayerToggleTime!) < + const Duration(milliseconds: 500)) { + return; + } + + // Toggle 2D layer preview. If enabling, trigger fetch. + final providerState = + Provider.of(context, listen: false); + setState(() { + _showLayer2D = !_showLayer2D; + _lastLayerToggleTime = now; + }); + if (_showLayer2D) { + _fetchLayer2D(providerState, statusModel); + } + }, + child: Stack( + children: [ + GlassCard( + outlined: true, + elevation: 1.0, + child: Padding( + padding: const EdgeInsets.all(4.5), + child: ClipRRect( + borderRadius: themeProvider.isGlassTheme + ? BorderRadius.circular(12.5) + : BorderRadius.circular(7.75), + child: Stack(children: [ + // Base thumbnail / spinner + ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // grayscale matrix + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: thumbnail != null && thumbnail.isNotEmpty + ? Image.memory( + thumbnail, + fit: BoxFit.cover, + ) + : Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + effectiveStatusColor), + ), ), - ), - ), - Positioned.fill( - child: Container( - color: Colors.black.withValues(alpha: 0.35), ), - ), - Positioned.fill( - child: Align( - alignment: Alignment.bottomCenter, - child: ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: progress, - child: thumbnail != null && thumbnail.isNotEmpty - ? Image.memory( - thumbnail, - fit: BoxFit.cover, - ) - : Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - effectiveStatusColor), + + // Dim overlay + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.35), + ), + ), + + // Progress wipe + Positioned.fill( + child: Align( + alignment: Alignment.bottomCenter, + child: ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: progress, + child: thumbnail != null && thumbnail.isNotEmpty + ? Image.memory( + thumbnail, + fit: BoxFit.cover, + ) + : Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + effectiveStatusColor), + ), ), - ), + ), ), ), ), - ), - ]), + + // 2D layer overlay (covers base thumbnail and status card when active) + if (_showLayer2D) + Positioned.fill( + child: _layer2DLoading + ? Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + effectiveStatusColor), + ), + ) + : (_layer2DImageProvider != null + ? Image( + image: _layer2DImageProvider!, + gaplessPlayback: true, + fit: BoxFit.cover, + ) + : (_layer2DBytes != null && + _layer2DBytes!.isNotEmpty + ? Image.memory( + _layer2DBytes!, + fit: BoxFit.cover, + ) + : Center( + child: Text('2D preview unavailable'), + ))), + ), + ]), + ), ), ), - ), - Positioned.fill( - right: 15, - child: Center( - child: StatusCard( - isCanceling: provider.isCanceling, - isPausing: provider.isPausing, - progress: progress, - statusColor: effectiveStatusColor, - status: status, + Positioned.fill( + right: 15, + child: Center( + child: StatusCard( + isCanceling: provider.isCanceling, + isPausing: provider.isPausing, + progress: progress, + statusColor: effectiveStatusColor, + status: status, + showPercentage: !_showLayer2D, + ), ), ), - ), - Positioned( - top: 0, - bottom: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.all(2), - child: Builder(builder: (context) { - final isGlassTheme = themeProvider.isGlassTheme; - return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: isGlassTheme - ? const Radius.circular(14.0) - : const Radius.circular(9.75), - bottomRight: isGlassTheme - ? const Radius.circular(14.0) - : const Radius.circular(9.75), - ), - ), - child: Padding( - padding: const EdgeInsets.all(2.5), - child: ClipRRect( + Positioned( + top: 0, + bottom: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.all(2), + child: Builder(builder: (context) { + final isGlassTheme = themeProvider.isGlassTheme; + return GlassCard( + shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topRight: isGlassTheme - ? const Radius.circular(11.5) - : const Radius.circular(7.75), + ? const Radius.circular(14.0) + : const Radius.circular(9.75), bottomRight: isGlassTheme - ? const Radius.circular(11.5) - : const Radius.circular(7.75), + ? const Radius.circular(14.0) + : const Radius.circular(9.75), ), - child: RotatedBox( - quarterTurns: 3, - child: LinearProgressIndicator( - minHeight: 30, - color: effectiveStatusColor, - value: progress, - backgroundColor: isGlassTheme - ? effectiveStatusColor.withValues(alpha: 0.1) - : null, + ), + child: Padding( + padding: const EdgeInsets.all(2.5), + child: ClipRRect( + borderRadius: BorderRadius.only( + topRight: isGlassTheme + ? const Radius.circular(11.5) + : const Radius.circular(7.75), + bottomRight: isGlassTheme + ? const Radius.circular(11.5) + : const Radius.circular(7.75), + ), + child: RotatedBox( + quarterTurns: 3, + child: LinearProgressIndicator( + minHeight: 15, + color: effectiveStatusColor, + value: progress, + backgroundColor: isGlassTheme + ? effectiveStatusColor.withValues(alpha: 0.1) + : null, + ), ), ), ), - ), - ); - }), + ); + }), + ), ), - ), - ], + ], + ), ), ); } + Future _fetchLayer2D( + StatusProvider provider, StatusModel? status) async { + if (status == null) return; + final fileData = status.printData?.fileData; + int? plateId; + final layerIndex = status.layer; + if (layerIndex == null) return; + + try { + if (fileData != null) { + final meta = await BackendService().getFileMetadata( + fileData.locationCategory ?? 'Local', fileData.path); + if (meta['plate_id'] != null) { + plateId = meta['plate_id'] as int?; + } + } + } catch (_) { + plateId = widget.initialPlateId; + } + if (plateId == null) return; + + final cached = LayerPreviewCache.instance.get(plateId, layerIndex); + if (cached != null) { + setState(() { + _layer2DBytes = cached; + _layer2DImageProvider = MemoryImage(cached); + _showLayer2D = true; + }); + LayerPreviewCache.instance + .preload(BackendService(), plateId, layerIndex, count: 2); + return; + } + + setState(() { + _layer2DLoading = true; + }); + try { + final bytes = await LayerPreviewCache.instance + .fetchAndCache(BackendService(), plateId, layerIndex); + if (bytes.isNotEmpty) { + final imgProv = MemoryImage(bytes); + // Start precaching but don't await it — decoding can be expensive + // and awaiting here can cause UI jank. Fire-and-forget instead. + precacheImage(imgProv, context).catchError((_) {}); + setState(() { + _layer2DBytes = bytes; + _layer2DImageProvider = imgProv; + _showLayer2D = true; + }); + LayerPreviewCache.instance + .preload(BackendService(), plateId, layerIndex, count: 2); + } + } catch (_) { + // ignore + } finally { + setState(() { + _layer2DLoading = false; + }); + } + } + + Future _prefetchThumbnails(StatusModel status) async { + // Prefetch the 3D thumbnail for the current file (Large) and the + // current 2D layer (and preload next layers). Best-effort; ignore + // any failures. + try { + final fileData = status.printData?.fileData; + if (fileData != null) { + // Prefetch 3D thumbnail (Large size) — fetch bytes and precache so + // Flutter's image cache holds a decoded image for instant display. + BackendService() + .getFileThumbnail( + fileData.locationCategory ?? 'Local', fileData.path, 'Large') + .then((bytes) async { + try { + if (bytes.isNotEmpty) { + await precacheImage(MemoryImage(bytes), context); + } + } catch (_) { + // ignore precache failures + } + }, onError: (_) {}); + } + } catch (_) { + // ignore + } + + // Prefetch current layer via LayerPreviewCache.fetchAndCache and + // then preload n+1/n+2 layers. + try { + final layerIndex = status.layer; + if (layerIndex == null) return; + + int? plateId; + try { + final fileData = status.printData?.fileData; + if (fileData != null) { + final meta = await BackendService().getFileMetadata( + fileData.locationCategory ?? 'Local', fileData.path); + if (meta['plate_id'] != null) { + plateId = meta['plate_id'] as int?; + } + } + } catch (_) { + plateId = widget.initialPlateId; + } + if (plateId == null) return; + + // Use fetchAndCache to dedupe concurrent fetches. + try { + final bytes = await LayerPreviewCache.instance + .fetchAndCache(BackendService(), plateId, layerIndex); + if (bytes.isNotEmpty) { + // If user already enabled 2D preview, immediately display the + // prefetched current layer so the preview reflects the active + // layer without requiring a manual toggle. + if (mounted && _showLayer2D) { + if (!(_layer2DBytes != null && + _bytesEqual(_layer2DBytes!, bytes))) { + setState(() { + _layer2DBytes = bytes; + _layer2DImageProvider = MemoryImage(bytes); + }); + } + } + // Fire off preloads for the next two layers in parallel; do not + // await to avoid blocking the UI thread. + for (int i = 1; i <= 2; i++) { + final target = layerIndex + i; + LayerPreviewCache.instance + .fetchAndCache(BackendService(), plateId, target) + .then((nextBytes) { + if (nextBytes.isNotEmpty) { + precacheImage(MemoryImage(nextBytes), context) + .catchError((_) {}); + } + }).catchError((_) {}); + } + } + } catch (_) { + // ignore + } + } catch (_) { + // ignore + } + } + + Future _maybePreloadNextLayers(StatusModel status) async { + final layerIndex = status.layer; + if (layerIndex == null) return; + + // Resolve plate id if not already resolved for current file path. + int? plateId = _resolvedPlateIdForPrefetch; + if (plateId == null) { + try { + final fileData = status.printData?.fileData; + if (fileData != null) { + final meta = await BackendService().getFileMetadata( + fileData.locationCategory ?? 'Local', fileData.path); + if (meta['plate_id'] != null) { + plateId = meta['plate_id'] as int?; + _resolvedPlateIdForPrefetch = plateId; + } + } + } catch (_) { + plateId = widget.initialPlateId; + _resolvedPlateIdForPrefetch = plateId; + } + } + if (plateId == null) return; + + try { + // Ensure current layer is cached (deduped) then fetch+precache next two. + // Fetch current layer (deduped) but don't block on any decoding. + LayerPreviewCache.instance + .fetchAndCache(BackendService(), plateId, layerIndex) + .then((curBytes) { + if (curBytes.isNotEmpty) { + // Precache decoded image for faster rendering (fire-and-forget). + precacheImage(MemoryImage(curBytes), context).catchError((_) {}); + // If the user currently has the 2D preview visible, immediately + // update the displayed image so the preview follows the layer. + if (mounted && _showLayer2D) { + // Avoid re-setting if the bytes are identical. + if (!(_layer2DBytes != null && + _bytesEqual(_layer2DBytes!, curBytes))) { + setState(() { + _layer2DBytes = curBytes; + _layer2DImageProvider = MemoryImage(curBytes); + }); + } + } + } + }).catchError((_) {}); + + // Launch preloads for the next two layers in parallel. + for (int i = 1; i <= 2; i++) { + final target = layerIndex + i; + LayerPreviewCache.instance + .fetchAndCache(BackendService(), plateId, target) + .then((nextBytes) { + if (nextBytes.isNotEmpty) { + precacheImage(MemoryImage(nextBytes), context).catchError((_) {}); + } + }).catchError((_) {}); + } + _lastPrefetchedLayer = layerIndex; + } catch (_) { + // ignore + } + } + Widget _buildButtons(StatusProvider provider, StatusModel? status) { final s = status; final isFinished = s != null && s.isIdle && s.layer != null; @@ -619,13 +982,19 @@ class StatusScreenState extends State { // * Active print (to allow pause/resume) // * Finished print (return home) // * Canceled print (return home) - // Disabled only when status not yet loaded or during cancel transition. - final pauseResumeEnabled = s != null && (!provider.isCanceling); + // Disabled when status not yet loaded, during cancel transition, or + // while a pause is latched (provider.isPausing) and we're not already + // in the paused state (i.e., disable the Pause action while it's + // latched). Resume should still be enabled when paused. final isPaused = s?.isPaused ?? false; + final pauseResumeEnabled = s != null && + !provider.isCanceling && + !(provider.isPausing && !isPaused); return Row(children: [ Expanded( child: GlassButton( + tint: GlassButtonTint.neutral, onPressed: (!canShowOptions || provider.isCanceling) ? null : () { @@ -664,6 +1033,7 @@ class StatusScreenState extends State { height: 65, width: 450, child: GlassButton( + tint: GlassButtonTint.neutral, style: buttonStyle, onPressed: () { Navigator.pop(ctx); @@ -690,6 +1060,7 @@ class StatusScreenState extends State { height: 65, width: 450, child: HoldButton( + tint: GlassButtonTint.negative, style: buttonStyle, duration: const Duration(seconds: 2), onPressed: () { @@ -723,6 +1094,11 @@ class StatusScreenState extends State { const SizedBox(width: 20), Expanded( child: GlassButton( + tint: isCanceled || isFinished + ? GlassButtonTint.neutral + : isPaused + ? GlassButtonTint.positive + : GlassButtonTint.warn, onPressed: !pauseResumeEnabled ? null : () { diff --git a/lib/tools/exposure_screen.dart b/lib/tools/exposure_screen.dart index d53ed6b..ec4093c 100644 --- a/lib/tools/exposure_screen.dart +++ b/lib/tools/exposure_screen.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:orion/backend_service/providers/manual_provider.dart'; +import 'package:orion/util/orion_config.dart'; import 'package:provider/provider.dart'; import 'package:orion/backend_service/providers/config_provider.dart'; import 'package:orion/glasser/glasser.dart'; @@ -38,6 +39,7 @@ class ExposureScreen extends StatefulWidget { class ExposureScreenState extends State { final _logger = Logger('Exposure'); + final _config = OrionConfig(); CancelableOperation? _exposureOperation; Completer? _exposureCompleter; @@ -45,6 +47,12 @@ class ExposureScreenState extends State { bool _apiErrorState = false; Future exposeScreen(String type) async { + int delayTime = 1; // Odyssey requires a 1 second delay before exposure + + if (_config.isNanoDlpMode()) { + delayTime = 0; + } + try { _logger.info('Testing exposure for $exposureTime seconds'); final manual = Provider.of(context, listen: false); @@ -67,7 +75,7 @@ class ExposureScreenState extends State { return; } - showExposureDialog(context, exposureTime, type: type); + showExposureDialog(context, exposureTime, delayTime, type: type); _exposureCompleter = Completer(); _exposureOperation = CancelableOperation.fromFuture( Future.any([ @@ -90,7 +98,8 @@ class ExposureScreenState extends State { } } - void showExposureDialog(BuildContext context, int countdownTime, + void showExposureDialog( + BuildContext context, int countdownTime, int delayTime, {String? type}) { _logger.info('Showing countdown dialog'); @@ -100,7 +109,7 @@ class ExposureScreenState extends State { builder: (BuildContext context) { return StreamBuilder( stream: (() async* { - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(Duration(seconds: delayTime)); yield* Stream.periodic(const Duration(milliseconds: 1), (i) => countdownTime * 1000 - i) .take((countdownTime * 1000) + 1); @@ -161,6 +170,7 @@ class ExposureScreenState extends State { height: 180, // Make the progress indicator larger width: 180, // Make the progress indicator larger child: CircularProgressIndicator( + backgroundColor: Colors.grey.shade800, value: snapshot.data! / (countdownTime * 1000), strokeWidth: 12, // Make the progress indicator thicker ), @@ -324,9 +334,8 @@ class ExposureScreenState extends State { const SizedBox(width: 30), Expanded( child: GlassButton( - onPressed: _apiErrorState - ? null - : () => exposeScreen('Dimensions'), + onPressed: + _apiErrorState ? null : () => exposeScreen('Logo'), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), @@ -339,13 +348,13 @@ class ExposureScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ PhosphorIcon( - PhosphorIconsFill.ruler, + PhosphorIcons.linuxLogo(), size: 40, color: _apiErrorState ? Colors.grey : null, ), const SizedBox(height: 8), Text( - 'Measure', + 'Logo', style: TextStyle( fontSize: 24, color: _apiErrorState ? Colors.grey : null, @@ -368,7 +377,7 @@ class ExposureScreenState extends State { Expanded( child: GlassButton( onPressed: - _apiErrorState ? null : () => exposeScreen('Blank'), + _apiErrorState ? null : () => exposeScreen('Measure'), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), @@ -380,13 +389,13 @@ class ExposureScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ PhosphorIcon( - PhosphorIcons.square(), + PhosphorIcons.ruler(), size: 40, color: _apiErrorState ? Colors.grey : null, ), const SizedBox(height: 8), Text( - 'Blank', + 'Measure', style: TextStyle( fontSize: 24, color: _apiErrorState ? Colors.grey : null, @@ -411,8 +420,8 @@ class ExposureScreenState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.cleaning_services, + PhosphorIcon( + PhosphorIcons.broom(), size: 40, color: _apiErrorState ? Colors.grey : null, ), diff --git a/lib/tools/force_screen.dart b/lib/tools/force_screen.dart new file mode 100644 index 0000000..5149dea --- /dev/null +++ b/lib/tools/force_screen.dart @@ -0,0 +1,572 @@ +/* +* Orion - Force Sensor Screen +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:orion/backend_service/providers/manual_provider.dart'; +import 'package:orion/glasser/glasser.dart'; +import 'package:orion/util/error_handling/error_dialog.dart'; +import 'package:orion/util/orion_config.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:orion/backend_service/providers/analytics_provider.dart'; + +class ForceSensorScreen extends StatefulWidget { + const ForceSensorScreen({super.key}); + + @override + ForceSensorScreenState createState() => ForceSensorScreenState(); +} + +class ForceSensorScreenState extends State { + VoidCallback? _listener; + late final AnalyticsProvider _prov; + bool _isPaused = false; + double _tareOffset = 0.0; + + @override + void initState() { + super.initState(); + _prov = Provider.of(context, listen: false); + _prov.refresh(); + _listener = () { + if (mounted && !_isPaused) setState(() {}); + }; + _prov.addListener(_listener!); + } + + @override + void dispose() { + if (_listener != null) _prov.removeListener(_listener!); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Use listen: false to prevent automatic rebuilds - we control it via _listener + final prov = Provider.of(context, listen: false); + final manual = Provider.of(context, listen: false); + final series = prov.pressureSeries.isNotEmpty + ? prov.pressureSeries + : prov.getSeriesForKey('Pressure'); + bool isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + void togglePause() { + setState( + () { + _isPaused = !_isPaused; + }, + ); + } + + void doTare() async { + try { + await manual.manualTareForceSensor(); + } catch (_) { + showErrorDialog(context, 'BLUE-BANANA'); + } + } + + return Scaffold( + body: isLandscape + ? buildLandscapeLayout( + context, + series, + isPaused: _isPaused, + onPauseToggle: togglePause, + onTare: doTare, + tareOffset: _tareOffset, + ) + : buildPortraitLayout( + context, + series, + isPaused: _isPaused, + onPauseToggle: togglePause, + onTare: doTare, + tareOffset: _tareOffset, + ), + ); + } +} + +Widget buildStatsCard(String label, String value) { + return GlassCard( + margin: const EdgeInsets.only( + left: 12.0, + right: 0.0, + top: 6.0, + bottom: 6.0, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + label, + style: const TextStyle( + fontSize: 18, + ), + ), + ), + const SizedBox(height: 8), + Center( + child: Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); +} + +OrionConfig config = OrionConfig(); + +String _formatMass(double grams) { + if (config.getFlag('overrideRawForceSensorValues', category: 'developer')) { + final negative = grams.isNegative; + final absVal = grams.abs(); + + String withCommas(String intPart) { + final buffer = StringBuffer(); + int count = 0; + for (int i = intPart.length - 1; i >= 0; i--) { + buffer.write(intPart[i]); + count++; + if (count % 3 == 0 && i != 0) buffer.write(','); + } + return buffer.toString().split('').reversed.join(); + } + + if (absVal >= 1000.0) { + // For 1,000 g and above, show whole grams without decimal places. + final rounded = absVal.round(); + final intWithCommas = withCommas(rounded.toString()); + return '${negative ? '-' : ''}$intWithCommas'; + } else { + // Below 1,000 g show one decimal place. + final fixed = absVal.toStringAsFixed(2); // e.g. "999.99" + final parts = fixed.split('.'); + final intPart = parts[0]; + final decPart = parts.length > 1 ? parts[1] : '0'; + final intWithCommas = withCommas(intPart); + return '${negative ? '-' : ''}$intWithCommas.$decPart'; + } + } else { + // Show in kg when >= 1000 g, otherwise show in g. + if (grams.abs() >= 1000.0) { + final kg = grams / 1000.0; + // Show kilograms with two decimal places (x.xx kg) + return '${kg.toStringAsFixed(2)} kg'; + } + return '${grams.toStringAsFixed(0)} g'; + } +} + +Widget buildControlButtons(BuildContext context, + {required bool isPaused, + required VoidCallback onPauseToggle, + required VoidCallback onTare}) { + final theme = Theme.of(context).copyWith( + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + minimumSize: WidgetStateProperty.resolveWith(( + Set states, + ) { + return const Size(double.infinity, double.infinity); + }), + ), + ), + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: GlassButton( + margin: const EdgeInsets.only( + left: 0.0, + right: 12.0, + top: 4.0, + bottom: 6.0, + ), + tint: !isPaused ? GlassButtonTint.none : GlassButtonTint.positive, + onPressed: onPauseToggle, + style: theme.elevatedButtonTheme.style, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isPaused + ? const PhosphorIcon(PhosphorIconsFill.play, size: 40) + : const PhosphorIcon(PhosphorIconsFill.pause, size: 40), + const SizedBox( + height: 8), // Add some space between icon and label + Text( + isPaused ? 'Resume' : 'Pause', + style: const TextStyle(fontSize: 22), + ), + ], + ), + ), + ), + Expanded( + child: GlassButton( + margin: const EdgeInsets.only( + left: 0.0, + right: 12.0, + top: 6.0, + bottom: 4.0, + ), + tint: GlassButtonTint.none, + onPressed: isPaused ? null : onTare, + style: theme.elevatedButtonTheme.style, + child: const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + PhosphorIcon(PhosphorIconsFill.scales, size: 40), + SizedBox(height: 8), // Add some space between icon and label + Text( + 'Tare', + style: TextStyle(fontSize: 22), + ), + ], + ), + ), + ), + ], + ); +} + +Widget buildPortraitLayout( + BuildContext context, + List> series, { + required bool isPaused, + required VoidCallback onPauseToggle, + required VoidCallback onTare, + required double tareOffset, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: GlassCard( + margin: const EdgeInsets.all(0.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _PressureLineChart( + series: series, tareOffset: tareOffset, isPaused: isPaused), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 120, + child: buildControlButtons(context, + isPaused: isPaused, onPauseToggle: onPauseToggle, onTare: onTare), + ), + ], + ); +} + +Widget buildLandscapeLayout( + BuildContext context, + List> series, { + required bool isPaused, + required VoidCallback onPauseToggle, + required VoidCallback onTare, + required double tareOffset, +}) { + // Derive numeric stats from the series to show in the stat cards. + final values = series + .map((m) { + final vRaw = m['v']; + if (vRaw is num) return vRaw.toDouble(); + return double.tryParse(vRaw?.toString() ?? ''); + }) + .where((v) => v != null) + .cast() + .toList(growable: false); + + final bool hasData = values.isNotEmpty; + final double currentVal = hasData ? values.last : 0.0; + final double maxVal = hasData ? values.reduce(max) : 0.0; + final double minVal = hasData ? values.reduce(min) : 0.0; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 140, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: SizedBox( + width: 140, + child: buildStatsCard( + 'Maximum', + _formatMass(maxVal), + ), + ), + ), + Expanded( + child: SizedBox( + width: 140, + child: buildStatsCard( + 'Current', + _formatMass(currentVal), + ), + ), + ), + Expanded( + child: SizedBox( + width: 140, + child: buildStatsCard( + 'Minimum', + _formatMass(minVal), + ), + ), + ), + ], + ), + ), + Expanded( + flex: 3, + child: GlassCard( + margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 12.0), + child: _PressureLineChart(series: series, tareOffset: tareOffset), + ), + ), + SizedBox( + width: 140, + child: buildControlButtons(context, + isPaused: isPaused, onPauseToggle: onPauseToggle, onTare: onTare), + ), + ], + ); +} + +class _PressureLineChart extends StatefulWidget { + final List> series; + final double tareOffset; + final bool isPaused; + const _PressureLineChart( + {required this.series, this.tareOffset = 0.0, this.isPaused = false}); + + @override + State<_PressureLineChart> createState() => _PressureLineChartState(); +} + +class _PressureLineChartState extends State<_PressureLineChart> { + static const int _windowSize = 900; // stable X-axis window, 15Hz * 60s + double? _displayMin; + double? _displayMax; + double _windowMaxX = 0.0; + // Persistent mapping from sample id -> X coordinate so older points keep + // their X positions and the chart appears to scroll rather than redraw. + final Map _idToX = {}; + double _lastX = -1.0; + + // _isPaused removed; pausing is handled by the controls section where needed. + + List _toSpots(List> serie) { + final last = serie.length; + final start = last - _windowSize < 0 ? 0 : last - _windowSize; + final window = serie.sublist(start, last); + final spots = []; + final currentIds = {}; + for (var i = 0; i < window.length; i++) { + final item = window[i]; + final idRaw = item['id'] ?? i; // fallback to index if no id + final key = idRaw is Object ? idRaw : idRaw.toString(); + currentIds.add(key); + + final vRaw = item['v']; + final v = vRaw is num + ? vRaw.toDouble() + : double.tryParse(vRaw?.toString() ?? ''); + if (v == null) continue; + + double x; + if (_idToX.containsKey(key)) { + x = _idToX[key]!; + } else { + _lastX = _lastX + 1.0; + x = _lastX; + _idToX[key] = x; + } + spots.add(FlSpot(x, v)); + } + + // Trim mapping to current window to keep memory bounded. + final toRemove = []; + _idToX.forEach((k, v) { + if (!currentIds.contains(k)) toRemove.add(k); + }); + for (final k in toRemove) { + _idToX.remove(k); + } + + // Window max X is the last assigned X (or fallback) + _windowMaxX = _lastX <= 0 ? (_windowSize - 1).toDouble() : _lastX; + // To keep the grid fixed we render the chart in a stationary X range + // [0, _windowSize-1] and shift the sample X positions into that range. + final windowStart = _windowMaxX <= 0 + ? 0.0 + : max(0.0, _windowMaxX - (_windowSize - 1).toDouble()); + + final remapped = spots + .map((s) => FlSpot(s.x - windowStart, s.y)) + .toList(growable: false); + return remapped; + } + + void _updateDisplayRange(List spots) { + if (spots.isEmpty) return; + final minY = spots.map((s) => s.y).reduce(min); + final maxY = spots.map((s) => s.y).reduce(max); + final span = maxY - minY; + final pad = span == 0 ? (maxY.abs() * 0.05 + 1.0) : (span * 0.05); + + // Default safe range is [-100, 100]. Expand only when data goes outside + // that range, up to +/-60000 (60kg). + // If you manage to exceed that, well, may your printer find peace. + double targetMin; + double targetMax; + const double hardLimit = 60000.0; + + if (minY >= -100.0 && maxY <= 100.0) { + // Data within default bounds: keep the simple default range + targetMin = -100.0; + targetMax = 100.0; + } else { + // Expand to include data with a small padding, but clamp to hard limits + targetMin = max(minY - pad, -hardLimit); + targetMax = min(maxY + pad, hardLimit); + // Ensure we always include zero if data is near zero-ish to keep chart centered + if (targetMin > 0) targetMin = 0; + if (targetMax < 0) targetMax = 0; + } + + // If the incoming data lies significantly outside the current display + // range, expand immediately to avoid clipping spikes. Otherwise interpolate + // more quickly than before so the chart follows changes responsively. + const double immediateFraction = + 0.25; // fraction of current span to trigger immediate jump + const double immediateAbs = + 200.0; // absolute threshold to trigger immediate jump + const double smoothAlpha = 0.6; // faster smoothing than before + + if (_displayMin == null || _displayMax == null) { + _displayMin = targetMin; + _displayMax = targetMax; + } else { + final curSpan = (_displayMax! - _displayMin!).abs(); + final needImmediate = (minY < + _displayMin! - max(immediateAbs, curSpan * immediateFraction)) || + (maxY > + _displayMax! + max(immediateAbs, curSpan * immediateFraction)); + + if (needImmediate) { + // Jump immediately to include outlier(s). + _displayMin = targetMin; + _displayMax = targetMax; + } else { + // Smoothly move towards the target but faster than before. + _displayMin = _displayMin! + (targetMin - _displayMin!) * smoothAlpha; + _displayMax = _displayMax! + (targetMax - _displayMax!) * smoothAlpha; + } + } + } + + @override + void didUpdateWidget(covariant _PressureLineChart oldWidget) { + super.didUpdateWidget(oldWidget); + final spots = _toSpots(widget.series); + // Only update display range when not paused + if (!widget.isPaused) { + _updateDisplayRange(spots); + } + } + + @override + Widget build(BuildContext context) { + final spots = _toSpots(widget.series); + if (spots.isEmpty) return const Center(child: Text('No data')); + + // Only update display range when not paused + if (!widget.isPaused) { + _updateDisplayRange(spots); + } + final displayMin = _displayMin ?? spots.map((s) => s.y).reduce(min) - 1.0; + final displayMax = _displayMax ?? spots.map((s) => s.y).reduce(max) + 1.0; + + return LineChart( + duration: Duration.zero, + LineChartData( + borderData: FlBorderData( + border: Border.all( + color: Colors.transparent, + ), + ), + gridData: FlGridData(show: true), + titlesData: FlTitlesData( + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + minY: displayMin, + maxY: displayMax, + // Fixed screen range so the grid remains stationary while the data + // moves underneath. X runs from 0 to _windowSize-1. + maxX: (_windowSize + 10.0).toDouble(), + minX: -10.0, + lineBarsData: [ + LineChartBarData( + spots: spots, + gradient: LinearGradient( + colors: [ + Colors.greenAccent, + Colors.redAccent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + isCurved: true, + isStrokeCapRound: true, + dotData: FlDotData(show: false), + color: Theme.of(context).colorScheme.primary, + barWidth: 1.5, + ) + ], + ), + ); + } +} diff --git a/lib/tools/tools_screen.dart b/lib/tools/tools_screen.dart index 836f154..bb3706f 100644 --- a/lib/tools/tools_screen.dart +++ b/lib/tools/tools_screen.dart @@ -17,9 +17,12 @@ import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + import 'package:orion/glasser/glasser.dart'; -import 'package:orion/tools/exposure_screen.dart'; import 'package:orion/tools/move_z_screen.dart'; +import 'package:orion/tools/exposure_screen.dart'; +import 'package:orion/tools/force_screen.dart'; class ToolsScreen extends StatefulWidget { const ToolsScreen({super.key}); @@ -53,18 +56,24 @@ class ToolsScreenState extends State { ? const MoveZScreen() : _selectedIndex == 1 ? const ExposureScreen() - : const MoveZScreen(), + : _selectedIndex == 2 + ? const ForceSensorScreen() + : const MoveZScreen(), bottomNavigationBar: GlassBottomNavigationBar( type: BottomNavigationBarType.fixed, - items: const [ + items: [ BottomNavigationBarItem( - icon: Icon(Icons.height), + icon: PhosphorIcon(PhosphorIcons.arrowsDownUp()), label: 'Move Z', ), BottomNavigationBarItem( - icon: Icon(Icons.lightbulb), + icon: PhosphorIcon(PhosphorIcons.lightbulbFilament()), label: 'Exposure', - ) + ), + BottomNavigationBarItem( + icon: PhosphorIcon(PhosphorIcons.chartLineUp()), + label: 'Force Sensor', + ), // TODO: Implement Self Test /*BottomNavigationBarItem( icon: Icon(Icons.check), diff --git a/lib/util/error_handling/connection_error_dialog.dart b/lib/util/error_handling/connection_error_dialog.dart index e136e55..7f3639f 100644 --- a/lib/util/error_handling/connection_error_dialog.dart +++ b/lib/util/error_handling/connection_error_dialog.dart @@ -1,7 +1,19 @@ /* - * Orion - Connection Error Dialog - * Shows live reconnection attempt counts and countdown to next attempt. - */ +* Orion - Connection Error Dialog +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ import 'dart:async'; @@ -46,7 +58,7 @@ Future showConnectionErrorDialog(BuildContext context) async { 'showConnectionErrorDialog: attempting to show dialog (triesLeft=$triesLeft)'); showDialog( context: context, - barrierDismissible: true, + barrierDismissible: false, useRootNavigator: true, builder: (BuildContext ctx) { return _ConnectionErrorDialogContent(); @@ -139,8 +151,7 @@ class _ConnectionErrorDialogContentState try { final cfg = OrionConfig(); backendName = cfg.getString('backend', category: 'advanced'); - final devNano = cfg.getFlag('nanoDLPmode', category: 'developer'); - final isNano = backendName == 'nanodlp' || devNano; + final isNano = cfg.isNanoDlpMode(); if (isNano) { final base = cfg.getString('nanodlp.base_url', category: 'advanced'); final useCustom = cfg.getFlag('useCustomUrl', category: 'advanced'); diff --git a/lib/util/error_handling/connection_error_watcher.dart b/lib/util/error_handling/connection_error_watcher.dart index b826139..6b1876a 100644 --- a/lib/util/error_handling/connection_error_watcher.dart +++ b/lib/util/error_handling/connection_error_watcher.dart @@ -1,3 +1,20 @@ +/* +* Orion - Connection Error Watcher +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; @@ -38,9 +55,13 @@ class ConnectionErrorWatcher { } void _onProviderChange() async { - final _log = Logger('ConnErrorWatcher'); + final log = Logger('ConnErrorWatcher'); try { final hasError = _provider.error != null; + final everHadSuccess = _provider.hasEverConnected; + // If the provider is still performing its initial attempt, don't + // surface the connection error dialog yet; the startup gate will + // show a blocking startup UI while initialAttemptInProgress is true. // Only log transitions or when there's something noteworthy to report final providerError = _provider.error; final shouldLog = (providerError != _lastProviderError) || @@ -48,11 +69,26 @@ class ConnectionErrorWatcher { (providerError != null) || _dialogVisible; if (shouldLog) { - _log.info( + log.info( 'provider error=${providerError != null} dialogVisible=$_dialogVisible'); _lastProviderError = providerError; _lastDialogVisible = _dialogVisible; } + // Suppress the dialog while initial startup is still in progress or we + // have never seen a successful status. StartupGate presents the blocking + // overlay in that phase, so surfacing an additional dialog would be + // redundant. After we have connected successfully at least once, allow + // the dialog to appear for any subsequent connection interruptions. + if (_provider.initialAttemptInProgress || !everHadSuccess) { + if (_dialogVisible) { + try { + Navigator.of(_context, rootNavigator: true).maybePop(); + } catch (_) {} + _dialogVisible = false; + } + return; + } + if (hasError && !_dialogVisible) { _dialogVisible = true; // Show the dialog; this Future completes when the dialog is dismissed diff --git a/lib/util/error_handling/notification_watcher.dart b/lib/util/error_handling/notification_watcher.dart new file mode 100644 index 0000000..f2510e9 --- /dev/null +++ b/lib/util/error_handling/notification_watcher.dart @@ -0,0 +1,349 @@ +/* +* Orion - Notification Watcher +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:orion/backend_service/providers/notification_provider.dart'; +import 'package:orion/glasser/glasser.dart'; +import 'package:orion/backend_service/backend_service.dart'; +import 'package:orion/backend_service/nanodlp/models/nano_notification_types.dart'; + +/// Installs a watcher that will show a GlassAlertDialog for each new +/// notification reported by [NotificationProvider]. The provided [context] +/// must be attached to a Navigator (top-level app context). +class NotificationWatcher { + final BuildContext _context; + late final NotificationProvider _provider; + bool _listening = false; + final Set _locallyAcked = {}; + + NotificationWatcher._(this._context) { + _provider = Provider.of(_context, listen: false); + } + + static NotificationWatcher? install(BuildContext context) { + try { + final watcher = NotificationWatcher._(context); + watcher._start(); + return watcher; + } catch (_) { + return null; + } + } + + void _start() { + if (_listening) return; + _listening = true; + _provider.addListener(_onProviderChange); + _onProviderChange(); + } + + void _onProviderChange() { + try { + var items = _provider.popPendingNotifications(); + if (items.isEmpty) return; + // Keep only the highest-priority notifications (lower number = higher priority). + if (items.length > 1) { + final prios = items.map((i) => getNanoTypePriority(i.type)); + final minPrio = prios.reduce((a, b) => a < b ? a : b); + items = items + .where((i) => getNanoTypePriority(i.type) == minPrio) + .toList(growable: false); + } + // Sort the remaining items deterministically by priority (should be same) and timestamp (newest first) + items.sort((a, b) { + final p = + getNanoTypePriority(a.type).compareTo(getNanoTypePriority(b.type)); + if (p != 0) return p; + return (b.timestamp ?? 0).compareTo(a.timestamp ?? 0); + }); + // Show dialogs for each pending notification on next frame. + WidgetsBinding.instance.addPostFrameCallback((_) async { + for (final item in items) { + try { + await _showNotificationDialog(_context, item); + } catch (_) {} + } + }); + } catch (_) {} + } + + /// Show a notification dialog safely: retry a few times until a Navigator + /// and MaterialLocalizations are available on the provided [context]. + Future _showNotificationDialog( + BuildContext context, NotificationItem item) async { + final completer = Completer(); + + void attemptShow(int triesLeft) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final hasNavigator = Navigator.maybeOf(context) != null; + final hasMaterialLocs = Localizations.of( + context, MaterialLocalizations) != + null; + if (!hasNavigator || !hasMaterialLocs) { + if (triesLeft > 0) { + Future.delayed(const Duration(milliseconds: 300), () { + attemptShow(triesLeft - 1); + }); + } else { + if (!completer.isCompleted) completer.complete(); + } + return; + } + + try { + final cfg = getNanoTypeConfig(item.type); + final actions = (cfg['actions'] as List) + .map((e) => e.toString()) + .toList(growable: false); + // Build the dialog and capture its inner BuildContext so we can + // programmatically dismiss it if the server-side notification + // disappears. + BuildContext? dialogCtx; + final key = '${item.timestamp}:${item.type}:${item.text}'; + + final dialogFuture = showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (BuildContext ctx) { + dialogCtx = ctx; + // Build action buttons from config + // Build core button widgets, split into primary vs others so + // primary actions (resume/continue/confirm/etc.) appear on the + // right side of the dialog. + final List coreButtons = []; + for (final act in actions) { + final label = act[0].toUpperCase() + act.substring(1); + + Future onPressed() async { + try { + // Map actions to backend calls. Keep failures isolated. + if (act == 'stop') { + try { + await BackendService().resumePrint(); + // Give a moment for the state to update before canceling. + await Future.delayed(const Duration(milliseconds: 500)); + await BackendService().cancelPrint(); + } catch (_) {} + } else if (act == 'pause') { + try { + await BackendService().pausePrint(); + } catch (_) {} + } else if (act == 'resume' || act == 'continue') { + try { + await BackendService().resumePrint(); + } catch (_) {} + } else if (act == 'confirm' || + act == 'ack' || + act == 'acknowledge') { + // Acknowledge this notification on the server when possible. + if (item.timestamp != null) { + final k = key; + try { + await BackendService() + .disableNotification(item.timestamp!); + _locallyAcked.add(k); + } catch (_) {} + } + } else { + // Unknown action - no-op for safety. + } + } finally { + try { + Navigator.of(ctx).pop(); + } catch (_) {} + } + } + + final GlassButtonTint tint; + if (act == 'stop') { + tint = GlassButtonTint.negative; + } else if (act == 'pause') { + tint = GlassButtonTint.warn; + } else if (act == 'resume' || + act == 'close' || + act == 'confirm' || + act == 'ack' || + act == 'acknowledge' || + act == 'continue') { + tint = GlassButtonTint.neutral; + } else { + tint = GlassButtonTint.none; + } + + final style = ElevatedButton.styleFrom( + minimumSize: Size(0, act == 'stop' ? 56 : 60), + ); + + coreButtons.add( + GlassButton( + onPressed: onPressed, + tint: tint, + style: style, + child: Text(label, style: const TextStyle(fontSize: 22)), + ), + ); + } + + // Place non-primary buttons first, primary buttons (resume/confirm) + // after them so primary is on the right. Primary set matches the + // tint mapping above. + final primarySet = { + 'resume', + 'close', + 'confirm', + 'ack', + 'acknowledge', + 'continue' + }; + + final List primary = []; + final List others = []; + for (var i = 0; i < actions.length; i++) { + final act = actions[i]; + final core = coreButtons[i]; + if (primarySet.contains(act)) { + primary.add(core); + } else { + others.add(core); + } + } + + final ordered = [...others, ...primary]; + + // Wrap with Flexible and padding; last item gets no trailing padding. + final buttons = ordered.asMap().entries.map((entry) { + final idx = entry.key; + final widget = entry.value; + final isLast = idx == ordered.length - 1; + return Flexible( + child: Padding( + padding: EdgeInsets.only(right: isLast ? 0.0 : 6.0), + child: widget, + ), + ); + }).toList(growable: false); + + return GlassAlertDialog( + title: Row( + children: [ + PhosphorIcon( + PhosphorIcons.warning(), + color: Colors.orangeAccent, + size: 28, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + getNanoTypeTitle(item.type), + style: const TextStyle( + fontSize: 24, fontWeight: FontWeight.w600), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Text(item.text ?? '(no text)', + style: const TextStyle( + fontSize: 22, color: Colors.white70), + textAlign: TextAlign.center), + const SizedBox(height: 16), + ], + ), + actions: [ + Row(children: buttons), + ], + ); + }, + ); + + // Start a periodic watcher that will auto-close the dialog if the + // server no longer reports the notification key. Cancel the timer + // when the dialog completes. + Timer? autoCloseTimer; + autoCloseTimer = + Timer.periodic(const Duration(milliseconds: 500), (_) { + try { + final serverHas = _provider.serverKeys.contains(key); + if (!serverHas && dialogCtx != null) { + try { + Navigator.of(dialogCtx!, rootNavigator: true).pop(); + } catch (_) {} + autoCloseTimer?.cancel(); + } + } catch (_) {} + }); + + dialogFuture.then((_) async { + // Dialog dismissed (either by user or auto-close). If the server + // still contains the notification key at this moment, assume the + // user dismissed it and send disableNotification. If the server + // no longer contains it, it was acked elsewhere and no network + // call is necessary. + try { + final serverHas = _provider.serverKeys.contains(key); + if (serverHas && item.timestamp != null) { + try { + await BackendService().disableNotification(item.timestamp!); + } catch (_) {} + } + } catch (_) {} + + try { + autoCloseTimer?.cancel(); + } catch (_) {} + + if (!completer.isCompleted) completer.complete(); + }).catchError((err, st) { + try { + autoCloseTimer?.cancel(); + } catch (_) {} + if (!completer.isCompleted) completer.complete(); + }); + } catch (e) { + if (triesLeft > 0) { + Future.delayed(const Duration(milliseconds: 300), () { + attemptShow(triesLeft - 1); + }); + } else { + if (!completer.isCompleted) completer.complete(); + } + } + }); + } + + attemptShow(5); + return completer.future; + } + + void dispose() { + if (_listening) { + _provider.removeListener(_onProviderChange); + _listening = false; + } + } + + // Use centralized NanoDLP notification lookup utilities in + // `lib/backend_service/nanodlp/nanodlp_notification_types.dart`. +} diff --git a/lib/util/hold_button.dart b/lib/util/hold_button.dart index fcfd04e..07e54c9 100644 --- a/lib/util/hold_button.dart +++ b/lib/util/hold_button.dart @@ -1,5 +1,5 @@ /* -* Orion - Orion HoldButton +* Orion - Hold Button * Copyright (C) 2025 Open Resin Alliance * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,6 +26,7 @@ class HoldButton extends StatefulWidget { final Widget child; final ButtonStyle? style; final Duration duration; + final GlassButtonTint tint; const HoldButton({ super.key, @@ -33,6 +34,7 @@ class HoldButton extends StatefulWidget { required this.child, this.style, this.duration = const Duration(seconds: 3), + this.tint = GlassButtonTint.none, }); @override @@ -94,6 +96,7 @@ class HoldButtonState extends State with TickerProviderStateMixin { // Intentionally left empty: HoldButton manages tap events via GestureDetector. onPressed: () {}, style: widget.style, + tint: widget.tint, child: Center( child: widget.child, ), diff --git a/lib/util/layer_preview_cache.dart b/lib/util/layer_preview_cache.dart new file mode 100644 index 0000000..d2a81f7 --- /dev/null +++ b/lib/util/layer_preview_cache.dart @@ -0,0 +1,110 @@ +/* +* Orion - Layer Preview Cache +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:typed_data'; + +import 'package:orion/backend_service/backend_service.dart'; + +/// Simple in-memory LRU cache for 2D layer previews. +class LayerPreviewCache { + LayerPreviewCache._private(); + static final LayerPreviewCache instance = LayerPreviewCache._private(); + + // Key format: '$plateId:$layer' + final _map = {}; + final _order = []; // keys in insertion order for simple LRU + final int _maxEntries = 100; + + // Use proper string interpolation so each plate/layer gets a unique key. + String _key(int plateId, int layer) => '$plateId:$layer'; + + // Track in-flight fetches so concurrent requests for the same + // plate/layer are deduped and only one network call is made. + final _inflight = >{}; + + /// Fetch a specific plate/layer via [backend] and cache the result. + /// Concurrent callers for the same plate/layer will await the same + /// in-flight future. + Future fetchAndCache( + BackendService backend, int plateId, int layer) async { + final k = _key(plateId, layer); + final existing = _map[k]; + if (existing != null) return existing; + final inflight = _inflight[k]; + if (inflight != null) return await inflight; + + final future = backend.getPlateLayerImage(plateId, layer).then((bytes) { + if (bytes.isNotEmpty) set(plateId, layer, bytes); + return bytes; + }).whenComplete(() { + _inflight.remove(k); + }); + + _inflight[k] = future; + return await future; + } + + Uint8List? get(int plateId, int layer) { + final k = _key(plateId, layer); + final v = _map[k]; + if (v == null) return null; + // Refresh order (move to end) + _order.remove(k); + _order.add(k); + return v; + } + + void set(int plateId, int layer, Uint8List bytes) { + final k = _key(plateId, layer); + if (_map.containsKey(k)) { + _order.remove(k); + } + _map[k] = bytes; + _order.add(k); + _evictIfNeeded(); + } + + void _evictIfNeeded() { + while (_order.length > _maxEntries) { + final oldest = _order.removeAt(0); + _map.remove(oldest); + } + } + + void clear() { + _map.clear(); + _order.clear(); + } + + /// Preload [count] layers starting at [layer+1]. Runs best-effort in + /// background using the provided backend service instance. + void preload(BackendService backend, int plateId, int layer, + {int count = 2}) async { + for (int i = 1; i <= count; i++) { + final target = layer + i; + final k = _key(plateId, target); + if (_map.containsKey(k)) continue; + try { + // Use fetchAndCache which dedupes inflight requests and ensures + // resizing is performed off the main isolate where supported. + await fetchAndCache(backend, plateId, target); + } catch (_) { + // ignore preload failures + } + } + } +} diff --git a/lib/util/sl1_thumbnail.dart b/lib/util/sl1_thumbnail.dart index a28df41..6d1bd40 100644 --- a/lib/util/sl1_thumbnail.dart +++ b/lib/util/sl1_thumbnail.dart @@ -18,10 +18,9 @@ import 'dart:io'; import 'package:logging/logging.dart'; import 'package:orion/backend_service/backend_service.dart'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; import 'package:flutter/foundation.dart'; -import 'dart:typed_data'; -import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -33,8 +32,8 @@ class ThumbnailUtil { /// which forwards to the existing `ApiService` implementation. static Future extractThumbnail( String location, String subdirectory, String filename, - {String size = "Small", OdysseyClient? client}) async { - final OdysseyClient odysseyClient = client ?? BackendService(); + {String size = "Small", BackendClient? client}) async { + final BackendClient odysseyClient = client ?? BackendService(); try { // Build a safe relative path for the file on the server. Normalize // separators and strip any leading slashes so we never request @@ -139,8 +138,8 @@ class ThumbnailUtil { /// janking the UI thread. static Future extractThumbnailBytes( String location, String subdirectory, String filename, - {String size = "Small", OdysseyClient? client}) async { - final OdysseyClient odysseyClient = client ?? BackendService(); + {String size = "Small", BackendClient? client}) async { + final BackendClient odysseyClient = client ?? BackendService(); try { String finalLocation = _isDefaultDir(subdirectory) ? filename diff --git a/lib/util/status_card.dart b/lib/util/status_card.dart index d0aa592..a5b6528 100644 --- a/lib/util/status_card.dart +++ b/lib/util/status_card.dart @@ -17,7 +17,6 @@ import 'package:flutter/material.dart'; -import 'package:orion/glasser/glasser.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; class StatusCard extends StatefulWidget { @@ -26,6 +25,7 @@ class StatusCard extends StatefulWidget { final double progress; final Color statusColor; final StatusModel? status; + final bool showPercentage; const StatusCard({ super.key, @@ -34,6 +34,7 @@ class StatusCard extends StatefulWidget { required this.progress, required this.statusColor, required this.status, + this.showPercentage = true, }); @override @@ -52,6 +53,12 @@ class StatusCardState extends State { cardIcon = const Icon(Icons.stop); } else if (widget.isPausing || s?.isPaused == true) { cardIcon = const Icon(Icons.pause); + } else if (s?.layer != null && s?.isPaused != true) { + // Active printing (not paused/canceled): when we're rendering the + // compact circular form (e.g., because percentage is hidden), show a + // pause icon to indicate active printing rather than the default help + // icon. + cardIcon = const Icon(Icons.pause); } // Determine what to show in the progress circle: @@ -95,68 +102,79 @@ class StatusCardState extends State { s?.isIdle != true); // While the print is active, show the progress in percentage. (overlapping text for outline effect) - return isActive - ? Stack( - children: [ - Text( - '${(widget.progress * 100).toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 75, - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 5 - ..color = Theme.of(context).colorScheme.primaryContainer, - ), - ), - Text( - '${(widget.progress * 100).toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 75, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ) - : Builder( - builder: (context) { - return GlassCard( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Stack( - children: [ - Positioned( - top: 0, - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.all(10), - child: CircularProgressIndicator( - value: circleProgress, - strokeWidth: 6, - valueColor: AlwaysStoppedAnimation( - widget.statusColor), - backgroundColor: - widget.statusColor.withValues(alpha: 0.5), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(25), - child: Icon( - cardIcon.icon, - color: widget.statusColor, - size: 70, - ), - ) - ], + final shouldShowPercentage = isActive && widget.showPercentage; + final shouldShowEmpty = isActive && !widget.showPercentage; + + if (shouldShowPercentage) { + return Stack( + children: [ + Text( + '${(widget.progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 75, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5 + ..color = Theme.of(context).colorScheme.primaryContainer, + ), + ), + Text( + '${(widget.progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 75, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ); + } + + if (shouldShowEmpty) { + // Active print but percentage hidden: render absolutely nothing. + return const SizedBox.shrink(); + } + + // Default compact form with icon + return Builder( + builder: (context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Stack( + children: [ + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.all(10), + child: CircularProgressIndicator( + value: circleProgress, + strokeWidth: 6, + valueColor: + AlwaysStoppedAnimation(widget.statusColor), + backgroundColor: + widget.statusColor.withValues(alpha: 0.5), + ), ), ), - ); - }, - ); + Padding( + padding: const EdgeInsets.all(25), + child: Icon( + cardIcon.icon, + color: widget.statusColor, + size: 70, + ), + ) + ], + ), + ), + ); + }, + ); } } diff --git a/lib/util/thumbnail_cache.dart b/lib/util/thumbnail_cache.dart index b713943..38c6176 100644 --- a/lib/util/thumbnail_cache.dart +++ b/lib/util/thumbnail_cache.dart @@ -197,6 +197,51 @@ class ThumbnailCache { _cache.remove(key); _inFlight.remove(key); } + // Also remove any disk cache files for this location asynchronously. + scheduleMicrotask(() async { + try { + final dir = await _ensureDiskCacheDir(); + final files = dir.listSync().whereType(); + for (final f in files) { + final decoded = Uri.decodeComponent(p.basename(f.path)); + if (decoded.startsWith(prefix)) { + try { + await f.delete(); + } catch (_) { + // ignore individual delete failures + } + } + } + } catch (_) { + // ignore disk errors + } + }); + } + + /// Delete all cached files on disk. This is best-effort and will not + /// throw on failure; it may be expensive so callers should prefer to run + /// this asynchronously (it already returns a Future). + Future clearDisk() async { + try { + final dir = await _ensureDiskCacheDir(); + final files = dir.listSync().whereType().toList(growable: false); + for (final f in files) { + try { + await f.delete(); + } catch (_) { + // ignore deletion errors + } + } + } catch (e, st) { + _log.fine('Failed to clear disk thumbnail cache: $e', e, st); + } + } + + /// Clear both in-memory and on-disk caches. Prefer calling this + /// from an async context so disk work can be awaited. + Future clearAll() async { + clear(); + await clearDisk(); } String _cacheKey(String location, OrionApiFile file, String size) { @@ -328,7 +373,7 @@ class ThumbnailCache { final fname = _diskFileNameForKey(key); final file = File(p.join(dir.path, fname)); // Write atomically by writing to a temp file and renaming. - final tmpFile = File(p.join(dir.path, '\$${fname}.tmp')); + final tmpFile = File(p.join(dir.path, '\$$fname.tmp')); await tmpFile.writeAsBytes(bytes, flush: true); await tmpFile.rename(file.path); // Suppress successful disk write logs. diff --git a/pubspec.lock b/pubspec.lock index ebbea41..51f6381 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flex_seed_scheme: dependency: "direct main" description: @@ -1173,4 +1181,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.27.0" + flutter: ">=3.27.4" diff --git a/pubspec.yaml b/pubspec.yaml index 568cd53..83ce37c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: sdk: flutter marquee: ^2.3.0 json_annotation: ^4.9.0 + fl_chart: ^1.1.1 dev_dependencies: flutter_test: @@ -102,6 +103,7 @@ flutter: - assets/images/opensource.svg - assets/images/placeholder.png - assets/images/bsod.png + - assets/images/open_resin_alliance_logo_darkmode.png - assets/scripts/set_orion_config.sh - README.md - CHANGELOG.md diff --git a/scripts/install_orion_athena2.sh b/scripts/install_orion_athena2.sh index 873d3cc..9f77562 100644 --- a/scripts/install_orion_athena2.sh +++ b/scripts/install_orion_athena2.sh @@ -12,6 +12,7 @@ set -euo pipefail tmp_tar="" tmp_dir="" +CLEAR_THUMBNAILS=0 cleanup() { if [[ -n "$tmp_tar" && -f "$tmp_tar" ]]; then @@ -25,14 +26,16 @@ cleanup() { trap cleanup EXIT SCRIPT_NAME="$(basename "$0")" -ORION_URL="https://github.com/Open-Resin-Alliance/Orion/releases/download/BRANCH_nanodlp_basic_support/orion_armv7.tar.gz" -DOWNSAMPLED_RES="200, 200" +ORION_URL="https://github.com/Open-Resin-Alliance/Orion/releases/download/BRANCH_athena_features/orion_armv7.tar.gz" +DOWNSAMPLED_RES="210, 210" uninstall_orion() { local mode=${1:-manual} local enable_nano=1 if [[ $mode == "reinstall" ]]; then enable_nano=0 + elif [[ $mode == "reinstall_keep" ]]; then + enable_nano=0 fi printf '\n[%s] Removing existing Orion installation...\n' "$SCRIPT_NAME" @@ -71,15 +74,23 @@ uninstall_orion() { rm -rf "$ORION_DIR" rm -f "$DEST_DSI" - if [[ -f "$CONFIG_PATH" ]]; then - rm -f "$CONFIG_PATH" - fi - if [[ -f "$VENDOR_CONFIG_PATH" ]]; then - rm -f "$VENDOR_CONFIG_PATH" + # Delete configs only for full override reinstall or manual uninstall + if [[ $mode != "reinstall_keep" ]]; then + if [[ -f "$CONFIG_PATH" ]]; then + rm -f "$CONFIG_PATH" + fi + if [[ -f "$VENDOR_CONFIG_PATH" ]]; then + rm -f "$VENDOR_CONFIG_PATH" + fi fi rm -f "$ACTIVATE_PATH" "$REVERT_PATH" + # Clear thumbnail cache automatically on full override reinstall + if [[ $mode == "reinstall" ]]; then + clear_thumbnail_cache_for_user "$ORIGINAL_USER" + fi + if [[ $mode != "reinstall" ]]; then printf '\n[%s] Orion has been removed from this system.\n' "$SCRIPT_NAME" else @@ -101,6 +112,15 @@ require_root() { main() { require_root "$@" + # Parse CLI options (e.g., --clear-thumbnails) + for arg in "$@"; do + case "$arg" in + --clear-thumbnails|--clear-thumbnail-cache) + CLEAR_THUMBNAILS=1 + ;; + esac + done + ORIGINAL_USER=${SUDO_USER:-} if [[ -z "$ORIGINAL_USER" || "$ORIGINAL_USER" == "root" ]]; then printf '\n[%s] Unable to determine invoking non-root user. Please run with sudo from the target account.\n' "$SCRIPT_NAME" >&2 @@ -134,11 +154,14 @@ main() { BIN_DIR="/usr/local/bin" ACTIVATE_PATH="${BIN_DIR}/activate_orion" REVERT_PATH="${BIN_DIR}/revert_orion" + CLEAR_THUMBS_PATH="${BIN_DIR}/clear_orion_thumbnails" printf '\nTemporary Orion installer for Athena 2\n========================================\n' printf ' - Target user : %s\n' "$ORIGINAL_USER" printf ' - Target home : %s\n' "$TARGET_HOME" printf ' - Orion source : %s\n\n' "$ORION_URL" + printf 'Options:\n' + printf ' --clear-thumbnails Clear Orion thumbnail disk cache for the target user during install.\n\n' read -r -p "Continue with installation? [y/N] " reply reply=${reply:-N} @@ -155,13 +178,23 @@ main() { if [[ $existing == true ]]; then printf '\n[%s] Existing Orion installation detected.\n' "$SCRIPT_NAME" while true; do - read -r -p "Choose: [O]verride & reinstall / [U]ninstall / [C]ancel: " choice + read -r -p "Choose: [O]verride & reinstall (clears cache & configs) / [R]einstall (keep cache & configs) / [T]humbnail cache clear / [U]ninstall / [C]ancel: " choice choice=${choice:-C} case "$choice" in [Oo]) uninstall_orion reinstall break ;; + [Rr]) + uninstall_orion reinstall_keep + break + ;; + [Tt]) + printf '\n[%s] Clearing thumbnail cache for %s...\n' "$SCRIPT_NAME" "$ORIGINAL_USER" + clear_thumbnail_cache_for_user "$ORIGINAL_USER" + printf '[%s] Thumbnail cache cleared.\n' "$SCRIPT_NAME" + exit 0 + ;; [Uu]) uninstall_orion manual exit 0 @@ -171,7 +204,7 @@ main() { exit 0 ;; *) - printf ' Invalid selection. Please choose O, U, or C.\n' + printf ' Invalid selection. Please choose O, R, T, U, or C.\n' ;; esac done @@ -234,7 +267,7 @@ main() { { "general": { "themeMode": "glass", - "colorSeed": "orange", + "colorSeed": "vendor", "useUsbByDefault": true }, "advanced": { @@ -267,7 +300,13 @@ EOF "vendorMachineName": "Athena 2", "machineModelName": "Athena 2", "homePosition": "up", - "vendorUrl": "https://concepts3d.ca" + "vendorUrl": "https://concepts3d.ca", + "vendorThemeSeed": "#FFFFA500", + "vendorThemeGradient": [ + "#FF221505", + "#FF3A2605", + "#FF5B3B05" + ] }, "featureFlags": { "enableBetaFeatures": false, @@ -369,10 +408,81 @@ systemctl start nanodlp-dsi.service EOF chmod 0755 "$REVERT_PATH" + # Install helper to clear thumbnail cache for the Orion user + printf '\n[%s] Installing helper command: %s...\n' "$SCRIPT_NAME" "$CLEAR_THUMBS_PATH" + cat >"$CLEAR_THUMBS_PATH" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + exec sudo -E "$0" "$@" + else + echo "clear_orion_thumbnails must be run as root or via sudo" >&2 + exit 1 + fi +fi + +# Determine target non-root user +TARGET_USER=${SUDO_USER:-${USER}} +if [[ -z "${TARGET_USER}" || "${TARGET_USER}" == "root" ]]; then + echo "Unable to determine non-root target user." >&2 + exit 1 +fi + +TARGET_HOME=$(eval echo "~${TARGET_USER}") +if [[ ! -d "${TARGET_HOME}" ]]; then + echo "Home directory for ${TARGET_USER} not found at ${TARGET_HOME}" >&2 + exit 1 +fi + +# Default Linux cache dir used by the app: $HOME/.cache/orion_thumbnail_cache +CACHE_DIR="${TARGET_HOME}/.cache/orion_thumbnail_cache" + +echo "Clearing Orion thumbnail cache at ${CACHE_DIR} (user: ${TARGET_USER})..." +rm -rf -- "${CACHE_DIR}" || true +echo "Done." +EOF + chmod 0755 "$CLEAR_THUMBS_PATH" + + # If requested via CLI option, clear thumbnail cache now for target user + if [[ "$CLEAR_THUMBNAILS" -eq 1 ]]; then + printf '\n[%s] Clearing thumbnail cache for %s...\n' "$SCRIPT_NAME" "$ORIGINAL_USER" + clear_thumbnail_cache_for_user "$ORIGINAL_USER" + fi + printf '\nInstallation complete!\n' printf ' Default config written to %s.\n' "$CONFIG_PATH" printf ' Use "activate_orion" to launch Orion and "revert_orion" to restore NanoDLP.\n' + printf ' Use "clear_orion_thumbnails" to clear the thumbnail disk cache.\n' systemctl status orion.service --no-pager } +# Helper: clear thumbnail disk cache for a given user name +clear_thumbnail_cache_for_user() { + local user_name="$1" + local home_dir + home_dir=$(eval echo "~${user_name}") + if [[ -z "$home_dir" || ! -d "$home_dir" ]]; then + printf '[%s] Cannot resolve home for user %s; skipping cache clear.\n' "$SCRIPT_NAME" "$user_name" >&2 + return 0 + fi + + # Default Linux cache dir + local cache_dir_default="${home_dir}/.cache/orion_thumbnail_cache" + # If XDG_CACHE_HOME is defined for the environment, also clear that path + local xdg_cache_home="${XDG_CACHE_HOME:-}" + local cache_dir_xdg="" + if [[ -n "$xdg_cache_home" ]]; then + cache_dir_xdg="${xdg_cache_home%/}/orion_thumbnail_cache" + fi + + printf '[%s] Clearing thumbnail cache at %s\n' "$SCRIPT_NAME" "$cache_dir_default" + rm -rf -- "$cache_dir_default" || true + if [[ -n "$cache_dir_xdg" ]]; then + printf '[%s] Clearing thumbnail cache at %s\n' "$SCRIPT_NAME" "$cache_dir_xdg" + rm -rf -- "$cache_dir_xdg" || true + fi +} + main "$@" \ No newline at end of file diff --git a/test/backend/nanodlp_state_handler_test.dart b/test/backend/nanodlp_state_handler_test.dart index f3547b8..f23f533 100644 --- a/test/backend/nanodlp_state_handler_test.dart +++ b/test/backend/nanodlp_state_handler_test.dart @@ -16,7 +16,7 @@ */ import 'package:flutter_test/flutter_test.dart'; -import 'package:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_state_handler.dart'; import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; void main() { diff --git a/test/fakes/fake_odyssey_client.dart b/test/fakes/fake_odyssey_client.dart index 96be54f..cd6a9c0 100644 --- a/test/fakes/fake_odyssey_client.dart +++ b/test/fakes/fake_odyssey_client.dart @@ -1,10 +1,27 @@ +/* +* Orion - Fake Odyssey Client +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + import 'dart:typed_data'; import 'dart:async'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; /// Simple fake client used by unit tests to assert ManualProvider behavior. -class FakeOdysseyClient implements OdysseyClient { +class FakeBackendClient implements BackendClient { bool moveCalled = false; double? lastMoveHeight; bool manualHomeCalled = false; @@ -131,4 +148,34 @@ class FakeOdysseyClient implements OdysseyClient { Future getBackendVersion() { return Future.value('0.0.0'); } + + @override + Future>> getNotifications() async { + return >[]; + } + + @override + Future disableNotification(int timestamp) async { + // no-op fake for tests + return; + } + + @override + Future getPlateLayerImage(int plateId, int layer) async { + // Tests that use this fake generally don't fetch real plate layers. + // Return empty bytes which callers should handle as placeholder. + return Uint8List(0); + } + + @override + Future>> getAnalytics(int n) { + // TODO: implement getAnalytics + throw UnimplementedError(); + } + + @override + Future getAnalyticValue(int id) async { + // Not used in most tests; return null to indicate unsupported. + return null; + } } diff --git a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart index af973fb..3f97805 100644 --- a/test/fakes/fake_odyssey_client_for_thumbnail_test.dart +++ b/test/fakes/fake_odyssey_client_for_thumbnail_test.dart @@ -1,11 +1,28 @@ +/* +* Orion - Fake Odyssey Client for Thumbnail Test +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + import 'dart:async'; import 'dart:typed_data'; -import 'package:orion/backend_service/odyssey/odyssey_client.dart'; +import 'package:orion/backend_service/backend_client.dart'; -class FakeOdysseyClientForThumbnailTest implements OdysseyClient { +class FakeBackendClientForThumbnailTest implements BackendClient { final Uint8List bytes; - FakeOdysseyClientForThumbnailTest(this.bytes); + FakeBackendClientForThumbnailTest(this.bytes); @override Future getFileThumbnail( @@ -13,6 +30,13 @@ class FakeOdysseyClientForThumbnailTest implements OdysseyClient { return bytes; } + @override + Future getPlateLayerImage(int plateId, int layer) async { + // For thumbnail tests, just return the supplied bytes as a stand-in + // for a plate layer image. + return bytes; + } + // Minimal implementations for the rest of the interface used to satisfy // the abstract class. They should not be called by the thumbnail test. @override @@ -131,4 +155,27 @@ class FakeOdysseyClientForThumbnailTest implements OdysseyClient { Future getBackendVersion() { throw UnimplementedError(); } + + @override + Future>> getNotifications() async { + return >[]; + } + + @override + Future disableNotification(int timestamp) async { + // no-op for this fake + return; + } + + @override + Future>> getAnalytics(int n) { + // TODO: implement getAnalytics + throw UnimplementedError(); + } + + @override + Future getAnalyticValue(int id) async { + // Not used by thumbnail tests. + return null; + } } diff --git a/test/manual_provider_test.dart b/test/manual_provider_test.dart index 415999e..7c25c76 100644 --- a/test/manual_provider_test.dart +++ b/test/manual_provider_test.dart @@ -5,7 +5,7 @@ import 'fakes/fake_odyssey_client.dart'; void main() { group('ManualProvider', () { test('move forwards to client and toggles busy flag', () async { - final fake = FakeOdysseyClient(); + final fake = FakeBackendClient(); final provider = ManualProvider(client: fake); expect(provider.busy, isFalse); @@ -20,7 +20,7 @@ void main() { }); test('move handles client exception gracefully', () async { - final fake = FakeOdysseyClient(); + final fake = FakeBackendClient(); fake.throwOnMove = true; final provider = ManualProvider(client: fake); @@ -32,7 +32,7 @@ void main() { }); test('manualHome forwards correctly', () async { - final fake = FakeOdysseyClient(); + final fake = FakeBackendClient(); final provider = ManualProvider(client: fake); final ok = await provider.manualHome(); expect(ok, isTrue); @@ -40,7 +40,7 @@ void main() { }); test('manualCommand forwards correctly', () async { - final fake = FakeOdysseyClient(); + final fake = FakeBackendClient(); final provider = ManualProvider(client: fake); final ok = await provider.manualCommand('M112'); expect(ok, isTrue); @@ -48,7 +48,7 @@ void main() { }); test('manualCure and displayTest forwards correctly', () async { - final fake = FakeOdysseyClient(); + final fake = FakeBackendClient(); final provider = ManualProvider(client: fake); final ok1 = await provider.manualCure(true); final ok2 = await provider.displayTest('Grid'); diff --git a/test/nanodlp/finished_flow_test.dart b/test/nanodlp/finished_flow_test.dart index 77f967e..b06628c 100644 --- a/test/nanodlp/finished_flow_test.dart +++ b/test/nanodlp/finished_flow_test.dart @@ -17,7 +17,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:orion/backend_service/nanodlp/models/nano_status.dart'; -import 'package:orion/backend_service/nanodlp/nanodlp_state_handler.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_state_handler.dart'; import 'package:orion/backend_service/nanodlp/nanodlp_mappers.dart'; import 'package:orion/backend_service/odyssey/models/status_models.dart'; diff --git a/test/nanodlp_thumbnail_generator_test.dart b/test/nanodlp_thumbnail_generator_test.dart index 383964a..0f83043 100644 --- a/test/nanodlp_thumbnail_generator_test.dart +++ b/test/nanodlp_thumbnail_generator_test.dart @@ -2,7 +2,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; -import 'package:orion/backend_service/nanodlp/nanodlp_thumbnail_generator.dart'; +import 'package:orion/backend_service/nanodlp/helpers/nano_thumbnail_generator.dart'; void main() { group('NanoDlpThumbnailGenerator', () { diff --git a/test/thumbnail_cache_clear_test.dart b/test/thumbnail_cache_clear_test.dart new file mode 100644 index 0000000..ee7a167 --- /dev/null +++ b/test/thumbnail_cache_clear_test.dart @@ -0,0 +1,75 @@ +/* +* Orion - Thumbnail Cache Clear Test +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:orion/util/thumbnail_cache.dart'; +import 'package:path/path.dart' as p; + +Directory _expectedDiskCacheDir() { + if (Platform.isLinux) { + final xdg = Platform.environment['XDG_CACHE_HOME'] ?? + (Platform.environment['HOME'] != null + ? p.join(Platform.environment['HOME']!, '.cache') + : null); + if (xdg != null && xdg.isNotEmpty) { + return Directory(p.join(xdg, 'orion_thumbnail_cache')); + } else { + return Directory( + p.join(Directory.systemTemp.path, 'orion_thumbnail_cache')); + } + } else if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? '.'; + return Directory( + p.join(home, 'Library', 'Caches', 'orion_thumbnail_cache')); + } else if (Platform.isWindows) { + final local = Platform.environment['LOCALAPPDATA'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + return Directory(p.join(local, 'orion_thumbnail_cache')); + } else { + return Directory( + p.join(Directory.systemTemp.path, 'orion_thumbnail_cache')); + } +} + +void main() { + test('clearDisk removes files in disk cache directory', () async { + final dir = _expectedDiskCacheDir(); + if (!await dir.exists()) await dir.create(recursive: true); + + final testFile = File(p.join(dir.path, 'thumbnail_cache_clear_test.tmp')); + await testFile.writeAsString('dummy'); + expect(await testFile.exists(), isTrue, + reason: 'test file should exist before clearDisk'); + + // Now call clearDisk and expect the file to be removed (best-effort). + await ThumbnailCache.instance.clearDisk(); + + // Allow for potential async deletion scheduling; check that file no longer exists + // or that the directory may have been cleaned. + final existsAfter = await testFile.exists(); + expect(existsAfter, isFalse, + reason: + 'clearDisk should remove cache files created under the thumbnail disk cache dir'); + }); + + test('clear() runs without error (memory clear)', () async { + // This test simply ensures the in-memory clear does not throw. + ThumbnailCache.instance.clear(); + }); +} diff --git a/test/thumbnail_util_test.dart b/test/thumbnail_util_test.dart index 1c62e51..fefa539 100644 --- a/test/thumbnail_util_test.dart +++ b/test/thumbnail_util_test.dart @@ -35,7 +35,7 @@ void main() { return null; }); final data = Uint8List.fromList(List.generate(16, (i) => i % 256)); - final fake = FakeOdysseyClientForThumbnailTest(data); + final fake = FakeBackendClientForThumbnailTest(data); final path = await ThumbnailUtil.extractThumbnail('local', '', 'test.sl1', client: fake); diff --git a/test/widget_test.dart b/test/widget_test.dart index 2af071b..0ea4b57 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,19 +1,40 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +/* +* Orion - Widget Test +* Copyright (C) 2025 Open Resin Alliance +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:orion/main.dart'; +import 'package:provider/provider.dart'; +import 'package:orion/util/providers/locale_provider.dart'; +import 'package:orion/util/providers/theme_provider.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const OrionMainApp()); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => LocaleProvider()), + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ], + child: const OrionMainApp(), + ), + ); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);