diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f46add..d686f57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: - name: Install Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.3.8' channel: 'stable' - name: Install dependencies diff --git a/.github/workflows/fix-typos.yml b/.github/workflows/fix-typos.yml new file mode 100644 index 0000000..84590bc --- /dev/null +++ b/.github/workflows/fix-typos.yml @@ -0,0 +1,19 @@ +name: Automatically fix typos +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: main + - uses: sobolevn/misspell-fixer-action@master + - uses: peter-evans/create-pull-request@v4.2.0 + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + with: + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 5f0411d..66027d2 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,36 @@ building a navigation menu in `Flutter`. - [3. Creating pages to navigate to](#3-creating-pages-to-navigate-to) - [4. Adding the navigation menu](#4-adding-the-navigation-menu) - [4.1 Using the `drawer` attribute in `Scaffold`](#41-using-the-drawer-attribute-in-scaffold) - - [4.1.1 Creating menu](#411-creating-menu) - - [4.1.2 Run it!](#412-run-it) + - [4.1.1 Creating menu](#411-creating-menu) + - [4.1.2 Run it!](#412-run-it) - [4.2 Slider menu with animation](#42-slider-menu-with-animation) - [4.2.1 Simplify `HomePage` and `App` class](#421-simplify-homepage-and-app-class) - [4.2.2 Creating `AnimationController`](#422-creating-animationcontroller) - [4.2.3 Making our animations *work* with `AnimatedBuilder`](#423-making-our-animations-work-with-animatedbuilder) - [4.2.4 Creating `SlidingMenu`](#424-creating-slidingmenu) - [4.2.5 Run the app!](#425-run-the-app) + - [5. Adding a dynamic section to the menu](#5-adding-a-dynamic-section-to-the-menu) + - [5.1 How the `JSON` will look like](#51-how-the-json-will-look-like) + - [5.2 Dealing with information from the `JSON` file](#52-dealing-with-information-from-the-json-file) + - [5.2.1 `MenuItemInfo` class](#521-menuiteminfo-class) + - [5.2.2 Loading menu items from `JSON` file/local storage](#522-loading-menu-items-from-json-filelocal-storage) + - [5.2.3 Updating menu items](#523-updating-menu-items) + - [5.2.4 Updating menu items](#524-updating-menu-items) + - [5.3 Using loaded `JSON` data in menu](#53-using-loaded-json-data-in-menu) + - [5.4 Displaying menu items](#54-displaying-menu-items) + - [5.5 Reordering items](#55-reordering-items) + - [5.6 Adding text customization](#56-adding-text-customization) + - [5.7 Adding icons to menu items](#57-adding-icons-to-menu-items) + - [6. Adding `i18n` to our app](#6-adding-i18n-to-our-app) + - [6.1 Adding supported languages and delegates](#61-adding-supported-languages-and-delegates) + - [6.2 Creating our custom delegate for localizing our own labels](#62-creating-our-custom-delegate-for-localizing-our-own-labels) + - [6.3 Changing labels according to locale](#63-changing-labels-according-to-locale) + - [6.3.1 A note on `i18n` on dynamic menus](#631-a-note-on-i18n-on-dynamic-menus) + - [6.3.1.1 Translations in `assets/menu_items.json`](#6311-translations-in-assetsmenu_itemsjson) + - [6.3.1.2 Lookup automatic translations of dynamic menu items](#6312-lookup-automatic-translations-of-dynamic-menu-items) + - [6.4 Using `Riverpod` to toggle between languages](#64-using-riverpod-to-toggle-between-languages) + - [7. Fixing decoration when expanding titles](#7-fixing-decoration-when-expanding-titles) + - [8. Adding basic navigation for each menu item](#8-adding-basic-navigation-for-each-menu-item) - [Star the repo! ⭐️](#star-the-repo-️) @@ -139,7 +161,7 @@ checking a `Todo` item as *complete*. The design we're doing should look like the following. -

+

