diff --git a/lib/constants/values.dart b/lib/constants/values.dart new file mode 100644 index 0000000..4c8b27b --- /dev/null +++ b/lib/constants/values.dart @@ -0,0 +1 @@ +const DURATION_INFINITY = Duration(days: 999); diff --git a/lib/extensions/snackbar.dart b/lib/extensions/snackbar.dart index af484fd..524ba99 100644 --- a/lib/extensions/snackbar.dart +++ b/lib/extensions/snackbar.dart @@ -1,20 +1,49 @@ import 'package:flutter/material.dart'; +import 'package:share_location/constants/values.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; final supabase = Supabase.instance.client; extension ShowSnackBar on BuildContext { - void showSnackBar({ - required String message, - Color backgroundColor = Colors.white, + static ScaffoldFeatureController? + pendingSnackBar; + + ScaffoldFeatureController showSnackBar({ + required final String message, + final Color backgroundColor = Colors.white, + final Duration duration = const Duration(seconds: 4), }) { - ScaffoldMessenger.of(this).showSnackBar(SnackBar( - content: Text(message), - backgroundColor: backgroundColor, - )); + pendingSnackBar?.close(); + pendingSnackBar = null; + + return ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + duration: duration, + ), + ); } - void showErrorSnackBar({required String message}) { - showSnackBar(message: message, backgroundColor: Colors.red); + void showErrorSnackBar({required final String message}) { + showSnackBar( + message: message, + backgroundColor: Colors.red, + ); + } + + void showSuccessSnackBar({required final String message}) { + showSnackBar( + message: message, + backgroundColor: Colors.green, + ); + } + + void showPendingSnackBar({required final String message}) { + pendingSnackBar = showSnackBar( + message: message, + backgroundColor: Colors.yellow, + duration: DURATION_INFINITY, + ); } } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 0cdc20f..5e3dc2b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; @@ -12,6 +13,7 @@ import 'package:share_location/utils/loadable.dart'; import 'package:share_location/widgets/camera_button.dart'; import 'package:share_location/widgets/change_camera_button.dart'; import 'package:share_location/widgets/today_photo_button.dart'; +import 'package:share_location/widgets/uploading_photo.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class MainScreen extends StatefulWidget { @@ -27,6 +29,7 @@ class _MainScreenState extends AuthRequiredState with Loadable { bool isRecording = false; bool hasGrantedPermissions = false; List? lastPhoto; + Uint8List? uploadingPhotoAnimation; late User _user; @@ -114,26 +117,27 @@ class _MainScreenState extends AuthRequiredState with Loadable { return; } + context.showPendingSnackBar(message: 'Taking photo...'); + controller!.setFlashMode(FlashMode.off); final file = File((await controller!.takePicture()).path); + setState(() { + uploadingPhotoAnimation = file.readAsBytesSync(); + }); + + context.showPendingSnackBar(message: 'Uploading photo...'); + try { await FileManager.uploadFile(_user, file); } catch (error) { - if (mounted) { - context.showErrorSnackBar(message: error.toString()); - } + context.showErrorSnackBar(message: error.toString()); return; } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Photo saved.'), - backgroundColor: Colors.green, - ), - ); + context.showSuccessSnackBar(message: 'Photo uploaded!'); + if (mounted) { await getLastPhoto(); } } @@ -148,8 +152,12 @@ class _MainScreenState extends AuthRequiredState with Loadable { isRecording = false; }); + context.showPendingSnackBar(message: 'Saving video...'); + final file = File((await controller!.stopVideoRecording()).path); + context.showPendingSnackBar(message: 'Uploading video...'); + try { await FileManager.uploadFile(_user, file); } catch (error) { @@ -159,13 +167,9 @@ class _MainScreenState extends AuthRequiredState with Loadable { return; } + context.showSuccessSnackBar(message: 'Video uploaded!'); + if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Video saved.'), - backgroundColor: Colors.green, - ), - ); await getLastPhoto(); } } @@ -211,18 +215,21 @@ class _MainScreenState extends AuthRequiredState with Loadable { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - ChangeCameraButton(onChangeCamera: () { - final currentCameraIndex = GlobalValuesManager - .cameras - .indexOf(controller!.description); - final availableCameras = - GlobalValuesManager.cameras.length; + ChangeCameraButton( + onChangeCamera: () { + final currentCameraIndex = GlobalValuesManager + .cameras + .indexOf(controller!.description); + final availableCameras = + GlobalValuesManager.cameras.length; - onNewCameraSelected( - GlobalValuesManager.cameras[ - (currentCameraIndex + 1) % availableCameras], - ); - }), + onNewCameraSelected( + GlobalValuesManager.cameras[ + (currentCameraIndex + 1) % + availableCameras], + ); + }, + ), CameraButton( active: isRecording, onVideoBegin: () async { @@ -251,6 +258,15 @@ class _MainScreenState extends AuthRequiredState with Loadable { ) ], ), + if (uploadingPhotoAnimation != null) + UploadingPhoto( + data: uploadingPhotoAnimation!, + onDone: () { + setState(() { + uploadingPhotoAnimation = null; + }); + }, + ) ], ), ); diff --git a/lib/widgets/camera_button.dart b/lib/widgets/camera_button.dart index ae79b5f..6baa80b 100644 --- a/lib/widgets/camera_button.dart +++ b/lib/widgets/camera_button.dart @@ -1,11 +1,13 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class CameraButton extends StatelessWidget { +class CameraButton extends StatefulWidget { final bool active; final VoidCallback onPhotoShot; final VoidCallback onVideoBegin; final VoidCallback onVideoEnd; + final bool disabled; const CameraButton({ Key? key, @@ -13,61 +15,111 @@ class CameraButton extends StatelessWidget { required this.onVideoBegin, required this.onVideoEnd, this.active = false, + this.disabled = false, }) : super(key: key); + @override + State createState() => _CameraButtonState(); +} + +class _CameraButtonState extends State { + bool shrinkIcon = false; + @override Widget build(BuildContext context) { - return InkWell( + return GestureDetector( onTap: () { + if (widget.disabled) { + return; + } + + setState(() { + shrinkIcon = false; + }); + HapticFeedback.heavyImpact(); - if (active) { - onVideoEnd(); + if (widget.active) { + widget.onVideoEnd(); } else { - onPhotoShot(); + widget.onPhotoShot(); + } + }, + onLongPressDown: (_) { + if (widget.disabled) { + return; + } + + setState(() { + shrinkIcon = true; + }); + }, + onLongPressUp: () { + if (widget.disabled) { + return; + } + + setState(() { + shrinkIcon = false; + }); + + if (widget.active) { + widget.onVideoEnd(); } }, onLongPress: () { + if (widget.disabled) { + return; + } + HapticFeedback.heavyImpact(); - if (active) { - onVideoEnd(); + if (widget.active) { + widget.onVideoEnd(); } else { - onVideoBegin(); + widget.onVideoBegin(); } }, - child: Stack( - alignment: Alignment.center, - children: active - ? const [ - Icon( - Icons.circle, - size: 75, - color: Colors.white, - ), - Icon( - Icons.circle, - size: 65, - color: Colors.red, - ), - Icon( - Icons.stop, - size: 45, - color: Colors.white, - ), - ] - : [ - Icon( - Icons.circle, - size: 75, - color: Colors.white.withOpacity(.2), - ), - const Icon( - Icons.circle, - size: 50, - color: Colors.white, - ), - ], + child: Opacity( + opacity: widget.disabled ? 0.5 : 1.0, + child: Stack( + alignment: Alignment.center, + children: widget.active + ? const [ + Icon( + Icons.circle, + size: 75, + color: Colors.white, + ), + Icon( + Icons.circle, + size: 65, + color: Colors.red, + ), + Icon( + Icons.stop, + size: 45, + color: Colors.white, + ), + ] + : [ + Icon( + Icons.circle, + size: 75, + color: Colors.white.withOpacity(.2), + ), + AnimatedScale( + duration: kLongPressTimeout, + curve: Curves.easeInOut, + scale: shrinkIcon ? .8 : 1, + child: const Icon( + Icons.circle, + size: 50, + color: Colors.white, + ), + ), + ], + ), ), ); } diff --git a/lib/widgets/raw_memory_display.dart b/lib/widgets/raw_memory_display.dart index fef81d1..11881ec 100644 --- a/lib/widgets/raw_memory_display.dart +++ b/lib/widgets/raw_memory_display.dart @@ -9,12 +9,14 @@ import 'package:video_player/video_player.dart'; class RawMemoryDisplay extends StatefulWidget { final Uint8List data; final MemoryType type; + final bool loopVideo; final String? filename; const RawMemoryDisplay({ Key? key, required this.data, required this.type, + this.loopVideo = false, this.filename, }) : super(key: key); @@ -56,7 +58,7 @@ class _RawMemoryDisplayState extends State { videoController = VideoPlayerController.file(file); videoController!.initialize().then((value) { setState(() {}); - videoController!.setLooping(true); + videoController!.setLooping(widget.loopVideo); videoController!.play(); }); } diff --git a/lib/widgets/uploading_photo.dart b/lib/widgets/uploading_photo.dart new file mode 100644 index 0000000..a14c778 --- /dev/null +++ b/lib/widgets/uploading_photo.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:share_location/constants/spacing.dart'; + +class UploadingPhoto extends StatefulWidget { + final Uint8List data; + final VoidCallback onDone; + + const UploadingPhoto({ + Key? key, + required this.data, + required this.onDone, + }) : super(key: key); + + @override + State createState() => _UploadingPhotoState(); +} + +class _UploadingPhotoState extends State + with TickerProviderStateMixin { + late final AnimationController controller; + late final Animation animation; + + @override + void initState() { + super.initState(); + + controller = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + animation = Tween(begin: 1.0, end: 0.8).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeOutQuad, + ), + ); + + controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + widget.onDone(); + } + }); + } + }); + + controller.forward(); + } + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: animation, + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + border: Border.all( + color: Colors.white, + width: 15, + ), + borderRadius: BorderRadius.circular(SMALL_SPACE), + ), + child: Image.memory(widget.data, fit: BoxFit.cover), + ), + ); + } +}