diff --git a/build.gradle.kts b/build.gradle.kts index e146a4741481c..9b4f6e57b4e79 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -173,6 +173,7 @@ tasks.rat { "learning/tour-of-beam/frontend/**/*.gen.dart", "learning/tour-of-beam/frontend/.metadata", "learning/tour-of-beam/frontend/pubspec.lock", + "learning/tour-of-beam/frontend/lib/firebase_options.dart", // Ignore .gitkeep file "**/.gitkeep", diff --git a/learning/tour-of-beam/frontend/README.md b/learning/tour-of-beam/frontend/README.md index 2e27a05ad163a..cba80719dfec7 100644 --- a/learning/tour-of-beam/frontend/README.md +++ b/learning/tour-of-beam/frontend/README.md @@ -17,47 +17,67 @@ under the License. --> - # Tour of Beam - These are the main sources of the Tour of Beam website. +# Tour of Beam - # About +These are the main sources of the Tour of Beam website. + +# About ## Getting started -Running, debugging, and testing all require this first step that fetches -dependencies and generates code: + +This project relies on generated code for some functionality: +deserializers, test mocks, constants for asset files, +extracted Beam symbols for the editor, etc. + +All generated files are version-controlled, so after checkout the project is immediately runnable. +However, after changes you may need to re-run code generation: ```bash -cd ../../../playground/frontend/playground_components -flutter pub get -flutter pub run build_runner build -cd ../../../learning/tour-of-beam/frontend -flutter pub get -flutter pub run build_runner build +cd beam +./gradlew :playground:frontend:playground_components:generateCode +cd learning/tour-of-beam/frontend +flutter pub run build_runner build --delete-conflicting-outputs ``` ### Run The following command is used to build and serve the frontend app locally: -`$ flutter run -d chrome` +```bash +flutter run -d chrome +``` + +### Backend Selection + +To change the Google Project that is used as the backend: + +1. Update Firebase configuration: + https://firebase.google.com/docs/flutter/setup?platform=web + +2. In `/lib/config.dart`, update: + 1. Google Project ID and region. + 2. Playground's backend URLs. + +# Deployment + +# Tests +Install ChromeDriver to run integration tests in a browser: https://docs.flutter.dev/testing/integration-tests#running-in-a-browser +Run integration tests: +flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/counter_test.dart \ + -d web-server - # Deployment +# Packages - # Tests - Install ChromeDriver to run integration tests in a browser: https://docs.flutter.dev/testing/integration-tests#running-in-a-browser - Run integration tests: - flutter drive \ - --driver=test_driver/integration_test.dart \ - --target=integration_test/counter_test.dart \ - -d web-server +`flutter pub get` - # Packages - `flutter pub get` +# Contribution guide - # Contribution guide - For checks: `./gradlew rat` +For checks: `./gradlew rat` +Exclusions for file checks can be added in the Tour of Beam section of this file: `beam/build.gradle.kts` - # Additional resources +# Additional resources - # Troubleshooting +# Troubleshooting diff --git a/learning/tour-of-beam/frontend/lib/auth/notifier.dart b/learning/tour-of-beam/frontend/lib/auth/notifier.dart new file mode 100644 index 0000000000000..2eb7be819f39c --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/auth/notifier.dart @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_auth_platform_interface/firebase_auth_platform_interface.dart'; +import 'package:flutter/material.dart'; + +class AuthNotifier extends ChangeNotifier { + AuthNotifier() { + FirebaseAuth.instance.authStateChanges().listen((user) { + notifyListeners(); + }); + } + + bool get isAuthenticated => FirebaseAuth.instance.currentUser != null; + + Future getToken() async { + return await FirebaseAuth.instance.currentUser?.getIdToken(); + } + + Future logIn(AuthProvider authProvider) async { + await FirebaseAuth.instance.signInWithPopup(authProvider); + } + + Future logOut() async { + await FirebaseAuth.instance.signOut(); + } +} diff --git a/learning/tour-of-beam/frontend/test/overflow_test.dart b/learning/tour-of-beam/frontend/lib/cache/cache.dart similarity index 58% rename from learning/tour-of-beam/frontend/test/overflow_test.dart rename to learning/tour-of-beam/frontend/lib/cache/cache.dart index 7559b7c5d25eb..c11e2790c4227 100644 --- a/learning/tour-of-beam/frontend/test/overflow_test.dart +++ b/learning/tour-of-beam/frontend/lib/cache/cache.dart @@ -17,22 +17,12 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:tour_of_beam/locator.dart'; -import 'package:tour_of_beam/pages/tour/screen.dart'; -import 'package:tour_of_beam/pages/tour/state.dart'; -import 'common/test_screen_wrapper.dart'; -void main() async { - await initializeServiceLocator(); +import '../repositories/client/client.dart'; - testWidgets('WelcomeScreen overflow', (tester) async { - tester.binding.window.physicalSizeTestValue = const Size(500, 296); - // TODO(nausharipov): fix the failure - await tester.pumpWidget( - TestScreenWrapper( - child: TourScreen(TourNotifier(initialSdkId: '')), - ), - ); - }); +/// A base class for caching entities from network requests. +abstract class Cache extends ChangeNotifier { + final TobClient client; + + Cache({required this.client}); } diff --git a/learning/tour-of-beam/frontend/lib/cache/content_tree.dart b/learning/tour-of-beam/frontend/lib/cache/content_tree.dart index 296b9a84238cf..5f35af7e55e03 100644 --- a/learning/tour-of-beam/frontend/lib/cache/content_tree.dart +++ b/learning/tour-of-beam/frontend/lib/cache/content_tree.dart @@ -18,24 +18,19 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; - import '../models/content_tree.dart'; -import '../repositories/client/client.dart'; +import 'cache.dart'; -class ContentTreeCache extends ChangeNotifier { - final TobClient client; +class ContentTreeCache extends Cache { + ContentTreeCache({ + required super.client, + }); final _treesBySdkId = {}; final _futuresBySdkId = >{}; - ContentTreeCache({ - required this.client, - }); - ContentTreeModel? getContentTree(String sdkId) { - final future = _futuresBySdkId[sdkId]; - if (future == null) { + if (_futuresBySdkId.containsKey(sdkId)) { unawaited(_loadContentTree(sdkId)); } diff --git a/learning/tour-of-beam/frontend/lib/cache/sdk.dart b/learning/tour-of-beam/frontend/lib/cache/sdk.dart index 068fda06b7652..8b26a3c1805b7 100644 --- a/learning/tour-of-beam/frontend/lib/cache/sdk.dart +++ b/learning/tour-of-beam/frontend/lib/cache/sdk.dart @@ -18,22 +18,19 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:playground_components/playground_components.dart'; -import '../repositories/client/client.dart'; import '../repositories/models/get_sdks_response.dart'; +import 'cache.dart'; -class SdkCache extends ChangeNotifier { - final TobClient client; +class SdkCache extends Cache { + SdkCache({ + required super.client, + }); final _sdks = []; Future? _future; - SdkCache({ - required this.client, - }); - List getSdks() { if (_future == null) { unawaited(_loadSdks()); diff --git a/learning/tour-of-beam/frontend/lib/cache/unit_content.dart b/learning/tour-of-beam/frontend/lib/cache/unit_content.dart index 25d703064808b..d8499a641e272 100644 --- a/learning/tour-of-beam/frontend/lib/cache/unit_content.dart +++ b/learning/tour-of-beam/frontend/lib/cache/unit_content.dart @@ -18,21 +18,17 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; - import '../models/unit_content.dart'; -import '../repositories/client/client.dart'; +import 'cache.dart'; -class UnitContentCache extends ChangeNotifier { - final TobClient client; +class UnitContentCache extends Cache { + UnitContentCache({ + required super.client, + }); final _unitContents = >{}; final _futures = >>{}; - UnitContentCache({ - required this.client, - }); - UnitContentModel? getUnitContent(String sdkId, String unitId) { final future = _futures[sdkId]?[unitId]; if (future == null) { diff --git a/learning/tour-of-beam/frontend/lib/cache/unit_progress.dart b/learning/tour-of-beam/frontend/lib/cache/unit_progress.dart new file mode 100644 index 0000000000000..5649e7464674d --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/cache/unit_progress.dart @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:get_it/get_it.dart'; + +import '../auth/notifier.dart'; +import '../enums/unit_completion.dart'; +import '../repositories/models/get_user_progress_response.dart'; +import '../state.dart'; +import 'cache.dart'; + +class UnitProgressCache extends Cache { + UnitProgressCache({required super.client}); + + final _completedUnitIds = {}; + final _updatingUnitIds = {}; + Future? _future; + + Set getUpdatingUnitIds() => _updatingUnitIds; + + void addUpdatingUnitId(String unitId) { + _updatingUnitIds.add(unitId); + notifyListeners(); + } + + void clearUpdatingUnitId(String unitId) { + _updatingUnitIds.remove(unitId); + notifyListeners(); + } + + bool canCompleteUnit(String? unitId) { + if (unitId == null) { + return false; + } + return _getUnitCompletion(unitId) == UnitCompletion.uncompleted; + } + + UnitCompletion _getUnitCompletion(String unitId) { + final authNotifier = GetIt.instance.get(); + if (!authNotifier.isAuthenticated) { + return UnitCompletion.unauthenticated; + } + if (_updatingUnitIds.contains(unitId)) { + return UnitCompletion.updating; + } + if (isUnitCompleted(unitId)) { + return UnitCompletion.completed; + } + return UnitCompletion.uncompleted; + } + + bool isUnitCompleted(String? unitId) { + return getCompletedUnits().contains(unitId); + } + + Future updateCompletedUnits() async { + final sdkId = GetIt.instance.get().sdkId; + if (sdkId != null) { + await _loadCompletedUnits(sdkId); + } + } + + Set getCompletedUnits() { + if (_future == null) { + unawaited(updateCompletedUnits()); + } + + return _completedUnitIds; + } + + Future _loadCompletedUnits(String sdkId) async { + _future = client.getUserProgress(sdkId); + final result = await _future; + + _completedUnitIds.clear(); + if (result != null) { + for (final unitProgress in result.units) { + if (unitProgress.isCompleted) { + _completedUnitIds.add(unitProgress.id); + } + } + } + + notifyListeners(); + } +} diff --git a/learning/tour-of-beam/frontend/lib/components/builders/content_tree.dart b/learning/tour-of-beam/frontend/lib/components/builders/content_tree.dart index 1ee9a3b87d01d..8ef706ead45ad 100644 --- a/learning/tour-of-beam/frontend/lib/components/builders/content_tree.dart +++ b/learning/tour-of-beam/frontend/lib/components/builders/content_tree.dart @@ -33,13 +33,13 @@ class ContentTreeBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - final cache = GetIt.instance.get(); + final contentTreeCache = GetIt.instance.get(); return AnimatedBuilder( - animation: cache, + animation: contentTreeCache, builder: (context, child) => builder( context, - cache.getContentTree(sdkId), + contentTreeCache.getContentTree(sdkId), child, ), ); diff --git a/learning/tour-of-beam/frontend/lib/components/builders/sdks.dart b/learning/tour-of-beam/frontend/lib/components/builders/sdks.dart index 8aeea14613006..e6a5c6f97d35c 100644 --- a/learning/tour-of-beam/frontend/lib/components/builders/sdks.dart +++ b/learning/tour-of-beam/frontend/lib/components/builders/sdks.dart @@ -31,11 +31,11 @@ class SdksBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - final cache = GetIt.instance.get(); + final sdkCache = GetIt.instance.get(); return AnimatedBuilder( - animation: cache, - builder: (context, child) => builder(context, cache.getSdks(), child), + animation: sdkCache, + builder: (context, child) => builder(context, sdkCache.getSdks(), child), ); } } diff --git a/learning/tour-of-beam/frontend/lib/components/footer.dart b/learning/tour-of-beam/frontend/lib/components/footer.dart index e801836bb8984..08af18188f049 100644 --- a/learning/tour-of-beam/frontend/lib/components/footer.dart +++ b/learning/tour-of-beam/frontend/lib/components/footer.dart @@ -16,6 +16,8 @@ * limitations under the License. */ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:playground_components/playground_components.dart'; @@ -90,7 +92,7 @@ class _ReportIssueButton extends StatelessWidget { return TextButton( style: _linkButtonStyle, onPressed: () { - launchUrl(Uri.parse(BeamLinks.reportIssue)); + unawaited(launchUrl(Uri.parse(BeamLinks.reportIssue))); }, child: const Text('ui.reportIssue').tr(), ); @@ -105,7 +107,7 @@ class _PrivacyPolicyButton extends StatelessWidget { return TextButton( style: _linkButtonStyle, onPressed: () { - launchUrl(Uri.parse(BeamLinks.privacyPolicy)); + unawaited(launchUrl(Uri.parse(BeamLinks.privacyPolicy))); }, child: const Text('ui.privacyPolicy').tr(), ); diff --git a/learning/tour-of-beam/frontend/lib/components/login/login_button.dart b/learning/tour-of-beam/frontend/lib/components/login/button.dart similarity index 71% rename from learning/tour-of-beam/frontend/lib/components/login/login_button.dart rename to learning/tour-of-beam/frontend/lib/components/login/button.dart index 36d96faffe5a8..2fb2038d6bfd8 100644 --- a/learning/tour-of-beam/frontend/lib/components/login/login_button.dart +++ b/learning/tour-of-beam/frontend/lib/components/login/button.dart @@ -20,7 +20,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:playground_components/playground_components.dart'; -import 'login_content.dart'; +import 'content.dart'; class LoginButton extends StatelessWidget { const LoginButton(); @@ -29,26 +29,18 @@ class LoginButton extends StatelessWidget { Widget build(BuildContext context) { return TextButton( onPressed: () { - _openOverlay(context); + final closeNotifier = PublicNotifier(); + openOverlay( + context: context, + closeNotifier: closeNotifier, + positioned: Positioned( + right: BeamSizes.size10, + top: BeamSizes.appBarHeight, + child: LoginContent(onLoggedIn: closeNotifier.notifyPublic), + ), + ); }, child: const Text('ui.signIn').tr(), ); } - - void _openOverlay(BuildContext context) { - OverlayEntry? overlay; - overlay = OverlayEntry( - builder: (context) => DismissibleOverlay( - close: () { - overlay?.remove(); - }, - child: const Positioned( - right: BeamSizes.size10, - top: BeamSizes.appBarHeight, - child: LoginContent(), - ), - ), - ); - Overlay.of(context)?.insert(overlay); - } } diff --git a/learning/tour-of-beam/frontend/lib/components/login/login_content.dart b/learning/tour-of-beam/frontend/lib/components/login/content.dart similarity index 76% rename from learning/tour-of-beam/frontend/lib/components/login/login_content.dart rename to learning/tour-of-beam/frontend/lib/components/login/content.dart index aabdd026a5119..ae1fe80fa0281 100644 --- a/learning/tour-of-beam/frontend/lib/components/login/login_content.dart +++ b/learning/tour-of-beam/frontend/lib/components/login/content.dart @@ -17,51 +17,47 @@ */ import 'package:easy_localization/easy_localization.dart'; +import 'package:firebase_auth_platform_interface/firebase_auth_platform_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; import '../../assets/assets.gen.dart'; +import '../../auth/notifier.dart'; import '../../constants/sizes.dart'; class LoginContent extends StatelessWidget { - const LoginContent(); + final VoidCallback onLoggedIn; - @override - Widget build(BuildContext context) { - return _Body( - child: Column( - children: [ - Text( - 'ui.signIn', - style: Theme.of(context).textTheme.titleLarge, - ).tr(), - const SizedBox(height: BeamSizes.size10), - const Text( - 'dialogs.signInIf', - textAlign: TextAlign.center, - ).tr(), - const _Divider(), - const _BrandedLoginButtons(), - ], - ), - ); - } -} - -class _Body extends StatelessWidget { - final Widget child; - const _Body({required this.child}); + const LoginContent({ + required this.onLoggedIn, + }); @override Widget build(BuildContext context) { - return Material( - elevation: BeamSizes.size10, - borderRadius: BorderRadius.circular(10), + return OverlayBody( child: Container( width: TobSizes.authOverlayWidth, padding: const EdgeInsets.all(BeamSizes.size24), - child: child, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'ui.signIn', + style: Theme.of(context).textTheme.titleLarge, + ).tr(), + const SizedBox(height: BeamSizes.size10), + const Text( + 'dialogs.signInIf', + textAlign: TextAlign.center, + ).tr(), + const _Divider(), + _BrandedLoginButtons( + onLoggedIn: onLoggedIn, + ), + ], + ), ), ); } @@ -82,10 +78,16 @@ class _Divider extends StatelessWidget { } class _BrandedLoginButtons extends StatelessWidget { - const _BrandedLoginButtons(); + final VoidCallback onLoggedIn; + + const _BrandedLoginButtons({ + required this.onLoggedIn, + }); @override Widget build(BuildContext context) { + final authNotifier = GetIt.instance.get(); + final isLightTheme = Theme.of(context).brightness == Brightness.light; final textStyle = MaterialStatePropertyAll(Theme.of(context).textTheme.bodyMedium); @@ -129,7 +131,10 @@ class _BrandedLoginButtons extends StatelessWidget { ), const SizedBox(height: BeamSizes.size16), ElevatedButton.icon( - onPressed: () {}, + onPressed: () async { + await authNotifier.logIn(GoogleAuthProvider()); + onLoggedIn(); + }, style: isLightTheme ? googleLightButtonStyle : darkButtonStyle, icon: SvgPicture.asset(Assets.svg.googleLogo), label: const Text('ui.continueGoogle').tr(), diff --git a/learning/tour-of-beam/frontend/lib/components/profile/avatar.dart b/learning/tour-of-beam/frontend/lib/components/profile/avatar.dart index 2c8c07d9ef174..d4b88584dab65 100644 --- a/learning/tour-of-beam/frontend/lib/components/profile/avatar.dart +++ b/learning/tour-of-beam/frontend/lib/components/profile/avatar.dart @@ -16,42 +16,43 @@ * limitations under the License. */ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:playground_components/playground_components.dart'; -import '../../assets/assets.gen.dart'; -import 'profile_content.dart'; +import 'user_menu.dart'; class Avatar extends StatelessWidget { - const Avatar(); + final User user; + const Avatar({required this.user}); @override Widget build(BuildContext context) { + final photoUrl = user.photoURL; return GestureDetector( onTap: () { - _openOverlay(context); + final closeNotifier = PublicNotifier(); + openOverlay( + context: context, + closeNotifier: closeNotifier, + positioned: Positioned( + right: BeamSizes.size10, + top: BeamSizes.appBarHeight, + child: UserMenu( + closeOverlayCallback: closeNotifier.notifyPublic, + user: user, + ), + ), + ); }, child: CircleAvatar( backgroundColor: BeamColors.white, - foregroundImage: AssetImage(Assets.png.laptopLight.path), - ), - ); - } - - void _openOverlay(BuildContext context) { - OverlayEntry? overlay; - overlay = OverlayEntry( - builder: (context) => DismissibleOverlay( - close: () { - overlay?.remove(); - }, - child: const Positioned( - right: BeamSizes.size10, - top: BeamSizes.appBarHeight, - child: ProfileContent(), + foregroundImage: photoUrl == null ? null : NetworkImage(photoUrl), + child: const Icon( + Icons.person, + color: BeamColors.grey3, ), ), ); - Overlay.of(context)?.insert(overlay); } } diff --git a/learning/tour-of-beam/frontend/lib/components/profile/profile_content.dart b/learning/tour-of-beam/frontend/lib/components/profile/user_menu.dart similarity index 69% rename from learning/tour-of-beam/frontend/lib/components/profile/profile_content.dart rename to learning/tour-of-beam/frontend/lib/components/profile/user_menu.dart index f6a0e1a6e82b8..5fb49fa7192b1 100644 --- a/learning/tour-of-beam/frontend/lib/components/profile/profile_content.dart +++ b/learning/tour-of-beam/frontend/lib/components/profile/user_menu.dart @@ -17,67 +17,72 @@ */ import 'package:easy_localization/easy_localization.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; import '../../assets/assets.gen.dart'; +import '../../auth/notifier.dart'; import '../../constants/sizes.dart'; -class ProfileContent extends StatelessWidget { - const ProfileContent(); +class UserMenu extends StatelessWidget { + final VoidCallback closeOverlayCallback; + final User user; - @override - Widget build(BuildContext context) { - return _Body( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - _Info(), - BeamDivider(), - _Buttons(), - ], - ), - ); - } -} - -class _Body extends StatelessWidget { - final Widget child; - - const _Body({required this.child}); + const UserMenu({ + required this.closeOverlayCallback, + required this.user, + }); @override Widget build(BuildContext context) { - return Material( - elevation: BeamSizes.size10, - borderRadius: BorderRadius.circular(10), + return OverlayBody( child: SizedBox( width: TobSizes.authOverlayWidth, - child: child, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Info(user: user), + const BeamDivider(), + _Buttons( + closeOverlayCallback: closeOverlayCallback, + ), + ], + ), ), ); } } class _Info extends StatelessWidget { - const _Info(); + final User user; + + const _Info({ + required this.user, + }); @override Widget build(BuildContext context) { + final displayName = user.displayName; + final email = user.email; + return Padding( padding: const EdgeInsets.all(BeamSizes.size16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Name Surname', - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - 'email@mail.com', - style: Theme.of(context).textTheme.bodySmall, - ), + if (displayName != null) + Text( + displayName, + style: Theme.of(context).textTheme.titleLarge, + ), + if (email != null) + Text( + email, + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ); @@ -85,10 +90,16 @@ class _Info extends StatelessWidget { } class _Buttons extends StatelessWidget { - const _Buttons(); + final VoidCallback closeOverlayCallback; + + const _Buttons({ + required this.closeOverlayCallback, + }); @override Widget build(BuildContext context) { + final authNotifier = GetIt.instance.get(); + return Column( children: [ _IconLabel( @@ -105,7 +116,10 @@ class _Buttons extends StatelessWidget { ), const BeamDivider(), _IconLabel( - onTap: () {}, + onTap: () async { + await authNotifier.logOut(); + closeOverlayCallback(); + }, iconPath: Assets.svg.profileLogout, label: 'ui.signOut'.tr(), ), @@ -123,7 +137,7 @@ class _Buttons extends StatelessWidget { class _IconLabel extends StatelessWidget { final String iconPath; final String label; - final void Function()? onTap; + final VoidCallback? onTap; // TODO(alexeyinkin): Auto-determine. final bool isSvg; diff --git a/learning/tour-of-beam/frontend/lib/components/scaffold.dart b/learning/tour-of-beam/frontend/lib/components/scaffold.dart index f8352140436ef..007e6398ecca5 100644 --- a/learning/tour-of-beam/frontend/lib/components/scaffold.dart +++ b/learning/tour-of-beam/frontend/lib/components/scaffold.dart @@ -16,11 +16,14 @@ * limitations under the License. */ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; +import '../state.dart'; import 'footer.dart'; -import 'login/login_button.dart'; +import 'login/button.dart'; import 'logo.dart'; import 'profile/avatar.dart'; import 'sdk_dropdown.dart'; @@ -33,22 +36,18 @@ class TobScaffold extends StatelessWidget { required this.child, }); - // TODO(nausharipov): get state - static const _isAuthorized = true; - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + automaticallyImplyLeading: false, title: const Logo(), actions: const [ - _ActionVerticalPadding(child: SdkDropdown()), + _ActionVerticalPadding(child: _SdkSelector()), SizedBox(width: BeamSizes.size12), _ActionVerticalPadding(child: ToggleThemeButton()), SizedBox(width: BeamSizes.size6), - _ActionVerticalPadding( - child: _isAuthorized ? Avatar() : LoginButton(), - ), + _ActionVerticalPadding(child: _Profile()), SizedBox(width: BeamSizes.size16), ], ), @@ -62,6 +61,21 @@ class TobScaffold extends StatelessWidget { } } +class _Profile extends StatelessWidget { + const _Profile(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: FirebaseAuth.instance.userChanges(), + builder: (context, snapshot) { + final user = snapshot.data; + return user == null ? const LoginButton() : Avatar(user: user); + }, + ); + } +} + class _ActionVerticalPadding extends StatelessWidget { final Widget child; @@ -75,3 +89,26 @@ class _ActionVerticalPadding extends StatelessWidget { ); } } + +class _SdkSelector extends StatelessWidget { + const _SdkSelector(); + + @override + Widget build(BuildContext context) { + final appNotifier = GetIt.instance.get(); + return AnimatedBuilder( + animation: appNotifier, + builder: (context, child) { + final sdkId = appNotifier.sdkId; + return sdkId == null + ? Container() + : SdkDropdown( + sdkId: sdkId, + onChanged: (value) { + appNotifier.sdkId = value; + }, + ); + }, + ); + } +} diff --git a/learning/tour-of-beam/frontend/lib/components/sdk_dropdown.dart b/learning/tour-of-beam/frontend/lib/components/sdk_dropdown.dart index 689860b8466c2..9e84e3698e374 100644 --- a/learning/tour-of-beam/frontend/lib/components/sdk_dropdown.dart +++ b/learning/tour-of-beam/frontend/lib/components/sdk_dropdown.dart @@ -22,7 +22,13 @@ import 'package:playground_components/playground_components.dart'; import 'builders/sdks.dart'; class SdkDropdown extends StatelessWidget { - const SdkDropdown(); + final String sdkId; + final ValueChanged onChanged; + + const SdkDropdown({ + required this.sdkId, + required this.onChanged, + }); @override Widget build(BuildContext context) { @@ -34,9 +40,11 @@ class SdkDropdown extends StatelessWidget { return _DropdownWrapper( child: DropdownButton( - value: sdks.first.id, + value: sdkId, onChanged: (sdk) { - // TODO(nausharipov): change SDK + if (sdk != null) { + onChanged(sdk); + } }, items: sdks .map( @@ -46,7 +54,6 @@ class SdkDropdown extends StatelessWidget { ), ) .toList(growable: false), - isDense: true, alignment: Alignment.center, focusColor: BeamColors.transparent, borderRadius: BorderRadius.circular(BeamSizes.size6), @@ -63,9 +70,10 @@ class _DropdownWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - return DecoratedBox( + return Container( + padding: const EdgeInsets.only(left: BeamSizes.size10), decoration: BoxDecoration( - color: Theme.of(context).hoverColor, + color: Theme.of(context).selectedRowColor, borderRadius: BorderRadius.circular(BeamSizes.size6), ), child: DropdownButtonHideUnderline(child: child), diff --git a/learning/tour-of-beam/frontend/lib/constants/sizes.dart b/learning/tour-of-beam/frontend/lib/constants/sizes.dart index bb9a665c8a30f..53ad1c7c2d03c 100644 --- a/learning/tour-of-beam/frontend/lib/constants/sizes.dart +++ b/learning/tour-of-beam/frontend/lib/constants/sizes.dart @@ -22,7 +22,6 @@ class TobSizes { } class ScreenSizes { - // TODO(nausharipov): name better static const medium = 1024; } diff --git a/learning/tour-of-beam/frontend/lib/constants/storage_keys.dart b/learning/tour-of-beam/frontend/lib/constants/storage_keys.dart new file mode 100644 index 0000000000000..84b289e115e8c --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/constants/storage_keys.dart @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class StorageKeys { + static const sdkId = 'sdkId'; +} diff --git a/learning/tour-of-beam/frontend/lib/enums/unit_completion.dart b/learning/tour-of-beam/frontend/lib/enums/unit_completion.dart new file mode 100644 index 0000000000000..746cfd4d38e57 --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/enums/unit_completion.dart @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +enum UnitCompletion { + completed, + uncompleted, + + /// Sent the request to complete or to undo completion. + updating, + unauthenticated, +} diff --git a/learning/tour-of-beam/frontend/lib/firebase_options.dart b/learning/tour-of-beam/frontend/lib/firebase_options.dart new file mode 100644 index 0000000000000..e2a871d637b36 --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/firebase_options.dart @@ -0,0 +1,63 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for android - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.iOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for ios - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyBtAreurqJ5D4IK6cNisZh5dnDRKljbJAw', + authDomain: 'astest-369409.firebaseapp.com', + projectId: 'astest-369409', + storageBucket: 'astest-369409.appspot.com', + messagingSenderId: '534850967604', + appId: '1:534850967604:web:55c6af8da7940df1ddd261', + ); +} diff --git a/learning/tour-of-beam/frontend/lib/locator.dart b/learning/tour-of-beam/frontend/lib/locator.dart index f8146a212e246..7bdb0406edd08 100644 --- a/learning/tour-of-beam/frontend/lib/locator.dart +++ b/learning/tour-of-beam/frontend/lib/locator.dart @@ -19,28 +19,40 @@ import 'package:app_state/app_state.dart'; import 'package:get_it/get_it.dart'; +import 'auth/notifier.dart'; import 'cache/content_tree.dart'; import 'cache/sdk.dart'; import 'cache/unit_content.dart'; +import 'cache/unit_progress.dart'; import 'pages/welcome/page.dart'; +import 'repositories/client/client.dart'; import 'repositories/client/cloud_functions_client.dart'; import 'router/page_factory.dart'; import 'router/route_information_parser.dart'; +import 'state.dart'; Future initializeServiceLocator() async { - _initializeCaches(); + _initializeAuth(); _initializeState(); + _initializeCaches(); +} + +void _initializeAuth() { + GetIt.instance.registerSingleton(AuthNotifier()); } void _initializeCaches() { final client = CloudFunctionsTobClient(); + GetIt.instance.registerSingleton(client); GetIt.instance.registerSingleton(ContentTreeCache(client: client)); GetIt.instance.registerSingleton(SdkCache(client: client)); GetIt.instance.registerSingleton(UnitContentCache(client: client)); + GetIt.instance.registerSingleton(UnitProgressCache(client: client)); } void _initializeState() { + GetIt.instance.registerSingleton(AppNotifier()); GetIt.instance.registerSingleton( PageStack( bottomPage: WelcomePage(), diff --git a/learning/tour-of-beam/frontend/lib/main.dart b/learning/tour-of-beam/frontend/lib/main.dart index 602a187a97f73..b5cc5d64a9c43 100644 --- a/learning/tour-of-beam/frontend/lib/main.dart +++ b/learning/tour-of-beam/frontend/lib/main.dart @@ -20,16 +20,21 @@ import 'package:app_state/app_state.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization_ext/easy_localization_ext.dart'; import 'package:easy_localization_loader/easy_localization_loader.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; import 'package:provider/provider.dart'; import 'package:url_strategy/url_strategy.dart'; +import 'firebase_options.dart'; import 'locator.dart'; import 'router/route_information_parser.dart'; void main() async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); setPathUrlStrategy(); await EasyLocalization.ensureInitialized(); await PlaygroundComponents.ensureInitialized(); diff --git a/learning/tour-of-beam/frontend/lib/models/unit_progress.dart b/learning/tour-of-beam/frontend/lib/models/unit_progress.dart new file mode 100644 index 0000000000000..473c8ae0d4e6d --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/models/unit_progress.dart @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:json_annotation/json_annotation.dart'; + +part 'unit_progress.g.dart'; + +@JsonSerializable(createToJson: false) +class UnitProgressModel { + final String id; + final bool isCompleted; + + const UnitProgressModel({ + required this.id, + required this.isCompleted, + }); + + factory UnitProgressModel.fromJson(Map json) => + _$UnitProgressModelFromJson(json); +} diff --git a/learning/tour-of-beam/frontend/lib/models/unit_progress.g.dart b/learning/tour-of-beam/frontend/lib/models/unit_progress.g.dart new file mode 100644 index 0000000000000..c1a773cd66a9c --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/models/unit_progress.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unit_progress.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UnitProgressModel _$UnitProgressModelFromJson(Map json) => + UnitProgressModel( + id: json['id'] as String, + isCompleted: json['isCompleted'] as bool, + ); diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart index bfa63c94df4f4..bcdb686a10b75 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/content_tree.dart @@ -28,6 +28,7 @@ import '../../../models/unit.dart'; class ContentTreeController extends ChangeNotifier { String _sdkId; List _treeIds; + // TODO(nausharipov): non-nullable currentNode? NodeModel? _currentNode; final _contentTreeCache = GetIt.instance.get(); final _expandedIds = {}; @@ -47,6 +48,11 @@ class ContentTreeController extends ChangeNotifier { Sdk get sdk => Sdk.parseOrCreate(_sdkId); String get sdkId => _sdkId; + set sdkId(String newValue) { + _sdkId = newValue; + notifyListeners(); + } + List get treeIds => _treeIds; NodeModel? get currentNode => _currentNode; diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/controllers/unit.dart b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/unit.dart new file mode 100644 index 0000000000000..5331e454ff45f --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/pages/tour/controllers/unit.dart @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/widgets.dart'; +import 'package:get_it/get_it.dart'; + +import '../../../cache/unit_progress.dart'; +import '../../../repositories/client/client.dart'; + +class UnitController extends ChangeNotifier { + final String unitId; + final String sdkId; + + UnitController({ + required this.unitId, + required this.sdkId, + }); + + Future completeUnit() async { + final client = GetIt.instance.get(); + final unitProgressCache = GetIt.instance.get(); + try { + unitProgressCache.addUpdatingUnitId(unitId); + await client.postUnitComplete(sdkId, unitId); + } finally { + await unitProgressCache.updateCompletedUnits(); + unitProgressCache.clearUpdatingUnitId(unitId); + } + } +} diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/screen.dart b/learning/tour-of-beam/frontend/lib/pages/tour/screen.dart index 3557111c0f76a..a73d68efc4b28 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/screen.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/screen.dart @@ -27,37 +27,37 @@ import 'widgets/content_tree.dart'; import 'widgets/playground_demo.dart'; class TourScreen extends StatelessWidget { - final TourNotifier notifier; + final TourNotifier tourNotifier; - const TourScreen(this.notifier); + const TourScreen(this.tourNotifier); @override Widget build(BuildContext context) { return TobScaffold( child: MediaQuery.of(context).size.width > ScreenBreakpoints.twoColumns - ? _WideTour(notifier) - : _NarrowTour(notifier), + ? _WideTour(tourNotifier) + : _NarrowTour(tourNotifier), ); } } class _WideTour extends StatelessWidget { - final TourNotifier notifier; + final TourNotifier tourNotifier; - const _WideTour(this.notifier); + const _WideTour(this.tourNotifier); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ContentTreeWidget(controller: notifier.contentTreeController), + ContentTreeWidget(controller: tourNotifier.contentTreeController), Expanded( child: SplitView( direction: Axis.horizontal, - first: ContentWidget(notifier), + first: ContentWidget(tourNotifier), second: PlaygroundDemoWidget( - playgroundController: notifier.playgroundController, + playgroundController: tourNotifier.playgroundController, ), ), ), @@ -67,9 +67,9 @@ class _WideTour extends StatelessWidget { } class _NarrowTour extends StatelessWidget { - final TourNotifier notifier; + final TourNotifier tourNotifier; - const _NarrowTour(this.notifier); + const _NarrowTour(this.tourNotifier); @override Widget build(BuildContext context) { @@ -79,8 +79,8 @@ class _NarrowTour extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ContentTreeWidget(controller: notifier.contentTreeController), - Expanded(child: ContentWidget(notifier)), + ContentTreeWidget(controller: tourNotifier.contentTreeController), + Expanded(child: ContentWidget(tourNotifier)), ], ), DecoratedBox( diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/state.dart b/learning/tour-of-beam/frontend/lib/pages/tour/state.dart index 242aa860adc9f..48a6dfc3215a7 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/state.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/state.dart @@ -16,22 +16,32 @@ * limitations under the License. */ +import 'dart:async'; + import 'package:app_state/app_state.dart'; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; +import '../../auth/notifier.dart'; import '../../cache/unit_content.dart'; +import '../../cache/unit_progress.dart'; import '../../config.dart'; import '../../models/unit.dart'; import '../../models/unit_content.dart'; +import '../../state.dart'; import 'controllers/content_tree.dart'; +import 'controllers/unit.dart'; import 'path.dart'; class TourNotifier extends ChangeNotifier with PageStateMixin { final ContentTreeController contentTreeController; final PlaygroundController playgroundController; + UnitController? currentUnitController; + final _appNotifier = GetIt.instance.get(); + final _authNotifier = GetIt.instance.get(); final _unitContentCache = GetIt.instance.get(); + final _unitProgressCache = GetIt.instance.get(); UnitContentModel? _currentUnitContent; TourNotifier({ @@ -42,9 +52,11 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { initialTreeIds: initialTreeIds, ), playgroundController = _createPlaygroundController(initialSdkId) { - contentTreeController.addListener(_onChanged); - _unitContentCache.addListener(_onChanged); - _onChanged(); + contentTreeController.addListener(_onUnitChanged); + _unitContentCache.addListener(_onUnitChanged); + _appNotifier.addListener(_onAppNotifierChanged); + _authNotifier.addListener(_onUnitProgressChanged); + _onUnitChanged(); } @override @@ -53,7 +65,30 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { treeIds: contentTreeController.treeIds, ); - void _onChanged() { + String? get currentUnitId => currentUnitController?.unitId; + UnitContentModel? get currentUnitContent => _currentUnitContent; + + void _createCurrentUnitController(String sdkId, String unitId) { + currentUnitController = UnitController( + unitId: unitId, + sdkId: sdkId, + ); + } + + Future _onUnitProgressChanged() async { + await _unitProgressCache.updateCompletedUnits(); + } + + void _onAppNotifierChanged() { + final sdkId = _appNotifier.sdkId; + if (sdkId != null) { + playgroundController.setSdk(Sdk.parseOrCreate(sdkId)); + contentTreeController.sdkId = sdkId; + _onUnitProgressChanged(); + } + } + + void _onUnitChanged() { emitPathChanged(); final currentNode = contentTreeController.currentNode; if (currentNode is UnitModel) { @@ -62,8 +97,8 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { sdk.id, currentNode.id, ); - - _setCurrentUnitContent(content, sdk: sdk); + _createCurrentUnitController(contentTreeController.sdkId, currentNode.id); + _setCurrentUnitContent(content); } else { _emptyPlayground(); } @@ -71,12 +106,7 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { notifyListeners(); } - UnitContentModel? get currentUnitContent => _currentUnitContent; - - void _setCurrentUnitContent( - UnitContentModel? content, { - required Sdk sdk, - }) { + Future _setCurrentUnitContent(UnitContentModel? content) async { if (content == _currentUnitContent) { return; } @@ -89,25 +119,28 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { final taskSnippetId = content.taskSnippetId; if (taskSnippetId == null) { - _emptyPlayground(); + await _emptyPlayground(); return; } - playgroundController.examplesLoader.load( - ExamplesLoadingDescriptor( - descriptors: [ - UserSharedExampleLoadingDescriptor( - sdk: sdk, - snippetId: taskSnippetId, - ), - ], - ), - ); + final selectedSdk = _appNotifier.sdk; + if (selectedSdk != null) { + await playgroundController.examplesLoader.load( + ExamplesLoadingDescriptor( + descriptors: [ + UserSharedExampleLoadingDescriptor( + sdk: selectedSdk, + snippetId: taskSnippetId, + ), + ], + ), + ); + } } // TODO(alexeyinkin): Hide the entire right pane instead. - void _emptyPlayground() { - playgroundController.examplesLoader.load( + Future _emptyPlayground() async { + await playgroundController.examplesLoader.load( ExamplesLoadingDescriptor( descriptors: [ EmptyExampleLoadingDescriptor(sdk: contentTreeController.sdk), @@ -143,11 +176,13 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { examplesLoader: ExamplesLoader(), ); - playgroundController.examplesLoader.load( - ExamplesLoadingDescriptor( - descriptors: [ - EmptyExampleLoadingDescriptor(sdk: Sdk.parseOrCreate(initialSdkId)), - ], + unawaited( + playgroundController.examplesLoader.load( + ExamplesLoadingDescriptor( + descriptors: [ + EmptyExampleLoadingDescriptor(sdk: Sdk.parseOrCreate(initialSdkId)), + ], + ), ), ); @@ -156,8 +191,10 @@ class TourNotifier extends ChangeNotifier with PageStateMixin { @override void dispose() { - _unitContentCache.removeListener(_onChanged); - contentTreeController.removeListener(_onChanged); + _unitContentCache.removeListener(_onUnitChanged); + contentTreeController.removeListener(_onUnitChanged); + _appNotifier.removeListener(_onAppNotifierChanged); + _authNotifier.removeListener(_onUnitProgressChanged); super.dispose(); } } diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/complete_unit_button.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/complete_unit_button.dart new file mode 100644 index 0000000000000..f29c04a56c5ae --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/complete_unit_button.dart @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:playground_components/playground_components.dart'; + +import '../../../cache/unit_progress.dart'; +import '../state.dart'; + +class CompleteUnitButton extends StatelessWidget { + final TourNotifier tourNotifier; + const CompleteUnitButton(this.tourNotifier); + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final unitProgressCache = GetIt.instance.get(); + + return AnimatedBuilder( + animation: unitProgressCache, + builder: (context, child) { + final canComplete = + unitProgressCache.canCompleteUnit(tourNotifier.currentUnitId); + final borderColor = + canComplete ? themeData.primaryColor : themeData.disabledColor; + final onPressed = canComplete + ? tourNotifier.currentUnitController?.completeUnit + : null; + + return Flexible( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: themeData.primaryColor, + side: BorderSide(color: borderColor), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(BeamSizes.size4), + ), + ), + ), + onPressed: onPressed, + child: const Text( + 'pages.tour.completeUnit', + overflow: TextOverflow.visible, + ).tr(), + ), + ); + }, + ); + } +} diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/completeness_indicator.dart similarity index 72% rename from learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart rename to learning/tour-of-beam/frontend/lib/pages/tour/widgets/completeness_indicator.dart index 6f3d6ba56087b..c75eeaf1bb39a 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/tour_progress_indicator.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/completeness_indicator.dart @@ -20,19 +20,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:playground_components/playground_components.dart'; -class TourProgressIndicator extends StatelessWidget { - // TODO(nausharipov): replace assetPath with progress enum - final String assetPath; +import '../../../assets/assets.gen.dart'; + +class CompletenessIndicator extends StatelessWidget { + final bool isCompleted; final bool isSelected; - const TourProgressIndicator({ - required this.assetPath, + const CompletenessIndicator({ + required this.isCompleted, required this.isSelected, }); @override Widget build(BuildContext context) { final ext = Theme.of(context).extension()!; + final Color color; + if (isCompleted) { + color = BeamColors.green; + } else if (isSelected) { + color = ext.selectedProgressColor; + } else { + color = ext.unselectedProgressColor; + } return Padding( padding: const EdgeInsets.only( @@ -40,10 +49,8 @@ class TourProgressIndicator extends StatelessWidget { right: BeamSizes.size8, ), child: SvgPicture.asset( - assetPath, - color: isSelected - ? ext.selectedProgressColor - : ext.unselectedProgressColor, + isCompleted ? Assets.svg.unitProgress100 : Assets.svg.unitProgress0, + color: color, ), ); } diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content.dart index a45b87f8a2a38..8677d4362d9d9 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content.dart @@ -16,18 +16,18 @@ * limitations under the License. */ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:playground_components/playground_components.dart'; import '../../../constants/sizes.dart'; import '../state.dart'; +import 'complete_unit_button.dart'; import 'unit_content.dart'; class ContentWidget extends StatelessWidget { - final TourNotifier notifier; + final TourNotifier tourNotifier; - const ContentWidget(this.notifier); + const ContentWidget(this.tourNotifier); @override Widget build(BuildContext context) { @@ -44,9 +44,9 @@ class ContentWidget extends StatelessWidget { ), ), child: AnimatedBuilder( - animation: notifier, + animation: tourNotifier, builder: (context, child) { - final currentUnitContent = notifier.currentUnitContent; + final currentUnitContent = tourNotifier.currentUnitContent; return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -56,7 +56,7 @@ class ContentWidget extends StatelessWidget { ? Container() : UnitContentWidget(unitContent: currentUnitContent), ), - const _ContentFooter(), + _ContentFooter(tourNotifier), ], ); }, @@ -66,7 +66,8 @@ class ContentWidget extends StatelessWidget { } class _ContentFooter extends StatelessWidget { - const _ContentFooter(); + final TourNotifier tourNotifier; + const _ContentFooter(this.tourNotifier); @override Widget build(BuildContext context) { @@ -85,26 +86,7 @@ class _ContentFooter extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Flexible( - child: OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: themeData.primaryColor, - side: BorderSide(color: themeData.primaryColor), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(BeamSizes.size4), - ), - ), - ), - child: const Text( - 'pages.tour.completeUnit', - overflow: TextOverflow.ellipsis, - ).tr(), - onPressed: () { - // TODO(nausharipov): complete unit - }, - ), - ), + CompleteUnitButton(tourNotifier), ], ), ); diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content_tree.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content_tree.dart index d6cf5fc140326..a40f14d35c60d 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content_tree.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/content_tree.dart @@ -24,6 +24,7 @@ import '../controllers/content_tree.dart'; import 'content_tree_title.dart'; import 'module.dart'; +// TODO(nausharipov): make it collapsible class ContentTreeWidget extends StatelessWidget { final ContentTreeController controller; diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart index 132a1326238d9..df8f014867d8f 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/group_title.dart @@ -17,11 +17,13 @@ */ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; -import '../../../assets/assets.gen.dart'; +import '../../../cache/unit_progress.dart'; import '../../../models/group.dart'; -import 'tour_progress_indicator.dart'; +import '../../../models/node.dart'; +import 'completeness_indicator.dart'; class GroupTitleWidget extends StatelessWidget { final GroupModel group; @@ -38,10 +40,7 @@ class GroupTitleWidget extends StatelessWidget { onTap: onTap, child: Row( children: [ - TourProgressIndicator( - assetPath: Assets.svg.unitProgress0, - isSelected: false, - ), + _GroupProgressIndicator(group: group), Text( group.title, style: Theme.of(context).textTheme.headlineMedium, @@ -51,3 +50,68 @@ class GroupTitleWidget extends StatelessWidget { ); } } + +class _GroupProgressIndicator extends StatelessWidget { + final GroupModel group; + const _GroupProgressIndicator({required this.group}); + + @override + Widget build(BuildContext context) { + final unitProgressCache = GetIt.instance.get(); + + return AnimatedBuilder( + animation: unitProgressCache, + builder: (context, child) { + final progress = _getGroupProgress( + group.nodes, + unitProgressCache.getCompletedUnits(), + ); + + if (progress == 1) { + return const CompletenessIndicator( + isCompleted: true, + isSelected: false, + ); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: BeamSizes.size6), + height: BeamSizes.size8, + width: BeamSizes.size8, + child: CircularProgressIndicator( + strokeWidth: BeamSizes.size3, + color: BeamColors.green, + backgroundColor: Theme.of(context) + .extension()! + .unselectedProgressColor, + value: progress, + ), + ); + }, + ); + } + + double _getGroupProgress( + List groupNodes, + Set completedUnits, + ) { + int completed = 0; + int total = 0; + + void countNodes(List nodes) { + for (final node in nodes) { + if (node is GroupModel) { + countNodes(node.nodes); + } else { + total += 1; + if (completedUnits.contains(node.id)) { + completed += 1; + } + } + } + } + + countNodes(groupNodes); + return completed / total; + } +} diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/playground_demo.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/playground_demo.dart index 4b5347a24810a..c8016a120caf7 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/playground_demo.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/playground_demo.dart @@ -48,7 +48,6 @@ class PlaygroundDemoWidget extends StatelessWidget { first: SnippetEditor( controller: snippetController, isEditable: true, - goToContextLine: false, ), second: OutputWidget( playgroundController: playgroundController, diff --git a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart index 3675fec64ab2a..9952b7f84c281 100644 --- a/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart +++ b/learning/tour-of-beam/frontend/lib/pages/tour/widgets/unit.dart @@ -17,12 +17,13 @@ */ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; -import '../../../assets/assets.gen.dart'; +import '../../../cache/unit_progress.dart'; import '../../../models/unit.dart'; import '../controllers/content_tree.dart'; -import 'tour_progress_indicator.dart'; +import 'completeness_indicator.dart'; class UnitWidget extends StatelessWidget { final UnitModel unit; @@ -35,6 +36,8 @@ class UnitWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final unitProgressCache = GetIt.instance.get(); + return AnimatedBuilder( animation: contentTreeController, builder: (context, child) { @@ -50,9 +53,12 @@ class UnitWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: BeamSizes.size10), child: Row( children: [ - TourProgressIndicator( - assetPath: Assets.svg.unitProgress0, - isSelected: isSelected, + AnimatedBuilder( + animation: unitProgressCache, + builder: (context, child) => CompletenessIndicator( + isCompleted: unitProgressCache.isUnitCompleted(unit.id), + isSelected: isSelected, + ), ), Expanded( child: Text(unit.title), diff --git a/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart b/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart index 30d07ed9b5745..b848a9a582d85 100644 --- a/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart +++ b/learning/tour-of-beam/frontend/lib/pages/welcome/screen.dart @@ -16,53 +16,59 @@ * limitations under the License. */ +import 'package:app_state/app_state.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:get_it/get_it.dart'; import 'package:playground_components/playground_components.dart'; import '../../assets/assets.gen.dart'; +import '../../auth/notifier.dart'; import '../../components/builders/content_tree.dart'; import '../../components/builders/sdks.dart'; +import '../../components/login/content.dart'; import '../../components/scaffold.dart'; import '../../constants/sizes.dart'; import '../../models/module.dart'; +import '../../state.dart'; +import '../tour/page.dart'; import 'state.dart'; class WelcomeScreen extends StatelessWidget { - final WelcomeNotifier notifier; + final WelcomeNotifier welcomeNotifier; - const WelcomeScreen(this.notifier); + const WelcomeScreen(this.welcomeNotifier); @override Widget build(BuildContext context) { return TobScaffold( child: SingleChildScrollView( child: MediaQuery.of(context).size.width > ScreenBreakpoints.twoColumns - ? _WideWelcome(notifier) - : _NarrowWelcome(notifier), + ? _WideWelcome(welcomeNotifier) + : _NarrowWelcome(welcomeNotifier), ), ); } } class _WideWelcome extends StatelessWidget { - final WelcomeNotifier notifier; + final WelcomeNotifier welcomeNotifier; - const _WideWelcome(this.notifier); + const _WideWelcome(this.welcomeNotifier); @override Widget build(BuildContext context) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: const [ Expanded( - child: _SdkSelection(notifier), + child: _SdkSelection(), ), Expanded( - child: _TourSummary(notifier), + child: _TourSummary(), ), ], ), @@ -71,30 +77,29 @@ class _WideWelcome extends StatelessWidget { } class _NarrowWelcome extends StatelessWidget { - final WelcomeNotifier notifier; + final WelcomeNotifier welcomeNotifier; - const _NarrowWelcome(this.notifier); + const _NarrowWelcome(this.welcomeNotifier); @override Widget build(BuildContext context) { return Column( - children: [ - _SdkSelection(notifier), - _TourSummary(notifier), + children: const [ + _SdkSelection(), + _TourSummary(), ], ); } } class _SdkSelection extends StatelessWidget { - final WelcomeNotifier notifier; - - const _SdkSelection(this.notifier); + const _SdkSelection(); static const double _minimalHeight = 900; @override Widget build(BuildContext context) { + final appNotifier = GetIt.instance.get(); return Container( constraints: BoxConstraints( minHeight: MediaQuery.of(context).size.height - @@ -127,12 +132,14 @@ class _SdkSelection extends StatelessWidget { } return AnimatedBuilder( - animation: notifier, - builder: (context, child) => _Buttons( + animation: appNotifier, + builder: (context, child) => _SdkButtons( sdks: sdks, - sdkId: notifier.sdkId, - setSdkId: (v) => notifier.sdkId = v, - onStartPressed: notifier.startTour, + sdkId: appNotifier.sdkId, + setSdkId: (v) => appNotifier.sdkId = v, + onStartPressed: () { + _startTour(appNotifier.sdkId); + }, ), ); }, @@ -144,19 +151,25 @@ class _SdkSelection extends StatelessWidget { ), ); } + + void _startTour(String? sdkId) { + if (sdkId == null) { + return; + } + GetIt.instance.get().push(TourPage(sdkId: sdkId)); + } } class _TourSummary extends StatelessWidget { - final WelcomeNotifier notifier; - - const _TourSummary(this.notifier); + const _TourSummary(); @override Widget build(BuildContext context) { + final appNotifier = GetIt.instance.get(); return AnimatedBuilder( - animation: notifier, + animation: appNotifier, builder: (context, child) { - final sdkId = notifier.sdkId; + final sdkId = appNotifier.sdkId; if (sdkId == null) { return Container(); } @@ -211,13 +224,32 @@ class _IntroText extends StatelessWidget { color: BeamColors.grey2, constraints: const BoxConstraints(maxWidth: _dividerMaxWidth), ), - RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyLarge, - children: [ + const _IntroTextBody(), + ], + ); + } +} + +class _IntroTextBody extends StatelessWidget { + const _IntroTextBody(); + + @override + Widget build(BuildContext context) { + final authNotifier = GetIt.instance.get(); + return AnimatedBuilder( + animation: authNotifier, + builder: (context, child) => RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge, + children: [ + TextSpan( + text: 'pages.welcome.ifSaveProgress'.tr(), + ), + if (authNotifier.isAuthenticated) TextSpan( - text: 'pages.welcome.ifSaveProgress'.tr(), - ), + text: 'pages.welcome.signIn'.tr(), + ) + else TextSpan( text: 'pages.welcome.signIn'.tr(), style: Theme.of(context) @@ -226,25 +258,37 @@ class _IntroText extends StatelessWidget { .copyWith(color: Theme.of(context).primaryColor), recognizer: TapGestureRecognizer() ..onTap = () { - // TODO(nausharipov): sign in + _openLoginDialog(context); }, ), - TextSpan(text: '\n\n${'pages.welcome.selectLanguage'.tr()}'), - ], - ), + TextSpan(text: '\n\n${'pages.welcome.selectLanguage'.tr()}'), + ], ), - ], + ), + ); + } + + void _openLoginDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => Dialog( + child: LoginContent( + onLoggedIn: () { + Navigator.pop(context); + }, + ), + ), ); } } -class _Buttons extends StatelessWidget { +class _SdkButtons extends StatelessWidget { final List sdks; final String? sdkId; final ValueChanged setSdkId; final VoidCallback onStartPressed; - const _Buttons({ + const _SdkButtons({ required this.sdks, required this.sdkId, required this.setSdkId, diff --git a/learning/tour-of-beam/frontend/lib/pages/welcome/state.dart b/learning/tour-of-beam/frontend/lib/pages/welcome/state.dart index d95b288a61bec..0b381a9957432 100644 --- a/learning/tour-of-beam/frontend/lib/pages/welcome/state.dart +++ b/learning/tour-of-beam/frontend/lib/pages/welcome/state.dart @@ -18,32 +18,10 @@ import 'package:app_state/app_state.dart'; import 'package:flutter/widgets.dart'; -import 'package:get_it/get_it.dart'; - -import '../tour/page.dart'; import 'path.dart'; class WelcomeNotifier extends ChangeNotifier with PageStateMixin { - String? _sdkId; - + // TODO(nausharipov): remove state from Welcome? @override PagePath get path => const WelcomePath(); - - String? get sdkId => _sdkId; - - set sdkId(String? newValue) { - _sdkId = newValue; - notifyListeners(); - } - - void startTour() { - final sdkId = _sdkId; - if (sdkId == null) { - return; - } - - GetIt.instance.get().push( - TourPage(sdkId: sdkId), - ); - } } diff --git a/learning/tour-of-beam/frontend/lib/repositories/client/client.dart b/learning/tour-of-beam/frontend/lib/repositories/client/client.dart index bdeb3214316c1..66fd4a9963169 100644 --- a/learning/tour-of-beam/frontend/lib/repositories/client/client.dart +++ b/learning/tour-of-beam/frontend/lib/repositories/client/client.dart @@ -19,6 +19,7 @@ import '../../models/content_tree.dart'; import '../../models/unit_content.dart'; import '../models/get_sdks_response.dart'; +import '../models/get_user_progress_response.dart'; abstract class TobClient { Future getContentTree(String sdkId); @@ -26,4 +27,8 @@ abstract class TobClient { Future getSdks(); Future getUnitContent(String sdkId, String unitId); + + Future getUserProgress(String sdkId); + + Future postUnitComplete(String sdkId, String id); } diff --git a/learning/tour-of-beam/frontend/lib/repositories/client/cloud_functions_client.dart b/learning/tour-of-beam/frontend/lib/repositories/client/cloud_functions_client.dart index e7029fcd829d4..8986de4352905 100644 --- a/learning/tour-of-beam/frontend/lib/repositories/client/cloud_functions_client.dart +++ b/learning/tour-of-beam/frontend/lib/repositories/client/cloud_functions_client.dart @@ -17,16 +17,21 @@ */ import 'dart:convert'; +import 'dart:io'; +import 'package:get_it/get_it.dart'; import 'package:http/http.dart' as http; +import '../../auth/notifier.dart'; import '../../config.dart'; import '../../models/content_tree.dart'; import '../../models/unit_content.dart'; import '../models/get_content_tree_response.dart'; import '../models/get_sdks_response.dart'; +import '../models/get_user_progress_response.dart'; import 'client.dart'; +// TODO(nausharipov): add repository and handle exceptions class CloudFunctionsTobClient extends TobClient { @override Future getSdks() async { @@ -64,4 +69,36 @@ class CloudFunctionsTobClient extends TobClient { final map = jsonDecode(utf8.decode(json.bodyBytes)) as Map; return UnitContentModel.fromJson(map); } + + @override + Future getUserProgress(String sdkId) async { + final token = await GetIt.instance.get().getToken(); + if (token == null) { + return null; + } + final json = await http.get( + Uri.parse( + '$cloudFunctionsBaseUrl/getUserProgress?sdk=$sdkId', + ), + headers: { + HttpHeaders.authorizationHeader: 'Bearer $token', + }, + ); + final map = jsonDecode(utf8.decode(json.bodyBytes)) as Map; + final response = GetUserProgressResponse.fromJson(map); + return response; + } + + @override + Future postUnitComplete(String sdkId, String id) async { + final token = await GetIt.instance.get().getToken(); + await http.post( + Uri.parse( + '$cloudFunctionsBaseUrl/postUnitComplete?sdk=$sdkId&id=$id', + ), + headers: { + HttpHeaders.authorizationHeader: 'Bearer $token', + }, + ); + } } diff --git a/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.dart b/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.dart new file mode 100644 index 0000000000000..b9ef766c99dd0 --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.dart @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:json_annotation/json_annotation.dart'; + +import '../../models/unit_progress.dart'; + +part 'get_user_progress_response.g.dart'; + +@JsonSerializable(createToJson: false) +class GetUserProgressResponse { + final List units; + + const GetUserProgressResponse({required this.units}); + + factory GetUserProgressResponse.fromJson(Map json) => + _$GetUserProgressResponseFromJson(json); +} diff --git a/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.g.dart b/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.g.dart new file mode 100644 index 0000000000000..3f4bfae2e2947 --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/repositories/models/get_user_progress_response.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_user_progress_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetUserProgressResponse _$GetUserProgressResponseFromJson( + Map json) => + GetUserProgressResponse( + units: (json['units'] as List) + .map((e) => UnitProgressModel.fromJson(e as Map)) + .toList(), + ); diff --git a/learning/tour-of-beam/frontend/lib/state.dart b/learning/tour-of-beam/frontend/lib/state.dart new file mode 100644 index 0000000000000..c67b037d8d92b --- /dev/null +++ b/learning/tour-of-beam/frontend/lib/state.dart @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:playground_components/playground_components.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'constants/storage_keys.dart'; + +class AppNotifier extends ChangeNotifier { + String? _sdkId; + + AppNotifier() { + unawaited(_readSdkId()); + } + + // TODO(nausharipov): remove sdkId getter and setter + String? get sdkId => _sdkId; + Sdk? get sdk => Sdk.tryParse(_sdkId); + + set sdkId(String? newValue) { + _sdkId = newValue; + unawaited(_writeSdkId(newValue)); + notifyListeners(); + } + + Future _writeSdkId(String? value) async { + final preferences = await SharedPreferences.getInstance(); + if (value != null) { + await preferences.setString(StorageKeys.sdkId, value); + } else { + await preferences.remove(StorageKeys.sdkId); + } + } + + Future _readSdkId() async { + final preferences = await SharedPreferences.getInstance(); + _sdkId = preferences.getString(StorageKeys.sdkId); + notifyListeners(); + } +} diff --git a/learning/tour-of-beam/frontend/pubspec.lock b/learning/tour-of-beam/frontend/pubspec.lock index 71590abb4eff0..0f19f8a44aabc 100644 --- a/learning/tour-of-beam/frontend/pubspec.lock +++ b/learning/tour-of-beam/frontend/pubspec.lock @@ -8,6 +8,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "46.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.12" aligned_dialog: dependency: transitive description: @@ -28,7 +35,7 @@ packages: name: app_state url: "https://pub.dartlang.org" source: hosted - version: "0.8.1" + version: "0.8.4" archive: dependency: transitive description: @@ -267,6 +274,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.4" + firebase_auth_platform_interface: + dependency: "direct main" + description: + name: firebase_auth_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "6.11.7" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" fixnum: dependency: transitive description: @@ -285,7 +334,7 @@ packages: name: flutter_code_editor url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "0.2.5" flutter_driver: dependency: transitive description: flutter @@ -388,6 +437,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.2" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.4" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + url: "https://pub.dartlang.org" + source: hosted + version: "5.5.1" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.2" googleapis_auth: dependency: transitive description: @@ -651,7 +735,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.3" pool: dependency: transitive description: @@ -694,6 +778,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.1" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" rxdart: dependency: transitive description: diff --git a/learning/tour-of-beam/frontend/pubspec.yaml b/learning/tour-of-beam/frontend/pubspec.yaml index fa059b5153b85..0d6a7329edf27 100644 --- a/learning/tour-of-beam/frontend/pubspec.yaml +++ b/learning/tour-of-beam/frontend/pubspec.yaml @@ -32,11 +32,15 @@ dependencies: easy_localization: ^3.0.1 easy_localization_ext: ^0.1.0 easy_localization_loader: ^1.0.0 + firebase_auth: ^4.1.1 + firebase_auth_platform_interface: ^6.11.7 + firebase_core: ^2.1.1 flutter: { sdk: flutter } flutter_markdown: ^0.6.12 flutter_svg: ^1.0.3 get_it: ^7.2.0 google_fonts: ^3.0.1 + google_sign_in: ^5.4.2 http: ^0.13.5 json_annotation: ^4.7.0 markdown: ^6.0.1 diff --git a/learning/tour-of-beam/frontend/test/main_test.dart b/learning/tour-of-beam/frontend/test/main_test.dart new file mode 100644 index 0000000000000..dc0b1dd00b038 --- /dev/null +++ b/learning/tour-of-beam/frontend/test/main_test.dart @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +void main() { + // TODO(nausharipov): add unit and integration tests: // https://github.com/apache/beam/issues/24982 +} diff --git a/playground/frontend/playground_components/lib/playground_components.dart b/playground/frontend/playground_components/lib/playground_components.dart index 007a3ec29b643..2fab3043cb4c7 100644 --- a/playground/frontend/playground_components/lib/playground_components.dart +++ b/playground/frontend/playground_components/lib/playground_components.dart @@ -17,16 +17,13 @@ */ export 'src/cache/example_cache.dart'; - export 'src/constants/colors.dart'; export 'src/constants/links.dart'; export 'src/constants/sizes.dart'; - export 'src/controllers/example_loaders/examples_loader.dart'; export 'src/controllers/playground_controller.dart'; - +export 'src/controllers/public_notifier.dart'; export 'src/enums/complexity.dart'; - export 'src/models/category_with_examples.dart'; export 'src/models/example.dart'; export 'src/models/example_base.dart'; @@ -46,27 +43,19 @@ export 'src/models/sdk.dart'; export 'src/models/shortcut.dart'; export 'src/models/toast.dart'; export 'src/models/toast_type.dart'; - export 'src/playground_components.dart'; - export 'src/repositories/code_client/grpc_code_client.dart'; export 'src/repositories/code_repository.dart'; export 'src/repositories/example_client/grpc_example_client.dart'; export 'src/repositories/example_repository.dart'; - export 'src/router/router_delegate.dart'; - export 'src/services/symbols/loaders/yaml.dart'; - export 'src/theme/switch_notifier.dart'; export 'src/theme/theme.dart'; - export 'src/util/pipeline_options.dart'; - export 'src/widgets/bubble.dart'; export 'src/widgets/clickable.dart'; export 'src/widgets/complexity.dart'; -export 'src/widgets/dismissible_overlay.dart'; export 'src/widgets/divider.dart'; export 'src/widgets/header_icon_button.dart'; export 'src/widgets/loading_error.dart'; @@ -76,6 +65,9 @@ export 'src/widgets/output/output.dart'; export 'src/widgets/output/output_area.dart'; export 'src/widgets/output/output_tab.dart'; export 'src/widgets/output/output_tabs.dart'; +export 'src/widgets/overlay/body.dart'; +export 'src/widgets/overlay/dismissible.dart'; +export 'src/widgets/overlay/opener.dart'; export 'src/widgets/reset_button.dart'; export 'src/widgets/run_or_cancel_button.dart'; export 'src/widgets/shortcut_tooltip.dart'; diff --git a/playground/frontend/playground_components/lib/src/controllers/public_notifier.dart b/playground/frontend/playground_components/lib/src/controllers/public_notifier.dart new file mode 100644 index 0000000000000..cbc30fe84b9de --- /dev/null +++ b/playground/frontend/playground_components/lib/src/controllers/public_notifier.dart @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +/// Exposes notifyListeners that was protected in the superclass. +/// +/// Use this object when you need to fire callbacks that for some +/// reason cannot listen to the object you write your code in. +class PublicNotifier extends ChangeNotifier { + void notifyPublic() => notifyListeners(); +} diff --git a/learning/tour-of-beam/frontend/test/common/test_screen_wrapper.dart b/playground/frontend/playground_components/lib/src/widgets/overlay/body.dart similarity index 66% rename from learning/tour-of-beam/frontend/test/common/test_screen_wrapper.dart rename to playground/frontend/playground_components/lib/src/widgets/overlay/body.dart index 8146e336c3dfe..5a45c0bcd3042 100644 --- a/learning/tour-of-beam/frontend/test/common/test_screen_wrapper.dart +++ b/playground/frontend/playground_components/lib/src/widgets/overlay/body.dart @@ -17,24 +17,20 @@ */ import 'package:flutter/material.dart'; -import 'package:playground_components/playground_components.dart'; -import 'package:provider/provider.dart'; -class TestScreenWrapper extends StatelessWidget { +import '../../constants/sizes.dart'; + +class OverlayBody extends StatelessWidget { final Widget child; - const TestScreenWrapper({required this.child}); + + const OverlayBody({required this.child}); @override Widget build(BuildContext context) { - return ThemeSwitchNotifierProvider( - child: Consumer( - builder: (context, themeSwitchNotifier, _) { - return MaterialApp( - theme: kLightTheme, - home: child, - ); - }, - ), + return Material( + elevation: BeamSizes.size10, + borderRadius: BorderRadius.circular(BeamSizes.size10), + child: child, ); } } diff --git a/playground/frontend/playground_components/lib/src/widgets/dismissible_overlay.dart b/playground/frontend/playground_components/lib/src/widgets/overlay/dismissible.dart similarity index 97% rename from playground/frontend/playground_components/lib/src/widgets/dismissible_overlay.dart rename to playground/frontend/playground_components/lib/src/widgets/overlay/dismissible.dart index 2119c5314c7e7..e32e55c56a716 100644 --- a/playground/frontend/playground_components/lib/src/widgets/dismissible_overlay.dart +++ b/playground/frontend/playground_components/lib/src/widgets/overlay/dismissible.dart @@ -19,7 +19,7 @@ import 'package:flutter/material.dart'; class DismissibleOverlay extends StatelessWidget { - final void Function() close; + final VoidCallback close; final Positioned child; const DismissibleOverlay({ diff --git a/playground/frontend/playground_components/lib/src/widgets/overlay/opener.dart b/playground/frontend/playground_components/lib/src/widgets/overlay/opener.dart new file mode 100644 index 0000000000000..cb4e107f5f02a --- /dev/null +++ b/playground/frontend/playground_components/lib/src/widgets/overlay/opener.dart @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +import '../../controllers/public_notifier.dart'; +import 'dismissible.dart'; + +void openOverlay({ + required BuildContext context, + required PublicNotifier closeNotifier, + required Positioned positioned, +}) { + final overlay = OverlayEntry( + builder: (context) { + return DismissibleOverlay( + close: closeNotifier.notifyPublic, + child: positioned, + ); + }, + ); + closeNotifier.addListener(overlay.remove); + Overlay.of(context)?.insert(overlay); +}