From 5f013b8008baf4d35ed9f256cb03b2b4d71d026d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= <61871580+Alejandro-FA@users.noreply.github.com> Date: Tue, 29 Oct 2024 01:24:35 +0100 Subject: [PATCH] Add option to change the language between English and Spanish. --- l10n.yaml | 2 +- lib/controllers/localization_controller.dart | 19 ++++++ .../localization_controller.g.dart | 27 ++++++++ lib/l10n/app_en.arb | 7 +- lib/l10n/app_es.arb | 7 +- lib/main.dart | 6 +- lib/pages/cv.dart | 10 ++- lib/repositories/file_repository.g.dart | 2 +- lib/widgets/language_toggle_button.dart | 43 +++++++++++++ lib/widgets/page_scaffold.dart | 40 ++++++------ lib/widgets/sliver_app_bar.dart | 33 ++++++---- package.json | 2 +- test/responsive_test.dart.bak | 64 +++++++++++++++++++ test/widget_test.dart | 49 -------------- 14 files changed, 222 insertions(+), 89 deletions(-) create mode 100644 lib/controllers/localization_controller.dart create mode 100644 lib/controllers/localization_controller.g.dart create mode 100644 lib/widgets/language_toggle_button.dart create mode 100644 test/responsive_test.dart.bak delete mode 100644 test/widget_test.dart diff --git a/l10n.yaml b/l10n.yaml index b58b5f7..ec51e17 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,6 +1,6 @@ arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart -use-deferred-loading: true +use-deferred-loading: false nullable-getter: false format: true diff --git a/lib/controllers/localization_controller.dart b/lib/controllers/localization_controller.dart new file mode 100644 index 0000000..8defbbc --- /dev/null +++ b/lib/controllers/localization_controller.dart @@ -0,0 +1,19 @@ +import 'dart:ui'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'localization_controller.g.dart'; + +@riverpod +class LocalizationController extends _$LocalizationController { + static const locales = [ + Locale('en'), + Locale('es'), + ]; + + @override + // Consider returning null to use the system locale by default + Locale build() => locales[0]; + + void toggleLocale() => state = state == locales[0] ? locales[1] : locales[0]; +} diff --git a/lib/controllers/localization_controller.g.dart b/lib/controllers/localization_controller.g.dart new file mode 100644 index 0000000..be5b56d --- /dev/null +++ b/lib/controllers/localization_controller.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'localization_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$localizationControllerHash() => + r'62bb56d1a30988418a99562ffe447ecb1dd618ae'; + +/// See also [LocalizationController]. +@ProviderFor(LocalizationController) +final localizationControllerProvider = + AutoDisposeNotifierProvider.internal( + LocalizationController.new, + name: r'localizationControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$localizationControllerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$LocalizationController = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 78616b4..5a80309 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,5 +3,10 @@ "@contentPath": { "description": "The base path to content files" }, - "cvFile": "alejandro_fernandez_cv-en.pdf" + "cvFile": "alejandro_fernandez_cv-en.pdf", + "research": "Research", + "projects": "Projects", + "cv": "Curriculum Vitae", + "experience": "Experience", + "education": "Education" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b735df5..4336a64 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,9 @@ { "contentPath": "assets/content/es/", - "cvFile": "alejandro_fernandez_cv-en.pdf" + "cvFile": "alejandro_fernandez_cv-en.pdf", + "research": "Investigación", + "projects": "Proyectos", + "cv": "Curriculum Vitae", + "experience": "Experiencia", + "education": "Educación" } diff --git a/lib/main.dart b/lib/main.dart index 8c7fe07..f66c843 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; +import 'controllers/localization_controller.dart'; import 'navigation/router.dart'; import 'theme/material_theme.dart'; import 'theme/text_theme.dart'; @@ -14,13 +15,13 @@ void main() { runApp(ProviderScope(child: MyApp(router: AppRouter()))); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { const MyApp({required this.router, super.key}); final AppRouter router; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final textTheme = createTextTheme(context, 'Noto Sans', 'Silkscreen'); final theme = MaterialTheme(textTheme); @@ -34,6 +35,7 @@ class MyApp extends StatelessWidget { themeMode: ThemeMode.system, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + locale: ref.watch(localizationControllerProvider), ); } } diff --git a/lib/pages/cv.dart b/lib/pages/cv.dart index ce5640f..b3abafb 100644 --- a/lib/pages/cv.dart +++ b/lib/pages/cv.dart @@ -45,7 +45,10 @@ class CVPage extends ConsumerWidget { Padding( // Check: https://medium.com/geekculture/dynamically-pinned-list-headers-ee5aa23f1db4 padding: const EdgeInsets.symmetric(vertical: 20), - child: Text('Education', style: textTheme.displaySmall), + child: Text( + AppLocalizations.of(context).education, + style: textTheme.displaySmall, + ), ), Timeline( lineColor: theme.colorScheme.surfaceContainerHighest, @@ -88,7 +91,10 @@ After completing my Baccalaureate studies with an honorary distinction, I decide horizontal: 0, vertical: 20, ), - child: Text('Experience', style: textTheme.displaySmall), + child: Text( + AppLocalizations.of(context).experience, + style: textTheme.displaySmall, + ), ), Timeline( lineColor: theme.colorScheme.surfaceContainerHighest, diff --git a/lib/repositories/file_repository.g.dart b/lib/repositories/file_repository.g.dart index fd65407..ce5905b 100644 --- a/lib/repositories/file_repository.g.dart +++ b/lib/repositories/file_repository.g.dart @@ -6,7 +6,7 @@ part of 'file_repository.dart'; // RiverpodGenerator // ************************************************************************** -String _$fileRepositoryHash() => r'445c2f892f9801d97a194ede02364d4262a62385'; +String _$fileRepositoryHash() => r'3e4f0f2c47a5764f3475de7fb02fcc39dad2df76'; /// See also [fileRepository]. @ProviderFor(fileRepository) diff --git a/lib/widgets/language_toggle_button.dart b/lib/widgets/language_toggle_button.dart new file mode 100644 index 0000000..c9e3bba --- /dev/null +++ b/lib/widgets/language_toggle_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../controllers/localization_controller.dart'; + +class LanguageToggleButton extends ConsumerWidget { + const LanguageToggleButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = ref.watch(localizationControllerProvider); + final textTheme = Theme.of(context).textTheme; + final isEnglish = locale.languageCode == 'en'; + + return TextButton( + onPressed: () { + ref.read(localizationControllerProvider.notifier).toggleLocale(); + }, + child: Row( + children: [ + Text( + 'EN', + style: textTheme.bodyMedium?.copyWith( + fontWeight: isEnglish ? FontWeight.bold : null, + decoration: isEnglish ? TextDecoration.underline : null, + decorationColor: textTheme.bodyMedium?.color, + ), + ), + // Text('|', style: textTheme.bodySmall), + const SizedBox(height: 12, child: VerticalDivider(thickness: 1.5)), + Text( + 'ES', + style: textTheme.bodyMedium?.copyWith( + fontWeight: !isEnglish ? FontWeight.bold : null, + decoration: !isEnglish ? TextDecoration.underline : null, + decorationColor: textTheme.bodyMedium?.color, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/page_scaffold.dart b/lib/widgets/page_scaffold.dart index c0cdb87..9c765bc 100644 --- a/lib/widgets/page_scaffold.dart +++ b/lib/widgets/page_scaffold.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../models/route_data.dart'; import '../models/social_media_data.dart'; @@ -21,24 +22,6 @@ class PageScaffold extends StatelessWidget { final bool socialMediaRail; final Widget? floatingActionButton; - static const menuRoutes = [ - RouteData( - name: 'Research', - path: '/research', - icon: Icons.article, - ), - RouteData( - name: 'Projects', - path: '/projects', - icon: Icons.terminal, - ), - RouteData( - name: 'Curriculum Vitae', - path: '/cv', - icon: Icons.school, - ), - ]; - static const socialMedia = [ SocialMediaData( url: 'https://github.com/Alejandro-FA', @@ -57,13 +40,30 @@ class PageScaffold extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final menuRoutes = [ + RouteData( + name: AppLocalizations.of(context).research, + path: '/research', + icon: Icons.article, + ), + RouteData( + name: AppLocalizations.of(context).projects, + path: '/projects', + icon: Icons.terminal, + ), + RouteData( + name: AppLocalizations.of(context).cv, + path: '/cv', + icon: Icons.school, + ), + ]; return Title( title: title, color: theme.colorScheme.primary, child: Scaffold( floatingActionButton: floatingActionButton, - drawer: const MyNavigationDrawer( + drawer: MyNavigationDrawer( menuRoutes: menuRoutes, socialMedia: socialMedia, ), @@ -76,7 +76,7 @@ class PageScaffold extends StatelessWidget { CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - const MySliverAppBar(menuRoutes: menuRoutes), + MySliverAppBar(menuRoutes: menuRoutes), ...slivers, ], ), diff --git a/lib/widgets/sliver_app_bar.dart b/lib/widgets/sliver_app_bar.dart index b646ffb..821c561 100644 --- a/lib/widgets/sliver_app_bar.dart +++ b/lib/widgets/sliver_app_bar.dart @@ -4,6 +4,7 @@ import '../models/route_data.dart'; import '../theme/material_window_class.dart'; import 'better_link.dart'; import 'home_button.dart'; +import 'language_toggle_button.dart'; class MySliverAppBar extends StatelessWidget { const MySliverAppBar({required this.menuRoutes, super.key}); @@ -13,6 +14,26 @@ class MySliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; + final actions = isWideScreen(context) + ? [ + ...menuRoutes.map((route) => _MenuButton(route: route)), + const Padding( + padding: EdgeInsets.only(left: 8), + child: LanguageToggleButton(), + ), + ] + : [ + const Padding( + padding: EdgeInsets.only(right: 8), + child: LanguageToggleButton(), + ), + IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + Scaffold.of(context).openDrawer(); + }, + ), + ]; return SliverAppBar( pinned: false, @@ -26,17 +47,7 @@ class MySliverAppBar extends StatelessWidget { padding: const EdgeInsets.all(16), ), titleSpacing: 0, - actions: [ - if (isWideScreen(context)) - ...menuRoutes.map((route) => _MenuButton(route: route)) - else - IconButton( - icon: const Icon(Icons.menu), - onPressed: () { - Scaffold.of(context).openDrawer(); - }, - ), - ], + actions: actions, ); } diff --git a/package.json b/package.json index 0652118..db56932 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "generate-sitemap": "tsx scripts/generate_sitemap.ts lib/pages/*.dart --baseUrl ${npm_package_config_url}", "minify:js": "esbuild 'build/web/**/*.js' --outdir=build/web/ --minify --tree-shaking=true --allow-overwrite", "minify:mjs": "esbuild 'build/web/**/*.mjs' --outdir=build/web/ --minify --tree-shaking=true --allow-overwrite --out-extension:.js=.mjs", - "code-generation": "dart run build_runner build" + "code-generation": "dart run build_runner build && flutter gen-l10n" }, "repository": { "type": "git", diff --git a/test/responsive_test.dart.bak b/test/responsive_test.dart.bak new file mode 100644 index 0000000..21f45ec --- /dev/null +++ b/test/responsive_test.dart.bak @@ -0,0 +1,64 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:portfolio/models/route_data.dart'; +import 'package:portfolio/widgets/sliver_app_bar.dart'; + +const routesEnglish = [ + RouteData( + name: 'Research', + path: '/research', + icon: Icons.article, + ), + RouteData( + name: 'Projects', + path: '/projects', + icon: Icons.terminal, + ), + RouteData( + name: 'Curriculum Vitae', + path: '/cv', + icon: Icons.school, + ), +]; + +void main() { + testWidgets('ResponsiveAppBar adapts based on screen width', (tester) async { + tester.view.devicePixelRatio = 1.0; + const appTitle = 'Alejandro'; + + // Build our app and trigger a frame. + // Build our app with English top bar and trigger a frame. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: BouncingScrollPhysics(), + slivers: [ + MySliverAppBar(menuRoutes: routesEnglish), + ], + ), + ), + ), + ); + + // Test actions visibility when screen width is greater than the breakpoint + tester.view.physicalSize = const Size(840, 600); + await tester.pump(); + expect(find.byIcon(Icons.menu), findsNothing); + expect(find.text(appTitle), findsOneWidget); + + // Test actions visibility when screen width is less than the breakpoint + tester.view.physicalSize = const Size(839, 600); + await tester.pump(); + expect(find.byIcon(Icons.menu), findsOneWidget); + expect(find.text(appTitle), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 9b7025d..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:portfolio/widgets/page_scaffold.dart'; - -void main() { - testWidgets('ResponsiveAppBar adapts based on screen width', (tester) async { - tester.view.devicePixelRatio = 1.0; - const appTitle = 'My app with a long title'; - - // Build our app and trigger a frame. - await tester.pumpWidget( - const MaterialApp( - home: PageScaffold( - slivers: [ - SliverFillRemaining( - hasScrollBody: true, - child: Center( - child: Text( - appTitle, - style: TextStyle(fontSize: 24), - ), - ), - ), - ], - ), - ), - ); - - // Test actions visibility when screen width is greater than the breakpoint - tester.view.physicalSize = const Size(1200, 800); // width, height - await tester.pump(); - expect(find.byType(TextButton), findsNWidgets(3)); // Should show 3 buttons - expect(find.byIcon(Icons.menu), findsNothing); // No menu icon - expect(find.text(appTitle), findsOneWidget); // Should show title - - // Test actions visibility when screen width is less than the breakpoint - tester.view.physicalSize = const Size(600, 800); // width, height - await tester.pump(); - expect(find.byType(TextButton), findsNothing); // No action buttons - expect(find.byIcon(Icons.menu), findsOneWidget); // Should show menu icon - }); -}