mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 05:22:13 +00:00
Fix: Issues with the camera initialization
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
d6432677df
commit
fae5ca3d25
21 changed files with 228 additions and 162 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.2.17
|
## 0.2.18
|
||||||
|
|
||||||
- New: Adds an "Ask a Friend" button to new contact suggestions.
|
- New: Adds an "Ask a Friend" button to new contact suggestions.
|
||||||
- New: Adds security profiles.
|
- New: Adds security profiles.
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
- Fix: Issue with receiving messages when user closed app while decrypting
|
- Fix: Issue with receiving messages when user closed app while decrypting
|
||||||
- Fix: Background message fetching reliability.
|
- Fix: Background message fetching reliability.
|
||||||
- Fix: Issue with focus changing when taking a picture
|
- Fix: Issue with focus changing when taking a picture
|
||||||
|
- Fix: Issues with the camera initialization
|
||||||
|
|
||||||
## 0.2.16
|
## 0.2.16
|
||||||
|
|
||||||
|
|
|
||||||
2
fastlane/Appfile
Normal file
2
fastlane/Appfile
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY_PATH"] || "../../local_data/accesskeys/upload_track_releases_google_play.json")
|
||||||
|
package_name("eu.twonly") # Your application ID
|
||||||
15
fastlane/Fastfile
Normal file
15
fastlane/Fastfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
default_platform(:android)
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
desc "Submit a new App Bundle to the Google Play Internal Track"
|
||||||
|
lane :internal do
|
||||||
|
# This lane assumes that `flutter build appbundle` has already been run from the flutter root.
|
||||||
|
upload_to_play_store(
|
||||||
|
track: 'internal',
|
||||||
|
aab: 'build/app/outputs/bundle/release/app-release.aab',
|
||||||
|
skip_upload_metadata: true,
|
||||||
|
skip_upload_images: true,
|
||||||
|
skip_upload_screenshots: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
32
fastlane/README.md
Normal file
32
fastlane/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
### android internal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android internal
|
||||||
|
```
|
||||||
|
|
||||||
|
Submit a new App Bundle to the Google Play Internal Track
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
18
fastlane/report.xml
Normal file
18
fastlane/report.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="fastlane.lanes">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000214">
|
||||||
|
|
||||||
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
|
<testcase classname="fastlane.lanes" name="1: upload_to_play_store" time="160.411287">
|
||||||
|
|
||||||
|
</testcase>
|
||||||
|
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
@ -33,4 +33,5 @@ class AppState {
|
||||||
static bool allowErrorTrackingViaSentry = false;
|
static bool allowErrorTrackingViaSentry = false;
|
||||||
static bool gotMessageFromServer = false;
|
static bool gotMessageFromServer = false;
|
||||||
static int latestAppVersionId = 116;
|
static int latestAppVersionId = 116;
|
||||||
|
static bool hasCameraPermissions = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,24 +46,43 @@ class ScreenshotController {
|
||||||
}
|
}
|
||||||
late GlobalKey _containerKey;
|
late GlobalKey _containerKey;
|
||||||
|
|
||||||
Future<ScreenshotImageHelper?> capture({double? pixelRatio}) async {
|
Future<ScreenshotImageHelper?> capture({
|
||||||
|
double? pixelRatio,
|
||||||
|
int retries = 20,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final findRenderObject = _containerKey.currentContext?.findRenderObject();
|
final findRenderObject = _containerKey.currentContext?.findRenderObject();
|
||||||
if (findRenderObject == null) {
|
if (findRenderObject == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final boundary = findRenderObject as RenderRepaintBoundary;
|
final boundary = findRenderObject as RenderRepaintBoundary;
|
||||||
|
|
||||||
final context = _containerKey.currentContext;
|
final context = _containerKey.currentContext;
|
||||||
var tmpPixelRatio = pixelRatio;
|
var tmpPixelRatio = pixelRatio;
|
||||||
if (tmpPixelRatio == null) {
|
if (tmpPixelRatio == null) {
|
||||||
if (context != null && context.mounted) {
|
if (context != null && context.mounted) {
|
||||||
tmpPixelRatio =
|
tmpPixelRatio = tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio;
|
||||||
tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1);
|
final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1);
|
||||||
return ScreenshotImageHelper(image: image);
|
return ScreenshotImageHelper(image: image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (retries > 0) {
|
||||||
|
final completer = Completer<ScreenshotImageHelper?>();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final result = await capture(
|
||||||
|
pixelRatio: pixelRatio,
|
||||||
|
retries: retries - 1,
|
||||||
|
);
|
||||||
|
completer.complete(result);
|
||||||
|
});
|
||||||
|
Timer(const Duration(milliseconds: 50), () {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
WidgetsBinding.instance.scheduleFrame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ class _StartNewChatView extends State<AddNewShortcutView> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: FilledButton.icon(
|
floatingActionButton: FilledButton.icon(
|
||||||
onPressed: (_selectedGroups.isEmpty || shortcutEmoji == null)
|
onPressed: (_selectedGroups.isEmpty || shortcutEmoji == null)
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class SelectedCameraDetails {
|
||||||
bool cameraLoaded = false;
|
bool cameraLoaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CameraPreviewControllerView extends StatelessWidget {
|
class CameraPreviewControllerView extends StatefulWidget {
|
||||||
const CameraPreviewControllerView({
|
const CameraPreviewControllerView({
|
||||||
required this.mainController,
|
required this.mainController,
|
||||||
required this.isVisible,
|
required this.isVisible,
|
||||||
|
|
@ -62,23 +62,52 @@ class CameraPreviewControllerView extends StatelessWidget {
|
||||||
final bool isVisible;
|
final bool isVisible;
|
||||||
final bool hideControllers;
|
final bool hideControllers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CameraPreviewControllerView> createState() => _CameraPreviewControllerViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CameraPreviewControllerViewState extends State<CameraPreviewControllerView> {
|
||||||
|
Future<bool>? _permissionsFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (!AppState.hasCameraPermissions) {
|
||||||
|
_permissionsFuture = checkPermissions().then((hasPermission) {
|
||||||
|
if (hasPermission) {
|
||||||
|
AppState.hasCameraPermissions = true;
|
||||||
|
}
|
||||||
|
return hasPermission;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder(
|
if (AppState.hasCameraPermissions) {
|
||||||
future: checkPermissions(),
|
return CameraPreviewView(
|
||||||
|
sendToGroup: widget.sendToGroup,
|
||||||
|
mainCameraController: widget.mainController,
|
||||||
|
isVisible: widget.isVisible,
|
||||||
|
hideControllers: widget.hideControllers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<bool>(
|
||||||
|
future: _permissionsFuture,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
if (snap.hasData) {
|
if (snap.hasData) {
|
||||||
if (snap.data!) {
|
if (snap.data!) {
|
||||||
return CameraPreviewView(
|
return CameraPreviewView(
|
||||||
sendToGroup: sendToGroup,
|
sendToGroup: widget.sendToGroup,
|
||||||
mainCameraController: mainController,
|
mainCameraController: widget.mainController,
|
||||||
isVisible: isVisible,
|
isVisible: widget.isVisible,
|
||||||
hideControllers: hideControllers,
|
hideControllers: widget.hideControllers,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return PermissionHandlerView(
|
return PermissionHandlerView(
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
mainController.selectCamera(0, true);
|
widget.mainController.selectCamera(0, true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -210,8 +239,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
Future<void> initAsync() async {
|
Future<void> initAsync() async {
|
||||||
_hasAudioPermission = await Permission.microphone.isGranted;
|
_hasAudioPermission = await Permission.microphone.isGranted;
|
||||||
|
|
||||||
if (!_hasAudioPermission &&
|
if (!_hasAudioPermission && !userService.currentUser.requestedAudioPermission) {
|
||||||
!userService.currentUser.requestedAudioPermission) {
|
|
||||||
await UserService.update((u) => u.requestedAudioPermission = true);
|
await UserService.update((u) => u.requestedAudioPermission = true);
|
||||||
await requestMicrophonePermission();
|
await requestMicrophonePermission();
|
||||||
}
|
}
|
||||||
|
|
@ -232,8 +260,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateScaleFactor(double newScale) async {
|
Future<void> updateScaleFactor(double newScale) async {
|
||||||
if (mc.selectedCameraDetails.scaleFactor == newScale ||
|
if (mc.selectedCameraDetails.scaleFactor == newScale || mc.cameraController == null) {
|
||||||
mc.cameraController == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await mc.cameraController?.setZoomLevel(
|
await mc.cameraController?.setZoomLevel(
|
||||||
|
|
@ -316,9 +343,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
bool sharedFromGallery = false,
|
bool sharedFromGallery = false,
|
||||||
MediaType? mediaType,
|
MediaType? mediaType,
|
||||||
}) async {
|
}) async {
|
||||||
final type =
|
final type = mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image);
|
||||||
mediaType ??
|
|
||||||
((videoFilePath != null) ? MediaType.video : MediaType.image);
|
|
||||||
final mediaFileService = await initializeMediaUpload(
|
final mediaFileService = await initializeMediaUpload(
|
||||||
type,
|
type,
|
||||||
userService.currentUser.defaultShowTime,
|
userService.currentUser.defaultShowTime,
|
||||||
|
|
@ -359,10 +384,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
mainCameraController: mc,
|
mainCameraController: mc,
|
||||||
previewLink: mc.sharedLinkForPreview,
|
previewLink: mc.sharedLinkForPreview,
|
||||||
),
|
),
|
||||||
transitionsBuilder:
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
(context, animation, secondaryAnimation, child) {
|
return child;
|
||||||
return child;
|
},
|
||||||
},
|
|
||||||
transitionDuration: Duration.zero,
|
transitionDuration: Duration.zero,
|
||||||
reverseTransitionDuration: Duration.zero,
|
reverseTransitionDuration: Duration.zero,
|
||||||
),
|
),
|
||||||
|
|
@ -392,16 +416,13 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isFront =>
|
bool get isFront => mc.cameraController?.description.lensDirection == CameraLensDirection.front;
|
||||||
mc.cameraController?.description.lensDirection ==
|
|
||||||
CameraLensDirection.front;
|
|
||||||
|
|
||||||
Future<void> onPanUpdate(dynamic details) async {
|
Future<void> onPanUpdate(dynamic details) async {
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mc.cameraController == null ||
|
if (mc.cameraController == null || !mc.cameraController!.value.isInitialized) {
|
||||||
!mc.cameraController!.value.isInitialized) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,8 +551,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startVideoRecording() async {
|
Future<void> startVideoRecording() async {
|
||||||
if (mc.cameraController != null &&
|
if (mc.cameraController != null && mc.cameraController!.value.isRecordingVideo) {
|
||||||
mc.cameraController!.value.isRecordingVideo) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -551,8 +571,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_currentTime = clock.now();
|
_currentTime = clock.now();
|
||||||
});
|
});
|
||||||
if (_videoRecordingStarted != null &&
|
if (_videoRecordingStarted != null &&
|
||||||
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
|
_currentTime.difference(_videoRecordingStarted!).inSeconds >= maxVideoRecordingTime) {
|
||||||
maxVideoRecordingTime) {
|
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
_videoRecordingTimer = null;
|
_videoRecordingTimer = null;
|
||||||
stopVideoRecording();
|
stopVideoRecording();
|
||||||
|
|
@ -589,8 +608,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_videoRecordingLocked = false;
|
_videoRecordingLocked = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mc.cameraController == null ||
|
if (mc.cameraController == null || !mc.cameraController!.value.isRecordingVideo) {
|
||||||
!mc.cameraController!.value.isRecordingVideo) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,8 +636,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length ||
|
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length || mc.cameraController == null) {
|
||||||
mc.cameraController == null) {
|
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
|
|
@ -643,9 +660,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
|
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
|
||||||
});
|
});
|
||||||
// Get the position of the pointer
|
// Get the position of the pointer
|
||||||
final renderBox =
|
final renderBox = keyTriggerButton.currentContext!.findRenderObject()! as RenderBox;
|
||||||
keyTriggerButton.currentContext!.findRenderObject()!
|
|
||||||
as RenderBox;
|
|
||||||
final localPosition = renderBox.globalToLocal(
|
final localPosition = renderBox.globalToLocal(
|
||||||
details.globalPosition,
|
details.globalPosition,
|
||||||
);
|
);
|
||||||
|
|
@ -681,24 +696,18 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown &&
|
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null && !mc.isVideoRecording)
|
||||||
widget.sendToGroup != null &&
|
|
||||||
!mc.isVideoRecording)
|
|
||||||
ShowTitleText(
|
ShowTitleText(
|
||||||
title: widget.sendToGroup!.groupName,
|
title: widget.sendToGroup!.groupName,
|
||||||
desc: context.lang.cameraPreviewSendTo,
|
desc: context.lang.cameraPreviewSendTo,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown &&
|
if (!mc.isSharePreviewIsShown && mc.sharedLinkForPreview != null && !mc.isVideoRecording)
|
||||||
mc.sharedLinkForPreview != null &&
|
|
||||||
!mc.isVideoRecording)
|
|
||||||
ShowTitleText(
|
ShowTitleText(
|
||||||
title: mc.sharedLinkForPreview?.host ?? '',
|
title: mc.sharedLinkForPreview?.host ?? '',
|
||||||
desc: 'Link',
|
desc: 'Link',
|
||||||
isLink: true,
|
isLink: true,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown &&
|
if (!mc.isSharePreviewIsShown && !mc.isVideoRecording && !widget.hideControllers)
|
||||||
!mc.isVideoRecording &&
|
|
||||||
!widget.hideControllers)
|
|
||||||
CameraTopActions(
|
CameraTopActions(
|
||||||
selectedCameraDetails: mc.selectedCameraDetails,
|
selectedCameraDetails: mc.selectedCameraDetails,
|
||||||
hasAudioPermission: _hasAudioPermission,
|
hasAudioPermission: _hasAudioPermission,
|
||||||
|
|
@ -742,8 +751,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
videoRecordingStarted: _videoRecordingStarted,
|
videoRecordingStarted: _videoRecordingStarted,
|
||||||
maxVideoRecordingTime: maxVideoRecordingTime,
|
maxVideoRecordingTime: maxVideoRecordingTime,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
|
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null || widget.hideControllers)
|
||||||
widget.hideControllers)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 5,
|
left: 5,
|
||||||
top: 10,
|
top: 10,
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,10 @@ class MainCameraController {
|
||||||
|
|
||||||
Future<void>? _initializeFuture;
|
Future<void>? _initializeFuture;
|
||||||
Future<void>? _pendingDisposal;
|
Future<void>? _pendingDisposal;
|
||||||
|
int _cameraSessionId = 0;
|
||||||
|
|
||||||
Future<void> closeCamera() async {
|
Future<void> closeCamera() async {
|
||||||
|
_cameraSessionId++;
|
||||||
contactsVerified = {};
|
contactsVerified = {};
|
||||||
scannedNewProfiles = {};
|
scannedNewProfiles = {};
|
||||||
scannedUrl = null;
|
scannedUrl = null;
|
||||||
|
|
@ -119,11 +121,14 @@ class MainCameraController {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> selectCamera(int sCameraId, bool init) async {
|
Future<void> selectCamera(int sCameraId, bool init) async {
|
||||||
await _pendingDisposal;
|
|
||||||
initCameraStarted = true;
|
initCameraStarted = true;
|
||||||
|
final sessionId = ++_cameraSessionId;
|
||||||
|
await _pendingDisposal;
|
||||||
|
if (sessionId != _cameraSessionId) return;
|
||||||
|
|
||||||
if (AppEnvironment.cameras.isEmpty) {
|
if (AppEnvironment.cameras.isEmpty) {
|
||||||
AppEnvironment.cameras = await availableCameras();
|
AppEnvironment.cameras = await availableCameras();
|
||||||
|
if (sessionId != _cameraSessionId) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cameraId = sCameraId;
|
var cameraId = sCameraId;
|
||||||
|
|
@ -145,10 +150,13 @@ class MainCameraController {
|
||||||
selectedCameraDetails.isZoomAble = false;
|
selectedCameraDetails.isZoomAble = false;
|
||||||
|
|
||||||
if (cameraController == null) {
|
if (cameraController == null) {
|
||||||
|
final hasMic = await Permission.microphone.isGranted;
|
||||||
|
if (sessionId != _cameraSessionId) return;
|
||||||
|
|
||||||
cameraController = CameraController(
|
cameraController = CameraController(
|
||||||
AppEnvironment.cameras[cameraId],
|
AppEnvironment.cameras[cameraId],
|
||||||
ResolutionPreset.high,
|
ResolutionPreset.high,
|
||||||
enableAudio: await Permission.microphone.isGranted,
|
enableAudio: hasMic,
|
||||||
imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888,
|
imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,15 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
||||||
|
|
||||||
class CameraZoomButtons extends StatefulWidget {
|
String beautifulZoomScale(double scale) {
|
||||||
|
var tmp = scale.toStringAsFixed(1);
|
||||||
|
if (tmp[0] == '0') {
|
||||||
|
tmp = tmp.substring(1, tmp.length);
|
||||||
|
}
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CameraZoomButtons extends StatelessWidget {
|
||||||
const CameraZoomButtons({
|
const CameraZoomButtons({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.updateScaleFactor,
|
required this.updateScaleFactor,
|
||||||
|
|
@ -25,32 +33,10 @@ class CameraZoomButtons extends StatefulWidget {
|
||||||
final Future<void> Function(int sCameraId, bool init) selectCamera;
|
final Future<void> Function(int sCameraId, bool init) selectCamera;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CameraZoomButtons> createState() => _CameraZoomButtonsState();
|
Widget build(BuildContext context) {
|
||||||
}
|
final showWideAngleZoom = selectedCameraDetails.minAvailableZoom < 1;
|
||||||
|
|
||||||
String beautifulZoomScale(double scale) {
|
|
||||||
var tmp = scale.toStringAsFixed(1);
|
|
||||||
if (tmp[0] == '0') {
|
|
||||||
tmp = tmp.substring(1, tmp.length);
|
|
||||||
}
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CameraZoomButtonsState extends State<CameraZoomButtons> {
|
|
||||||
bool showWideAngleZoom = false;
|
|
||||||
bool showWideAngleZoomIOS = false;
|
|
||||||
bool _isDisposed = false;
|
|
||||||
int? _wideCameraIndex;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
unawaited(initAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> initAsync() async {
|
|
||||||
showWideAngleZoom = (await widget.controller.getMinZoomLevel()) < 1;
|
|
||||||
|
|
||||||
|
int? wideCameraIndex;
|
||||||
var index = AppEnvironment.cameras.indexWhere(
|
var index = AppEnvironment.cameras.indexWhere(
|
||||||
(t) => t.lensType == CameraLensType.ultraWide,
|
(t) => t.lensType == CameraLensType.ultraWide,
|
||||||
);
|
);
|
||||||
|
|
@ -60,33 +46,13 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_wideCameraIndex = index;
|
wideCameraIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isFront =
|
final isFront = controller.description.lensDirection == CameraLensDirection.front;
|
||||||
widget.controller.description.lensDirection ==
|
|
||||||
CameraLensDirection.front;
|
|
||||||
|
|
||||||
if (!showWideAngleZoom &&
|
final showWideAngleZoomIOS = !showWideAngleZoom && Platform.isIOS && wideCameraIndex != null && !isFront;
|
||||||
Platform.isIOS &&
|
|
||||||
_wideCameraIndex != null &&
|
|
||||||
!isFront) {
|
|
||||||
showWideAngleZoomIOS = true;
|
|
||||||
} else {
|
|
||||||
showWideAngleZoomIOS = false;
|
|
||||||
}
|
|
||||||
if (_isDisposed) return;
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_isDisposed = true; // Set the flag to true when disposing
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final zoomButtonStyle = TextButton.styleFrom(
|
final zoomButtonStyle = TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -97,24 +63,21 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
|
||||||
|
|
||||||
const zoomTextStyle = TextStyle(fontSize: 13);
|
const zoomTextStyle = TextStyle(fontSize: 13);
|
||||||
final isSmallerFocused =
|
final isSmallerFocused =
|
||||||
widget.scaleFactor < 1 ||
|
scaleFactor < 1 || (showWideAngleZoomIOS && selectedCameraDetails.cameraId == wideCameraIndex);
|
||||||
(showWideAngleZoomIOS &&
|
|
||||||
widget.selectedCameraDetails.cameraId == _wideCameraIndex);
|
|
||||||
final isMiddleFocused =
|
final isMiddleFocused =
|
||||||
widget.scaleFactor >= 1 &&
|
scaleFactor >= 1 &&
|
||||||
widget.scaleFactor < 2 &&
|
scaleFactor < 2 &&
|
||||||
!(showWideAngleZoomIOS &&
|
!(showWideAngleZoomIOS && selectedCameraDetails.cameraId == wideCameraIndex);
|
||||||
widget.selectedCameraDetails.cameraId == _wideCameraIndex);
|
|
||||||
|
|
||||||
final maxLevel = max(
|
final maxLevel = max(
|
||||||
min(widget.selectedCameraDetails.maxAvailableZoom, 2),
|
min(selectedCameraDetails.maxAvailableZoom, 2),
|
||||||
widget.scaleFactor,
|
scaleFactor,
|
||||||
);
|
);
|
||||||
|
|
||||||
final minLevel = beautifulZoomScale(
|
final minLevel = beautifulZoomScale(
|
||||||
widget.selectedCameraDetails.minAvailableZoom,
|
selectedCameraDetails.minAvailableZoom,
|
||||||
);
|
);
|
||||||
final currentLevel = beautifulZoomScale(widget.scaleFactor);
|
final currentLevel = beautifulZoomScale(scaleFactor);
|
||||||
return Center(
|
return Center(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
|
@ -132,20 +95,18 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (showWideAngleZoomIOS) {
|
if (showWideAngleZoomIOS) {
|
||||||
if (_wideCameraIndex != null) {
|
if (wideCameraIndex != null) {
|
||||||
await widget.selectCamera(_wideCameraIndex!, true);
|
await selectCamera(wideCameraIndex, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final level = await widget.controller.getMinZoomLevel();
|
final level = await controller.getMinZoomLevel();
|
||||||
widget.updateScaleFactor(level);
|
updateScaleFactor(level);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: showWideAngleZoomIOS
|
child: showWideAngleZoomIOS
|
||||||
? const Text('0.5')
|
? const Text('0.5')
|
||||||
: Text(
|
: Text(
|
||||||
widget.scaleFactor < 1
|
scaleFactor < 1 ? '${currentLevel}x' : '${minLevel}x',
|
||||||
? '${currentLevel}x'
|
|
||||||
: '${minLevel}x',
|
|
||||||
style: zoomTextStyle,
|
style: zoomTextStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -156,39 +117,33 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (showWideAngleZoomIOS &&
|
if (showWideAngleZoomIOS && selectedCameraDetails.cameraId == wideCameraIndex) {
|
||||||
widget.selectedCameraDetails.cameraId ==
|
await selectCamera(0, true);
|
||||||
_wideCameraIndex) {
|
|
||||||
await widget.selectCamera(0, true);
|
|
||||||
} else {
|
} else {
|
||||||
widget.updateScaleFactor(1.0);
|
updateScaleFactor(1.0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
isMiddleFocused
|
isMiddleFocused ? '${beautifulZoomScale(scaleFactor)}x' : '1.0x',
|
||||||
? '${beautifulZoomScale(widget.scaleFactor)}x'
|
|
||||||
: '1.0x',
|
|
||||||
style: zoomTextStyle,
|
style: zoomTextStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
style: zoomButtonStyle.copyWith(
|
style: zoomButtonStyle.copyWith(
|
||||||
foregroundColor: WidgetStateProperty.all(
|
foregroundColor: WidgetStateProperty.all(
|
||||||
(widget.scaleFactor >= 2) ? Colors.yellow : Colors.white,
|
(scaleFactor >= 2) ? Colors.yellow : Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final level = min(
|
final level = min(
|
||||||
await widget.controller.getMaxZoomLevel(),
|
await controller.getMaxZoomLevel(),
|
||||||
2,
|
2,
|
||||||
).toDouble();
|
).toDouble();
|
||||||
|
|
||||||
if (showWideAngleZoomIOS &&
|
if (showWideAngleZoomIOS && selectedCameraDetails.cameraId == wideCameraIndex) {
|
||||||
widget.selectedCameraDetails.cameraId ==
|
await selectCamera(0, true);
|
||||||
_wideCameraIndex) {
|
|
||||||
await widget.selectCamera(0, true);
|
|
||||||
}
|
}
|
||||||
widget.updateScaleFactor(level);
|
updateScaleFactor(level);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
'${beautifulZoomScale(maxLevel.toDouble())}x',
|
'${beautifulZoomScale(maxLevel.toDouble())}x',
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: _allGroups.isEmpty
|
floatingActionButton: _allGroups.isEmpty
|
||||||
? null
|
? null
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class UserCheckbox extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
substringBy(group.groupName, 12),
|
substringBy(group.groupName, 11),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
|
|
||||||
List<Widget> get actionsAtTheRight {
|
List<Widget> get actionsAtTheRight {
|
||||||
if (layers.isNotEmpty &&
|
if (layers.isNotEmpty &&
|
||||||
(layers.first.isEditing ||
|
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
|
||||||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return <Widget>[
|
return <Widget>[
|
||||||
|
|
@ -291,13 +290,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
if (media.type == MediaType.video) ...[
|
if (media.type == MediaType.video) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ActionButton(
|
ActionButton(
|
||||||
(mediaService.removeAudio)
|
(mediaService.removeAudio) ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
||||||
? Icons.volume_off_rounded
|
|
||||||
: Icons.volume_up_rounded,
|
|
||||||
tooltipText: 'Enable Audio in Video',
|
tooltipText: 'Enable Audio in Video',
|
||||||
color: (mediaService.removeAudio)
|
color: (mediaService.removeAudio) ? Colors.white.withAlpha(160) : Colors.white,
|
||||||
? Colors.white.withAlpha(160)
|
|
||||||
: Colors.white,
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await mediaService.toggleRemoveAudio();
|
await mediaService.toggleRemoveAudio();
|
||||||
if (mediaService.removeAudio) {
|
if (mediaService.removeAudio) {
|
||||||
|
|
@ -335,9 +330,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
FontAwesomeIcons.shieldHeart,
|
FontAwesomeIcons.shieldHeart,
|
||||||
tooltipText: context.lang.protectAsARealTwonly,
|
tooltipText: context.lang.protectAsARealTwonly,
|
||||||
color: media.requiresAuthentication
|
color: media.requiresAuthentication ? Theme.of(context).colorScheme.primary : Colors.white,
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Colors.white,
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await mediaService.setRequiresAuth(!media.requiresAuthentication);
|
await mediaService.setRequiresAuth(!media.requiresAuthentication);
|
||||||
selectedGroupIds = HashSet();
|
selectedGroupIds = HashSet();
|
||||||
|
|
@ -383,8 +376,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
|
|
||||||
List<Widget> get actionsAtTheTop {
|
List<Widget> get actionsAtTheTop {
|
||||||
if (layers.isNotEmpty &&
|
if (layers.isNotEmpty &&
|
||||||
(layers.first.isEditing ||
|
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
|
||||||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|
@ -474,6 +466,14 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
return (layers.first as BackgroundLayerData).image.image;
|
return (layers.first as BackgroundLayerData).image.image;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (layers.length == 2) {
|
||||||
|
final filterLayer = layers[1];
|
||||||
|
if (layers.first is BackgroundLayerData && filterLayer is FilterLayerData) {
|
||||||
|
if (filterLayer.page == 1) {
|
||||||
|
return (layers.first as BackgroundLayerData).image.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (final x in layers) {
|
for (final x in layers) {
|
||||||
x.showCustomButtons = false;
|
x.showCustomButtons = false;
|
||||||
|
|
@ -513,15 +513,15 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScreenshotImageHelper? image;
|
ScreenshotImageHelper? image;
|
||||||
var bytes = await widget.screenshotImage?.getBytes();
|
|
||||||
if (media.type == MediaType.gif) {
|
if (media.type == MediaType.gif) {
|
||||||
|
final bytes = await widget.screenshotImage?.getBytes();
|
||||||
if (bytes != null) {
|
if (bytes != null) {
|
||||||
mediaService.originalPath.writeAsBytesSync(bytes.toList());
|
mediaService.originalPath.writeAsBytesSync(bytes.toList());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
image = await getEditedImageBytes();
|
image = await getEditedImageBytes();
|
||||||
if (image == null) return null;
|
if (image == null) return null;
|
||||||
bytes = await image.getBytes();
|
final bytes = await image.getBytes();
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
Log.error('imageBytes are empty');
|
Log.error('imageBytes are empty');
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -657,9 +657,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
await askToCloseThenClose();
|
await askToCloseThenClose();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: widget.sharedFromGallery
|
backgroundColor: widget.sharedFromGallery ? null : Colors.white.withAlpha(0),
|
||||||
? null
|
|
||||||
: Colors.white.withAlpha(0),
|
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@ class _ChatListViewState extends State<ChatListView> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: !_hasContacts
|
floatingActionButton: !_hasContacts
|
||||||
? null
|
? null
|
||||||
: Padding(
|
: Padding(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class _GroupCreateSelectGroupNameViewState
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(context.lang.selectGroupName),
|
title: Text(context.lang.selectGroupName),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: FilledButton.icon(
|
floatingActionButton: FilledButton.icon(
|
||||||
onPressed: (textFieldGroupName.text.isEmpty || _isLoading)
|
onPressed: (textFieldGroupName.text.isEmpty || _isLoading)
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
|
||||||
: context.lang.addMember,
|
: context.lang.addMember,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: FilledButton.icon(
|
floatingActionButton: FilledButton.icon(
|
||||||
onPressed: selectedUsers.isEmpty ? null : submitChanges,
|
onPressed: selectedUsers.isEmpty ? null : submitChanges,
|
||||||
label: Text(
|
label: Text(
|
||||||
|
|
|
||||||
|
|
@ -267,15 +267,17 @@ class HomeViewState extends State<HomeView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_offsetRatio == 0)
|
Positioned.fill(
|
||||||
Positioned.fill(
|
child: _offsetRatio == 0
|
||||||
child: GestureDetector(
|
? GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onDoubleTap: _mainCameraController.onDoubleTap,
|
onDoubleTap: _mainCameraController.onDoubleTap,
|
||||||
onTapDown: _mainCameraController.onTapDown,
|
onTapDown: _mainCameraController.onTapDown,
|
||||||
),
|
)
|
||||||
),
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
key: const ValueKey('camera_controls'),
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ class _SelectAdditionalUsers extends State<SelectAdditionalUsers> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(context.lang.additionalUserSelectTitle),
|
title: Text(context.lang.additionalUserSelectTitle),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: FilledButton.icon(
|
floatingActionButton: FilledButton.icon(
|
||||||
onPressed: selectedUsers.isEmpty
|
onPressed: selectedUsers.isEmpty
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.text.title),
|
title: Text(widget.text.title),
|
||||||
),
|
),
|
||||||
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: FilledButton.icon(
|
floatingActionButton: FilledButton.icon(
|
||||||
onPressed: selectedUsers.isEmpty
|
onPressed: selectedUsers.isEmpty
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.2.16+125
|
version: 0.2.17+126
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue