diff --git a/assets/images/live_photo.svg b/assets/images/live_photo.svg new file mode 100644 index 0000000..6b533eb --- /dev/null +++ b/assets/images/live_photo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/constants/apis.dart b/lib/constants/apis.dart index fa2d82b..1064a6e 100644 --- a/lib/constants/apis.dart +++ b/lib/constants/apis.dart @@ -1,3 +1,26 @@ -const SUPABASE_API_URL = 'https://gmqzelvauqziurlloawb.supabase.co'; -const SUPABASE_API_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImdtcXplbHZhdXF6aXVybGxvYXdiIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjAzODE5MDcsImV4cCI6MTk3NTk1NzkwN30.D_964EIlD9WRFnG6MWtQtmIg04eMBbZhIEF7zl--bKw'; +import 'dart:convert'; + +// Encode keys to base64 to avoid simple bots from scraping them +final SUPABASE_API_URL = String.fromCharCodes( + base64.decode( + 'aHR0cHM6Ly9nbXF6ZWx' + '2YXVxeml1cmxsb2F3Yi5zdXBhYmFzZS5jbwo=', + ), +); +final SUPABASE_API_KEY = String.fromCharCodes( + base64.decode( + 'ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6' + + 'WlNJc0luSmxaaUk2SW1kdGNYcGxiSFpoZFhGNmFYVnliR3h2WVhkaUlpd2ljbTlzWlNJNkltRnVi' + + 'MjRpTENKcFlYUWlPakUyTmpBek9ERTVNRGNzSW1WNGNDSTZNVGszTlRrMU56a3dOMzAuRF85NjRF' + + 'SWxEOVdSRm5HNk1XdFF0bUlnMDRlTUJiWmhJRUY3emwtLWJLdwo=', + ), +); +final PEXELS_API_KEY = String.fromCharCodes( + base64.decode( + 'NTYz' + + 'NDkyYW' + + 'Q2ZjkxNzAwMDAxM' + + 'DAwMDAxYzE2ODA' + + '0MGU2NjkzNGNlMT' + + 'kzNjdmZjA5NGU2NDMyM2IK', + ), +); diff --git a/lib/constants/values.dart b/lib/constants/values.dart index 4aa3605..a2552c2 100644 --- a/lib/constants/values.dart +++ b/lib/constants/values.dart @@ -8,3 +8,12 @@ final UnmodifiableSetView DEFAULT_ZOOM_LEVELS = UnmodifiableSetView({0.6, 1, 2, 5, 10, 20, 50, 100}); const CALENDAR_DATE_IN_MAX_DELAY = Duration(milliseconds: 500); const CACHE_INVALIDATION_DURATION = Duration(days: 7); +const WELCOME_SCREEN_PHOTOS_QUERIES = [ + 'happy', + 'people', + 'couple', + 'family', + 'fun', + 'friends', + 'romantic', +]; diff --git a/lib/controllers/status_controller.dart b/lib/controllers/status_controller.dart index 2481e0e..e860a20 100644 --- a/lib/controllers/status_controller.dart +++ b/lib/controllers/status_controller.dart @@ -26,4 +26,10 @@ class StatusController extends PropertyChangeNotifier { _done = true; notifyListeners('done'); } + + void reset() { + _done = false; + _isForwarding = false; + notifyListeners('done'); + } } diff --git a/lib/locale/l10n/app_de.arb b/lib/locale/l10n/app_de.arb index e592452..9de2183 100644 --- a/lib/locale/l10n/app_de.arb +++ b/lib/locale/l10n/app_de.arb @@ -3,6 +3,7 @@ "generalError": "Ein Fehler ist aufgetreten", "generalCancelButtonLabel": "Abbrechen", + "generalContinueButtonLabel": "Weiter", "welcomeScreenDescription": "Finde heraus was du den ganzen Tag gemacht hast und erlebe Erinnerungen wieder, die du komplett vergessen hast!", "welcomeScreenSubtitle": "Was hab ich heute gemacht?", diff --git a/lib/locale/l10n/app_en.arb b/lib/locale/l10n/app_en.arb index 0332396..7edae7a 100644 --- a/lib/locale/l10n/app_en.arb +++ b/lib/locale/l10n/app_en.arb @@ -3,10 +3,14 @@ "generalError": "There was an error", "generalCancelButtonLabel": "Cancel", + "generalContinueButtonLabel": "Continue", "welcomeScreenDescription": "Find out what you did all the days and unlock moments you completely forgot!", "welcomeScreenSubtitle": "What did I do today?", "welcomeScreenStartButtonTitle": "Start", + "welcomeScreenCreateMemoriesGuideDescription": "Create photos and video thorough the day...", + "welcomeScreenViewMemoriesGuideDescription": "...and relieve your best moments at the end of the day!", + "welcomeScreenGetStartedLabel": "Get started", "serverLoadingScreenDescription": "We are loading your data", diff --git a/lib/managers/photo_manager.dart b/lib/managers/photo_manager.dart new file mode 100644 index 0000000..f816c2f --- /dev/null +++ b/lib/managers/photo_manager.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; + +class PhotoManager { + static const MAX_PHOTOS_PER_PAGE = 80; + + // Searches for photos based on `query` and returns a random one. + static Future getRandomPhoto(final String query) async { + final url = + 'https://api.pexels.com/v1/search?query=$query&per_page=$MAX_PHOTOS_PER_PAGE&orientation=portait'; + final response = await http.get(Uri.parse(url)); + final data = jsonDecode(response.body); + final photoIndex = Random().nextInt(data['per_page']); + + return data['photos'][photoIndex]['src']['portrait']; + } +} diff --git a/lib/screens/timeline_screen/memory_slide.dart b/lib/screens/timeline_screen/memory_slide.dart index c312857..9855835 100644 --- a/lib/screens/timeline_screen/memory_slide.dart +++ b/lib/screens/timeline_screen/memory_slide.dart @@ -82,6 +82,7 @@ class _MemorySlideState extends State return Consumer( builder: (___, timeline, ____) => Status( controller: controller, + isIndeterminate: controller == null, paused: timeline.paused, hideProgressBar: !timeline.showOverlay, child: MemoryView( diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 3141dbb..635d1f4 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -2,71 +2,74 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:quid_faciam_hodie/constants/spacing.dart'; -import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; -import 'package:quid_faciam_hodie/widgets/logo.dart'; -import 'grant_permission_screen.dart'; +import 'welcome_screen/pages/get_started_page.dart'; +import 'welcome_screen/pages/guide_page.dart'; +import 'welcome_screen/pages/initial_page.dart'; -class WelcomeScreen extends StatelessWidget { +class WelcomeScreen extends StatefulWidget { static const ID = '/welcome'; const WelcomeScreen({Key? key}) : super(key: key); + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + final controller = PageController(); + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + void nextPage() { + controller.animateToPage( + (controller.page! + 1).toInt(), + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + } + @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; return PlatformScaffold( body: Padding( - padding: const EdgeInsets.all(MEDIUM_SPACE), + padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Logo(), - const SizedBox(height: LARGE_SPACE), - Text( - localizations.appTitleQuestion, - textAlign: TextAlign.center, - style: platformThemeData( - context, - material: (data) => data.textTheme.headline1, - cupertino: (data) => data.textTheme.navLargeTitleTextStyle, - ), - ), - const SizedBox(height: SMALL_SPACE), - Text( - localizations.welcomeScreenSubtitle, - style: platformThemeData( - context, - material: (data) => data.textTheme.bodySmall, - cupertino: (data) => data.textTheme.navTitleTextStyle, - ), - ), - const SizedBox(height: LARGE_SPACE), - Text( - localizations.welcomeScreenDescription, - textAlign: TextAlign.center, - style: platformThemeData( - context, - material: (data) => data.textTheme.bodyText1, - cupertino: (data) => data.textTheme.textStyle, - ), - ), - const SizedBox(height: LARGE_SPACE), - PlatformElevatedButton( - child: IconButtonChild( - icon: Icon(context.platformIcons.forward), - label: Text(localizations.welcomeScreenStartButtonTitle), - ), - onPressed: () { - Navigator.pushNamed( - context, - GrantPermissionScreen.ID, + child: PageView.builder( + itemBuilder: (context, index) { + switch (index) { + case 0: + return InitialPage( + onNextPage: nextPage, ); - }, - ) - ], + case 1: + return GuidePage( + onNextPage: nextPage, + description: localizations + .welcomeScreenCreateMemoriesGuideDescription, + picture: 'assets/images/live_photo.svg', + ); + case 2: + return GuidePage( + onNextPage: nextPage, + description: + localizations.welcomeScreenViewMemoriesGuideDescription, + ); + case 3: + return const GetStartedPage(); + default: + return const SizedBox(); + } + }, + controller: controller, + itemCount: 4, ), ), ), diff --git a/lib/screens/welcome_screen/crabs/logo.dart b/lib/screens/welcome_screen/crabs/logo.dart new file mode 100644 index 0000000..5484a8f --- /dev/null +++ b/lib/screens/welcome_screen/crabs/logo.dart @@ -0,0 +1,15 @@ +import 'package:coast/coast.dart'; +import 'package:flutter/material.dart'; +import 'package:quid_faciam_hodie/widgets/logo.dart'; + +class CrabLogo extends StatelessWidget { + const CrabLogo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Crab( + tag: 'logo', + child: Logo(), + ); + } +} diff --git a/lib/screens/welcome_screen/crabs/next_button.dart b/lib/screens/welcome_screen/crabs/next_button.dart new file mode 100644 index 0000000..dbe4c19 --- /dev/null +++ b/lib/screens/welcome_screen/crabs/next_button.dart @@ -0,0 +1,30 @@ +import 'package:coast/coast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; + +class CrabNextButton extends StatelessWidget { + final VoidCallback onPressed; + + const CrabNextButton({ + Key? key, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Crab( + tag: 'next_button', + child: PlatformElevatedButton( + onPressed: onPressed, + child: IconButtonChild( + icon: Icon(context.platformIcons.forward), + label: Text(localizations.generalContinueButtonLabel), + ), + ), + ); + } +} diff --git a/lib/screens/welcome_screen/pages/get_started_page.dart b/lib/screens/welcome_screen/pages/get_started_page.dart new file mode 100644 index 0000000..e849608 --- /dev/null +++ b/lib/screens/welcome_screen/pages/get_started_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/screens/grant_permission_screen.dart'; +import 'package:quid_faciam_hodie/screens/welcome_screen/crabs/logo.dart'; +import 'package:quid_faciam_hodie/utils/theme.dart'; +import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; + +class GetStartedPage extends StatelessWidget { + const GetStartedPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CrabLogo(), + const SizedBox(height: LARGE_SPACE), + Text( + localizations.appTitleQuestion, + style: getTitleTextStyle(context), + ), + const SizedBox(height: MEDIUM_SPACE), + Text( + localizations.welcomeScreenGetStartedLabel, + style: getSubTitleTextStyle(context), + ), + const SizedBox(height: MEDIUM_SPACE), + PlatformElevatedButton( + child: IconButtonChild( + icon: Icon(context.platformIcons.forward), + label: Text(localizations.welcomeScreenStartButtonTitle), + ), + onPressed: () { + Navigator.pushNamed( + context, + GrantPermissionScreen.ID, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/welcome_screen/pages/guide_page.dart b/lib/screens/welcome_screen/pages/guide_page.dart new file mode 100644 index 0000000..cb79ef6 --- /dev/null +++ b/lib/screens/welcome_screen/pages/guide_page.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/screens/welcome_screen/crabs/next_button.dart'; +import 'package:quid_faciam_hodie/screens/welcome_screen/photo_switching.dart'; +import 'package:quid_faciam_hodie/utils/theme.dart'; + +class GuidePage extends StatelessWidget { + final String? picture; + final String description; + final VoidCallback onNextPage; + + const GuidePage({ + Key? key, + required this.description, + required this.onNextPage, + this.picture, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + picture == null + ? const Expanded( + child: Padding( + padding: EdgeInsets.only(top: LARGE_SPACE), + child: PhotoSwitching(), + ), + ) + : SvgPicture.asset(picture!, height: 400), + const SizedBox(height: LARGE_SPACE), + Text( + description, + style: getBodyTextTextStyle(context), + ), + const SizedBox(height: LARGE_SPACE), + CrabNextButton(onPressed: onNextPage), + ], + ), + ), + ); + } +} diff --git a/lib/screens/welcome_screen/pages/initial_page.dart b/lib/screens/welcome_screen/pages/initial_page.dart new file mode 100644 index 0000000..47de217 --- /dev/null +++ b/lib/screens/welcome_screen/pages/initial_page.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/screens/welcome_screen/crabs/logo.dart'; +import 'package:quid_faciam_hodie/screens/welcome_screen/crabs/next_button.dart'; +import 'package:quid_faciam_hodie/utils/theme.dart'; + +class InitialPage extends StatefulWidget { + final VoidCallback onNextPage; + + const InitialPage({ + Key? key, + required this.onNextPage, + }) : super(key: key); + + @override + State createState() => _InitialPageState(); +} + +class _InitialPageState extends State { + late String photoURL; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CrabLogo(), + const SizedBox(height: HUGE_SPACE), + Text( + localizations.appTitleQuestion, + style: getTitleTextStyle(context), + ), + const SizedBox(height: SMALL_SPACE), + Text( + localizations.welcomeScreenSubtitle, + style: getSubTitleTextStyle(context), + ), + const SizedBox(height: MEDIUM_SPACE), + Text( + localizations.welcomeScreenDescription, + style: getBodyTextTextStyle(context), + ), + const SizedBox(height: LARGE_SPACE), + CrabNextButton( + onPressed: widget.onNextPage, + ) + ], + ), + ), + ); + } +} diff --git a/lib/screens/welcome_screen/photo_switching.dart b/lib/screens/welcome_screen/photo_switching.dart new file mode 100644 index 0000000..a0e088d --- /dev/null +++ b/lib/screens/welcome_screen/photo_switching.dart @@ -0,0 +1,68 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/constants/values.dart'; +import 'package:quid_faciam_hodie/managers/photo_manager.dart'; +import 'package:quid_faciam_hodie/utils/loadable.dart'; +import 'package:quid_faciam_hodie/widgets/status.dart'; + +class PhotoSwitching extends StatefulWidget { + const PhotoSwitching({Key? key}) : super(key: key); + + @override + State createState() => _PhotoSwitchingState(); +} + +class _PhotoSwitchingState extends State with Loadable { + late String photoURL; + + @override + void initState() { + super.initState(); + + callWithLoading(getNextPhoto); + } + + Future getNextPhoto() async { + final query = WELCOME_SCREEN_PHOTOS_QUERIES[ + Random().nextInt(WELCOME_SCREEN_PHOTOS_QUERIES.length)]; + final url = await PhotoManager.getRandomPhoto(query); + + if (!mounted) { + return; + } + + setState(() { + photoURL = url; + }); + } + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(MEDIUM_SPACE), + child: Image.network( + photoURL, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return Status( + autoStart: true, + onEnd: () async { + getNextPhoto(); + }, + duration: const Duration(seconds: 3), + child: child, + ); + } + return const SizedBox.expand(); + }, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/lib/widgets/status.dart b/lib/widgets/status.dart index 9244dcf..2059f4d 100644 --- a/lib/widgets/status.dart +++ b/lib/widgets/status.dart @@ -9,12 +9,20 @@ class Status extends StatefulWidget { final Widget child; final bool paused; final bool hideProgressBar; + final bool autoStart; + final bool isIndeterminate; + final VoidCallback? onEnd; + final Duration duration; const Status({ Key? key, required this.child, this.paused = false, this.hideProgressBar = false, + this.autoStart = false, + this.isIndeterminate = false, + this.duration = const Duration(seconds: 5), + this.onEnd, this.controller, }) : super(key: key); @@ -23,7 +31,7 @@ class Status extends StatefulWidget { } class _StatusState extends State with TickerProviderStateMixin { - late final Animation animation; + Animation? animation; AnimationController? animationController; @override @@ -48,28 +56,48 @@ class _StatusState extends State with TickerProviderStateMixin { super.dispose(); } + @override + void initState() { + super.initState(); + + if (widget.autoStart) { + initializeAnimation(); + } + } + void initializeAnimation() { animationController = AnimationController( - duration: widget.controller!.duration, + duration: widget.controller?.duration ?? widget.duration, vsync: this, - ); + )..addStatusListener((status) { + if (status == AnimationStatus.completed) { + widget.onEnd?.call(); + } + }); + animation = Tween(begin: 0.0, end: 1.0).animate(animationController!) ..addStatusListener((status) { if (status == AnimationStatus.completed) { - widget.controller!.setDone(); + widget.controller?.setDone(); } }); animationController!.forward(); - widget.controller!.addListener(() { - if (widget.controller!.isForwarding) { - animationController!.forward(); - } else { - animationController!.stop(); - } - }); + if (widget.controller != null) { + widget.controller!.addListener(() { + if (widget.controller!.isForwarding) { + animationController!.forward(); + } else { + animationController!.stop(); + } + }); + } + + if (widget.autoStart) { + animationController?.forward(); + } } @override @@ -82,8 +110,8 @@ class _StatusState extends State with TickerProviderStateMixin { widget.child, Positioned( left: 0, + right: 0, bottom: SMALL_SPACE, - width: MediaQuery.of(context).size.width, height: BAR_HEIGHT, child: Padding( padding: const EdgeInsets.symmetric(horizontal: SMALL_SPACE), @@ -91,7 +119,7 @@ class _StatusState extends State with TickerProviderStateMixin { duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut, opacity: widget.hideProgressBar ? 0.0 : 1.0, - child: (widget.controller == null) + child: (widget.isIndeterminate || animation == null) ? ClipRRect( borderRadius: BorderRadius.circular(HUGE_SPACE), child: LinearProgressIndicator( @@ -102,13 +130,13 @@ class _StatusState extends State with TickerProviderStateMixin { ), ) : AnimatedBuilder( - animation: animation, + animation: animation!, builder: (_, __) => ClipRRect( borderRadius: BorderRadius.circular(HUGE_SPACE), child: LinearProgressIndicator( - value: animation.value, - valueColor: - const AlwaysStoppedAnimation(Colors.white), + value: animation!.value, + valueColor: const AlwaysStoppedAnimation( + Colors.white), backgroundColor: Colors.white.withOpacity(0.1), ), ), diff --git a/pubspec.lock b/pubspec.lock index c5af600..bc5bc3b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,6 +78,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + coast: + dependency: "direct main" + description: + name: coast + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" collection: dependency: transitive description: @@ -295,7 +302,7 @@ packages: source: hosted version: "0.15.0" http: - dependency: transitive + dependency: "direct main" description: name: http url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 90ea384..5466173 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,8 @@ dependencies: flutter_platform_widgets: ^2.0.0 lottie: ^1.4.1 settings_ui: ^2.0.2 + coast: ^2.0.2 + http: ^0.13.5 dev_dependencies: flutter_test: @@ -83,6 +85,7 @@ flutter: assets: - assets/ - assets/lottie/flying-astronaut.json + - assets/images/ # To add assets to your application, add an assets section, like this: # assets: