diff --git a/lib/widgets/panorama.dart b/lib/widgets/panorama.dart new file mode 100644 index 0000000..800dfd5 --- /dev/null +++ b/lib/widgets/panorama.dart @@ -0,0 +1,731 @@ +// +// Apache License +// Version 2.0, January 2004 +// http://www.apache.org/licenses/ +// +// TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +// +// 1. Definitions. +// +// "License" shall mean the terms and conditions for use, reproduction, +// and distribution as defined by Sections 1 through 9 of this document. +// +// "Licensor" shall mean the copyright owner or entity authorized by +// the copyright owner that is granting the License. +// +// "Legal Entity" shall mean the union of the acting entity and all +// other entities that control, are controlled by, or are under common +// control with that entity. For the purposes of this definition, +// "control" means (i) the power, direct or indirect, to cause the +// direction or management of such entity, whether by contract or +// otherwise, or (ii) ownership of fifty percent (50%) or more of the +// outstanding shares, or (iii) beneficial ownership of such entity. +// +// "You" (or "Your") shall mean an individual or Legal Entity +// exercising permissions granted by this License. +// +// "Source" form shall mean the preferred form for making modifications, +// including but not limited to software source code, documentation +// source, and configuration files. +// +// "Object" form shall mean any form resulting from mechanical +// transformation or translation of a Source form, including but +// not limited to compiled object code, generated documentation, +// and conversions to other media types. +// +// "Work" shall mean the work of authorship, whether in Source or +// Object form, made available under the License, as indicated by a +// copyright notice that is included in or attached to the work +// (an example is provided in the Appendix below). +// +// "Derivative Works" shall mean any work, whether in Source or Object +// form, that is based on (or derived from) the Work and for which the +// editorial revisions, annotations, elaborations, or other modifications +// represent, as a whole, an original work of authorship. For the purposes +// of this License, Derivative Works shall not include works that remain +// separable from, or merely link (or bind by name) to the interfaces of, +// the Work and Derivative Works thereof. +// +// "Contribution" shall mean any work of authorship, including +// the original version of the Work and any modifications or additions +// to that Work or Derivative Works thereof, that is intentionally +// submitted to Licensor for inclusion in the Work by the copyright owner +// or by an individual or Legal Entity authorized to submit on behalf of +// the copyright owner. For the purposes of this definition, "submitted" +// means any form of electronic, verbal, or written communication sent +// to the Licensor or its representatives, including but not limited to +// communication on electronic mailing lists, source code control systems, +// and issue tracking systems that are managed by, or on behalf of, the +// Licensor for the purpose of discussing and improving the Work, but +// excluding communication that is conspicuously marked or otherwise +// designated in writing by the copyright owner as "Not a Contribution." +// +// "Contributor" shall mean Licensor and any individual or Legal Entity +// on behalf of whom a Contribution has been received by Licensor and +// subsequently incorporated within the Work. +// +// 2. Grant of Copyright License. Subject to the terms and conditions of +// this License, each Contributor hereby grants to You a perpetual, +// worldwide, non-exclusive, no-charge, royalty-free, irrevocable +// copyright license to reproduce, prepare Derivative Works of, +// publicly display, publicly perform, sublicense, and distribute the +// Work and such Derivative Works in Source or Object form. +// +// 3. Grant of Patent License. Subject to the terms and conditions of +// this License, each Contributor hereby grants to You a perpetual, +// worldwide, non-exclusive, no-charge, royalty-free, irrevocable +// (except as stated in this section) patent license to make, have made, +// use, offer to sell, sell, import, and otherwise transfer the Work, +// where such license applies only to those patent claims licensable +// by such Contributor that are necessarily infringed by their +// Contribution(s) alone or by combination of their Contribution(s) +// with the Work to which such Contribution(s) was submitted. If You +// institute patent litigation against any entity (including a +// cross-claim or counterclaim in a lawsuit) alleging that the Work +// or a Contribution incorporated within the Work constitutes direct +// or contributory patent infringement, then any patent licenses +// granted to You under this License for that Work shall terminate +// as of the date such litigation is filed. +// +// 4. Redistribution. You may reproduce and distribute copies of the +// Work or Derivative Works thereof in any medium, with or without +// modifications, and in Source or Object form, provided that You +// meet the following conditions: +// +// (a) You must give any other recipients of the Work or +// Derivative Works a copy of this License; and +// +// (b) You must cause any modified files to carry prominent notices +// stating that You changed the files; and +// +// (c) You must retain, in the Source form of any Derivative Works +// that You distribute, all copyright, patent, trademark, and +// attribution notices from the Source form of the Work, +// excluding those notices that do not pertain to any part of +// the Derivative Works; and +// +// (d) If the Work includes a "NOTICE" text file as part of its +// distribution, then any Derivative Works that You distribute must +// include a readable copy of the attribution notices contained +// within such NOTICE file, excluding those notices that do not +// pertain to any part of the Derivative Works, in at least one +// of the following places: within a NOTICE text file distributed +// as part of the Derivative Works; within the Source form or +// documentation, if provided along with the Derivative Works; or, +// within a display generated by the Derivative Works, if and +// wherever such third-party notices normally appear. The contents +// of the NOTICE file are for informational purposes only and +// do not modify the License. You may add Your own attribution +// notices within Derivative Works that You distribute, alongside +// or as an addendum to the NOTICE text from the Work, provided +// that such additional attribution notices cannot be construed +// as modifying the License. +// +// You may add Your own copyright statement to Your modifications and +// may provide additional or different license terms and conditions +// for use, reproduction, or distribution of Your modifications, or +// for any such Derivative Works as a whole, provided Your use, +// reproduction, and distribution of the Work otherwise complies with +// the conditions stated in this License. +// +// 5. Submission of Contributions. Unless You explicitly state otherwise, +// any Contribution intentionally submitted for inclusion in the Work +// by You to the Licensor shall be under the terms and conditions of +// this License, without any additional terms or conditions. +// Notwithstanding the above, nothing herein shall supersede or modify +// the terms of any separate license agreement you may have executed +// with Licensor regarding such Contributions. +// +// 6. Trademarks. This License does not grant permission to use the trade +// names, trademarks, service marks, or product names of the Licensor, +// except as required for reasonable and customary use in describing the +// origin of the Work and reproducing the content of the NOTICE file. +// +// 7. Disclaimer of Warranty. Unless required by applicable law or +// agreed to in writing, Licensor provides the Work (and each +// Contributor provides its Contributions) on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied, including, without limitation, any warranties or conditions +// of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +// PARTICULAR PURPOSE. You are solely responsible for determining the +// appropriateness of using or redistributing the Work and assume any +// risks associated with Your exercise of permissions under this License. +// +// 8. Limitation of Liability. In no event and under no legal theory, +// whether in tort (including negligence), contract, or otherwise, +// unless required by applicable law (such as deliberate and grossly +// negligent acts) or agreed to in writing, shall any Contributor be +// liable to You for damages, including any direct, indirect, special, +// incidental, or consequential damages of any character arising as a +// result of this License or out of the use or inability to use the +// Work (including but not limited to damages for loss of goodwill, +// work stoppage, computer failure or malfunction, or any and all +// other commercial damages or losses), even if such Contributor +// has been advised of the possibility of such damages. +// +// 9. Accepting Warranty or Additional Liability. While redistributing +// the Work or Derivative Works thereof, You may choose to offer, +// and charge a fee for, acceptance of support, warranty, indemnity, +// or other liability obligations and/or rights consistent with this +// License. However, in accepting such obligations, You may act only +// on Your own behalf and on Your sole responsibility, not on behalf +// of any other Contributor, and only if You agree to indemnify, +// defend, and hold each Contributor harmless for any liability +// incurred by, or claims asserted against, such Contributor by reason +// of your accepting any such warranty or additional liability. +// +// END OF TERMS AND CONDITIONS +// +// APPENDIX: How to apply the Apache License to your work. +// +// To apply the Apache License to your work, attach the following +// boilerplate notice, with the fields enclosed by brackets "[]" +// replaced with your own identifying information. (Don't include +// the brackets!) The text should be enclosed in the appropriate +// comment syntax for the file format. We also recommend that a +// file or class name and description of purpose be included on the +// same "printed page" as the copyright notice for easier +// identification within third-party archives. +// +// Copyright [yyyy] [name of copyright owner] +// +// 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. +// +// Adapted from https://github.com/zesage/panorama to remove nonfunctional motion sensor control and fix any linting +// warnings + + +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_cube/flutter_cube.dart'; + +class Panorama extends StatefulWidget { + const Panorama({ + Key? key, + this.latitude = 0, + this.longitude = 0, + this.zoom = 1.0, + this.minLatitude = -90.0, + this.maxLatitude = 90.0, + this.minLongitude = -180.0, + this.maxLongitude = 180.0, + this.minZoom = 1.0, + this.maxZoom = 5.0, + this.sensitivity = 1.0, + this.animSpeed = 0.0, + this.animReverse = true, + this.latSegments = 32, + this.lonSegments = 64, + this.interactive = true, + this.croppedArea = const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0), + this.croppedFullWidth = 1.0, + this.croppedFullHeight = 1.0, + this.onViewChanged, + this.onTap, + this.onLongPressStart, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onImageLoad, + this.child, + this.hotspots, + }) : super(key: key); + + /// The initial latitude, in degrees, between -90 and 90. default to 0 (the vertical center of the image). + final double latitude; + + /// The initial longitude, in degrees, between -180 and 180. default to 0 (the horizontal center of the image). + final double longitude; + + /// The initial zoom, default to 1.0. + final double zoom; + + /// The minimal latitude to show. default to -90.0 + final double minLatitude; + + /// The maximal latitude to show. default to 90.0 + final double maxLatitude; + + /// The minimal longitude to show. default to -180.0 + final double minLongitude; + + /// The maximal longitude to show. default to 180.0 + final double maxLongitude; + + /// The minimal zomm. default to 1.0 + final double minZoom; + + /// The maximal zomm. default to 5.0 + final double maxZoom; + + /// The sensitivity of the gesture. default to 1.0 + final double sensitivity; + + /// The Speed of rotation by animation. default to 0.0 + final double animSpeed; + + /// Reverse rotation when the current longitude reaches the minimal or maximum. default to true + final bool animReverse; + + /// The number of vertical divisions of the sphere. + final int latSegments; + + /// The number of horizontal divisions of the sphere. + final int lonSegments; + + /// Interact with the panorama. default to true + final bool interactive; + + /// Area of the image was cropped from the full sized photo sphere. + final Rect croppedArea; + + /// Original full width from which the image was cropped. + final double croppedFullWidth; + + /// Original full height from which the image was cropped. + final double croppedFullHeight; + + /// This event will be called when the view direction has changed, it contains latitude and longitude about the current view. + final Function(double longitude, double latitude, double tilt)? onViewChanged; + + /// This event will be called when the user has tapped, it contains latitude and longitude about where the user tapped. + final Function(double longitude, double latitude, double tilt)? onTap; + + /// This event will be called when the user has started a long press, it contains latitude and longitude about where the user pressed. + final Function(double longitude, double latitude, double tilt)? onLongPressStart; + + /// This event will be called when the user has drag-moved after a long press, it contains latitude and longitude about where the user pressed. + final Function(double longitude, double latitude, double tilt)? onLongPressMoveUpdate; + + /// This event will be called when the user has stopped a long presses, it contains latitude and longitude about where the user pressed. + final Function(double longitude, double latitude, double tilt)? onLongPressEnd; + + /// This event will be called when provided image is loaded on texture. + final Function()? onImageLoad; + + /// Specify an Image(equirectangular image) widget to the panorama. + final Image? child; + + /// Place widgets in the panorama. + final List? hotspots; + + @override + State createState() => _PanoramaState(); +} + +class _PanoramaState extends State with SingleTickerProviderStateMixin { + Scene? scene; + Object? surface; + late double latitude; + late double longitude; + double latitudeDelta = 0; + double longitudeDelta = 0; + double zoomDelta = 0; + late Offset _lastFocalPoint; + double? _lastZoom; + final double _radius = 500; + final double _dampingFactor = 0.05; + double _animateDirection = 1.0; + late AnimationController _controller; + double screenOrientation = 0.0; + Vector3 orientation = Vector3(0, radians(90), 0); + StreamSubscription? _orientationSubscription; + StreamSubscription? _screenOrientSubscription; + late StreamController _streamController; + Stream? _stream; + ImageStream? _imageStream; + + void _handleTapUp(TapUpDetails details) { + final Vector3 o = positionToLatLon(details.localPosition.dx, details.localPosition.dy); + widget.onTap!(degrees(o.x), degrees(-o.y), degrees(o.z)); + } + + void _handleLongPressStart(LongPressStartDetails details) { + final Vector3 o = positionToLatLon(details.localPosition.dx, details.localPosition.dy); + widget.onLongPressStart!(degrees(o.x), degrees(-o.y), degrees(o.z)); + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + final Vector3 o = positionToLatLon(details.localPosition.dx, details.localPosition.dy); + widget.onLongPressMoveUpdate!(degrees(o.x), degrees(-o.y), degrees(o.z)); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + final Vector3 o = positionToLatLon(details.localPosition.dx, details.localPosition.dy); + widget.onLongPressEnd!(degrees(o.x), degrees(-o.y), degrees(o.z)); + } + + void _handleScaleStart(ScaleStartDetails details) { + _lastFocalPoint = details.localFocalPoint; + _lastZoom = null; + } + + void _handleScaleUpdate(ScaleUpdateDetails details) { + final offset = details.localFocalPoint - _lastFocalPoint; + _lastFocalPoint = details.localFocalPoint; + latitudeDelta += widget.sensitivity * 0.5 * math.pi * offset.dy / scene!.camera.viewportHeight; + longitudeDelta -= widget.sensitivity * _animateDirection * 0.5 * math.pi * offset.dx / scene!.camera.viewportHeight; + _lastZoom ??= scene!.camera.zoom; + zoomDelta += _lastZoom! * details.scale - (scene!.camera.zoom + zoomDelta); + if (!_controller.isAnimating) { + _controller.reset(); + if (widget.animSpeed != 0) { + _controller.repeat(); + } else { + _controller.forward(); + } + } + } + + void _updateView() { + if (scene == null) return; + // auto rotate + longitudeDelta += 0.001 * widget.animSpeed; + // animate vertical rotating + latitude += latitudeDelta * _dampingFactor * widget.sensitivity; + latitudeDelta *= 1 - _dampingFactor * widget.sensitivity; + // animate horizontal rotating + longitude += _animateDirection * longitudeDelta * _dampingFactor * widget.sensitivity; + longitudeDelta *= 1 - _dampingFactor * widget.sensitivity; + // animate zomming + final double zoom = scene!.camera.zoom + zoomDelta * _dampingFactor; + zoomDelta *= 1 - _dampingFactor; + scene!.camera.zoom = zoom.clamp(widget.minZoom, widget.maxZoom); + // stop animation if not needed + if (latitudeDelta.abs() < 0.001 && longitudeDelta.abs() < 0.001 && zoomDelta.abs() < 0.001) { + if (widget.animSpeed == 0 && _controller.isAnimating) { + _controller.stop(); + } + } + + // rotate for screen orientation + Quaternion q = Quaternion.axisAngle(Vector3(0, 0, 1), screenOrientation); + // rotate for device orientation + q *= Quaternion.euler(-orientation.z, -orientation.y, -orientation.x); + // rotate to latitude zero + q *= Quaternion.axisAngle(Vector3(1, 0, 0), math.pi * 0.5); + + // check and limit the rotation range + Vector3 o = quaternionToOrientation(q); + final double minLat = radians(math.max(-89.9, widget.minLatitude)); + final double maxLat = radians(math.min(89.9, widget.maxLatitude)); + final double minLon = radians(widget.minLongitude); + final double maxLon = radians(widget.maxLongitude); + final double lat = (-o.y).clamp(minLat, maxLat); + final double lon = o.x.clamp(minLon, maxLon); + if (lat + latitude < minLat) latitude = minLat - lat; + if (lat + latitude > maxLat) latitude = maxLat - lat; + if (maxLon - minLon < math.pi * 2) { + if (lon + longitude < minLon || lon + longitude > maxLon) { + longitude = (lon + longitude < minLon ? minLon : maxLon) - lon; + // reverse rotation when reaching the boundary + if (widget.animSpeed != 0) { + if (widget.animReverse) { + _animateDirection *= -1.0; + } else { + _controller.stop(); + } + } + } + } + o.x = lon; + o.y = -lat; + q = orientationToQuaternion(o); + + // rotate to longitude zero + q *= Quaternion.axisAngle(Vector3(0, 1, 0), -math.pi * 0.5); + // rotate around the global Y axis + q *= Quaternion.axisAngle(Vector3(0, 1, 0), longitude); + // rotate around the local X axis + q = Quaternion.axisAngle(Vector3(1, 0, 0), -latitude) * q; + + o = quaternionToOrientation(q * Quaternion.axisAngle(Vector3(0, 1, 0), math.pi * 0.5)); + widget.onViewChanged?.call(degrees(o.x), degrees(-o.y), degrees(o.z)); + + q.rotate(scene!.camera.target..setFrom(Vector3(0, 0, -_radius))); + q.rotate(scene!.camera.up..setFrom(Vector3(0, 1, 0))); + scene!.update(); + _streamController.add(null); + } + + void _updateTexture(ImageInfo imageInfo, bool synchronousCall) { + surface?.mesh.texture = imageInfo.image; + surface?.mesh.textureRect = + Rect.fromLTWH(0, 0, imageInfo.image.width.toDouble(), imageInfo.image.height.toDouble()); + scene!.texture = imageInfo.image; + scene!.update(); + widget.onImageLoad?.call(); + } + + void _loadTexture(ImageProvider? provider) { + if (provider == null) return; + _imageStream?.removeListener(ImageStreamListener(_updateTexture)); + _imageStream = provider.resolve(const ImageConfiguration()); + ImageStreamListener listener = ImageStreamListener(_updateTexture); + _imageStream!.addListener(listener); + } + + void _onSceneCreated(Scene scene) { + this.scene = scene; + scene.camera.near = 1.0; + scene.camera.far = _radius + 1.0; + scene.camera.fov = 75; + scene.camera.zoom = widget.zoom; + scene.camera.position.setFrom(Vector3(0, 0, 0.1)); + if (widget.child != null) { + final Mesh mesh = generateSphereMesh( + radius: _radius, + latSegments: widget.latSegments, + lonSegments: widget.lonSegments, + croppedArea: widget.croppedArea, + croppedFullWidth: widget.croppedFullWidth, + croppedFullHeight: widget.croppedFullHeight); + surface = Object(name: 'surface', mesh: mesh, backfaceCulling: false); + _loadTexture(widget.child!.image); + scene.world.add(surface!); + _updateView(); + } + } + + Matrix4 matrixFromLatLon(double lat, double lon) { + return Matrix4.rotationY(radians(90.0 - lon))..rotateX(radians(lat)); + } + + Vector3 positionToLatLon(double x, double y) { + // transform viewport coordinate to NDC, values between -1 and 1 + final Vector4 v = + Vector4(2.0 * x / scene!.camera.viewportWidth - 1.0, 1.0 - 2.0 * y / scene!.camera.viewportHeight, 1.0, 1.0); + // create projection matrix + final Matrix4 m = scene!.camera.projectionMatrix * scene!.camera.lookAtMatrix; + // apply inversed projection matrix + m.invert(); + v.applyMatrix4(m); + // apply perspective division + v.scale(1 / v.w); + // get rotation from two vectors + final Quaternion q = Quaternion.fromTwoVectors(v.xyz, Vector3(0.0, 0.0, -_radius)); + // get euler angles from rotation + return quaternionToOrientation(q * Quaternion.axisAngle(Vector3(0, 1, 0), math.pi * 0.5)); + } + + Vector3 positionFromLatLon(double lat, double lon) { + // create projection matrix + final Matrix4 m = scene!.camera.projectionMatrix * scene!.camera.lookAtMatrix * matrixFromLatLon(lat, lon); + // apply projection matrix + final Vector4 v = Vector4(0.0, 0.0, -_radius, 1.0)..applyMatrix4(m); + // apply perspective division and transform NDC to the viewport coordinate + return Vector3( + (1.0 + v.x / v.w) * scene!.camera.viewportWidth / 2, + (1.0 - v.y / v.w) * scene!.camera.viewportHeight / 2, + v.z, + ); + } + + Widget buildHotspotWidgets(List? hotspots) { + final List widgets = []; + if (hotspots != null && scene != null) { + for (Hotspot hotspot in hotspots) { + final Vector3 pos = positionFromLatLon(hotspot.latitude, hotspot.longitude); + final Offset orgin = Offset(hotspot.width * hotspot.orgin.dx, hotspot.height * hotspot.orgin.dy); + final Matrix4 transform = scene!.camera.lookAtMatrix * matrixFromLatLon(hotspot.latitude, hotspot.longitude); + final Widget child = Positioned( + left: pos.x - orgin.dx, + top: pos.y - orgin.dy, + width: hotspot.width, + height: hotspot.height, + child: Transform( + origin: orgin, + transform: transform..invert(), + child: Offstage( + offstage: pos.z < 0, + child: hotspot.widget, + ), + ), + ); + widgets.add(child); + } + } + return Stack(children: widgets); + } + + @override + void initState() { + super.initState(); + latitude = degrees(widget.latitude); + longitude = degrees(widget.longitude); + _streamController = StreamController.broadcast(); + _stream = _streamController.stream; + + + _controller = AnimationController(duration: const Duration(milliseconds: 60000), vsync: this) + ..addListener(_updateView); + if (widget.animSpeed != 0) _controller.repeat(); + } + + @override + void dispose() { + _imageStream?.removeListener(ImageStreamListener(_updateTexture)); + _orientationSubscription?.cancel(); + _screenOrientSubscription?.cancel(); + _controller.dispose(); + _streamController.close(); + super.dispose(); + } + + @override + void didUpdateWidget(Panorama oldWidget) { + super.didUpdateWidget(oldWidget); + if (surface == null) return; + if (widget.latSegments != oldWidget.latSegments || + widget.lonSegments != oldWidget.lonSegments || + widget.croppedArea != oldWidget.croppedArea || + widget.croppedFullWidth != oldWidget.croppedFullWidth || + widget.croppedFullHeight != oldWidget.croppedFullHeight) { + surface!.mesh = generateSphereMesh( + radius: _radius, + latSegments: widget.latSegments, + lonSegments: widget.lonSegments, + croppedArea: widget.croppedArea, + croppedFullWidth: widget.croppedFullWidth, + croppedFullHeight: widget.croppedFullHeight); + } + if (widget.child?.image != oldWidget.child?.image) { + _loadTexture(widget.child?.image); + } + } + + @override + Widget build(BuildContext context) { + Widget pano = Stack( + children: [ + Cube(interactive: false, onSceneCreated: _onSceneCreated), + StreamBuilder( + stream: _stream, + builder: (BuildContext context, AsyncSnapshot snapshot) { + return buildHotspotWidgets(widget.hotspots); + }, + ), + ], + ); + + return widget.interactive + ? GestureDetector( + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + onTapUp: widget.onTap == null ? null : _handleTapUp, + onLongPressStart: widget.onLongPressStart == null ? null : _handleLongPressStart, + onLongPressMoveUpdate: widget.onLongPressMoveUpdate == null ? null : _handleLongPressMoveUpdate, + onLongPressEnd: widget.onLongPressEnd == null ? null : _handleLongPressEnd, + child: pano, + ) + : pano; + } +} + +class Hotspot { + Hotspot({ + this.name, + this.latitude = 0.0, + this.longitude = 0.0, + this.orgin = const Offset(0.5, 0.5), + this.width = 32.0, + this.height = 32.0, + this.widget, + }); + + /// The name of this hotspot. + String? name; + + /// The initial latitude, in degrees, between -90 and 90. + final double latitude; + + /// The initial longitude, in degrees, between -180 and 180. + final double longitude; + + /// The local orgin of this hotspot. Default is Offset(0.5, 0.5). + final Offset orgin; + + // The width of widget. Default is 32.0 + double width; + + // The height of widget. Default is 32.0 + double height; + + Widget? widget; +} + +Mesh generateSphereMesh( + {num radius = 1.0, + int latSegments = 16, + int lonSegments = 16, + ui.Image? texture, + Rect croppedArea = const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0), + double croppedFullWidth = 1.0, + double croppedFullHeight = 1.0}) { + int count = (latSegments + 1) * (lonSegments + 1); + List vertices = List.filled(count, Vector3.zero()); + List texcoords = List.filled(count, Offset.zero); + List indices = List.filled(latSegments * lonSegments * 2, Polygon(0, 0, 0)); + + int i = 0; + for (int y = 0; y <= latSegments; ++y) { + final double tv = y / latSegments; + final double v = (croppedArea.top + croppedArea.height * tv) / croppedFullHeight; + final double sv = math.sin(v * math.pi); + final double cv = math.cos(v * math.pi); + for (int x = 0; x <= lonSegments; ++x) { + final double tu = x / lonSegments; + final double u = (croppedArea.left + croppedArea.width * tu) / croppedFullWidth; + vertices[i] = + Vector3(radius * math.cos(u * math.pi * 2.0) * sv, radius * cv, radius * math.sin(u * math.pi * 2.0) * sv); + texcoords[i] = Offset(tu, 1.0 - tv); + i++; + } + } + + i = 0; + for (int y = 0; y < latSegments; ++y) { + final int base1 = (lonSegments + 1) * y; + final int base2 = (lonSegments + 1) * (y + 1); + for (int x = 0; x < lonSegments; ++x) { + indices[i++] = Polygon(base1 + x, base1 + x + 1, base2 + x); + indices[i++] = Polygon(base1 + x + 1, base2 + x + 1, base2 + x); + } + } + + final Mesh mesh = Mesh(vertices: vertices, texcoords: texcoords, indices: indices, texture: texture); + return mesh; +} + +Vector3 quaternionToOrientation(Quaternion q) { + // final Matrix4 m = Matrix4.compose(Vector3.zero(), q, Vector3.all(1.0)); + // return Vector3(v.z, v.y, v.x); + final storage = q.storage; + final double x = storage[0]; + final double y = storage[1]; + final double z = storage[2]; + final double w = storage[3]; + final double roll = math.atan2(-2 * (x * y - w * z), 1.0 - 2 * (x * x + z * z)); + final double pitch = math.asin(2 * (y * z + w * x)); + final double yaw = math.atan2(-2 * (x * z - w * y), 1.0 - 2 * (x * x + y * y)); + return Vector3(yaw, pitch, roll); +} + +Quaternion orientationToQuaternion(Vector3 v) { + final Matrix4 m = Matrix4.identity(); + m.rotateZ(v.z); + m.rotateX(v.y); + m.rotateY(v.x); + return Quaternion.fromRotation(m.getRotation()); +} diff --git a/lib/widgets/sessions/session_view.dart b/lib/widgets/sessions/session_view.dart index 7a3a71b..b16b157 100644 --- a/lib/widgets/sessions/session_view.dart +++ b/lib/widgets/sessions/session_view.dart @@ -4,9 +4,9 @@ import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/widgets/formatted_text.dart'; +import 'package:contacts_plus_plus/widgets/panorama.dart'; import 'package:contacts_plus_plus/widgets/settings_page.dart'; import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; class SessionView extends StatefulWidget { const SessionView({required this.session, super.key}); @@ -18,7 +18,6 @@ class SessionView extends StatefulWidget { } class _SessionViewState extends State { - Future? _sessionFuture; @override @@ -59,133 +58,150 @@ class _SessionViewState extends State { }, child: ListView( children: [ - SizedBox( - height: 192, - child: Hero( - tag: session.id, - child: CachedNetworkImage( - imageUrl: Aux.neosDbToHttp(session.thumbnail), - imageBuilder: (context, image) { - return Material( - child: InkWell( - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PhotoView( - minScale: PhotoViewComputedScale.contained, - imageProvider: image, - heroAttributes: PhotoViewHeroAttributes(tag: session.id), + SizedBox( + height: 192, + child: CachedNetworkImage( + imageUrl: Aux.neosDbToHttp(session.thumbnail), + imageBuilder: (context, image) { + return Material( + child: InkWell( + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: const Text("Session Preview"), + ), + body: Center( + child: Panorama( + sensitivity: 2, + minZoom: 0.5, + zoom: 0.5, + child: Image(image: image), + ), + ), + ), ), - ), - ); - }, - child: Image( - image: image, - fit: BoxFit.cover, + ); + }, + child: Stack( + children: [ + SizedBox.expand( + child: Image( + image: image, + fit: BoxFit.cover, + ), + ), + const Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.all(16), + child: Icon(Icons.panorama_photosphere), + ), + ) + ], + ), + ), + ); + }, + errorWidget: (context, url, error) => const Icon( + Icons.broken_image, + size: 64, + ), + placeholder: (context, uri) => const Center(child: CircularProgressIndicator()), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8), + child: session.formattedDescription.isEmpty + ? Text("No description", style: Theme.of(context).textTheme.labelLarge) + : FormattedText( + session.formattedDescription, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const ListSectionHeader( + leadingText: "Tags:", + showLine: false, + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8.0), + child: Text( + session.tags.isEmpty ? "None" : session.tags.join(", "), + style: Theme.of(context).textTheme.labelMedium, + textAlign: TextAlign.start, + softWrap: true, ), ), - ); - }, - errorWidget: (context, url, error) => const Icon( - Icons.broken_image, - size: 64, + const ListSectionHeader( + leadingText: "Details:", + showLine: false, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Access: ", + style: Theme.of(context).textTheme.labelLarge, + ), + Text( + session.accessLevel.toReadableString(), + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Headless: ", + style: Theme.of(context).textTheme.labelLarge, + ), + Text( + session.headlessHost ? "Yes" : "No", + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + ListSectionHeader( + leadingText: "Users", + trailingText: + "${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")}", + showLine: false, + ), + ], ), - placeholder: (context, uri) => const Center(child: CircularProgressIndicator()), ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8), - child: session.formattedDescription.isEmpty - ? Text("No description", style: Theme.of(context).textTheme.labelLarge) - : FormattedText( - session.formattedDescription, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const ListSectionHeader( - leadingText: "Tags:", - showLine: false, - ), - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8.0), - child: Text( - session.tags.isEmpty ? "None" : session.tags.join(", "), - style: Theme.of(context).textTheme.labelMedium, - textAlign: TextAlign.start, - softWrap: true, - ), - ), - const ListSectionHeader( - leadingText: "Details:", - showLine: false, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Access: ", - style: Theme.of(context).textTheme.labelLarge, - ), - Text( - session.accessLevel.toReadableString(), - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Headless: ", - style: Theme.of(context).textTheme.labelLarge, - ), - Text( - session.headlessHost ? "Yes" : "No", - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ), - ListSectionHeader( - leadingText: "Users", - trailingText: - "${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")}", - showLine: false, - ), - ], - ), - ), - ] + + ] + session.sessionUsers .map((user) => ListTile( - dense: true, - title: Text( - user.username, - textAlign: TextAlign.start, - ), - subtitle: Text( - user.isPresent ? "Active" : "Inactive", - textAlign: TextAlign.start, - ), - )) + dense: true, + title: Text( + user.username, + textAlign: TextAlign.start, + ), + subtitle: Text( + user.isPresent ? "Active" : "Inactive", + textAlign: TextAlign.start, + ), + )) .toList(), ), ), ); }, - ); } } diff --git a/pubspec.lock b/pubspec.lock index 4183ae3..cbe3583 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + flutter_cube: + dependency: "direct main" + description: + name: flutter_cube + sha256: "71cf679a251166eb97f86751c56582b09abdbf859485fbf60524948813914c3b" + url: "https://pub.dev" + source: hosted + version: "0.1.1" flutter_downloader: dependency: "direct main" description: @@ -958,7 +966,7 @@ packages: source: hosted version: "3.0.7" vector_math: - dependency: transitive + dependency: "direct main" description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" diff --git a/pubspec.yaml b/pubspec.yaml index f105c7f..d067801 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.4.2+1 +version: 1.5.0+1 environment: sdk: '>=3.0.1' @@ -64,6 +64,8 @@ dependencies: image_picker: ^0.8.7+5 permission_handler: ^10.2.0 flutter_downloader: ^1.10.4 + flutter_cube: ^0.1.1 + vector_math: ^2.1.4 dev_dependencies: flutter_test: