diff --git a/mobile/assets/onboarding-1-screenshot.jpeg b/mobile/assets/onboarding-1-screenshot.jpeg new file mode 100644 index 0000000000..7b275c77cd Binary files /dev/null and b/mobile/assets/onboarding-1-screenshot.jpeg differ diff --git a/mobile/lib/pages/onboarding/onboarding.page.dart b/mobile/lib/pages/onboarding/onboarding.page.dart new file mode 100644 index 0000000000..a73a4e0314 --- /dev/null +++ b/mobile/lib/pages/onboarding/onboarding.page.dart @@ -0,0 +1,233 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +@RoutePage() +class OnboardingPage extends HookConsumerWidget { + const OnboardingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pageController = usePageController(keepPage: false); + + toNextPage() { + pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + return Scaffold( + appBar: AppBar( + title: SvgPicture.asset( + context.isDarkTheme + ? 'assets/immich-logo-inline-dark.svg' + : 'assets/immich-logo-inline-light.svg', + height: 48, + ), + centerTitle: false, + elevation: 0, + ), + body: SafeArea( + child: PageView( + controller: pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + OnboardingWelcome( + onNextPage: () => toNextPage(), + ), + OnboardingGalleryPermission( + onNextPage: () => toNextPage(), + ), + ], + ), + ), + ); + } +} + +class OnboardingWelcome extends StatelessWidget { + final VoidCallback onNextPage; + + const OnboardingWelcome({super.key, required this.onNextPage}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(18.0), + child: ListView( + physics: const ClampingScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Card( + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(32), + ), + ), + elevation: 3, + child: AnimatedHeroImage( + imagePath: 'assets/onboarding-1-screenshot.jpeg', + color: context.colorScheme.primary.withOpacity(0.25), + colorBlendMode: BlendMode.color, + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 32.0, + left: 8.0, + bottom: 8.0, + ), + child: Text( + "WELCOME", + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + "Let’s get you setup with some permissions that the app needs", + style: context.textTheme.headlineSmall, + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 64, + height: 64, + child: MaterialButton( + onPressed: onNextPage, + color: context.primaryColor, + textColor: Colors.white, + shape: const CircleBorder(), + child: Icon( + Icons.chevron_right_rounded, + color: context.colorScheme.onPrimary, + size: 32, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class AnimatedHeroImage extends StatefulWidget { + final String imagePath; + final Color color; + final BlendMode colorBlendMode; + + const AnimatedHeroImage({ + super.key, + required this.imagePath, + required this.color, + required this.colorBlendMode, + }); + + @override + AnimatedHeroImageState createState() => AnimatedHeroImageState(); +} + +class AnimatedHeroImageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _rotationAnimation; + late Animation _parallaxAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 15), + vsync: this, + )..repeat(reverse: true); + + _scaleAnimation = Tween(begin: 1.0, end: 1.15).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + _rotationAnimation = Tween(begin: 0.0, end: 0.025).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + _parallaxAnimation = + Tween(begin: Offset.zero, end: const Offset(0.05, 0.05)) + .animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + child: Image( + image: AssetImage(widget.imagePath), + filterQuality: FilterQuality.high, + isAntiAlias: true, + color: widget.color, + colorBlendMode: widget.colorBlendMode, + ), + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value, + child: Transform.translate( + offset: _parallaxAnimation.value, + child: child, + ), + ), + ); + }, + ); + } +} + +class OnboardingGalleryPermission extends StatelessWidget { + final VoidCallback onNextPage; + + const OnboardingGalleryPermission({super.key, required this.onNextPage}); + + @override + Widget build(BuildContext context) { + return Center( + child: ElevatedButton( + onPressed: onNextPage, + child: const Text("to location permission"), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 5adfeb4061..93901306c8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -41,6 +41,7 @@ import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; +import 'package:immich_mobile/pages/onboarding/onboarding.page.dart'; import 'package:immich_mobile/pages/onboarding/permission_onboarding.page.dart'; import 'package:immich_mobile/pages/photos/memory.page.dart'; import 'package:immich_mobile/pages/photos/photos.page.dart'; @@ -90,7 +91,8 @@ class AppRouter extends RootStackRouter { @override late final List routes = [ - AutoRoute(page: SplashScreenRoute.page, initial: true), + AutoRoute(page: OnboardingRoute.page, initial: true), + AutoRoute(page: SplashScreenRoute.page), AutoRoute( page: PermissionOnboardingRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 3bd8966175..eec61aeb02 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -136,15 +136,10 @@ class AlbumAssetSelectionRouteArgs { /// generated route for /// [AlbumOptionsPage] -class AlbumOptionsRoute extends PageRouteInfo { - AlbumOptionsRoute({ - Key? key, - List? children, - }) : super( +class AlbumOptionsRoute extends PageRouteInfo { + const AlbumOptionsRoute({List? children}) + : super( AlbumOptionsRoute.name, - args: AlbumOptionsRouteArgs( - key: key, - ), initialChildren: children, ); @@ -153,25 +148,11 @@ class AlbumOptionsRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs(); - return AlbumOptionsPage( - key: args.key, - ); + return const AlbumOptionsPage(); }, ); } -class AlbumOptionsRouteArgs { - const AlbumOptionsRouteArgs({this.key}); - - final Key? key; - - @override - String toString() { - return 'AlbumOptionsRouteArgs{key: $key}'; - } -} - /// generated route for /// [AlbumPreviewPage] class AlbumPreviewRoute extends PageRouteInfo { @@ -1129,6 +1110,25 @@ class NativeVideoViewerRouteArgs { } } +/// generated route for +/// [OnboardingPage] +class OnboardingRoute extends PageRouteInfo { + const OnboardingRoute({List? children}) + : super( + OnboardingRoute.name, + initialChildren: children, + ); + + static const String name = 'OnboardingRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const OnboardingPage(); + }, + ); +} + /// generated route for /// [PartnerDetailPage] class PartnerDetailRoute extends PageRouteInfo {