diff --git a/lib/constants/values.dart b/lib/constants/values.dart index 4c8b27b..c7caaef 100644 --- a/lib/constants/values.dart +++ b/lib/constants/values.dart @@ -1 +1,3 @@ const DURATION_INFINITY = Duration(days: 999); +const SECONDARY_BUTTONS_DURATION_MULTIPLIER = 1.8; +const PHOTO_SHOW_AFTER_CREATION_DURATION = Duration(milliseconds: 500); diff --git a/lib/main.dart b/lib/main.dart index e2eb3b1..c6bd314 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:share_location/constants/apis.dart'; +import 'package:share_location/screens/grant_permission_screen.dart'; import 'package:share_location/screens/login_screen.dart'; import 'package:share_location/screens/main_screen.dart'; import 'package:share_location/screens/timeline_screen.dart'; @@ -60,6 +61,7 @@ class MyApp extends StatelessWidget { MainScreen.ID: (context) => const MainScreen(), LoginScreen.ID: (context) => const LoginScreen(), TimelineScreen.ID: (context) => const TimelineScreen(), + GrantPermissionScreen.ID: (context) => const GrantPermissionScreen(), }, initialRoute: initialPage, ); diff --git a/lib/screens/grant_permission_screen.dart b/lib/screens/grant_permission_screen.dart new file mode 100644 index 0000000..84bc7da --- /dev/null +++ b/lib/screens/grant_permission_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:share_location/screens/main_screen.dart'; + +import 'main_screen/permissions_required_page.dart'; + +class GrantPermissionScreen extends StatelessWidget { + static const ID = 'grant_permission'; + + const GrantPermissionScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Grant Permission'), + ), + body: Center( + child: PermissionsRequiredPage( + onPermissionsGranted: () { + Navigator.pushReplacementNamed(context, MainScreen.ID); + }, + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index a9759a2..5a9aff2 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -4,14 +4,16 @@ import 'dart:typed_data'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:share_location/constants/spacing.dart'; +import 'package:share_location/constants/values.dart'; import 'package:share_location/extensions/snackbar.dart'; import 'package:share_location/managers/file_manager.dart'; import 'package:share_location/managers/global_values_manager.dart'; -import 'package:share_location/screens/main_screen/permissions_required_page.dart'; import 'package:share_location/utils/auth_required.dart'; import 'package:share_location/utils/loadable.dart'; +import 'package:share_location/widgets/animate_in_builder.dart'; import 'package:share_location/widgets/camera_button.dart'; import 'package:share_location/widgets/change_camera_button.dart'; +import 'package:share_location/widgets/fade_and_move_in_animation.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'; @@ -27,7 +29,6 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends AuthRequiredState with Loadable { bool isRecording = false; - bool hasGrantedPermissions = false; bool lockCamera = false; List? lastPhoto; Uint8List? uploadingPhotoAnimation; @@ -45,6 +46,7 @@ class _MainScreenState extends AuthRequiredState with Loadable { super.initState(); callWithLoading(getLastPhoto); + onNewCameraSelected(GlobalValuesManager.cameras[0]); } @override @@ -55,6 +57,10 @@ class _MainScreenState extends AuthRequiredState with Loadable { @override void didChangeAppLifecycleState(AppLifecycleState state) { + _updateCamera(state); + } + + void _updateCamera(final AppLifecycleState state) { final CameraController? cameraController = controller; // App state changed before we got the chance to initialize. @@ -109,6 +115,7 @@ class _MainScreenState extends AuthRequiredState with Loadable { if (!mounted) { return; } + setState(() {}); }); } @@ -203,20 +210,6 @@ class _MainScreenState extends AuthRequiredState with Loadable { backgroundColor: Colors.black, body: SafeArea( child: () { - if (!hasGrantedPermissions) { - return Center( - child: PermissionsRequiredPage( - onPermissionsGranted: () { - onNewCameraSelected(GlobalValuesManager.cameras[0]); - - setState(() { - hasGrantedPermissions = true; - }); - }, - ), - ); - } - if (isLoading) { return const Center( child: CircularProgressIndicator(), @@ -229,57 +222,93 @@ class _MainScreenState extends AuthRequiredState with Loadable { child: Stack( alignment: Alignment.center, children: [ - controller!.buildPreview(), Column( - mainAxisAlignment: MainAxisAlignment.end, children: [ - Padding( - padding: const EdgeInsets.all(LARGE_SPACE), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + AnimateInBuilder( + builder: (showPreview) => AnimatedOpacity( + opacity: showPreview ? 1.0 : 0.0, + duration: const Duration(milliseconds: 1100), + curve: Curves.easeOutQuad, + child: ClipRRect( + borderRadius: BorderRadius.circular(SMALL_SPACE), + child: AspectRatio( + aspectRatio: 1 / controller!.value.aspectRatio, + child: controller!.buildPreview(), + ), + ), + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - ChangeCameraButton( - onChangeCamera: () { - final currentCameraIndex = GlobalValuesManager - .cameras - .indexOf(controller!.description); - final availableCameras = - GlobalValuesManager.cameras.length; + Padding( + padding: const EdgeInsets.symmetric( + horizontal: LARGE_SPACE), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FadeAndMoveInAnimation( + translationDuration: + DEFAULT_TRANSLATION_DURATION * + SECONDARY_BUTTONS_DURATION_MULTIPLIER, + opacityDuration: DEFAULT_OPACITY_DURATION * + SECONDARY_BUTTONS_DURATION_MULTIPLIER, + child: ChangeCameraButton( + onChangeCamera: () { + final currentCameraIndex = + GlobalValuesManager.cameras + .indexOf(controller!.description); + final availableCameras = + GlobalValuesManager.cameras.length; - onNewCameraSelected( - GlobalValuesManager.cameras[ - (currentCameraIndex + 1) % - availableCameras], - ); - }, - ), - CameraButton( - disabled: lockCamera, - active: isRecording, - onVideoBegin: () async { - setState(() { - isRecording = true; - }); - - if (controller!.value.isRecordingVideo) { - // A recording has already started, do nothing. - return; - } - - await controller!.startVideoRecording(); - }, - onVideoEnd: takeVideo, - onPhotoShot: takePhoto, - ), - lastPhoto == null - ? const TodayPhotoButton() - : TodayPhotoButton( - data: lastPhoto![0], - type: lastPhoto![1], + onNewCameraSelected( + GlobalValuesManager.cameras[ + (currentCameraIndex + 1) % + availableCameras], + ); + }, + ), ), + FadeAndMoveInAnimation( + child: CameraButton( + disabled: lockCamera, + active: isRecording, + onVideoBegin: () async { + setState(() { + isRecording = true; + }); + + if (controller!.value.isRecordingVideo) { + // A recording has already started, do nothing. + return; + } + + await controller!.startVideoRecording(); + }, + onVideoEnd: takeVideo, + onPhotoShot: takePhoto, + ), + ), + FadeAndMoveInAnimation( + translationDuration: + DEFAULT_TRANSLATION_DURATION * + SECONDARY_BUTTONS_DURATION_MULTIPLIER, + opacityDuration: DEFAULT_OPACITY_DURATION * + SECONDARY_BUTTONS_DURATION_MULTIPLIER, + child: lastPhoto == null + ? const TodayPhotoButton() + : TodayPhotoButton( + data: lastPhoto![0], + type: lastPhoto![1], + ), + ), + ], + ), + ) ], ), - ) + ), ], ), if (uploadingPhotoAnimation != null) diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index bf6db31..83bc60e 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:share_location/constants/spacing.dart'; import 'package:share_location/managers/startup_page_manager.dart'; -import 'package:share_location/screens/main_screen.dart'; import 'package:share_location/widgets/logo.dart'; +import 'grant_permission_screen.dart'; + class WelcomeScreen extends StatelessWidget { static const ID = 'welcome'; @@ -43,7 +44,10 @@ class WelcomeScreen extends StatelessWidget { icon: const Icon(Icons.arrow_right), label: const Text('Start'), onPressed: () { - StartupPageManager.navigateToNewPage(context, MainScreen.ID); + StartupPageManager.navigateToNewPage( + context, + GrantPermissionScreen.ID, + ); }, ), ], diff --git a/lib/widgets/animate_in_builder.dart b/lib/widgets/animate_in_builder.dart new file mode 100644 index 0000000..d9f703e --- /dev/null +++ b/lib/widgets/animate_in_builder.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class AnimateInBuilder extends StatefulWidget { + final Widget Function(bool isActive) builder; + + const AnimateInBuilder({ + Key? key, + required this.builder, + }) : super(key: key); + + @override + State createState() => _AnimateInBuilderState(); +} + +class _AnimateInBuilderState extends State { + bool isActive = false; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + isActive = true; + }); + }); + } + + @override + Widget build(BuildContext context) { + return widget.builder(isActive); + } +} diff --git a/lib/widgets/delay_render.dart b/lib/widgets/delay_render.dart new file mode 100644 index 0000000..cbe0864 --- /dev/null +++ b/lib/widgets/delay_render.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class DelayRender extends StatefulWidget { + final Widget child; + final Widget placeholder; + final Duration delay; + + const DelayRender({ + Key? key, + required this.child, + this.placeholder = const SizedBox(), + this.delay = const Duration(milliseconds: 120), + }) : super(key: key); + + @override + State createState() => _DelayRenderState(); +} + +class _DelayRenderState extends State { + bool allowRendering = false; + + @override + void initState() { + super.initState(); + + startTimer(); + } + + Future startTimer() async { + await Future.delayed(widget.delay); + + if (!mounted) { + return; + } + + setState(() { + allowRendering = true; + }); + } + + @override + Widget build(BuildContext context) { + if (!allowRendering) { + return widget.placeholder; + } + + return widget.child; + } +} diff --git a/lib/widgets/fade_and_move_in_animation.dart b/lib/widgets/fade_and_move_in_animation.dart new file mode 100644 index 0000000..d3c184f --- /dev/null +++ b/lib/widgets/fade_and_move_in_animation.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +const DEFAULT_TRANSLATION_DURATION = Duration(milliseconds: 500); +const DEFAULT_OPACITY_DURATION = Duration(milliseconds: 800); + +class FadeAndMoveInAnimation extends StatefulWidget { + final Widget child; + final bool active; + + final Offset translationOffset; + final Duration translationDuration; + final Curve translationCurve; + + final Duration opacityDuration; + final Curve opacityCurve; + + const FadeAndMoveInAnimation({ + Key? key, + required this.child, + this.active = true, + this.translationOffset = const Offset(0, 60), + this.translationDuration = DEFAULT_TRANSLATION_DURATION, + this.translationCurve = Curves.easeOutQuad, + this.opacityDuration = DEFAULT_OPACITY_DURATION, + this.opacityCurve = Curves.linearToEaseOut, + }) : super(key: key); + + @override + State createState() => _FadeAndMoveInAnimationState(); +} + +class _FadeAndMoveInAnimationState extends State + with TickerProviderStateMixin { + late final AnimationController translationController; + late final Animation translationAnimation; + + bool opacityEnabled = false; + + @override + void initState() { + super.initState(); + + translationController = AnimationController( + vsync: this, + duration: widget.translationDuration, + ); + translationAnimation = Tween( + begin: 1, + end: 0, + ).animate( + CurvedAnimation( + parent: translationController, + curve: widget.translationCurve, + ), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (widget.active) { + translationController.forward(); + } else { + translationController.reverse(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + opacityEnabled = widget.active; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: translationController, + child: widget.child, + builder: (context, child) => Transform.translate( + offset: Offset( + widget.translationOffset.dx * translationAnimation.value, + widget.translationOffset.dy * translationAnimation.value, + ), + child: AnimatedOpacity( + duration: widget.opacityDuration, + curve: widget.opacityCurve, + opacity: opacityEnabled ? 1 : 0, + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/uploading_photo.dart b/lib/widgets/uploading_photo.dart index a14c778..51976d6 100644 --- a/lib/widgets/uploading_photo.dart +++ b/lib/widgets/uploading_photo.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:share_location/constants/spacing.dart'; +import 'package:share_location/constants/values.dart'; class UploadingPhoto extends StatefulWidget { final Uint8List data; @@ -39,7 +40,7 @@ class _UploadingPhotoState extends State controller.addStatusListener((status) { if (status == AnimationStatus.completed) { - Future.delayed(const Duration(milliseconds: 500), () { + Future.delayed(PHOTO_SHOW_AFTER_CREATION_DURATION, () { if (mounted) { widget.onDone(); } @@ -67,9 +68,9 @@ class _UploadingPhotoState extends State decoration: BoxDecoration( border: Border.all( color: Colors.white, - width: 15, + width: 12, ), - borderRadius: BorderRadius.circular(SMALL_SPACE), + borderRadius: BorderRadius.circular(MEDIUM_SPACE), ), child: Image.memory(widget.data, fit: BoxFit.cover), ),