From c2ac7062392b0897972a7d4d9f81feed38bc3f29 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 16 May 2026 17:12:46 +0200 Subject: [PATCH] Tutorial on how to use zoom. --- CHANGELOG.md | 1 + lib/src/model/json/userdata.model.dart | 3 + lib/src/model/json/userdata.model.g.dart | 4 +- .../camera_bottom_controls.dart | 44 +++-- .../zoom_tutorial_overlay.dart | 154 ++++++++++++++++++ .../camera_preview_controller_view.dart | 16 ++ 6 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4322ba57..22fa3acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.2.13 +- New: Tutorial on how to use zoom. - New: Manage storage view. - Improved: Media thumbnails for faster loading. diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index 450b4df7..b18218b9 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -165,6 +165,9 @@ class UserData { @JsonKey(defaultValue: false) bool skipSetupPages = false; + @JsonKey(defaultValue: false) + bool hasZoomed = false; + Map toJson() => _$UserDataToJson(this); } diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 651fea0c..21165539 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -102,7 +102,8 @@ UserData _$UserDataFromJson(Map json) => ..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null ? null : DateTime.parse(json['lastUserStudyDataUpload'] as String) - ..skipSetupPages = json['skipSetupPages'] as bool? ?? false; + ..skipSetupPages = json['skipSetupPages'] as bool? ?? false + ..hasZoomed = json['hasZoomed'] as bool? ?? false; Map _$UserDataToJson(UserData instance) => { 'userId': instance.userId, @@ -164,6 +165,7 @@ Map _$UserDataToJson(UserData instance) => { ?.toIso8601String(), 'currentSetupPage': instance.currentSetupPage, 'skipSetupPages': instance.skipSetupPages, + 'hasZoomed': instance.hasZoomed, }; const _$ThemeModeEnumMap = { diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart index a8475c34..b74981b7 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/zoom_selector.dart'; @@ -136,25 +138,33 @@ class CameraBottomControls extends StatelessWidget { } Widget _buildShutterButton() { - return GestureDetector( - onTap: onTakePicture, - key: keyTriggerButton, - child: Align( - child: Container( - height: 100, - width: 100, - clipBehavior: Clip.antiAliasWithSaveLayer, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - width: 7, - color: isVideoRecording ? Colors.red : Colors.white, + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + return ZoomTutorialOverlay( + hasZoomed: userService.currentUser.hasZoomed, + child: GestureDetector( + onTap: onTakePicture, + key: keyTriggerButton, + child: Align( + child: Container( + height: 100, + width: 100, + clipBehavior: Clip.antiAliasWithSaveLayer, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 7, + color: isVideoRecording ? Colors.red : Colors.white, + ), + ), + child: mc.currentFilterType.preview, + ), ), ), - child: mc.currentFilterType.preview, - ), - ), + ); + }, ); } diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart new file mode 100644 index 00000000..3914ba23 --- /dev/null +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class ZoomTutorialOverlay extends StatefulWidget { + const ZoomTutorialOverlay({ + required this.child, + required this.hasZoomed, + super.key, + }); + + final Widget child; + final bool hasZoomed; + + @override + State createState() => _ZoomTutorialOverlayState(); +} + +class _ZoomTutorialOverlayState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _dragAnim; + late Animation _opacityAnim; + late Animation _scaleAnim; + late Animation _textOpacityAnim; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3500), + )..repeat(); + + _opacityAnim = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 10), + TweenSequenceItem(tween: ConstantTween(1), weight: 70), + TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 10), + TweenSequenceItem(tween: ConstantTween(0), weight: 10), + ]).animate(_controller); + + _textOpacityAnim = TweenSequence([ + TweenSequenceItem(tween: ConstantTween(0), weight: 15), + TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 15), + TweenSequenceItem(tween: ConstantTween(1), weight: 50), + TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 15), + TweenSequenceItem(tween: ConstantTween(0), weight: 5), + ]).animate(_controller); + + _scaleAnim = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 1.2, + end: 0.85, + ).chain(CurveTween(curve: Curves.easeInQuad)), + weight: 20, + ), + TweenSequenceItem(tween: ConstantTween(0.85), weight: 80), + ]).animate(_controller); + + _dragAnim = TweenSequence([ + TweenSequenceItem(tween: ConstantTween(0), weight: 35), + TweenSequenceItem( + tween: Tween( + begin: 0, + end: -75, + ).chain(CurveTween(curve: Curves.easeInOutQuart)), + weight: 40, + ), + TweenSequenceItem(tween: ConstantTween(-75), weight: 25), + ]).animate(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.hasZoomed) return widget.child; + + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + widget.child, + IgnorePointer( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Positioned( + top: _dragAnim.value - 8, + right: 60, + child: Opacity( + opacity: _textOpacityAnim.value, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Drag to Zoom', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ), + ), + ), + ), + Opacity( + opacity: _opacityAnim.value, + child: Transform.translate( + offset: Offset(0, _dragAnim.value), + child: Transform.scale( + scale: _scaleAnim.value, + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withOpacity(0.5), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.handPointer, + size: 18, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index 954b4483..396e1cdf 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -613,6 +613,12 @@ class _CameraPreviewViewState extends State { if (mounted) showSnackbar(context, 'Error: $e'); } + void _incrementZoomUsageCount() { + if (!userService.currentUser.hasZoomed) { + UserService.update((u) => u.hasZoomed = true); + } + } + @override Widget build(BuildContext context) { if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length || @@ -660,9 +666,19 @@ class _CameraPreviewViewState extends State { }, onLongPressEnd: (a) { stopVideoRecording(); + if ((mc.selectedCameraDetails.scaleFactor - _baseScaleFactor) + .abs() > + 0.05) { + _incrementZoomUsageCount(); + } }, onPanEnd: (a) { stopVideoRecording(); + if ((mc.selectedCameraDetails.scaleFactor - _baseScaleFactor) + .abs() > + 0.05) { + _incrementZoomUsageCount(); + } }, onPanUpdate: onPanUpdate, child: Stack(