Tutorial on how to use zoom.

This commit is contained in:
otsmr 2026-05-16 17:12:46 +02:00
parent e9b550023f
commit c2ac706239
6 changed files with 204 additions and 18 deletions

View file

@ -2,6 +2,7 @@
## 0.2.13 ## 0.2.13
- New: Tutorial on how to use zoom.
- New: Manage storage view. - New: Manage storage view.
- Improved: Media thumbnails for faster loading. - Improved: Media thumbnails for faster loading.

View file

@ -165,6 +165,9 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool skipSetupPages = false; bool skipSetupPages = false;
@JsonKey(defaultValue: false)
bool hasZoomed = false;
Map<String, dynamic> toJson() => _$UserDataToJson(this); Map<String, dynamic> toJson() => _$UserDataToJson(this);
} }

View file

@ -102,7 +102,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null ..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null
? null ? null
: DateTime.parse(json['lastUserStudyDataUpload'] as String) : 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<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'userId': instance.userId, 'userId': instance.userId,
@ -164,6 +165,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
?.toIso8601String(), ?.toIso8601String(),
'currentSetupPage': instance.currentSetupPage, 'currentSetupPage': instance.currentSetupPage,
'skipSetupPages': instance.skipSetupPages, 'skipSetupPages': instance.skipSetupPages,
'hasZoomed': instance.hasZoomed,
}; };
const _$ThemeModeEnumMap = { const _$ThemeModeEnumMap = {

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/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/main_camera_controller.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/zoom_selector.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/zoom_selector.dart';
@ -136,25 +138,33 @@ class CameraBottomControls extends StatelessWidget {
} }
Widget _buildShutterButton() { Widget _buildShutterButton() {
return GestureDetector( return StreamBuilder(
onTap: onTakePicture, stream: userService.onUserUpdated,
key: keyTriggerButton, builder: (context, snapshot) {
child: Align( return ZoomTutorialOverlay(
child: Container( hasZoomed: userService.currentUser.hasZoomed,
height: 100, child: GestureDetector(
width: 100, onTap: onTakePicture,
clipBehavior: Clip.antiAliasWithSaveLayer, key: keyTriggerButton,
padding: const EdgeInsets.all(2), child: Align(
decoration: BoxDecoration( child: Container(
shape: BoxShape.circle, height: 100,
border: Border.all( width: 100,
width: 7, clipBehavior: Clip.antiAliasWithSaveLayer,
color: isVideoRecording ? Colors.red : Colors.white, 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, );
), },
),
); );
} }

View file

@ -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<ZoomTutorialOverlay> createState() => _ZoomTutorialOverlayState();
}
class _ZoomTutorialOverlayState extends State<ZoomTutorialOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _dragAnim;
late Animation<double> _opacityAnim;
late Animation<double> _scaleAnim;
late Animation<double> _textOpacityAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3500),
)..repeat();
_opacityAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween<double>(begin: 0, end: 1), weight: 10),
TweenSequenceItem(tween: ConstantTween<double>(1), weight: 70),
TweenSequenceItem(tween: Tween<double>(begin: 1, end: 0), weight: 10),
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 10),
]).animate(_controller);
_textOpacityAnim = TweenSequence<double>([
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 15),
TweenSequenceItem(tween: Tween<double>(begin: 0, end: 1), weight: 15),
TweenSequenceItem(tween: ConstantTween<double>(1), weight: 50),
TweenSequenceItem(tween: Tween<double>(begin: 1, end: 0), weight: 15),
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 5),
]).animate(_controller);
_scaleAnim = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(
begin: 1.2,
end: 0.85,
).chain(CurveTween(curve: Curves.easeInQuad)),
weight: 20,
),
TweenSequenceItem(tween: ConstantTween<double>(0.85), weight: 80),
]).animate(_controller);
_dragAnim = TweenSequence<double>([
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 35),
TweenSequenceItem(
tween: Tween<double>(
begin: 0,
end: -75,
).chain(CurveTween(curve: Curves.easeInOutQuart)),
weight: 40,
),
TweenSequenceItem(tween: ConstantTween<double>(-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,
),
),
),
),
),
),
],
);
},
),
),
],
);
}
}

View file

@ -613,6 +613,12 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
if (mounted) showSnackbar(context, 'Error: $e'); if (mounted) showSnackbar(context, 'Error: $e');
} }
void _incrementZoomUsageCount() {
if (!userService.currentUser.hasZoomed) {
UserService.update((u) => u.hasZoomed = true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length || if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length ||
@ -660,9 +666,19 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}, },
onLongPressEnd: (a) { onLongPressEnd: (a) {
stopVideoRecording(); stopVideoRecording();
if ((mc.selectedCameraDetails.scaleFactor - _baseScaleFactor)
.abs() >
0.05) {
_incrementZoomUsageCount();
}
}, },
onPanEnd: (a) { onPanEnd: (a) {
stopVideoRecording(); stopVideoRecording();
if ((mc.selectedCameraDetails.scaleFactor - _baseScaleFactor)
.abs() >
0.05) {
_incrementZoomUsageCount();
}
}, },
onPanUpdate: onPanUpdate, onPanUpdate: onPanUpdate,
child: Stack( child: Stack(