@@ -152,7 +174,7 @@ Regardless, the following constraints ought to be considered: - we will adopt a [**Progressive UI**](https://github.com/dwyl/product-roadmap/issues/43) approach, -where the user will only +where the person will only be shown the option to open the menu *after doing a specific action* (in this case, completing a simple @@ -418,7 +440,7 @@ After this, we are going to be adding a `showMenu` variable in `_HomePageState`, a flag that will let us know if we should show -the option for the user to open the menu. +the option for the person to open the menu. ```dart class _HomePageState extends State { @@ -611,7 +633,7 @@ Let's do it! In the wireframe, the menu currently has three items -that the user can click +that the person can click to navigate into the referring page: - the **Todo List** - the **Feature Tour** page @@ -702,7 +724,7 @@ class SettingsPage extends StatelessWidget { Both pages are very similar. They have some text -and a button that will allow the user +and a button that will allow the person to **navigate back**. These pages will be later used @@ -761,7 +783,7 @@ add this key to the We've also disabled `drawerEnableOpenDragGesture`, so the `Drawer` isn't opened with the right-to-left gesture, -so the user *has* to click the button to open the menu. +so the person *has* to click the button to open the menu. In the `IconButton`, in the `actions` attribute of the `Scaffold`, @@ -798,7 +820,7 @@ go from **right-to-left**, not the other way around, which is what `drawer` does. -## 4.1.1 Creating menu +### 4.1.1 Creating menu In the previous section we've used `DrawerMenu()`, @@ -912,7 +934,7 @@ The menu is essentially consisted of an `AppBar` and `ListView`, with many `ListTile` children, each one pertaining to the different page -the user can navigate into. +the person can navigate into. The `AppBar` is similar to the one found in the `_HomePageState` class. @@ -925,7 +947,7 @@ Each item uses a `Navigator.push()` function to navigate to the pages defined in `lib/pages.dart`. -## 4.1.2 Run it! +### 4.1.2 Run it! And that's it! Wasn't it easy? @@ -1356,6 +1378,2159 @@ and you should see the following result! ![final_sliding](https://user-images.githubusercontent.com/17494745/219359834-a4f7962b-9300-4b91-9f62-60d9d51952ab.gif) + +## 5. Adding a dynamic section to the menu + +You now have working menu! +But what if we want to make it **dynamic** +by reading contents from a `JSON` file +and persisting it on local storage? + +This is what we are going to be focusing on +for the next section. + +Before this, +let's make some preparations: + +- let's move the `sliding_main.dart` and `sliding_menu.dart` +files to a folder called `alt`. +This folder is localed in `lib`, making it `lib/alt`. +We're doing this because we are going to be using +the `Drawer` menu, so we'll just tidy up our workspace. + +- make sure that in your `main.dart`, +you're calling the app like so. + +```dart +void main() { + runApp(const App()); +} +``` + +- install the +[`shared_preferences`](https://pub.dev/packages/shared_preferences) package. +This will make it easy for us to save +stuff in the device's local storage! + +- open `pubspec.yaml` +and add `- assets/` to the `assets:` section. + +```yml + assets: + - assets/images/ + - assets/ +``` + +And now you're ready! + +### 5.1 How the `JSON` will look like + +Let's start with an initial view +of how the `JSON` file will look like. +We are assuming we are going to have +**nested menus up to 3 levels deep**. +For each `Menu Item` we will need: +- a `title`. +- an `id`. +- a field `index_in_level` +referring to the index of the menu item within the level. +- a `tiles` field, +pertaining to the child `menu items`/`tiles` of it. + +If you want to see how the file should look like, +do check [`assets/menu_items.json`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/3cb94b701377889cc7243a0aba9b4facf67314ab/assets/menu_items.json). + + +### 5.2 Dealing with information from the `JSON` file + +Let's create a file called `settings.dart` inside `lib`. +In this file we will create functions +that will load the information from the `JSON` file, +save it in the device's local storage +and update it accordingly. + +#### 5.2.1 `MenuItemInfo` class + +In this file we will create a class called `MenuItemInfo`. +This is the class that will represent +each menu item that is loaded from the `JSON` file. + +Open `lib/settings.dart` +and add the following code to it. + +```dart +/// Class holding the information of the tile +class MenuItemInfo { + late int id; + late int indexInLevel; + late String title; + late List tiles; + + MenuItemInfo({required this.id, required this.title, this.tiles = const []}); + + /// Converts `json` text to BasicTile + MenuItemInfo.fromJson(Map json) { + id = json['id']; + indexInLevel = json['index_in_level']; + title = json['title']; + if (json['tiles'] != null) { + tiles = []; + json['tiles'].forEach((v) { + tiles.add(MenuItemInfo.fromJson(v)); + }); + } + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['index_in_level'] = indexInLevel; + data['title'] = title; + if (tiles.isNotEmpty) { + data['tiles'] = tiles.map((v) => v.toJson()).toList(); + } else { + data['tiles'] = []; + } + return data; + } +} +``` + +We are creating a class field +for each key of the object within the `JSON` file. +The functions `fromJson` and `toJson` +convert the information from the `JSON` file +into a `MenuItemInfo` and decode into a `json` string, +respectively. + +Awesome! 🎉 + + +#### 5.2.2 Loading menu items from `JSON` file/local storage + +Now that we have our own class, +let's create a function to load these menu items +from the file! + +In the same `settings.dart` file, +create the following function. + +```dart +const jsonFilePath = 'assets/menu_items.json'; +const storageKey = 'menuItems'; + +Future> loadMenuItems() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final String? jsonStringFromLocalStorage = prefs.getString(storageKey); + + String jsonString; + // If local storage has content, return it. + if (jsonStringFromLocalStorage != null) { + jsonString = jsonStringFromLocalStorage; + } + + // If not, we initialize it + else { + // Setting local storage key with json string from file + final String jsonStringFromFile = await rootBundle.loadString(jsonFilePath); + prefs.setString(storageKey, jsonStringFromFile); + + jsonString = jsonStringFromFile; + } + + // Converting json to list of MenuItemInfo objects + List data = await json.decode(jsonString); + final List menuItems = data.map((obj) => MenuItemInfo.fromJson(obj)).toList(); + + // Return the MenuItemInfo list + return menuItems; +} +``` + +In our application, +we will persist the `JSON` file string +into the device's local storage +and update it accordingly. +With this in mind, +in the beginning of this function +we check if there is any +`JSON` string in the device's local storage. + +If **the `JSON` string is saved into our local storage**, +we simply use it to later decode it +into a list of `MenuItemInfo` (class we've created previously). +If **the `JSON` string is not saved into our local storage**, +we fetch it from the `assets/menu_items.json` file +and then later decode it in a similar fashion. + +This function returns +a list of `MenuItemInfo`. + + +#### 5.2.3 Updating menu items + +If the person wants to reorder the menu items, +we need to update these changes into our local storage +so it's always up-to-date and reflects the true state +of the list. + +When a person reorders a menu item in any level except the root, +we update the `tiles` list field (which pertains to the children menu items) +of the parent. + +> **Note** +> +> Reordering *root menu items* is much easier +> because we don't need to traverse the tree of menu items. +> We just need to update the indexes of each men item on root level. + +![update_example](https://user-images.githubusercontent.com/17494745/236282208-2160b55f-947f-4e03-80b4-3cbba4c02a14.png) + +Since we are importing information from the `JSON` file, +we don't know upfront how many levels the nested menu has. +Therefore, +we need a way to **traverse it**. + +According to the image, +we traverse the tree of menu items +until we find the menu item with the given `id`. +After the menu item is found, +we update its children with the reordered list. + +Let's create a +[recursive](https://en.wikipedia.org/wiki/Recursion_(computer_science)) +function that will traverse the tree +of menu items and update +a menu item with a given `id`. + +Inside the same file `settings.dart`, +add the following function. + +```dart +MenuItemInfo? _findAndUpdateMenuItem(MenuItemInfo item, int id, List updatedChildren) { + // Breaking case + if (item.id == id) { + item.tiles = updatedChildren; + return item; + } + + // Continue searching + else { + final children = item.tiles; + MenuItemInfo? ret; + for (MenuItemInfo child in children) { + ret = _findAndUpdateMenuItem(child, id, updatedChildren); + if (ret != null) { + break; + } + } + return ret; + } +} +``` + +This function `_findAndUpdateMenuItem` receives +the `id` of the menu item we want to update the children of +and the `updatedChildren` list of menu items. +The function recursively traverses the tree +until it finds the menu item with the `id`. +When it does, +it updates it and stops traversing. + +After execution, +this function returns the updated menu item. + +This function will be *extremely* useful +to update menu item list at any level. + +Let's use it! + + +#### 5.2.4 Updating menu items + +We are going to have widgets +that will render each menu item. + +We are going to have two "types" of menu items: +- **root menu items**, self-explanatory. +- **_n-th_ level menu item**, +which is nested from the second level upwards. + +*Because* we can have multiple root menu items, +we need to create two functions to update menu items: + +- for *root items*, +we simply receive the reordered root menu item list +and update our local storage. +- for *nested menu items*, +we iterate over each root menu item +and recursively try to find the `id` of the menu item +to update its children. +We then save the updated list to local storage. + +Let's implement these functions! + +In `settings.dart`, +add the next two functions: + +```dart +/// Updates the root menu item list [menuItems] in shared preferences. +updateRootObjectsInPreferences(List menuItems) async { + final jsonString = json.encode(menuItems); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setString(storageKey, jsonString); +} + +/// Update deeply nested menu item [item] with a new [updatedChildren] in shared preferences. +updateDeeplyNestedObjectInPreferences(MenuItemInfo itemToUpdate, List updatedChildren) async { + // Fetch the menu items from `.json` file + List menuItems = await loadMenuItems(); + + // Go over the root items list and find & update the object with new children + MenuItemInfo? updatedItem; + for (var item in menuItems) { + updatedItem = _findAndUpdateMenuItem(item, itemToUpdate.id, updatedChildren); + if (updatedItem != null) { + break; + } + } + + // Saving updated menu items encoded to json string. + final jsonString = json.encode(menuItems); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setString(storageKey, jsonString); +} +``` + +`updateRootObjectsInPreferences` receives the reordered menu item list. +It simply saves the updated list to the local storage. +On the other hand, `updateDeeplyNestedObjectInPreferences` +receives the item to update and the reordered children list. +Inside this latter function, +we go over each root menu item and traverse down the tree +to update the menu item's children. +After this, similarly to the previous function, +the updated menu item list is saved to local storage. + +We are going to be using these handful functions *later* +when we are rendering these menu items! + +> e.g. [`lib/settings.dart`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/0630989f1d6f849c7f78dab0d1e8eb632299898c/lib/settings.dart) + +> **Note** +> +> We didn't create an utils class like you can do in other languages. +> For this class to be statically accessed, +> it would *only have static members*. +> +> In Flutter, [we should avoid having classes with only static members.](https://dart-lang.github.io/linter/lints/avoid_classes_with_only_static_members.html). +> Luckily, Dart allows functions to exist outside of classes +> for this very reason. + + +### 5.3 Using loaded `JSON` data in menu + +Let's call the `loadMenuItems()` function +we've defined in `settings.dart` +in `menu.dart`. +Everytime the menu is opened, +we are going to load the menu items +and list them accordingly +in the drawer menu. + +Open `menu.dart`. +We are going to convert the +`DrawerMenu` class into a *stateful widget*. + +```dart +class DrawerMenu extends StatefulWidget { + const DrawerMenu({super.key}); + + @override + State createState() => _DrawerMenuState(); +} + +class _DrawerMenuState extends State { + late Future> menuItems; + + @override + void initState() { + super.initState(); + menuItems = loadMenuItems(); + } + + @override + Widget build(BuildContext context) { + // ... + } +} +``` + +By converting this widget into a stateful +widget. +The `loadMenuitems()` function +is used in the `initState()` overridden function +to fetch the menu items from the `json` or local storage +whenever the menu is mounted. + +These `menuItems` are going to be used in the `build()` function. +Speaking of which, we are going to change this method now! + +First, let's wrap the widgets +inside the `body:` paramater +with a `Column` and `Expanded` widget, +making it like so: + +```dart +body: Column( + children: [ + Expanded( + child: Container( + color: Colors.black, + child: ListView(key: todoTileKey, padding: const EdgeInsets.only(top: 32), children: [ + //... + ] + ) + ) + ) + ] +) +``` + +This is needed because +we don't know how *much height* the dynamic menu will +have within the drawer menu. +Hence why we use `Expanded` to expand +the contents as necessary. + +Inside the `ListView`, +we have an array of children where some menu items +were created (Feature Tour, Settings). +We are going to add the dynamic menu *below* these. +Add the following piece of code at the end of the array. + +```dart +Container( +color: Colors.black, +child: FutureBuilder>( + future: menuItems, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + // If the data is correctly loaded, + // we render a `ReorderableListView` whose children are `MenuItem` tiles. + if (snapshot.hasData) { + List menuItemInfoList = snapshot.data!; + + return DrawerMenuTilesList(key: dynamicMenuItemListKey, menuItemInfoList: menuItemInfoList); + } + + // While it's not loaded (error or waiting) + else { + return const SizedBox.shrink(); + } + })) +``` + +> e.g. [`lib/main.dart`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/0630989f1d6f849c7f78dab0d1e8eb632299898c/lib/main.dart) + + +Because `loadItems()` is an *asynchronous operations*, +we have to wait for it to conclude to properly display the menu items. +For this, we are using the +[`FutureBuilder`](https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html) +class to handle the possible states +of the `Future` class variable that `loadItems()` returns. + +We can generally display a loading button +when fetching the menu items +(for example, it fetches the menu items from an API). +However, to keep it simple here, +we will only render the menu items +if they are correctly fetched (`snapshot.hasData`). +If not, we don't render anything. + +Here, we are rendering a class +called `DynamicMenuTilesList`. +We haven't created it yet. +Let's do that! + +Inside `lib`, +create a file called `dynamic_menu.dart` +and create a simple class. + +```dart +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import 'settings.dart'; + + +// Widget with the list of Menu Item tiles +class DynamicMenuTilesList extends StatefulWidget { + final List menuItemInfoList; + + const DynamicMenuTilesList({super.key, required this.menuItemInfoList}); + + @override + State createState() => _DynamicMenuTilesListState(); +} + +class _DynamicMenuTilesListState extends State { + late List menuItemInfoList; + + @override + void initState() { + super.initState(); + menuItemInfoList = widget.menuItemInfoList; + } + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +This class simply receives +the list of menu items that were loaded +from the local storage +and uses it in its state. +For now, let's just render a +simple `Container` so we know the project *builds*. + +If we run our app, +we should see everything still looks the same. +After all, we're not rendering our dynamic menu items *yet*. + +

+ +

+ +> **Note** +> +> We've removed the +> `margin: const EdgeInsets.only(top: 100),` +> in the second container +> of the `ListView` children array +> just so we can see the dynamic menu better +> without having to scroll. + + +### 5.4 Displaying menu items + +Now let's get to the bread and butter +of this whole section: +*dispaying our menu items*. + +In `lib/dynamic_menu.dart`, +locate the `build()` function +in the `_DynamicMenuTilesListState` class +and change it like so: + +```dart + Widget build(BuildContext context) { + return ReorderableListView( + padding: const EdgeInsets.only(top: 32), + onReorder: (oldIndex, newIndex) {}, + children: menuItemInfoList + .map( + (tile) => MenuItem(key: ValueKey(tile.id), info: tile), + ) + .toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel))); + } +``` + +We are rendering a +[`ReorderableListView`](https://api.flutter.dev/flutter/material/ReorderableListView-class.html) +which, in turn, +renders a list of `MenuItem`s +(don't worry, we'll create this class right away). + +Since `DynamicMenuTilesList` receives a list +of `MenuItemInfo`, +we use `indexInLevel` to sort it by the index +that is defined in the `JSON` file/local storage. + +Essentially, +`DynamicMenuTilesList` +**is rendering the root menu items**. +Nested menu items will be rendered +in the `MenuItem` class. + +This `MenuItem` class +receives a `key` +and the `MenuItemInfo` object. + +Let's create `MenuItem` right now! + +In the same file, +create the stateful widget `MenuItem`. + +```dart +/// Widget that expands if there are child tiles or not. +class MenuItem extends StatefulWidget { + final Key key; + final MenuItemInfo info; + final double leftPadding; + + const MenuItem({required this.key, required this.info, this.leftPadding = 16}) : super(key: key); + + @override + State createState() => _MenuItemState(); +} + +class _MenuItemState extends State { + bool _expanded = false; + + late List menuItemInfoList; + + @override + void initState() { + super.initState(); + menuItemInfoList = widget.info.tiles; + } + + @override + Widget build(BuildContext context) { + // If the tile's children is empty, we render the leaf tile + if (menuItemInfoList.isEmpty) { + return Container( + key: widget.key, + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), + child: ListTile( + contentPadding: EdgeInsets.only(left: widget.leftPadding), + title: Text(widget.info.title, + style: const TextStyle( + fontSize: 25, + color: Colors.white, + ))), + ); + } + + // If the tile has children, we render this as an expandable tile. + else { + return Container( + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), + + // Rendering `ExpansionTile` which expands to render the children. + // The children are rendered in a `ReorderableListView` + // so they can be reordered on the same level. + child: ExpansionTile( + tilePadding: EdgeInsets.only(left: widget.leftPadding), + title: Text(widget.info.title, + style: const TextStyle( + fontSize: 25, + color: Colors.white, + )), + trailing: Icon( + _expanded ? Icons.expand_less : Icons.arrow_drop_down, + color: Colors.white, + ), + children: [ + ReorderableListView( + shrinkWrap: true, + onReorder: (oldIndex, newIndex) {}, + children: menuItemInfoList.map((tile) => MenuItem(key: ValueKey(tile.id), info: tile, leftPadding: widget.leftPadding + 16)).toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel)), + ) + ], + onExpansionChanged: (bool expanded) { + setState(() => _expanded = expanded); + }, + ), + ); + } + } +} +``` + +Phew! 😅 +That's a lot to unpack! + +The reason `MenuItem` is receiving a +[`Key`](https://api.flutter.dev/flutter/foundation/Key-class.html) +is because `ReorderableListView` +*needs it for when the person is reordering items*. +If you want to learn more about why, +please read +https://stackoverflow.com/questions/59444423/reorderablelistview-does-not-identify-keys-in-custom-widget. + +> **Note** +> +> We're going to implement reordering items +> in the next section! + +Additionally, +the `leftPadding` field is used +to add padding in nested menu items. + +In the State class, +we have two fields: +- `_expanded`, +a boolean pertaining to whether the menu item +is expanded or not. +- `childrenMenuItemInfoList`, +pertaining to the list of children menu items +of the given menu item. +This list can be empty. + +Since `childrenMenuItemInfoList` can be empty, +we need to conditionally render a menu item accordingly. +**If it's empty**, we simply render +a `ListTile` with the title of the menu. +**If it's not empty**, +we render an `ExpansionTile` that can be expanded or not +(hence why we use the `_expanded` boolean variable) +wrapped around a `ReorderableListView` +that lists `MenuItems`. + +This is a recursive behaviour. +We are rendering `MenuItems` +that serve as an `ExpansionTile` +or simple `ListTile`. + +Take the following image. + +

+ +

+ + +Every orange box +is a **root menu item** +that are rendered in the `DynamicMenuTilesList` class. +Each blue box +is a `MenuItem` that can either be an `ExpansionTile` +(which renders a list of `MenuItems` in itself) +or a `ListTile` +(which refers to a "leaf node", an item that has no children). + +Let's run the app. +We should be able to see our dynamic menu +and expand each menu item! + +

+ +

+ + +Awesome! 🎉 + +### 5.5 Reordering items + +Because we are using `ReorderableListView` +to render our lists of (nested or not) menu items, +we should be able to allow the people using the app +to reorder the items! + +Open `_DynamicMenuTilesListState` +and locate the `build()` function. +Change it to the following: + +```dart +Widget build(BuildContext context) { + return ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 32), + onReorder: (oldIndex, newIndex) => _reorderTiles(oldIndex, newIndex, menuItemInfoList), + children: menuItemInfoList + .map( + (tile) => MenuItem(key: ValueKey(tile.id), info: tile), + ) + .toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel))); +} +``` + +Because we're adding nested `ReorderableListView`s +inside `ReorderableListView`s, +for reordering to properly work on menu items +*on the same level*, +we need to add `physics: const NeverScrollableScrollPhysics()`. +For more information, visit +https://stackoverflow.com/questions/56726298/nesting-reorderable-lists. + +In `ReorderableListView`, when the person *long presses the menu item* +and drags it, +the `onReorder` callback function is invoked. +We are calling a function called +`_reorderTiles`, which is not yet implemented. +Let's do that! + +In the same class... + +```dart + void _reorderTiles(int oldIndex, int newIndex, List menuItemInfoList) { + // an adjustment is needed when moving the tile down the list + if (oldIndex < newIndex) { + newIndex--; + } + + // get the tile we are moving + final tile = menuItemInfoList.removeAt(oldIndex); + + // place the tile in the new position + menuItemInfoList.insert(newIndex, tile); + + // update the `indexInLevel` field of each item to be in order + menuItemInfoList.asMap().forEach((index, value) => value.indexInLevel = index); + + // Update state + setState(() { + menuItemInfoList = menuItemInfoList; + }); + + // update the menu item object with updated children in the `json` file. + updateRootObjectsInPreferences(menuItemInfoList); + } +``` + +The callback receives the `oldIndex` and the `newIndex` +of the menu item being changed. +If you want to understand how the reordering happens, +no better than this 4-minute explanation +on https://youtu.be/wwUR7841Ajs?t=292. + +What's important to understand here +is that the `indexInLevel` field +of the menu item's children +are updated to match the person's reordering +and then it's updated +on the person preferences +by calling `updateRootObjectsInPreferences`. +The latter function +*receives the updated menu items*. +**Remember we're dealing with root menu items**, +so we just *pass* the updated dynamic list. + +We are going to repeat this process in the `MenuItem` class. +Scroll to `MenuItem`, +locate the `build()` function +and find the `ReorderableListView`. + +```dart +// .... +children: [ + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: (oldIndex, newIndex) => _reorderTiles(oldIndex, newIndex, widget.info), + children: childrenMenuItemInfoList.map((tile) => MenuItem(key: ValueKey(tile.id), info: tile, leftPadding: widget.leftPadding + 16)).toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel)), + ) +], +``` + +Similarly to before, +we've added the `physics` parameter +and referenced a function on `onReorder`, +which we will need to implement. +In the same class, +add `_reorderTiles`. + +```dart + void _reorderTiles(int oldIndex, int newIndex, MenuItemInfo menuItemInfo) { + List menuItemInfoList = menuItemInfo.tiles; + + // an adjustment is needed when moving the tile down the list + if (oldIndex < newIndex) { + newIndex--; + } + + // get the tile we are moving + final tile = menuItemInfoList.removeAt(oldIndex); + + // place the tile in the new position + menuItemInfoList.insert(newIndex, tile); + + // update the `indexInLevel` field of each item to be in order + menuItemInfoList.asMap().forEach((index, value) => value.indexInLevel = index); + + // Update state + setState(() { + menuItemInfoList = menuItemInfoList; + }); + + // update the menu item object with updated children in the `json` file. + updateDeeplyNestedObjectInPreferences(menuItemInfo, menuItemInfoList); + } +``` + +As you can see, +it's *extremely similar* +to the function we've written in the +`DynamicMenuTilesList` class. +The *only difference* is that +we are calling `updateDeeplyNestedObjectInPreferences`, +which we've created previously. + +This reordering happens at +menu items that are **nested**. + +Now let's see what these changes +led us to! +Run the app and you should be able to reorder +menu items by *long pressing* and *dragging* +them on the same level. +And because we are calling the functions +to update the local storage, +these updates are *persisted* +whenever the person closes and reopens the drawer menu! 🥳 + +

+ +

+ +Hurray! +Everything seems to be working. +Let's tweak just *one more thing*: +the background color when the person performs the drag over. +For this, +we need to override the +[`proxyDecorator`](https://api.flutter.dev/flutter/material/ReorderableListView/proxyDecorator.html) +parameter of the `ReorderableListView`. + +In the same file `lib/dynamic_menu.dart`, +outside the classes we've created, +create this function: + +```dart +Widget _proxyDecorator(Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material( + elevation: elevation, + color: const Color.fromARGB(255, 76, 76, 76), + child: child, + ); + }, + child: child, + ); +} +``` + +> **Note** +> +> For more information about this, +> please visit https://github.com/flutter/flutter/issues/45799. + +We're keeping the default settings, +just changing the `color` to a dark gray. + +Now we only need to use this function +inside each `ReorderableListView` +in both `DynamicMenuTilesList` and `MenuItem` classes! + +```dart + ReorderableListView( + proxyDecorator: _proxyDecorator, // add this line to both + physics: const NeverScrollableScrollPhysics(), + onReorder: (oldIndex, newIndex) => _reorderTiles(oldIndex, newIndex, widget.info), + //... + ) +``` + +Your file should look like +[`lib/dynamic_menu.dart`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/2e48da8542ebc9c545b5f7b81ff04897acd944ea/lib/dynamic_menu.dart). + +And we're through! +If we run the app, +you'll verify that the background of the menu item +when dragged is different! + + +

+ +

+ + +And we're done! +We've successfully added a dynamic menu +to our app! +Give yourself a pat on the back! 👏 + + +### 5.6 Adding text customization + +Let's add further customization to our dynamic menu. +This process can be applied to other types of customization +pertaining to each menu item. + +In this small section, +we will focus on +*adding different text colour to each menu item.* + +We need to first add this information to the `JSON` file. +For each object, +add a field called `"text_color"`: + +```json + { + "id": 1, + "index_in_level": 0, + "title": "People", + "text_color": "#Ffb97e", // add this line + "tiles": [] + } +``` + +> [`assets/menu_items.json`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/e536a8d4dc22c324a3c594350e6a9decdbecefbc/assets/menu_items.json) + +This field has an +[hex triplet string](https://en.wikipedia.org/wiki/Web_colors#Hex_triplet) +pertaining to a color. + +We now need to parse this information +into our `MenuItemInfo` class. +For this, open `lib/settings.dart` +and make the following changes: + +```dart +class MenuItemInfo { + late int id; + late int indexInLevel; + late String title; + late Color textColor; // add this line + late List tiles; + + MenuItemInfo({required this.id, required this.title, this.tiles = const []}); + + MenuItemInfo.fromJson(Map json) { + id = json['id']; + indexInLevel = json['index_in_level']; + title = json['title']; + textColor = hexToColor(json['text_color']); // add this line + if (json['tiles'] != null) { + tiles = []; + json['tiles'].forEach((v) { + tiles.add(MenuItemInfo.fromJson(v)); + }); + } + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['index_in_level'] = indexInLevel; + data['title'] = title; + data['text_color'] = '#${textColor.value.toRadixString(16)}'; // add this line + if (tiles.isNotEmpty) { + data['tiles'] = tiles.map((v) => v.toJson()).toList(); + } else { + data['tiles'] = []; + } + return data; + } +} +``` + +Here we are making use of two functions: + +- when importing information from `JSON` file, +we use `hexToColor`. +We will implement this function to convert the hex string +to a [`Colors`](https://api.flutter.dev/flutter/material/Colors-class.html) +class. +- when encoding the class into a `JSON` format, +we convert the `Color` to a an hex string +by using the `toRadixString` function. +For more information, +check https://stackoverflow.com/questions/55147586/flutter-convert-color-to-hex-string. + +Let's implement `hexToColor`. +In the same file, +add this function. + +```dart +Color hexToColor(String hexString) { + try { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } catch (e) { + return const Color(0xFFFFFFFF); + } +} +``` + +This will receive a string +and try to convert to a `Color` object. +If it fails (whether because the string +is empty or invalid), +we default to *the color white*. + +All that's left is to +*use* this new field of the `MenuItemInfo` +in our widget that renders the menu items! + +For this, open `lib/dynamic_menu.dart` +and locate `ListTile` in both widgets +that render the menu item. +Change the for the following: + +```dart +style: TextStyle( + fontSize: 25, + color: widget.info.textColor, +))), +``` + +We are thus using the `widget.info` item menu class +we've changed earlier to render the +converted `textColor` (which is a `Color` object). + +> Check +> [`e536a8d`](https://github.com/dwyl/flutter-navigation-menu-demo/pull/5/commits/e536a8d4dc22c324a3c594350e6a9decdbecefbc) +> for the needed changes. + +If you run the app now, +nothing seems to change. +**This is because we are fetching the information from the local storage**. +The changes we've made to `assets/menu_items.json` +aren't saved because we have our local storage with the previous `JSON` state. + +To fix this, +we simply need to add one line to +`loadMenuItems()` function in `lib/settings.dart`. +Add it like so: + +```dart +Future> loadMenuItems() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + await prefs.remove(storageKey); // add this line +} +``` + +This will **remove the local storage content** +and *force* the app to fetch the information from the `JSON` file. +Run this one time and comment the line again. +This is important, **you need to comment the line again**. +Or else the tests will fail and your menu will always reset +to the contents of the `JSON` file +and *ignore* your drag and drop actions. + +And that's it! +If you run the app, +you should see the titles of the menu item changing! + + +

+ +

+ + +### 5.7 Adding icons to menu items + +Similarly to what we've done before, +let's allow the person to customize the icon for each menu item. +We are expecting this feature to allow support for: +- `emoji`. +- `icons`. +- `images` (up to `64x64px` size). + +For this, we are going go need to pass this information +in the `JSON` file `assets/menu_items.json`. + +```json +{ + "id": 1, + "index_in_level": 0, + "title": "People", + "text_color": "#Ffb97e", + + // This section is added + "icon": { + "colour": "#Ffb97e", + "code": 61668, + "emoji": "🧑‍🤝‍🧑", + "url": "https://cdn-icons-png.flaticon.com/512/4436/4436481.png" + }, +} +``` + +We are adding an `"icon"` field +that has four parameters: +- a `colour` field, pertaining to an hex colour code. +If this field is missing or invalid, +it defaults to a *white colour*. +- a `code`, referring to an int +pertaining the `material icon` class. +You can find each code in +https://api.flutter.dev/flutter/material/Icons-class.html#constants. +- an `emoji` in string format. +- an image `url`, +that is downscaled automatically to `64 x 64px`. + +> e.g. [`assets/menu_items.json`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/081a7eae7c92e1b1eea28514c194cdf4d7072c6b/assets/menu_items.json) + +We are going to need a class in `Dart` +so we can use this new information. +In `lib/settings.dart`, create the following class. + +```dart +class MenuItemInfoIcon { + late final int? code; + late final String? emoji; + late final String? url; + late final String? colour; + + MenuItemInfoIcon({this.code, this.emoji, this.url, this.colour}); + + MenuItemInfoIcon.fromJson(Map json) { + code = json['code']; + emoji = json['emoji']; + url = json['url']; + colour = json['colour']; + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + data['emoji'] = emoji; + data['url'] = url; + data['colour'] = colour; + + return data; + } +} +``` + +This is a simple class with each field that we've explained earlier. +Now let's *add* a field in `MenuItemInfo` +with this new class. +In the same file `lib/settings.dart`, +change `MenuItem` so it looks like so: + +```dart +class MenuItemInfo { + late int id; + late int indexInLevel; + late String title; + late Color textColor; + late MenuItemInfoIcon? _icon; + late List tiles; + + MenuItemInfo({required this.id, required this.title, this.tiles = const []}); + + /// We've migrated the `hexToColor` function to here... + Color _hexToColor(String hexString) { + try { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } catch (e) { + return const Color(0xFFFFFFFF); + } + } + + Widget? getIcon() { + bool iconExists = _icon != null; + + // Check if any icon information exists + if (iconExists) { + + // Icon parameters + int? iconCode = _icon?.code; + String? emojiText = _icon?.emoji; + String? imageUrl = _icon?.url; + String? colourHex = _icon?.colour; + + // Icon colour + Color colour = _hexToColor(colourHex!); + + if (iconCode != null) { + return Icon( + IconData(iconCode, fontFamily: 'MaterialIcons'), + color: colour, + ); + } + + if (emojiText != null) { + return Text(emojiText.toString(), style: TextStyle(color: colour, fontSize: 30)); + } + + if(imageUrl != null) { + return Container( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Image.network(imageUrl, fit: BoxFit.fitHeight, height: 64)); + } + } + + // If there's no icon information, return null + else { + return null; + } + } + + + MenuItemInfo.fromJson(Map json) { + id = json['id']; + indexInLevel = json['index_in_level']; + title = json['title']; + textColor = _hexToColor(json['text_color']); + if (json['tiles'] != null) { + tiles = []; + json['tiles'].forEach((v) { + tiles.add(MenuItemInfo.fromJson(v)); + }); + } + + // Add these new liens + _icon = null; + if (json['icon'] != null) { + _icon = MenuItemInfoIcon.fromJson(json['icon']); + } + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['index_in_level'] = indexInLevel; + data['title'] = title; + data['text_color'] = '#${textColor.value.toRadixString(16)}'; + if (tiles.isNotEmpty) { + data['tiles'] = tiles.map((v) => v.toJson()).toList(); + } else { + data['tiles'] = []; + } + + // Add these new lines + if (_icon != null) { + data['icon'] = _icon!.toJson(); + } + + return data; + } +} +``` + +We've made a few changes to this class: +- we've first migrated the `hexToColour` function +to be a *private function* inside `MenuItemInfo` class. +- added an `_icon` field with type `menuItemInfoIcon` +(class we've just defined). +- in the `MenuItemInfo.fromJson()` function, +we've added lines to parse the `icon` field from the `JSON` file. +- in the `MenuItemInfo.toJson()` function, +we've added lines to encode the `_icon` field into the `JSON` file. +- added a `getIcon()` function that, +depending on the fields that are present in the `icon` object, +will render a referring widget. +Ordering by priority, the `icon` will take precedence over +the `emoji` and the latter from the image `url`. +The `colour` field will change the `icon` colour, +if there's any. +If none of these fields are found, +nothing is rendered. + +The `getIcon()` function will be used in the widgets +in `lib/dynamic_menu.dart`. +In the `_MenuItemState` class, +add `leadileading: widget.info.getIcon(),` +to the `Container`s. + +> See the changes in [081a7ea](https://github.com/dwyl/flutter-navigation-menu-demo/pull/5/commits/081a7eae7c92e1b1eea28514c194cdf4d7072c6b). + +And you should be done! +If you run the application, +you will see that we can now add icons +to the menu items! + +

+ +

+ +Great job! 🥳 + +> **Note** +> +> Similarly to what we've done in the previous section, +> you need to clear the local storage to get the +> most up-to-date `JSON` file contents. +> +> Use `await prefs.remove(storageKey);` for this. + + +## 6. Adding `i18n` to our app + +Even though English is the most popular language currently, +there are still +[billions](https://www.statista.com/statistics/266808/the-most-spoken-languages-worldwide/) +of people who don't speak this language. +It's not fair to leave them out! +So let's add a way for users to toggle between languages. + +> The official `Flutter` docs +> have a page explaining how internationalization works +> on `Flutter` apps. +> Although necessary, it's an interesting read +> and will surely give you context to what we're about to implement! +> +> Visit https://docs.flutter.dev/accessibility-and-localization/internationalization for more information. + +To keep things simple, +we'll allow users to toggle between +**Portuguese** and **English**. + +Let's start by adding +[`flutter_localizations`](https://docs.flutter.dev/accessibility-and-localization/internationalization#setting-up) +to our `pubspec.yml` file +in the `dependencies` section. + +```yml +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter +``` + +We're going to be storing our translation files +inside the `assets/i18n` folder. +Let's give our app access to this folder +by adding this new folder to `pubspec.yml` +in the `assets` folder. + +```yml + assets: + - assets/images/ + - assets/i18n/ + - assets/menu_items.json +``` + +Now that we have everything ready, +let's start writing some code! 🧑‍💻 + + +### 6.1 Adding supported languages and delegates + +Let's head over to `main.dart` and +under the `MaterialApp` widget, +let's set the +[`supportedLocales`](https://api.flutter.dev/flutter/material/MaterialApp/supportedLocales.html) +property. +This property has a list of locales that the app +*has been localized for*. + +By default, if you're running the app on a simulator, +*American English* is supported. +Let's add another one. + +```dart +MaterialApp( + // ... + supportedLocales: const [ + Locale('en', 'US'), + Locale('pt', 'PT'), + ], +) +``` + +We now need to *verify if the person's device locale is supported by our app or not*. +`MaterialApp` has a property called +[`localeResolutionCallback`](https://api.flutter.dev/flutter/widgets/WidgetsApp/localeResolutionCallback.html) +for this. +We will *loop through the `supportedLocales`* +and check if our app supports the person's device locale or not. +If not, we default to `English`. + +```dart +MaterialApp( + // ... + localeResolutionCallback: (deviceLocale, supportedLocales) { + for (var locale in supportedLocales) { + if (locale.languageCode == deviceLocale!.languageCode && locale.countryCode == deviceLocale.countryCode) { + return deviceLocale; + } + } + return supportedLocales.first; + }, +) +``` + +The last setting we need to define under `MaterialApp` +relates to **delegates**. +A localization delegate +is responsible for providing localized values to the app as per the person's locale. +It's essentially *a bridge* between the app and the localization data. + +`Flutter` allows us to create `MaterialApp`s or `CupertinoApp`s, for example. +These have in-built widgets that should also be translated. +For these to be correctly translated, +we need to add delegates for these. +Luckily, `Flutter` provides us default delegates, +as well as a special delegate +(`GlobalWidgetsLocalizations`) +which handles +the direction of the text +(useful in the Arabic language, for example). + +Under `MaterialApp`, +add the following code: + +```dart +MaterialApp( + // ... + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + AppLocalization.delegate + ], +) +``` + +We've also added a delegate from `AppLocalization`. +*This class doesn't exist.* +Let's create it! +This `AppLocalization` class will handle +everything `i18n` related under-the-hood! + + +### 6.2 Creating our custom delegate for localizing our own labels + +Let's create our own custom delegate +to help translate our app's labels into +any language we like. + +For this, create a file called `app_localization.dart` +inside `lib`. + +```dart +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AppLocalization { + late final Locale _locale; + + AppLocalization(this._locale); + + static AppLocalization of(BuildContext context) { + return Localizations.of(context, AppLocalization)!; + } + + late Map _localizedValues; + + + Future loadLanguage() async { + String jsonStringValues = await rootBundle.loadString('assets/i18n/${_locale.languageCode}.json', cache: false); + + Map mappedValues = json.decode(jsonStringValues); + + // converting `dynamic` value to `String`, because `_localizedValues` is of type Map + _localizedValues = mappedValues.map((key, value) => MapEntry(key, value.toString())); + } + + + String? getTranslatedValue(String key) { + return _localizedValues[key]; + } + + static const LocalizationsDelegate delegate = _AppLocalizationDelegate(); +} + + +class _AppLocalizationDelegate extends LocalizationsDelegate { + const _AppLocalizationDelegate(); + + + @override + bool isSupported(Locale locale) { + return ["en", "pt"].contains(locale.languageCode); + } + + + @override + Future load(Locale locale) async { + AppLocalization appLocalization = AppLocalization(locale); + await appLocalization.loadLanguage(); + return appLocalization; + } + + + @override + bool shouldReload(_AppLocalizationDelegate old) => false; +} + +``` + +Here we are creating two classes: +`AppLocalization`, which is our main localization class +in which we will provide `_AppLocalizationDelegate`, +our custom delegate class. + +Let's go over the latter first. +Because we are extending the +[`LocalizationsDelegate`](https://api.flutter.dev/flutter/widgets/LocalizationsDelegate-class.html) class, +we need to override the `isSupported`, `load` and `shouldReload` functions. +These functions are self-explanatory: +- `isSupported` checks if a given locale is supported. +- `load`, which given a locale, it *loads* the language labels +to be displayed +(it calls a function in `AppLocalization` that does this). +- `shouldReload`, +returns true if the resources for this delegate +should be loaded again by calling the `load` method. + +In the `AppLocalization` class, +we offer the custom delegate class +we've defined earlier +and three public functions: + +- `of`, which is a useful method +to access the methods of the class +from widgets in an easier manner. +- `loadLanguage`, +which loads the translation file +according to the given locale. +- `getTranslatedValue`, +which will be used to display the label +translated to the current chosen locale of the device. + +Now we need to add the translation files! +We are going to create two files in `assets/i18n`: +`en.json` and `pt.json`, +the translations for English and Portuguese, respectively. + +Check both files inside [`assets/i18n`](https://github.com/dwyl/flutter-navigation-menu-demo/tree/c40d9dde2f537f3f58cea9ef5575b7dc3b5de575/assets/i18n). + + +### 6.3 Changing labels according to locale + +Now we need to display the localized label +across our app! +We just need to find all the `Text` instances +we want to change according to the locale +and use +`AppLocalization.of(context).getTranslatedValue("JSON_KEY_HERE").toString()`. + +Do this on across the app. +Check +[`c60546`](https://github.com/dwyl/flutter-navigation-menu-demo/pull/5/commits/c60546c65989f336334156ade489c0451993bfe9) +to see the lines you need to change. + + +#### 6.3.1 A note on `i18n` on dynamic menus + +We've just added `i18n` capabilities to labels +that are present in the static pages and menus, +*not on the dynamic menu*. + +If you look at what happens with `Gmail`, +you can create labels +and nest each one like our dynamic menu. + +

+ +

+ +However, these labels **aren't translated**. +And for good reason. +Do they actually need to be translated? +If the person has *defined them*, +he *understands* what he means. + +However, we understand that you might want to give the person +the *option* to toggle between translations +from the dynamic menu items that they provide. +In this case, +we give two ideas that you can try to implement this +on your own! + +> **Note** +> +> These are **suggestions** for implementation +> and should be thought as a fun exercise. +> Feel free to skip this, +> these are very much *optional*. + + +##### 6.3.1.1 Translations in `assets/menu_items.json` + +One option is to have the label translations +from the file that is parsed in the app `menu_items.json`. +Here's how the file would look like: + +```json + { + "id": 1, + "index_in_level": 0, + "title": { + "en": "People", + "pt": "Pessoas" + }, + "text_color": "#Ffb97e", + "icon": { + "colour": "#Ffb97e", + "code": 61668, + "emoji": "🧑‍🤝‍🧑", + "url": "https://cdn-icons-png.flaticon.com/512/4436/4436481.png" + }, + } +``` + +You would need to then parse the `title` +as a `late Map` class variable. + +You would later need to create a function +inside `AppLocalization` to handle these labels, +like so: + +```dart + String getMenuItemTitle(MenuItemInfo item) { + final Map title = item.title; + + return title[_locale.languageCode] ?? ""; + } +``` + +And use it on the `dynamic_menu.dart` widgets, +like: + +```dart +Text(AppLocalization.of(context).getMenuItemTitle(widget.info)) +``` + + +##### 6.3.1.2 Lookup automatic translations of dynamic menu items + +Another possible venue is to +have a set of +**pre-determined values that the app will translate automatically**. + +For example, the person is using the app in `Portuguese` +and has a menu item called `Definitions`. +In our dictionary, the app would detect this word +and translate it to `Definições`. + +For this, you would need to have +files to translate from both `PT` to `EN` +and `EN` to `PT`. +The number of these types of files would grow exponentially +as more languages would be supported. + +For example: + +```json +// en-pt.json +{ + "settings": "definições", + "home": "início", + + "people": "pessoas", + "online now": "online agora" +} +``` + + +```json +// pt-en.json +{ + "settings": "definições", + "home": "início", + + "people": "pessoas", + "online now": "online agora" +} +``` + +We could *use* these key-value pairs +to translate the menu item title automatically. +If the person had `PT` setup, +we would look at the `en-pt.json` file +and try to translate if any of the keys were found. + +However, as it was previously mentioned, +this method isn't sustainable at scale. +If we added another language, +we would need to look at `en-pt.json` +and `fr-pt.json`, for example. + + +### 6.4 Using `Riverpod` to toggle between languages + +We are going to use `Riverpod`, +a state management library, +to make it easy for the person +to change languages within the app. + +If you're not aware of what a state management library is +and/or don't know how `Riverpod` works, +we recommend you visit +[`dwyl/flutter-todo-list-tutorial`](https://github.com/dwyl/flutter-todo-list-tutorial) +to build foundational knowledge on this subject. + +In layman's terms, `Riverpod` +will allow us to create and manage +**global state** across the widget tree. +Any widget in this tree will be able to access and modify this state. +We'll save the **current locale** in this state, +so any widget can change it. + +After installing `Riverpod`, +let's wrap our app with a `ProviderScope` +in `main.dart`, +so it's providers created with `Riverpod` +are accessible throughout the widget tree. + +```dart +void main() { + runApp(const ProviderScope(child: App())); +} +``` + +Below the `main()` function, +we'll create our `Provider` +which will hold the current locale that is chosen by the person. + +```dart +final currentLocaleProvider = StateProvider((_) => const Locale('en', 'US')); +``` + +Now let's use this provider! +In the `App` class, +change the interface it extends. +Instead of: + +```dart +class App extends StatelessWidget +``` + +Change it to: + +```dart +class App extends ConsumerWidget +``` + +This interface is from `Riverpod`, +which will make it easy for us +to access the provider in the `App` widget. + +While we're at it, +let's also change the `HomePage` +and `_HomePageState` extending interfaces. + +```dart +class HomePage extends ConsumerStatefulWidget { + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState with SingleTickerProviderStateMixin +``` + +Now it's time to use our provider value! +Inside `App`'s `build()` function, +let's access the provider +and use it in the +[`locale`](https://api.flutter.dev/flutter/material/MaterialApp/locale.html) +parameter of `MaterialApp`. + +```dart +Widget build(BuildContext context, WidgetRef ref) { + final currentLocale = ref.watch(currentLocaleProvider); // add this + + return MaterialApp( + title: 'Navigation Flutter Menu App', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + debugShowCheckedModeBanner: false, + locale: currentLocale, // add this + ) +} +``` + +Finally, +all that's left is add the two buttons +to toggle between `Portuguese` and `English`. +In `_HomePageState`, +add the following piece of code +at the end of the `children` array +under `Column`. + +```dart + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton( + onPressed: () {ref.read(currentLocaleProvider.notifier).state = const Locale('pt', 'PT');}, + style: ElevatedButton.styleFrom(backgroundColor: const Color.fromARGB(255, 161, 30, 30)), + child: const Text("PT")), + ElevatedButton( + onPressed: () {ref.read(currentLocaleProvider.notifier).state = const Locale('en', 'US');}, + style: ElevatedButton.styleFrom(backgroundColor: const Color.fromARGB(255, 18, 50, 110)), + child: const Text("EN")), + ], + ), + ) +``` + +We're creating two `ElevatedButtons` +and updating the provider value +by using `ref.read(currentLocaleProvider.notifier).state = newValue`. + +> Check [`lib/main.dart`](https://github.com/dwyl/flutter-navigation-menu-demo/blob/c40d9dde2f537f3f58cea9ef5575b7dc3b5de575/lib/main.dart) +> to see how the visit should look like after these changes. + + +And that's it! +If we run the app, +we can see our `i18n` properly working +across all the widget tree! 🎉 + +

+ +

+ + +## 7. Fixing decoration when expanding titles + +You might have noticed that, +by having the borders always showing on top and bottom of each menu item, +when expanding menu items +**an overlap is noticeable**. + +

+ +

+ +You can clearly see +that under `Work` and `Everyone`, +the border is repeated. +This is not intended... + +So let's fix this! + +For this, +we are going to install +[`collection`](https://pub.dev/packages/collection). +This package will give us handy utils +to operate on lists. +We'll be using the `mapIndexed` function, +which will allows us to know the `index` +of a given list element whilst iterating over it. + +Head over to `lib/dynamic_menu.dart`, +locate the `MenuItem` class +and add two fields: +- `isLastInArray`, +a boolean which states if the element +is last in the array of menu items. +- `isFirstInArray`, +self-explanatory. + +```dart +class MenuItem extends StatefulWidget { + final Key key; + final MenuItemInfo info; + final double leftPadding; + final bool isLastInArray; // add this + final bool isFirstInArray; // add this + + const MenuItem({required this.key, required this.info, this.leftPadding = 16, this.isLastInArray = false, this.isFirstInArray = false}) + : super(key: key); + + @override + State createState() => _MenuItemState(); +} +``` + +Now we need to set these new fields +when instantiating `MenuItems`. +Find `_MenuItemState` and its `build()` function. +When rendering a `ReorderableListView`, +we are going to change the `children` parameter +to map over the children list of menu items +to use the `mapIndexed` function. + +```dart +children: childrenMenuItemInfoList.mapIndexed((index, tile) { + // Check if item is first or last in array + final isLastInArray = index == childrenMenuItemInfoList.length - 1; + final isFirstInArray = index == 0; + + // Render menu item + return MenuItem( + key: ValueKey(tile.id), + info: tile, + leftPadding: widget.leftPadding + 16, + isLastInArray: isLastInArray, + isFirstInArray: isFirstInArray, + ); +}).toList() +``` + +Awesome! +Now all that's left is making use of these variables +when rendering the decoration. +Let's migrate the behaviour of the decoration to a private function. +In the same class, +locate both +`decoration` parameters +and change them like so: + +```dart + decoration: _renderBorderDecoration(), +``` + +In the same class `_MenuItemState`, +let's implement this function! + +```dart +BoxDecoration _renderBorderDecoration() { + if (widget.isLastInArray) { + return const BoxDecoration(); + } + + if (widget.isFirstInArray) { + return const BoxDecoration(border: Border(top: BorderSide(color: Colors.white), bottom: BorderSide(color: Colors.white))); + } + + return const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))); +} +``` + +As you can see, we are rendering the container borders +depending on its position within the list. +This way, we prevent the overlap issue that was occuring earlier. + +> If you want to see the changes made, +> check [`6cdd78b`](https://github.com/dwyl/flutter-navigation-menu-demo/pull/5/commits/6cdd78bbc2c9b8b8fcceba20448b77ed8dc3a2f3). + +If you run the app, +you will see that the problem is resolved! + +

+ +

+ +Now the menu items look consistent +across the nested menu item lists! 🥳 + + +## 8. Adding basic navigation for each menu item + +As of now, our menu items aren't really useful at all. +Sure, they are being displayed correctly +but they aren't *navigating anywhere*. + +Luckily for us, +we can easily implement this! +We'll just create a sample page +that will display the title of the given menu item +with a button that will allow the person to go back. + +Head over to `lib/pages.dart` +and create a new *stateless widget* class. + +```dart +class DynamicMenuPage extends StatelessWidget { + final MenuItemInfo menuItem; + + const DynamicMenuPage({super.key, required this.menuItem}); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: dynamicMenuPageKey, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + menuItem.title, + style: const TextStyle(fontSize: 30), + textAlign: TextAlign.center, + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Go back'), + ), + ), + ], + ), + ), + ); + } +} +``` + +This is a simple class, similar to the other pages, +that simply renders a `Text` with the given menu item title. +Now we only need to *use this page* +when rendering a leaf menu item! + +Head over to `lib/dynamic_menu.dart`, +find the `_MenuItemState` class +and its `build()` function, +and make these changes: + +```dart + if (childrenMenuItemInfoList.isEmpty) { + return Container( + key: widget.key, + decoration: _renderBorderDecoration(), + child: ListTile( + contentPadding: EdgeInsets.only(left: widget.leftPadding), + leading: widget.info.getIcon(), + + // Add this `onTap` parameter + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => DynamicMenuPage(menuItem: widget.info)), + ); + }, + + title: Text(widget.info.title, + style: TextStyle( + fontSize: 25, + color: widget.info.textColor, + ))), + ); + } +``` + +We've just added an `onTap` parameter, +which is invoked whenever the user taps on the menu item. +This simply pushes the page we've just created +in the navigation stack, +showing our brand new page! + +If you run the app, +and click on a leaf menu item +(meaning it's not expandable), +the page should be shown! + + +

+ +

+ +Hurray! +We've got ourselves a solid dynamic menu! +Give yourself a pat on the back 👏. + # Star the repo! ⭐️ If you find this package/repo _useful_, diff --git a/assets/i18n/en.json b/assets/i18n/en.json new file mode 100644 index 0000000..151dd20 --- /dev/null +++ b/assets/i18n/en.json @@ -0,0 +1,16 @@ +{ + "title": "This is the main page", + "description": "Check the todo item below to open the menu above to check more pages.", + + "menu.todo": "Todo List (Personal)", + "menu.feature": "Feature Tour", + "menu.settings": "Settings", + + "feature_page.title": "This is the Tour page 🚩", + "feature_page.description": "As you can see, this is just a sample page. You can go back by pressing the button below.", + + "settings_page.title": "This is the Settings page ⚙️", + "settings_page.description": "As you can say, this is just a sample page. You can go back by pressing the button below.", + + "goBack_button": "Go back" +} diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json new file mode 100644 index 0000000..0f93e23 --- /dev/null +++ b/assets/i18n/pt.json @@ -0,0 +1,17 @@ +{ + "title": "Esta é a página inicial.", + "description": "Clica no item abaixo para abrir o menu acima para ver mais páginas.", + + "menu.todo": "Lista de tarefas (Pessoal)", + "menu.feature": "Visão geral", + "menu.settings": "Definições", + + "feature_page.title": "Esta é a página de visão geral 🚩", + "feature_page.description": "Isto é apenas uma página de exemplo. Para voltar, clique no botão abaixo.", + + "settings_page.title": "Esta é a página de configurações ⚙️", + "settings_page.description": "Isto é apenas uma página de exemplo. Para voltar, clique no botão abaixo.", + + "goBack_button": "Voltar" + } + \ No newline at end of file diff --git a/assets/menu_items.json b/assets/menu_items.json new file mode 100644 index 0000000..dcbc3a4 --- /dev/null +++ b/assets/menu_items.json @@ -0,0 +1,83 @@ +[ + { + "id": 1, + "index_in_level": 0, + "title": "People", + "text_color": "#Ffb97e", + "icon": { + "colour": "#Ffb97e", + "code": 61668, + "emoji": "🧑‍🤝‍🧑", + "url": "https://cdn-icons-png.flaticon.com/512/4436/4436481.png" + }, + "tiles": [ + { + "id": 2, + "index_in_level": 0, + "title": "Online Now", + "text_color": "#FFFFFF", + "icon": { + "colour": "#Ffb97e", + "emoji": "🧑‍🤝‍🧑", + "url": "https://cdn-icons-png.flaticon.com/512/4436/4436481.png" + }, + "tiles": [ + { + "id": 3, + "index_in_level": 0, + "title": "Family", + "text_color": "#FFFFFF", + "icon": { + "colour": "#Ffb97e", + "url": "https://cdn-icons-png.flaticon.com/512/4436/4436481.png" + }, + "tiles": [] + }, + { + "id": 4, + "index_in_level": 1, + "title": "Friends", + "text_color": "#FFFFFF", + "tiles": [ + { + "id": 5, + "index_in_level": 0, + "title": "Sports Team", + "text_color": "#FFFFFF", + "tiles": [] + }, + { + "id": 6, + "index_in_level": 1, + "title": "Gamerz", + "text_color": "#FFFFFF", + "tiles": [] + } + ] + }, + { + "id": 7, + "index_in_level": 2, + "title": "Work", + "text_color": "#FFFFFF", + "tiles": [] + } + ] + }, + { + "id": 8, + "index_in_level": 1, + "title": "Everyone", + "text_color": "#FFFFFF", + "tiles": [] + } + ] + }, + { + "id": 9, + "index_in_level": 1, + "title": "Potaro", + "text_color": "#0baedc", + "tiles": [] + } +] diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..88359b2 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..a4dc2c9 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - Flutter (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/ios" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.11.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bdcc5fb..dc520ad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F10048735EEC7E425DEA820C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C34932A8F6C50B180F487E4 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -29,9 +30,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0DC90FE23DEAF0DBC885D721 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 136A31C4DC76660F8C5D59B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3C34932A8F6C50B180F487E4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6D20CA4CE53FC128E186B9DC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -49,12 +54,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F10048735EEC7E425DEA820C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 18C1FD5F6F7A09C5C175B047 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3C34932A8F6C50B180F487E4 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -72,6 +86,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + ABF69AF9474D4B2A487CD9AC /* Pods */, + 18C1FD5F6F7A09C5C175B047 /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +114,17 @@ path = Runner; sourceTree = ""; }; + ABF69AF9474D4B2A487CD9AC /* Pods */ = { + isa = PBXGroup; + children = ( + 0DC90FE23DEAF0DBC885D721 /* Pods-Runner.debug.xcconfig */, + 136A31C4DC76660F8C5D59B9 /* Pods-Runner.release.xcconfig */, + 6D20CA4CE53FC128E186B9DC /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + BCF993DFA6E5F4F1CF475CB9 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + C02FCA6062F3011A9EDE4220 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -199,6 +228,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BCF993DFA6E5F4F1CF475CB9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C02FCA6062F3011A9EDE4220 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/sliding_main.dart b/lib/alt/sliding_main.dart similarity index 100% rename from lib/sliding_main.dart rename to lib/alt/sliding_main.dart diff --git a/lib/sliding_menu.dart b/lib/alt/sliding_menu.dart similarity index 99% rename from lib/sliding_menu.dart rename to lib/alt/sliding_menu.dart index ca40dee..eb92f8b 100644 --- a/lib/sliding_menu.dart +++ b/lib/alt/sliding_menu.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'pages.dart'; +import '../pages.dart'; class SlidingMenu extends StatelessWidget { const SlidingMenu({super.key}); diff --git a/lib/app_localization.dart b/lib/app_localization.dart new file mode 100644 index 0000000..0ac6ed5 --- /dev/null +++ b/lib/app_localization.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Utils class for app localization with delegate +class AppLocalization { + late final Locale _locale; + + AppLocalization(this._locale); + + static AppLocalization of(BuildContext context) { + return Localizations.of(context, AppLocalization)!; + } + + late Map _localizedValues; + + /// This function will load requested language `.json` file and will assign it to the `_localizedValues` map + Future loadLanguage() async { + + // https://stackoverflow.com/questions/60079645/flutter-how-to-mock-a-call-to-rootbundle-loadstring-then-reset-the-mocked + String jsonStringValues = await rootBundle.loadString('assets/i18n/${_locale.languageCode}.json', cache: false); + + Map mappedValues = json.decode(jsonStringValues); + + // converting `dynamic` value to `String`, because `_localizedValues` is of type Map + _localizedValues = mappedValues.map((key, value) => MapEntry(key, value.toString())); + } + + String? getTranslatedValue(String key) { + return _localizedValues[key]; + } + + static const LocalizationsDelegate delegate = _AppLocalizationDelegate(); +} + +/// Private overriden delegate class +class _AppLocalizationDelegate extends LocalizationsDelegate { + const _AppLocalizationDelegate(); + + // It will check if the user's locale is supported by our App or not + @override + bool isSupported(Locale locale) { + return ["en", "pt"].contains(locale.languageCode); + } + + // It will load the equivalent json file requested by the user + @override + Future load(Locale locale) async { + AppLocalization appLocalization = AppLocalization(locale); + await appLocalization.loadLanguage(); + return appLocalization; + } + + // coverage:ignore-start + @override + bool shouldReload(_AppLocalizationDelegate old) => false; + // coverage:ignore-end +} diff --git a/lib/dynamic_menu.dart b/lib/dynamic_menu.dart new file mode 100644 index 0000000..d975b7d --- /dev/null +++ b/lib/dynamic_menu.dart @@ -0,0 +1,232 @@ +import 'dart:ui'; + +import 'package:app/pages.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import 'settings.dart'; + +/// Proxy decorator function that overrides the background color +/// of the hovered menu item. +/// See https://github.com/flutter/flutter/issues/45799. +Widget _proxyDecorator(Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material( + elevation: elevation, + color: const Color.fromARGB(255, 76, 76, 76), + child: child, + ); + }, + child: child, + ); +} + +// Widget with the list of Menu Item tiles +class DynamicMenuTilesList extends StatefulWidget { + final List menuItemInfoList; + + const DynamicMenuTilesList({super.key, required this.menuItemInfoList}); + + @override + State createState() => _DynamicMenuTilesListState(); +} + +class _DynamicMenuTilesListState extends State { + late List menuItemInfoList; + + @override + void initState() { + super.initState(); + menuItemInfoList = widget.menuItemInfoList; + } + + /// Callback function that reorders the tiles + void _reorderTiles(int oldIndex, int newIndex, List menuItemInfoList) { + // an adjustment is needed when moving the tile down the list + if (oldIndex < newIndex) { + newIndex--; + } + + // get the tile we are moving + final tile = menuItemInfoList.removeAt(oldIndex); + + // place the tile in the new position + menuItemInfoList.insert(newIndex, tile); + + // update the `indexInLevel` field of each item to be in order + menuItemInfoList.asMap().forEach((index, value) => value.indexInLevel = index); + + // Update state + setState(() { + menuItemInfoList = menuItemInfoList; + }); + + // update the menu item object with updated children in the `json` file. + updateRootObjectsInPreferences(menuItemInfoList); + } + + @override + Widget build(BuildContext context) { + return ReorderableListView( + shrinkWrap: true, + // https://stackoverflow.com/questions/56726298/nesting-reorderable-lists + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 32), + proxyDecorator: _proxyDecorator, + onReorder: (oldIndex, newIndex) => _reorderTiles(oldIndex, newIndex, menuItemInfoList), + children: menuItemInfoList + .map( + (tile) => MenuItem(key: ValueKey(tile.id), info: tile), + ) + .toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel))); + } +} + +/// Widget that expands if there are child tiles or not. +class MenuItem extends StatefulWidget { + // https://stackoverflow.com/questions/59444423/reorderablelistview-does-not-identify-keys-in-custom-widget + final Key key; + final MenuItemInfo info; + final double leftPadding; + final bool isLastInArray; + final bool isFirstInArray; + + const MenuItem({required this.key, required this.info, this.leftPadding = 16, this.isLastInArray = false, this.isFirstInArray = false}) + : super(key: key); + + @override + State createState() => _MenuItemState(); +} + +class _MenuItemState extends State { + bool _expanded = false; + + late List childrenMenuItemInfoList; + + @override + void initState() { + super.initState(); + childrenMenuItemInfoList = widget.info.tiles; + } + + /// Callback function that reorders the tiles + void _reorderTiles(int oldIndex, int newIndex, MenuItemInfo menuItemInfo) { + List menuItemInfoList = menuItemInfo.tiles; + + // an adjustment is needed when moving the tile down the list + if (oldIndex < newIndex) { + newIndex--; + } + + // get the tile we are moving + final tile = menuItemInfoList.removeAt(oldIndex); + + // place the tile in the new position + menuItemInfoList.insert(newIndex, tile); + + // update the `indexInLevel` field of each item to be in order + menuItemInfoList.asMap().forEach((index, value) => value.indexInLevel = index); + + // Update state + setState(() { + menuItemInfoList = menuItemInfoList; + }); + + // update the menu item object with updated children in the `json` file. + updateDeeplyNestedObjectInPreferences(menuItemInfo, menuItemInfoList); + } + + /// Function that renders the border decoration according to if the menu item is last on the array + BoxDecoration _renderBorderDecoration() { + if (widget.isLastInArray) { + return const BoxDecoration(); + } + + if (widget.isFirstInArray) { + return const BoxDecoration(border: Border(top: BorderSide(color: Colors.white), bottom: BorderSide(color: Colors.white))); + } + + return const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))); + } + + @override + Widget build(BuildContext context) { + // If the tile's children is empty, we render the leaf tile + if (childrenMenuItemInfoList.isEmpty) { + return Container( + key: widget.key, + decoration: _renderBorderDecoration(), + child: ListTile( + contentPadding: EdgeInsets.only(left: widget.leftPadding), + leading: widget.info.getIcon(), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => DynamicMenuPage(menuItem: widget.info)), + ); + }, + title: Text(widget.info.title, + style: TextStyle( + fontSize: 25, + color: widget.info.textColor, + ))), + ); + } + + // If the tile has children, we render this as an expandable tile. + else { + return Container( + decoration: _renderBorderDecoration(), + + // Rendering `ExpansionTile` which expands to render the children. + // The children are rendered in a `ReorderableListView` + // so they can be reordered on the same level. + child: ExpansionTile( + tilePadding: EdgeInsets.only(left: widget.leftPadding, top: 6, bottom: 6), + title: Text(widget.info.title, + style: TextStyle( + fontSize: 25, + color: widget.info.textColor, + )), + trailing: Icon( + _expanded ? Icons.expand_less : Icons.arrow_drop_down, + color: Colors.white, + ), + leading: widget.info.getIcon(), + children: [ + ReorderableListView( + // https://stackoverflow.com/questions/56726298/nesting-reorderable-lists + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + proxyDecorator: _proxyDecorator, + onReorder: (oldIndex, newIndex) => _reorderTiles(oldIndex, newIndex, widget.info), + children: childrenMenuItemInfoList.mapIndexed((index, tile) { + // Check if item is first or last in array + final isLastInArray = index == childrenMenuItemInfoList.length - 1; + final isFirstInArray = index == 0; + + // Render menu item + return MenuItem( + key: ValueKey(tile.id), + info: tile, + leftPadding: widget.leftPadding + 16, + isLastInArray: isLastInArray, + isFirstInArray: isFirstInArray, + ); + }).toList() + ..sort((a, b) => a.info.indexInLevel.compareTo(b.info.indexInLevel)), + ) + ], + onExpansionChanged: (bool expanded) { + setState(() => _expanded = expanded); + }, + ), + ); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index d091c6a..527baea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,40 +1,71 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app_localization.dart'; import 'menu.dart'; const iconKey = Key("menu_icon"); const todoItemKey = Key("todo_item"); const homePageKey = Key("home_page"); +const ptButtonkey = Key("pt_button"); +const enButtonkey = Key("en_button"); // coverage:ignore-start void main() { - runApp(const App()); + runApp(const ProviderScope(child: App())); } -// coverage:ignore-end +// coverage:ignore-end -class App extends StatelessWidget { +/// Provider that tracks the current locale of the app +final currentLocaleProvider = StateProvider((_) => const Locale('en', 'US')); + +class App extends ConsumerWidget { const App({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final currentLocale = ref.watch(currentLocaleProvider); + return MaterialApp( title: 'Navigation Flutter Menu App', theme: ThemeData( primarySwatch: Colors.blue, ), debugShowCheckedModeBanner: false, + locale: currentLocale, + supportedLocales: const [ + Locale('en', 'US'), + Locale('pt', 'PT'), + ], + localeResolutionCallback: (deviceLocale, supportedLocales) { + for (var locale in supportedLocales) { + if (locale.languageCode == deviceLocale!.languageCode && locale.countryCode == deviceLocale.countryCode) { + return deviceLocale; + } + } + // coverage:ignore-start + return supportedLocales.first; + // coverage:ignore-end + }, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + AppLocalization.delegate + ], home: const HomePage()); } } -class HomePage extends StatefulWidget { +class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override - State createState() => _HomePageState(); + ConsumerState createState() => _HomePageState(); } -class _HomePageState extends State with SingleTickerProviderStateMixin { +class _HomePageState extends ConsumerState with SingleTickerProviderStateMixin { bool showMenu = false; final GlobalKey _scaffoldKey = GlobalKey(); @@ -82,16 +113,16 @@ class _HomePageState extends State with SingleTickerProviderStateMixin child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - "This is the main page", - style: TextStyle(fontSize: 30), + Text( + AppLocalization.of(context).getTranslatedValue("title").toString(), + style: const TextStyle(fontSize: 30), ), - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Text( - "Check the todo item below to open the menu above to check more pages.", + AppLocalization.of(context).getTranslatedValue("description").toString(), textAlign: TextAlign.center, - style: TextStyle(fontSize: 15, color: Colors.black87), + style: const TextStyle(fontSize: 15, color: Colors.black87), ), ), ListTile( @@ -107,13 +138,33 @@ class _HomePageState extends State with SingleTickerProviderStateMixin showMenu = true; }); }, + ), + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton( + key: ptButtonkey, + onPressed: () { + ref.read(currentLocaleProvider.notifier).state = const Locale('pt', 'PT'); + }, + style: ElevatedButton.styleFrom(backgroundColor: const Color.fromARGB(255, 161, 30, 30)), + child: const Text("PT")), + ElevatedButton( + key: enButtonkey, + onPressed: () { + ref.read(currentLocaleProvider.notifier).state = const Locale('en', 'US'); + }, + style: ElevatedButton.styleFrom(backgroundColor: const Color.fromARGB(255, 18, 50, 110)), + child: const Text("EN")), + ], + ), ) ], ), ), - endDrawer: SizedBox( - width: MediaQuery.of(context).size.width * 1.0, - child: const Drawer(child: DrawerMenu())), + endDrawer: SizedBox(width: MediaQuery.of(context).size.width * 1.0, child: const Drawer(child: DrawerMenu())), ); } } diff --git a/lib/menu.dart b/lib/menu.dart index 3b9c1bc..12130f0 100644 --- a/lib/menu.dart +++ b/lib/menu.dart @@ -1,17 +1,35 @@ import 'package:flutter/material.dart'; +import 'app_localization.dart'; import 'pages.dart'; +import 'dynamic_menu.dart'; +import 'settings.dart'; const drawerMenuKey = Key("drawer_menu"); +const closeMenuKey = Key("close_key_icon"); + const todoTileKey = Key("todo_tile"); const tourTileKey = Key("tour_tile"); const settingsTileKey = Key("settings_tile"); -const closeMenuKey = Key("close_key_icon"); +const dynamicMenuItemListKey = Key('dynamic_menu_item_list'); -class DrawerMenu extends StatelessWidget { +class DrawerMenu extends StatefulWidget { const DrawerMenu({super.key}); + @override + State createState() => _DrawerMenuState(); +} + +class _DrawerMenuState extends State { + late Future> menuItems; + + @override + void initState() { + super.initState(); + menuItems = loadMenuItems(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -34,73 +52,96 @@ class DrawerMenu extends StatelessWidget { ), ), ]), - body: Container( - color: Colors.black, - child: ListView(key: todoTileKey, padding: const EdgeInsets.only(top: 32), children: [ - Container( - padding: const EdgeInsets.only(top: 15, bottom: 15), - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white), top: BorderSide(color: Colors.white))), - child: const ListTile( - leading: Icon( - Icons.check_outlined, - color: Colors.white, - size: 50, - ), - title: Text('Todo List (Personal)', - style: TextStyle( - fontSize: 30, - color: Colors.white, - )), - ), - ), - Container( - margin: const EdgeInsets.only(top: 100), - padding: const EdgeInsets.only(top: 15, bottom: 15), - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), - child: ListTile( - key: tourTileKey, - leading: const Icon( - Icons.flag_outlined, - color: Colors.white, - size: 40, - ), - title: const Text('Feature Tour', - style: TextStyle( - fontSize: 25, - color: Colors.white, - )), - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const TourPage()), - ); - }, - ), - ), - Container( - padding: const EdgeInsets.only(top: 15, bottom: 15), - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), - child: ListTile( - key: settingsTileKey, - leading: const Icon( - Icons.settings, - color: Colors.white, - size: 40, - ), - title: const Text('Settings', - style: TextStyle( - fontSize: 25, - color: Colors.white, - )), - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsPage()), - ); - }, - ), - ), - ])), + body: Column( + children: [ + Expanded( + child: Container( + color: Colors.black, + child: ListView(key: todoTileKey, padding: const EdgeInsets.only(top: 32), children: [ + Container( + padding: const EdgeInsets.only(top: 15, bottom: 15), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white), top: BorderSide(color: Colors.white))), + child: ListTile( + leading: const Icon( + Icons.check_outlined, + color: Colors.white, + size: 50, + ), + title: Text(AppLocalization.of(context).getTranslatedValue("menu.todo").toString(), + style: const TextStyle( + fontSize: 30, + color: Colors.white, + )), + ), + ), + Container( + padding: const EdgeInsets.only(top: 15, bottom: 15), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), + child: ListTile( + key: tourTileKey, + leading: const Icon( + Icons.flag_outlined, + color: Colors.white, + size: 40, + ), + title: Text(AppLocalization.of(context).getTranslatedValue("menu.feature").toString(), + style: const TextStyle( + fontSize: 25, + color: Colors.white, + )), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TourPage()), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.only(top: 15, bottom: 15), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), + child: ListTile( + key: settingsTileKey, + leading: const Icon( + Icons.settings, + color: Colors.white, + size: 40, + ), + title: Text(AppLocalization.of(context).getTranslatedValue("menu.settings").toString(), + style: const TextStyle( + fontSize: 25, + color: Colors.white, + )), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsPage()), + ); + }, + ), + ), + Container( + color: Colors.black, + child: FutureBuilder>( + future: menuItems, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + // If the data is correctly loaded, + // we render a `ReorderableListView` whose children are `MenuItem` tiles. + if (snapshot.hasData) { + List menuItemInfoList = snapshot.data!; + + return DynamicMenuTilesList(key: dynamicMenuItemListKey, menuItemInfoList: menuItemInfoList); + } + + // While it's not loaded (error or waiting) + else { + return const SizedBox.shrink(); + } + })) + ])), + ), + ], + ), ); } } diff --git a/lib/pages.dart b/lib/pages.dart index 69a4d7f..36dac3c 100644 --- a/lib/pages.dart +++ b/lib/pages.dart @@ -1,7 +1,11 @@ +import 'package:app/settings.dart'; import 'package:flutter/material.dart'; +import 'app_localization.dart'; + const tourPageKey = Key("tour_page"); const settingsPageKey = Key("settings_page"); +const dynamicMenuPageKey = Key("dynamic_menu_page"); class TourPage extends StatelessWidget { const TourPage({super.key}); @@ -14,16 +18,17 @@ class TourPage extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - "This is the Tour page 🚩", - style: TextStyle(fontSize: 30), + Text( + AppLocalization.of(context).getTranslatedValue("feature_page.title").toString(), + style: const TextStyle(fontSize: 30), + textAlign: TextAlign.center, ), - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Text( - "As you can say, this is just a sample page. You can go back by pressing the button below.", + AppLocalization.of(context).getTranslatedValue("feature_page.description").toString(), textAlign: TextAlign.center, - style: TextStyle(fontSize: 15, color: Colors.black87), + style: const TextStyle(fontSize: 15, color: Colors.black87), ), ), ElevatedButton( @@ -50,16 +55,17 @@ class SettingsPage extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - "This is the Settings page ⚙️", - style: TextStyle(fontSize: 30), + Text( + AppLocalization.of(context).getTranslatedValue("settings_page.title").toString(), + style: const TextStyle(fontSize: 30), + textAlign: TextAlign.center, ), - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Text( - "As you can say, this is just a sample page. You can go back by pressing the button below.", + AppLocalization.of(context).getTranslatedValue("settings_page.description").toString(), textAlign: TextAlign.center, - style: TextStyle(fontSize: 15, color: Colors.black87), + style: const TextStyle(fontSize: 15, color: Colors.black87), ), ), ElevatedButton( @@ -74,3 +80,37 @@ class SettingsPage extends StatelessWidget { ); } } + +class DynamicMenuPage extends StatelessWidget { + final MenuItemInfo menuItem; + + const DynamicMenuPage({super.key, required this.menuItem}); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: dynamicMenuPageKey, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + menuItem.title, + style: const TextStyle(fontSize: 30), + textAlign: TextAlign.center, + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Go back'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..15e4ac1 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const jsonFilePath = 'assets/menu_items.json'; +const storageKey = 'menuItems'; + +/// Class that holds information about the possible tile icon +class MenuItemInfoIcon { + late final int? code; + late final String? emoji; + late final String? url; + late final String? colour; + + MenuItemInfoIcon({this.code, this.emoji, this.url, this.colour}); + + MenuItemInfoIcon.fromJson(Map json) { + code = json['code']; + emoji = json['emoji']; + url = json['url']; + colour = json['colour']; + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + data['emoji'] = emoji; + data['url'] = url; + data['colour'] = colour; + + return data; + } +} + +/// Class holding the information of the tile +class MenuItemInfo { + late int id; + late int indexInLevel; + late String title; + late Color textColor; + late MenuItemInfoIcon? _icon; + late List tiles; + + MenuItemInfo({required this.id, required this.title, this.tiles = const []}); + + /// Converts a [hexString] to a Color. + /// Color white is returned by default. + Color _hexToColor(String hexString) { + try { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } catch (e) { + return const Color(0xFFFFFFFF); + } + } + + /// Gets the icon widget of the tile. + /// The priority of the icon retrieved is by: + /// 1 - the `code` field. + /// 2 - the `emoji` field. + /// 3 - the `url` field. + /// If there is no icon, the `null` is returned. + Widget? getIcon() { + bool iconExists = _icon != null; + + // Check if any icon information exists + if (iconExists) { + + // Icon parameters + int? iconCode = _icon?.code; + String? emojiText = _icon?.emoji; + String? imageUrl = _icon?.url; + String? colourHex = _icon?.colour; + + // Icon colour + Color colour = _hexToColor(colourHex!); + + if (iconCode != null) { + return Icon( + IconData(iconCode, fontFamily: 'MaterialIcons'), + size: 40, + color: colour, + ); + } + + if (emojiText != null) { + return Text(emojiText.toString(), style: TextStyle(color: colour, fontSize: 30)); + } + + if(imageUrl != null) { + return Container( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Image.network(imageUrl, fit: BoxFit.fitHeight, height: 64)); + } + } + + // If there's no icon information, return null + else { + return null; + } + } + + /// Converts `json` text to BasicTile + MenuItemInfo.fromJson(Map json) { + id = json['id']; + indexInLevel = json['index_in_level']; + title = json['title']; + textColor = _hexToColor(json['text_color']); + + // Decoding `tiles` field + if (json['tiles'] != null) { + tiles = []; + json['tiles'].forEach((v) { + tiles.add(MenuItemInfo.fromJson(v)); + }); + } + + _icon = null; + // Decoding `icon` field + if (json['icon'] != null) { + _icon = MenuItemInfoIcon.fromJson(json['icon']); + } + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['index_in_level'] = indexInLevel; + data['title'] = title; + data['text_color'] = '#${textColor.value.toRadixString(16)}'; + + // Adding `tiles` field + if (tiles.isNotEmpty) { + data['tiles'] = tiles.map((v) => v.toJson()).toList(); + } else { + data['tiles'] = []; + } + + // Adding `icon` field + if (_icon != null) { + data['icon'] = _icon!.toJson(); + } + + return data; + } +} + +/// Loads the menu items from local storage. +/// If none is found, it loads the menu items from `.json` file. +/// Returns a `MenuItemInfo` menu list. +Future> loadMenuItems() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + //await prefs.remove(storageKey); + + final String? jsonStringFromLocalStorage = prefs.getString(storageKey); + + String jsonString; + // If local storage has content, return it. + if (jsonStringFromLocalStorage != null) { + jsonString = jsonStringFromLocalStorage; + } + + // If not, we initialize it + else { + // Setting local storage key with json string from file + final String jsonStringFromFile = await rootBundle.loadString(jsonFilePath); + prefs.setString(storageKey, jsonStringFromFile); + + jsonString = jsonStringFromFile; + } + + // Converting json to list of MenuItemInfo objects + List data = await json.decode(jsonString); + final List menuItems = data.map((obj) => MenuItemInfo.fromJson(obj)).toList(); + + // Return the MenuItemInfo list + return menuItems; +} + +/// Updates the root menu item list [menuItems] in shared preferences. +updateRootObjectsInPreferences(List menuItems) async { + final jsonString = json.encode(menuItems); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setString(storageKey, jsonString); +} + +/// Update deeply nested menu item [item] with a new [updatedChildren] in shared preferences. +updateDeeplyNestedObjectInPreferences(MenuItemInfo itemToUpdate, List updatedChildren) async { + // Fetch the menu items from `.json` file + List menuItems = await loadMenuItems(); + + // Go over the root items list and find & update the object with new children + MenuItemInfo? updatedItem; + for (var item in menuItems) { + updatedItem = _findAndUpdateMenuItem(item, itemToUpdate.id, updatedChildren); + if (updatedItem != null) { + break; + } + } + + // Saving updated menu items encoded to json string. + final jsonString = json.encode(menuItems); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setString(storageKey, jsonString); +} + +/// Recursively finds the deeply nested object from a given menu item id [id] +/// and updates its `tiles` field with [updatedChildren]. +/// +/// Returns the updated `MenuItemInfo` menu item. +/// Returns `null` if no `MenuitemInfo` was found with the given [id]. +MenuItemInfo? _findAndUpdateMenuItem(MenuItemInfo item, int id, List updatedChildren) { + // Breaking case + if (item.id == id) { + item.tiles = updatedChildren; + return item; + } + + // Continue searching + else { + final children = item.tiles; + MenuItemInfo? ret; + for (MenuItemInfo child in children) { + ret = _findAndUpdateMenuItem(child, id, updatedChildren); + if (ret != null) { + break; + } + } + return ret; + } +} diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..049abe2 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/pubspec.lock b/pubspec.lock index 13c2bc9..cda0cd3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8880b4cfe7b5b17d57c052a5a3a8cc1d4f546261c7cc8fbd717bd53f48db0568" + url: "https://pub.dev" + source: hosted + version: "59.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a89627f49b0e70e068130a36571409726b04dab12da7e5625941d2c8ec278b96 + url: "https://pub.dev" + source: hosted + version: "5.11.1" + args: + dependency: transitive + description: + name: args + sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a + url: "https://pub.dev" + source: hosted + version: "2.4.1" async: dependency: transitive description: @@ -17,6 +41,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "43865b79fbb78532e4bff7c33087aa43b1d488c4fdef014eaef568af6d8016dc" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "2f17434bd5d52a26762043d6b43bb53b3acd029b4d9071a329f46d67ef297e6d" + url: "https://pub.dev" + source: hosted + version: "8.5.0" characters: dependency: transitive description: @@ -33,14 +81,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - collection: + code_builder: dependency: transitive + description: + name: code_builder + sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + collection: + dependency: "direct main" description: name: collection sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 url: "https://pub.dev" source: hosted version: "1.17.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -49,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: f4f1f73ab3fd2afcbcca165ee601fe980d966af6a21b5970c6c9376955c528ad + url: "https://pub.dev" + source: hosted + version: "2.3.1" fake_async: dependency: transitive description: @@ -57,6 +137,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -70,11 +174,45 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: b83ac5827baadefd331ea1d85110f34645827ea234ccabf53a655f41901a9bf4 + url: "https://pub.dev" + source: hosted + version: "2.3.6" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + intl: + dependency: transitive + description: + name: intl + sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" + url: "https://pub.dev" + source: hosted + version: "0.17.0" js: dependency: transitive description: @@ -91,6 +229,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + url: "https://pub.dev" + source: hosted + version: "1.1.1" matcher: dependency: transitive description: @@ -115,6 +261,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: dd61809f04da1838a680926de50a9e87385c1de91c6579629c3d1723946e8059 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + network_image_mock: + dependency: "direct dev" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -123,11 +293,139 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "2ae08f2216225427e64ad224a24354221c2c7907e448e6e0e8b57b1eb9f10ad1" + url: "https://pub.dev" + source: hosted + version: "2.1.10" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + platform: + dependency: transitive + description: + name: platform + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "80e48bebc83010d5e67a11c9514af6b44bbac1ec77b4333c8ea65dbc79e2d8ef" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "858aaa72d8f61637d64e776aca82e1c67e6d9ee07979123c5d17115031c1b13b" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "0c1c16c56c9708aa9c361541a6f0e5cc6fc12a3232d866a687a7b7db30032b07" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: b20e191de6964e98032573cecb1d2b169d96ba63fdb586d24dcd1003ba7e94f6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" source_span: dependency: transitive description: @@ -144,6 +442,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + url: "https://pub.dev" + source: hosted + version: "0.7.2+1" stream_channel: dependency: transitive description: @@ -176,6 +482,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + url: "https://pub.dev" + source: hosted + version: "1.3.1" vector_math: dependency: transitive description: @@ -184,5 +498,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + watcher: + dependency: transitive + description: + name: watcher + sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: dd8f9344bc305ae2923e3d11a2a911d9a4e2c7dd6fe0ed10626d63211a69676e + url: "https://pub.dev" + source: hosted + version: "4.1.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" sdks: - dart: ">=2.18.4 <3.0.0" + dart: ">=2.19.0 <3.0.0" + flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9463490..11abdb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ version: 1.0.0+1 environment: sdk: '>=2.18.4 <3.0.0' + flutter: ">=3.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -31,11 +32,15 @@ environment: dependencies: flutter: sdk: flutter - + flutter_localizations: + sdk: flutter + flutter_riverpod: ^2.3.6 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + shared_preferences: ^2.1.0 + collection: ^1.17.0 dev_dependencies: flutter_test: @@ -47,6 +52,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^2.0.0 + network_image_mock: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -62,6 +68,8 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/images/ + - assets/i18n/ + - assets/menu_items.json # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 0000000..f9e15a6 --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,19 @@ +import 'package:app/settings.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Menu item info model', () { + var item = MenuItemInfo(id: 0, title: 'title', tiles: []); + + expect(item.title, equals('title')); + }); + + test('Menu item info icon model', () { + var item = MenuItemInfoIcon(code: 61668, emoji: '🧑‍🤝‍🧑', url: 'someurl.com', colour: '#Ffb97e'); + + expect(item.code, equals(61668)); + expect(item.emoji, equals('🧑‍🤝‍🧑')); + expect(item.url, equals('someurl.com')); + expect(item.colour, equals('#Ffb97e')); + }); +} diff --git a/test/settings_test.dart b/test/settings_test.dart new file mode 100644 index 0000000..2031384 --- /dev/null +++ b/test/settings_test.dart @@ -0,0 +1,32 @@ +import 'package:app/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('Load menu items with `SharedPreferences` not initialized.', () async { + // Initializing widget bindings and shared preferences in tests + WidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // List items that are fetched from file when Shared Preferences isn't initialized with menu items + final listItems = await loadMenuItems(); + + expect(listItems.length, equals(2)); + }); + + test('Updating deeply nested item', () async { + // Initializing widget bindings and shared preferences in tests + WidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // List items that are fetched from file when Shared Preferences isn't initialized with menu items + final listItems = await loadMenuItems(); + final updatedFriendsMenuItem = listItems.elementAt(0).tiles.elementAt(0).tiles.elementAt(1); + + // Updates nested item inside shared preferences + updateDeeplyNestedObjectInPreferences(updatedFriendsMenuItem, []); + + expect(listItems.length, equals(2)); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 5a1f11e..a2b32c1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,144 +1,441 @@ -// 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:app/menu.dart'; import 'package:app/pages.dart'; +import 'package:app/dynamic_menu.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:app/main.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Normal setup', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const App()); + const jsonFilePath = 'assets/menu_items.json'; + const storageKey = 'menuItems'; - // Verify that our counter starts at 0. - expect(find.text('This is the main page'), findsOneWidget); - }); + group('Open menu and simple navigation', () { + testWidgets('Normal setup', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); - testWidgets('Tapping on todo item should make menu button appear', (WidgetTester tester) async { - await tester.pumpWidget(const App()); + // Verify that our counter starts at 0. + expect(find.text('This is the main page'), findsOneWidget); + }); - final menuButton = find.byKey(iconKey); - final todoItem = find.byKey(todoItemKey); + testWidgets('Tapping on todo item should make menu button appear', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); - // Icon button should be visible - expect(menuButton.hitTestable(), findsNothing); + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); - // Tap on todo item - await tester.tap(todoItem); - await tester.pumpAndSettle(); + // Icon button should be visible + expect(menuButton.hitTestable(), findsNothing); - // Verify that our menu button is showing - expect(menuButton.hitTestable(), findsOneWidget); - }); + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); - testWidgets('Tapping on icon menu should show drawer menu', (WidgetTester tester) async { - await tester.pumpWidget(const App()); + // Verify that our menu button is showing + expect(menuButton.hitTestable(), findsOneWidget); + }); - final menuButton = find.byKey(iconKey); - final todoItem = find.byKey(todoItemKey); - final drawerMenu = find.byKey(drawerMenuKey); + testWidgets('Toggling between languages should make texts different', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); - // Icon button should be visible - expect(menuButton.hitTestable(), findsNothing); + final ptButton = find.byKey(ptButtonkey); + final enButton = find.byKey(enButtonkey); - // Tap on todo item - await tester.tap(todoItem); - await tester.pumpAndSettle(); + expect(find.text('This is the main page'), findsOneWidget); - // Verify that our menu button is showing - expect(menuButton.hitTestable(), findsOneWidget); - expect(drawerMenu.hitTestable(), findsNothing); + // Tap on PT + await tester.tap(ptButton); + await tester.pumpAndSettle(); - // Tap on icon - await tester.tap(menuButton); - await tester.pumpAndSettle(); + // Verify that the text has changed + expect(find.text('Esta é a página inicial.'), findsOneWidget); - // Verify that our drawer is showing - expect(drawerMenu.hitTestable(), findsOneWidget); + // Toggle back to english + await tester.tap(enButton); + await tester.pumpAndSettle(); - // Tap on icon again to close drawer - await tester.tap(find.byKey(closeMenuKey)); - await tester.pumpAndSettle(); - expect(find.byKey(homePageKey), findsOneWidget); - }); + expect(find.text('This is the main page'), findsOneWidget); + }); - testWidgets('Navigating into Tours Page', (WidgetTester tester) async { - await tester.pumpWidget(const App()); + testWidgets('Tapping on icon menu should show drawer menu', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); - final menuButton = find.byKey(iconKey); - final todoItem = find.byKey(todoItemKey); - final drawerMenu = find.byKey(drawerMenuKey); - final tourPageTile = find.byKey(tourTileKey); + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + final drawerMenu = find.byKey(drawerMenuKey); - // Icon button should be visible - expect(menuButton.hitTestable(), findsNothing); + // Icon button should be visible + expect(menuButton.hitTestable(), findsNothing); - // Tap on todo item - await tester.tap(todoItem); - await tester.pumpAndSettle(); + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); - // Verify that our menu button is showing - expect(menuButton.hitTestable(), findsOneWidget); - expect(drawerMenu.hitTestable(), findsNothing); + // Verify that our menu button is showing + expect(menuButton.hitTestable(), findsOneWidget); + expect(drawerMenu.hitTestable(), findsNothing); - // Tap on icon - await tester.tap(menuButton); - await tester.pumpAndSettle(); + // Tap on icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); - // Verify that our drawer is showing - expect(drawerMenu.hitTestable(), findsOneWidget); + // Verify that our drawer is showing + expect(drawerMenu.hitTestable(), findsOneWidget); - // Tap on tours page tile and check if TourPage is shown - await tester.tap(tourPageTile); - await tester.pumpAndSettle(); - expect(find.byKey(tourPageKey).hitTestable(), findsOneWidget); + // Tap on icon again to close drawer + await tester.tap(find.byKey(closeMenuKey)); + await tester.pumpAndSettle(); + expect(find.byKey(homePageKey), findsOneWidget); + }); - // Tap on button to go back - await tester.tap(find.text("Go back")); - await tester.pumpAndSettle(); - expect(find.byKey(tourPageKey).hitTestable(), findsNothing); - }); + testWidgets('Navigating into Tours Page', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + final drawerMenu = find.byKey(drawerMenuKey); + final tourPageTile = find.byKey(tourTileKey); + + // Icon button should be visible + expect(menuButton.hitTestable(), findsNothing); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Verify that our menu button is showing + expect(menuButton.hitTestable(), findsOneWidget); + expect(drawerMenu.hitTestable(), findsNothing); + + // Tap on icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); - testWidgets('Navigating into Settings Page', (WidgetTester tester) async { - await tester.pumpWidget(const App()); + // Verify that our drawer is showing + expect(drawerMenu.hitTestable(), findsOneWidget); + + // Tap on tours page tile and check if TourPage is shown + await tester.tap(tourPageTile); + await tester.pumpAndSettle(); + expect(find.byKey(tourPageKey).hitTestable(), findsOneWidget); + + // Tap on button to go back + await tester.tap(find.text("Go back")); + await tester.pumpAndSettle(); + expect(find.byKey(tourPageKey).hitTestable(), findsNothing); + }); + + testWidgets('Navigating into Settings Page', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + final drawerMenu = find.byKey(drawerMenuKey); + final settingsPageTile = find.byKey(settingsTileKey); + + // Icon button should be visible + expect(menuButton.hitTestable(), findsNothing); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Verify that our menu button is showing + expect(menuButton.hitTestable(), findsOneWidget); + expect(drawerMenu.hitTestable(), findsNothing); + + // Tap on icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // Verify that our drawer is showing + expect(drawerMenu.hitTestable(), findsOneWidget); + + // Tap on tours page tile and check if TourPage is shown + await tester.tap(settingsPageTile); + await tester.pumpAndSettle(); + expect(find.byKey(settingsPageKey).hitTestable(), findsOneWidget); + + // Tap on button to go back + await tester.tap(find.text("Go back")); + await tester.pumpAndSettle(); + expect(find.byKey(settingsPageKey).hitTestable(), findsNothing); + }); + }); - final menuButton = find.byKey(iconKey); - final todoItem = find.byKey(todoItemKey); - final drawerMenu = find.byKey(drawerMenuKey); - final settingsPageTile = find.byKey(settingsTileKey); + group('Dynamic menu', () { + const itemHeight = 50.0; + + /// Util function to simulate long press and drag + Future longPressDrag(WidgetTester tester, Offset start, Offset end) async { + const Duration longPressTimeout = Duration(milliseconds: 2000); + + final TestGesture drag = await tester.startGesture(start, pointer: 2000); + await tester.pump(longPressTimeout); + await drag.moveTo(end); + await tester.pumpAndSettle(); + await drag.up(); + } - // Icon button should be visible - expect(menuButton.hitTestable(), findsNothing); + /// Function to set mock shared preferences in unit tests + setUp(() async { + final String jsonString = await rootBundle.loadString(jsonFilePath); + final Map values = {storageKey: jsonString}; + SharedPreferences.setMockInitialValues(values); + }); - // Tap on todo item - await tester.tap(todoItem); - await tester.pumpAndSettle(); + testWidgets('Normal setup with shared preferences should show dynamic menu', (WidgetTester tester) async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); - // Verify that our menu button is showing - expect(menuButton.hitTestable(), findsOneWidget); - expect(drawerMenu.hitTestable(), findsNothing); + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + final drawerMenu = find.byKey(drawerMenuKey); + final dynamicMenuItemList = find.byKey(dynamicMenuItemListKey); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); - // Tap on icon - await tester.tap(menuButton); - await tester.pumpAndSettle(); + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); - // Verify that our drawer is showing - expect(drawerMenu.hitTestable(), findsOneWidget); + // Verify that our drawer is showing + expect(drawerMenu.hitTestable(), findsOneWidget); + expect(dynamicMenuItemList, findsOneWidget); + }); - // Tap on tours page tile and check if TourPage is shown - await tester.tap(settingsPageTile); - await tester.pumpAndSettle(); - expect(find.byKey(settingsPageKey).hitTestable(), findsOneWidget); + testWidgets('Navigating into dynamic menu page', (WidgetTester tester) async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); - // Tap on button to go back - await tester.tap(find.text("Go back")); - await tester.pumpAndSettle(); - expect(find.byKey(settingsPageKey).hitTestable(), findsNothing); + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // Click on second menu item which should navigate into a page + final potaroMenuItem = find.text("Potaro"); + var dynamicMenuItemPage = find.byKey(dynamicMenuPageKey); + + expect(potaroMenuItem, findsOneWidget); + expect(dynamicMenuItemPage, findsNothing); + + // Tap on "people" menu item + await tester.tap(potaroMenuItem); + await tester.pumpAndSettle(); + + dynamicMenuItemPage = find.byKey(dynamicMenuPageKey); + + // Should show page + expect(dynamicMenuItemPage, findsOneWidget); + + final goBackButton = find.text("Go back"); + await tester.tap(goBackButton); + await tester.pumpAndSettle(); + }); + + testWidgets('Click on first expandable menu item', (WidgetTester tester) async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // First menu and submenu item from `.json` file + final peopleMenuItem = find.text("People"); + var everyoneMenuItem = + find.text("Everyone"); // https://stackoverflow.com/questions/54651878/how-to-find-off-screen-listview-child-in-widget-tests + + expect(peopleMenuItem, findsOneWidget); + expect(everyoneMenuItem, findsNothing); + + // Tap on "people" menu item + await tester.tap(peopleMenuItem); + await tester.pumpAndSettle(); + + everyoneMenuItem = find.text("Everyone", skipOffstage: false); + + // "everyone" menu item should be shown + expect(peopleMenuItem, findsOneWidget); + expect(everyoneMenuItem, findsOneWidget); + }); + + testWidgets('Drag and drop nested elements', (WidgetTester tester) async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // First menu and submenu item from `.json` file + final peopleMenuItem = find.text("People"); + + // Tap on "people" menu item + await tester.tap(peopleMenuItem); + await tester.pumpAndSettle(); + + final onlineMenuItem = find.text("Online Now", skipOffstage: false); + final everyoneMenuItem = find.text("Everyone", skipOffstage: false); + + // Get list of menu items + var menuItemTitles = find.byType(MenuItem).evaluate().toList().map( + (e) { + var menuItem = e.widget as MenuItem; + return menuItem.info.title; + }, + ).toList(); + + expect(menuItemTitles, orderedEquals(["People", "Online Now", "Everyone", "Potaro"])); + + // Switching "Online Now" and "Everyone" + await longPressDrag( + tester, + tester.getCenter(onlineMenuItem), + tester.getCenter(everyoneMenuItem) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + + // Check if switching by drag and drop worked + menuItemTitles = find.byType(MenuItem).evaluate().toList().map( + (e) { + var menuItem = e.widget as MenuItem; + return menuItem.info.title; + }, + ).toList(); + + expect(menuItemTitles, orderedEquals(["People", "Everyone", "Online Now", "Potaro"])); + }); + + testWidgets('Drag and drop nested elements on third level', (WidgetTester tester) async { + // To mock the icons fetching images from Network. + // See https://github.com/stelynx/network_image_mock. + mockNetworkImagesFor(() async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // First menu and submenu item from `.json` file + final peopleMenuItem = find.text("People"); + + // Tap on "people" menu item + await tester.tap(peopleMenuItem); + await tester.pumpAndSettle(); + + final onlineMenuItem = find.text("Online Now", skipOffstage: false); + + // Tap on "Online Now" menu item + await tester.tap(onlineMenuItem); + await tester.pumpAndSettle(); + + final friendsMenuItem = find.text("Friends", skipOffstage: false); + final workMenuItem = find.text("Work", skipOffstage: false); + + // Switching "Online Now" and "Everyone" + await longPressDrag( + tester, + tester.getCenter(friendsMenuItem), + tester.getCenter(workMenuItem) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('Drag and drop root elements', (WidgetTester tester) async { + // Initialize app + await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpAndSettle(); + + final menuButton = find.byKey(iconKey); + final todoItem = find.byKey(todoItemKey); + + // Tap on todo item + await tester.tap(todoItem); + await tester.pumpAndSettle(); + + // Tap menu icon + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // First menu and submenu item from `.json` file + final peopleMenuItem = find.text("People"); + final potaroMenuItem = find.text("Potaro", skipOffstage: false); + + // Get list of menu items + var menuItemTitles = find.byType(MenuItem).evaluate().toList().map( + (e) { + var menuItem = e.widget as MenuItem; + return menuItem.info.title; + }, + ).toList(); + + expect(menuItemTitles, orderedEquals(["People", "Potaro"])); + + // Switch "People" and "Potaro" root menu items + await longPressDrag( + tester, + tester.getCenter(peopleMenuItem), + tester.getCenter(potaroMenuItem) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + + // Check if switching by drag and drop worked + menuItemTitles = find.byType(MenuItem).evaluate().toList().map( + (e) { + var menuItem = e.widget as MenuItem; + return menuItem.info.title; + }, + ).toList(); + + expect(menuItemTitles, orderedEquals(["Potaro", "People"])); + }); }); }