From 13ed7144cb7b6bedc57ee3d90416d484f2827c86 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Wed, 21 Jun 2023 22:07:12 +0200 Subject: [PATCH] feat: Add i18n using slang --- assets/i18n/strings.i18n.json | 56 +++++ build.yaml | 7 + flake.lock | 24 +-- flake.nix | 5 +- lib/i18n/strings.g.dart | 322 +++++++++++++++++++++++++++++ lib/main.dart | 4 + lib/src/data/type.dart | 17 +- lib/src/ui/pages/about.dart | 5 +- lib/src/ui/pages/anime_list.dart | 21 +- lib/src/ui/pages/anime_search.dart | 11 +- lib/src/ui/pages/details.dart | 21 +- lib/src/ui/pages/settings.dart | 36 ++-- pubspec.lock | 40 ++++ pubspec.yaml | 3 + 14 files changed, 493 insertions(+), 79 deletions(-) create mode 100644 assets/i18n/strings.i18n.json create mode 100644 build.yaml create mode 100644 lib/i18n/strings.g.dart diff --git a/assets/i18n/strings.i18n.json b/assets/i18n/strings.i18n.json new file mode 100644 index 0000000..f33043c --- /dev/null +++ b/assets/i18n/strings.i18n.json @@ -0,0 +1,56 @@ +{ + "settings": { + "title": "Settings", + "importAnime": "Import anime list", + "importAnimeDesc": "Import anime list exported from MyAnimeList.", + "invalidAnimeListTitle": "Invalid anime list", + "invalidAnimeListBody": "The selected file is not a MAL anime list. It lacks the \".xml.gz\" suffix.", + "importManga": "Import manga list", + "importMangaDesc": "Import manga list exported from MyAnimeList.", + "invalidMangaListTitle": "Invalid manga list", + "invalidMangaListBody": "The selected file is not a MAL manga list. It lacks the \".xml.gz\" suffix.", + "importIndicator": "$current of $total" + }, + "about": { + "title": "About", + "source": "Source code" + }, + "tooltips": { + "addNewItem": "Add new item" + }, + "content": { + "anime": "Anime", + "manga": "Manga" + }, + "search": { + "anime": "Anime search", + "manga": "Manga search", + "query": "Search query" + }, + "details": { + "title": "Details", + "removeTitle": "Remove $title?", + "removeBody": "Are you sure you want to remove \"$title\" from the list?", + "removeButton": "Remove", + "cancelButton": "Cancel", + "watchState": "Watch state", + "readState": "Read state", + "episodes": "Episodes", + "chapters": "Chapters", + "volumesOwned": "Volumes owned" + }, + "data": { + "ongoing": { + "anime": "Watching", + "manga": "Reading" + }, + "completed": "Completed", + "planned": { + "anime": "Plan to watch", + "manga": "Plan to read" + }, + "dropped": "Dropped", + "paused": "Paused", + "all": "All" + } +} \ No newline at end of file diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..a8af113 --- /dev/null +++ b/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + slang_build_runner: + options: + input_directory: assets/i18n + output_directory: lib/i18n \ No newline at end of file diff --git a/flake.lock b/flake.lock index d17e89e..e80d64b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,15 +1,12 @@ { "nodes": { "flake-utils": { - "inputs": { - "systems": "systems" - }, "locked": { - "lastModified": 1681202837, - "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", "owner": "numtide", "repo": "flake-utils", - "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", "type": "github" }, "original": { @@ -39,21 +36,6 @@ "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 869d2d2..1e3d376 100644 --- a/flake.nix +++ b/flake.nix @@ -38,9 +38,8 @@ devShell = pkgs.mkShell { buildInputs = with pkgs; [ flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android - pythonEnv gnumake # Build scripts - gitlint jq # Code hygiene - ripgrep # General utilities + gitlint jq # Code hygiene + ripgrep # General utilities ]; JAVA_HOME = pinnedJDK; diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart new file mode 100644 index 0000000..b6beb1f --- /dev/null +++ b/lib/i18n/strings.g.dart @@ -0,0 +1,322 @@ +/// Generated file. Do not edit. +/// +/// Locales: 1 +/// Strings: 36 +/// +/// Built on 2023-06-21 at 20:03 UTC + +// coverage:ignore-file +// ignore_for_file: type=lint + +import 'package:flutter/widgets.dart'; +import 'package:slang/builder/model/node.dart'; +import 'package:slang_flutter/slang_flutter.dart'; +export 'package:slang_flutter/slang_flutter.dart'; + +const AppLocale _baseLocale = AppLocale.en; + +/// Supported locales, see extension methods below. +/// +/// Usage: +/// - LocaleSettings.setLocale(AppLocale.en) // set locale +/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum +/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check +enum AppLocale with BaseAppLocale { + en(languageCode: 'en', build: _StringsEn.build); + + const AppLocale({required this.languageCode, this.scriptCode, this.countryCode, required this.build}); // ignore: unused_element + + @override final String languageCode; + @override final String? scriptCode; + @override final String? countryCode; + @override final TranslationBuilder build; + + /// Gets current instance managed by [LocaleSettings]. + _StringsEn get translations => LocaleSettings.instance.translationMap[this]!; +} + +/// Method A: Simple +/// +/// No rebuild after locale change. +/// Translation happens during initialization of the widget (call of t). +/// Configurable via 'translate_var'. +/// +/// Usage: +/// String a = t.someKey.anotherKey; +/// String b = t['someKey.anotherKey']; // Only for edge cases! +_StringsEn get t => LocaleSettings.instance.currentTranslations; + +/// Method B: Advanced +/// +/// All widgets using this method will trigger a rebuild when locale changes. +/// Use this if you have e.g. a settings page where the user can select the locale during runtime. +/// +/// Step 1: +/// wrap your App with +/// TranslationProvider( +/// child: MyApp() +/// ); +/// +/// Step 2: +/// final t = Translations.of(context); // Get t variable. +/// String a = t.someKey.anotherKey; // Use t variable. +/// String b = t['someKey.anotherKey']; // Only for edge cases! +class Translations { + Translations._(); // no constructor + + static _StringsEn of(BuildContext context) => InheritedLocaleData.of(context).translations; +} + +/// The provider for method B +class TranslationProvider extends BaseTranslationProvider { + TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance); + + static InheritedLocaleData of(BuildContext context) => InheritedLocaleData.of(context); +} + +/// Method B shorthand via [BuildContext] extension method. +/// Configurable via 'translate_var'. +/// +/// Usage (e.g. in a widget's build method): +/// context.t.someKey.anotherKey +extension BuildContextTranslationsExtension on BuildContext { + _StringsEn get t => TranslationProvider.of(this).translations; +} + +/// Manages all translation instances and the current locale +class LocaleSettings extends BaseFlutterLocaleSettings { + LocaleSettings._() : super(utils: AppLocaleUtils.instance); + + static final instance = LocaleSettings._(); + + // static aliases (checkout base methods for documentation) + static AppLocale get currentLocale => instance.currentLocale; + static Stream getLocaleStream() => instance.getLocaleStream(); + static AppLocale setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale useDeviceLocale() => instance.useDeviceLocale(); + @Deprecated('Use [AppLocaleUtils.supportedLocales]') static List get supportedLocales => instance.supportedLocales; + @Deprecated('Use [AppLocaleUtils.supportedLocalesRaw]') static List get supportedLocalesRaw => instance.supportedLocalesRaw; + static void setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); +} + +/// Provides utility functions without any side effects. +class AppLocaleUtils extends BaseAppLocaleUtils { + AppLocaleUtils._() : super(baseLocale: _baseLocale, locales: AppLocale.values); + + static final instance = AppLocaleUtils._(); + + // static aliases (checkout base methods for documentation) + static AppLocale parse(String rawLocale) => instance.parse(rawLocale); + static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode); + static AppLocale findDeviceLocale() => instance.findDeviceLocale(); + static List get supportedLocales => instance.supportedLocales; + static List get supportedLocalesRaw => instance.supportedLocalesRaw; +} + +// translations + +// Path: +class _StringsEn implements BaseTranslations { + + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsEn.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.en, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + dynamic operator[](String key) => $meta.getTranslation(key); + + late final _StringsEn _root = this; // ignore: unused_field + + // Translations + late final _StringsSettingsEn settings = _StringsSettingsEn._(_root); + late final _StringsAboutEn about = _StringsAboutEn._(_root); + late final _StringsTooltipsEn tooltips = _StringsTooltipsEn._(_root); + late final _StringsContentEn content = _StringsContentEn._(_root); + late final _StringsSearchEn search = _StringsSearchEn._(_root); + late final _StringsDetailsEn details = _StringsDetailsEn._(_root); + late final _StringsDataEn data = _StringsDataEn._(_root); +} + +// Path: settings +class _StringsSettingsEn { + _StringsSettingsEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get title => 'Settings'; + String get importAnime => 'Import anime list'; + String get importAnimeDesc => 'Import anime list exported from MyAnimeList.'; + String get invalidAnimeListTitle => 'Invalid anime list'; + String get invalidAnimeListBody => 'The selected file is not a MAL anime list. It lacks the ".xml.gz" suffix.'; + String get importManga => 'Import manga list'; + String get importMangaDesc => 'Import manga list exported from MyAnimeList.'; + String get invalidMangaListTitle => 'Invalid manga list'; + String get invalidMangaListBody => 'The selected file is not a MAL manga list. It lacks the ".xml.gz" suffix.'; + String importIndicator({required Object current, required Object total}) => '${current} of ${total}'; +} + +// Path: about +class _StringsAboutEn { + _StringsAboutEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get title => 'About'; + String get source => 'Source code'; +} + +// Path: tooltips +class _StringsTooltipsEn { + _StringsTooltipsEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get addNewItem => 'Add new item'; +} + +// Path: content +class _StringsContentEn { + _StringsContentEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get anime => 'Anime'; + String get manga => 'Manga'; +} + +// Path: search +class _StringsSearchEn { + _StringsSearchEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get anime => 'Anime search'; + String get manga => 'Manga search'; + String get query => 'Search query'; +} + +// Path: details +class _StringsDetailsEn { + _StringsDetailsEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get title => 'Details'; + String removeTitle({required Object title}) => 'Remove ${title}?'; + String removeBody({required Object title}) => 'Are you sure you want to remove "${title}" from the list?'; + String get removeButton => 'Remove'; + String get cancelButton => 'Cancel'; + String get watchState => 'Watch state'; + String get readState => 'Read state'; + String get episodes => 'Episodes'; + String get chapters => 'Chapters'; + String get volumesOwned => 'Volumes owned'; +} + +// Path: data +class _StringsDataEn { + _StringsDataEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + late final _StringsDataOngoingEn ongoing = _StringsDataOngoingEn._(_root); + String get completed => 'Completed'; + late final _StringsDataPlannedEn planned = _StringsDataPlannedEn._(_root); + String get dropped => 'Dropped'; + String get paused => 'Paused'; + String get all => 'All'; +} + +// Path: data.ongoing +class _StringsDataOngoingEn { + _StringsDataOngoingEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get anime => 'Watching'; + String get manga => 'Reading'; +} + +// Path: data.planned +class _StringsDataPlannedEn { + _StringsDataPlannedEn._(this._root); + + final _StringsEn _root; // ignore: unused_field + + // Translations + String get anime => 'Plan to watch'; + String get manga => 'Plan to read'; +} + +/// Flat map(s) containing all translations. +/// Only for edge cases! For simple maps, use the map function of this library. + +extension on _StringsEn { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'settings.title': return 'Settings'; + case 'settings.importAnime': return 'Import anime list'; + case 'settings.importAnimeDesc': return 'Import anime list exported from MyAnimeList.'; + case 'settings.invalidAnimeListTitle': return 'Invalid anime list'; + case 'settings.invalidAnimeListBody': return 'The selected file is not a MAL anime list. It lacks the ".xml.gz" suffix.'; + case 'settings.importManga': return 'Import manga list'; + case 'settings.importMangaDesc': return 'Import manga list exported from MyAnimeList.'; + case 'settings.invalidMangaListTitle': return 'Invalid manga list'; + case 'settings.invalidMangaListBody': return 'The selected file is not a MAL manga list. It lacks the ".xml.gz" suffix.'; + case 'settings.importIndicator': return ({required Object current, required Object total}) => '${current} of ${total}'; + case 'about.title': return 'About'; + case 'about.source': return 'Source code'; + case 'tooltips.addNewItem': return 'Add new item'; + case 'content.anime': return 'Anime'; + case 'content.manga': return 'Manga'; + case 'search.anime': return 'Anime search'; + case 'search.manga': return 'Manga search'; + case 'search.query': return 'Search query'; + case 'details.title': return 'Details'; + case 'details.removeTitle': return ({required Object title}) => 'Remove ${title}?'; + case 'details.removeBody': return ({required Object title}) => 'Are you sure you want to remove "${title}" from the list?'; + case 'details.removeButton': return 'Remove'; + case 'details.cancelButton': return 'Cancel'; + case 'details.watchState': return 'Watch state'; + case 'details.readState': return 'Read state'; + case 'details.episodes': return 'Episodes'; + case 'details.chapters': return 'Chapters'; + case 'details.volumesOwned': return 'Volumes owned'; + case 'data.ongoing.anime': return 'Watching'; + case 'data.ongoing.manga': return 'Reading'; + case 'data.completed': return 'Completed'; + case 'data.planned.anime': return 'Plan to watch'; + case 'data.planned.manga': return 'Plan to read'; + case 'data.dropped': return 'Dropped'; + case 'data.paused': return 'Paused'; + case 'data.all': return 'All'; + default: return null; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index d8fd7c3..04feda1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/service/database.dart'; import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; @@ -37,6 +38,9 @@ void main() async { AnimesLoadedEvent(), ); + WidgetsFlutterBinding.ensureInitialized(); + LocaleSettings.useDeviceLocale(); + runApp( MultiBlocProvider( providers: [ diff --git a/lib/src/data/type.dart b/lib/src/data/type.dart index 16068ca..5eac9a4 100644 --- a/lib/src/data/type.dart +++ b/lib/src/data/type.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:json_annotation/json_annotation.dart'; /// The type of medium we are tracking. Useful for UI stuff. @@ -74,25 +75,25 @@ extension MediumStateExtension on MediumTrackingState { case MediumTrackingState.ongoing: switch (type) { case TrackingMediumType.anime: - return 'Watching'; + return t.data.ongoing.anime; case TrackingMediumType.manga: - return 'Reading'; + return t.data.ongoing.manga; } case MediumTrackingState.completed: - return 'Completed'; + return t.data.completed; case MediumTrackingState.planned: switch (type) { case TrackingMediumType.anime: - return 'Plan to watch'; + return t.data.planned.anime; case TrackingMediumType.manga: - return 'Plan to read'; + return t.data.planned.manga; } case MediumTrackingState.dropped: - return 'Dropped'; + return t.data.dropped; case MediumTrackingState.paused: - return 'Paused'; + return t.data.paused; case MediumTrackingState.all: - return 'All'; + return t.data.all; } } } diff --git a/lib/src/ui/pages/about.dart b/lib/src/ui/pages/about.dart index 0ab7bf9..62a2b28 100644 --- a/lib/src/ui/pages/about.dart +++ b/lib/src/ui/pages/about.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/licenses.g.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:flutter/material.dart'; @@ -19,7 +20,7 @@ class AboutPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('About'), + title: Text(t.about.title), ), body: ListView.builder( itemCount: ossLicenses.length + 1, @@ -41,7 +42,7 @@ class AboutPage extends StatelessWidget { mode: LaunchMode.externalApplication, ); }, - child: const Text('Source'), + child: Text(t.about.source), ), ], ), diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index 69a153f..96e42fa 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; @@ -58,9 +59,9 @@ class AnimeListPageState extends State { String _getPageTitle(TrackingMediumType type) { switch (type) { case TrackingMediumType.anime: - return 'Anime'; + return t.content.anime; case TrackingMediumType.manga: - return 'Manga'; + return t.content.manga; } } @@ -156,14 +157,14 @@ class AnimeListPageState extends State { ), ListTile( leading: const Icon(Icons.settings), - title: const Text('Settings'), + title: Text(t.settings.title), onTap: () { Navigator.of(context).pushNamed(settingsRoute); }, ), ListTile( leading: const Icon(Icons.info), - title: const Text('About'), + title: Text(t.about.title), onTap: () { Navigator.of(context).pushNamed(aboutRoute); }, @@ -294,7 +295,7 @@ class AnimeListPageState extends State { AnimeSearchRequestedEvent(state.trackingType), ); }, - tooltip: 'Add new item', + tooltip: t.tooltips.addNewItem, child: const Icon(Icons.add), ), ); @@ -314,15 +315,15 @@ class AnimeListPageState extends State { _controller.jumpToPage(index); }, - items: const [ + items: [ BottomBarItem( - icon: Icon(Icons.tv), - title: Text('Anime'), + icon: const Icon(Icons.tv), + title: Text(t.content.anime), activeColor: Colors.blue, ), BottomBarItem( - icon: Icon(Icons.auto_stories), - title: Text('Manga'), + icon: const Icon(Icons.auto_stories), + title: Text(t.content.manga), activeColor: Colors.red, ), ], diff --git a/lib/src/ui/pages/anime_search.dart b/lib/src/ui/pages/anime_search.dart index 6c116c7..29c83d5 100644 --- a/lib/src/ui/pages/anime_search.dart +++ b/lib/src/ui/pages/anime_search.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; @@ -25,8 +26,8 @@ class AnimeSearchPage extends StatelessWidget { appBar: AppBar( title: Text( state.trackingType == TrackingMediumType.anime - ? 'Anime Search' - : 'Manga Search', + ? t.search.anime + : t.search.manga, ), ), body: Column( @@ -34,9 +35,9 @@ class AnimeSearchPage extends StatelessWidget { Padding( padding: const EdgeInsets.all(8), child: TextField( - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Search query', + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: t.search.query, ), onSubmitted: (_) { context.read().add( diff --git a/lib/src/ui/pages/details.dart b/lib/src/ui/pages/details.dart index 90bb652..b89dcd2 100644 --- a/lib/src/ui/pages/details.dart +++ b/lib/src/ui/pages/details.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/data/anime.dart'; import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; @@ -26,7 +27,7 @@ class DetailsPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Details'), + title: Text(t.details.title), ), body: BlocBuilder( builder: (context, state) { @@ -68,10 +69,10 @@ class DetailsPage extends StatelessWidget { builder: (context) { return AlertDialog( title: Text( - 'Remove "${state.data!.title}"?', + t.details.removeTitle(title: state.data!.title), ), content: Text( - 'Are you sure you want to remove "${state.data!.title}" from the list?', + t.details.removeBody(title: state.data!.title), ), actions: [ TextButton( @@ -82,14 +83,14 @@ class DetailsPage extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: Colors.red, ), - child: const Text('Remove'), + child: Text(t.details.removeButton), ), TextButton( onPressed: () { Navigator.of(context) .pop(false); }, - child: const Text('Cancel'), + child: Text(t.details.cancelButton), ), ], ); @@ -120,8 +121,8 @@ class DetailsPage extends StatelessWidget { ), child: DropdownSelector( title: state.trackingType == TrackingMediumType.anime - ? 'Watch state' - : 'Read state', + ? t.details.watchState + : t.details.readState, onChanged: (MediumTrackingState newState) { if (state.trackingType == TrackingMediumType.anime) { @@ -182,8 +183,8 @@ class DetailsPage extends StatelessWidget { child: IntegerInput( labelText: state.trackingType == TrackingMediumType.anime - ? 'Episodes' - : 'Chapters', + ? t.details.episodes + : t.details.chapters, onChanged: (value) { switch (state.trackingType) { case TrackingMediumType.anime: @@ -221,7 +222,7 @@ class DetailsPage extends StatelessWidget { vertical: 8, ), child: IntegerInput( - labelText: 'Volumes owned', + labelText: t.details.volumesOwned, onChanged: (value) { final data = state.data! as MangaTrackingData; context.read().add( diff --git a/lib/src/ui/pages/settings.dart b/lib/src/ui/pages/settings.dart index 363c101..2c21884 100644 --- a/lib/src/ui/pages/settings.dart +++ b/lib/src/ui/pages/settings.dart @@ -1,3 +1,4 @@ +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/ui/bloc/settings_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:file_picker/file_picker.dart'; @@ -32,15 +33,13 @@ class SettingsPage extends StatelessWidget { bottom: 0, child: Scaffold( appBar: AppBar( - title: const Text('Settings'), + title: Text(t.settings.title), ), body: ListView( children: [ ListTile( - title: const Text('Import anime list'), - subtitle: const Text( - 'Import anime list exported from MyAnimeList.', - ), + title: Text(t.settings.importAnime), + subtitle: Text(t.settings.importAnimeDesc), onTap: () async { // Pick the file final result = await FilePicker.platform.pickFiles(); @@ -49,11 +48,9 @@ class SettingsPage extends StatelessWidget { if (!result.files.first.path!.endsWith('.xml.gz')) { await showDialog( context: context, - builder: (_) => const AlertDialog( - title: Text('Invalid anime list'), - content: Text( - 'The selected file is not a MAL anime list. It lacks the ".xml.gz" suffix.', - ), + builder: (_) => AlertDialog( + title: Text(t.settings.invalidAnimeListTitle), + content: Text(t.settings.invalidAnimeListBody), ), ); return; @@ -68,10 +65,8 @@ class SettingsPage extends StatelessWidget { }, ), ListTile( - title: const Text('Import manga list'), - subtitle: const Text( - 'Import manga list exported from MyAnimeList.', - ), + title: Text(t.settings.importManga), + subtitle: Text(t.settings.importMangaDesc), onTap: () async { // Pick the file final result = await FilePicker.platform.pickFiles(); @@ -80,11 +75,9 @@ class SettingsPage extends StatelessWidget { if (!result.files.first.path!.endsWith('.xml.gz')) { await showDialog( context: context, - builder: (_) => const AlertDialog( - title: Text('Invalid manga list'), - content: Text( - 'The selected file is not a MAL manga list. It lacks the ".xml.gz" suffix.', - ), + builder: (_) => AlertDialog( + title: Text(t.settings.invalidMangaListTitle), + content: Text(t.settings.invalidMangaListBody), ), ); return; @@ -137,7 +130,10 @@ class SettingsPage extends StatelessWidget { child: CircularProgressIndicator(), ), Text( - '${state.importCurrent} of ${state.importTotal}', + t.settings.importIndicator( + current: state.importCurrent, + total: state.importTotal, + ), style: Theme.of(context).textTheme.bodyMedium, ), ], diff --git a/pubspec.lock b/pubspec.lock index 6484cb3..2c8ae39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + csv: + dependency: transitive + description: + name: csv + sha256: "016b31a51a913744a0a1655c74ff13c9379e1200e246a03d96c81c5d9ed297b5" + url: "https://pub.dev" + source: hosted + version: "5.0.2" cupertino_icons: dependency: "direct main" description: @@ -456,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.5" + json2yaml: + dependency: transitive + description: + name: json2yaml + sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51 + url: "https://pub.dev" + source: hosted + version: "3.0.1" json_annotation: dependency: "direct main" description: @@ -709,6 +725,30 @@ packages: description: flutter source: sdk version: "0.0.99" + slang: + dependency: "direct main" + description: + name: slang + sha256: "187e35d220765ff22be030b30506d1b8e0e94842a2c1801a6a2941c95db5a9eb" + url: "https://pub.dev" + source: hosted + version: "3.19.0" + slang_build_runner: + dependency: "direct dev" + description: + name: slang_build_runner + sha256: "3c48c91736704879b767552bf9e7ba38f1974dd06f44b5e15981cadfde06d760" + url: "https://pub.dev" + source: hosted + version: "3.19.0" + slang_flutter: + dependency: "direct main" + description: + name: slang_flutter + sha256: c6c58162ef66fe88be0313d8062a39e98ae9b539dde7b35f59fa206eb4db2030 + url: "https://pub.dev" + source: hosted + version: "3.19.0" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6c2f485..e4eba01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: get_it: ^7.2.0 jikan_api: ^2.0.0 json_annotation: 4.6.0 + slang: 3.19.0 + slang_flutter: 3.19.0 sqflite: ^2.2.4+1 swipeable_tile: ^2.0.0+3 url_launcher: ^6.1.8 @@ -36,6 +38,7 @@ dev_dependencies: sdk: flutter freezed: ^2.1.0+1 json_serializable: ^6.3.1 + slang_build_runner: 3.19.0 very_good_analysis: ^3.0.1 flutter: