From 9c8dad0176268759ccc477d0af07b62fb482840c Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Thu, 14 Nov 2024 00:08:09 +0800
Subject: [PATCH] :sparkles: Drawer navigation

---
 assets/translations/en-US.json                |   2 +
 assets/translations/zh-CN.json                |   2 +
 lib/main.dart                                 |   3 +
 lib/providers/navigation.dart                 | 112 ++++++++++++++++++
 lib/router.dart                               |  14 +++
 lib/screens/album.dart                        |  10 ++
 lib/screens/chat.dart                         |  10 ++
 .../navigation/app_bottom_navigation.dart     |  53 ++++++---
 lib/widgets/navigation/app_destinations.dart  |  33 ------
 .../navigation/app_drawer_navigation.dart     |  76 ++++++++++++
 lib/widgets/navigation/app_scaffold.dart      |   7 +-
 11 files changed, 274 insertions(+), 48 deletions(-)
 create mode 100644 lib/providers/navigation.dart
 create mode 100644 lib/screens/album.dart
 create mode 100644 lib/screens/chat.dart
 delete mode 100644 lib/widgets/navigation/app_destinations.dart
 create mode 100644 lib/widgets/navigation/app_drawer_navigation.dart

diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json
index d8c1f02..d8dd878 100644
--- a/assets/translations/en-US.json
+++ b/assets/translations/en-US.json
@@ -15,6 +15,8 @@
   "screenAccountPublisherEdit": "Edit Publisher",
   "screenAccountProfileEdit": "Edit Profile",
   "screenSettings": "Settings",
+  "screenAlbum": "Album",
+  "screenChat": "Chat",
   "dialogOkay": "Okay",
   "dialogCancel": "Cancel",
   "dialogConfirm": "Confirm",
diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json
index 8fe5d09..c6c4202 100644
--- a/assets/translations/zh-CN.json
+++ b/assets/translations/zh-CN.json
@@ -15,6 +15,8 @@
   "screenAccountPublisherEdit": "编辑发布者",
   "screenAccountProfileEdit": "编辑资料",
   "screenSettings": "设置",
+  "screenAlbum": "相册",
+  "screenChat": "聊天",
   "dialogOkay": "好的",
   "dialogCancel": "取消",
   "dialogConfirm": "确认",
diff --git a/lib/main.dart b/lib/main.dart
index ab347d1..640c460 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 import 'package:relative_time/relative_time.dart';
 import 'package:responsive_framework/responsive_framework.dart';
+import 'package:surface/providers/navigation.dart';
 import 'package:surface/providers/sn_attachment.dart';
 import 'package:surface/providers/sn_network.dart';
 import 'package:surface/providers/theme.dart';
@@ -39,6 +40,7 @@ class SolianApp extends StatelessWidget {
           providers: [
             Provider(create: (_) => SnNetworkProvider()),
             Provider(create: (ctx) => SnAttachmentProvider(ctx)),
+            ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
             ChangeNotifierProvider(create: (_) => ThemeProvider()),
           ],
@@ -59,6 +61,7 @@ class AppMainContent extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    context.read<NavigationProvider>();
     context.read<UserProvider>();
 
     final th = context.watch<ThemeProvider>();
diff --git a/lib/providers/navigation.dart b/lib/providers/navigation.dart
new file mode 100644
index 0000000..3fb101e
--- /dev/null
+++ b/lib/providers/navigation.dart
@@ -0,0 +1,112 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:material_symbols_icons/symbols.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class AppNavDestination {
+  final String label;
+  final String screen;
+  final Widget icon;
+  final bool isPinned;
+
+  const AppNavDestination({
+    required this.label,
+    required this.screen,
+    required this.icon,
+    this.isPinned = false,
+  });
+}
+
+class NavigationProvider extends ChangeNotifier {
+  int? _currentIndex;
+
+  int? get currentIndex => _currentIndex;
+
+  static const List<AppNavDestination> kAllDestination = [
+    AppNavDestination(
+      icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
+      screen: 'home',
+      label: 'screenHome',
+    ),
+    AppNavDestination(
+      icon: Icon(Symbols.explore, weight: 400, opticalSize: 20),
+      screen: 'explore',
+      label: 'screenExplore',
+    ),
+    AppNavDestination(
+      icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
+      screen: 'account',
+      label: 'screenAccount',
+    ),
+    AppNavDestination(
+      icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
+      screen: 'album',
+      label: 'screenAlbum',
+    ),
+    AppNavDestination(
+      icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
+      screen: 'chat',
+      label: 'screenChat',
+    ),
+  ];
+  static const List<String> kDefaultPinnedDestination = [
+    'home',
+    'explore',
+    'account'
+  ];
+
+  List<AppNavDestination> destinations = [];
+
+  int get pinnedDestinationCount =>
+      destinations.where((ele) => ele.isPinned).length;
+
+  NavigationProvider() {
+    buildDestinations(kDefaultPinnedDestination);
+    SharedPreferences.getInstance().then((prefs) {
+      final pinned = prefs.getStringList("app_pinned_navigation");
+      if (pinned != null) buildDestinations(pinned);
+    });
+  }
+
+  void buildDestinations(List<String> pinned) {
+    destinations = kAllDestination
+        .map(
+          (ele) => AppNavDestination(
+            label: ele.label,
+            screen: ele.screen,
+            icon: ele.icon,
+            isPinned: pinned.contains(ele.screen),
+          ),
+        )
+        .toList();
+    notifyListeners();
+  }
+
+  int getIndexInRange(int min, int max) {
+    return math.max(min, math.min(_currentIndex ?? 0, max));
+  }
+
+  bool isIndexInRange(int min, int max) {
+    return _currentIndex != null &&
+        _currentIndex! >= min &&
+        _currentIndex! < max;
+  }
+
+  void autoDetectIndex(GoRouter? state) {
+    if (state == null) return;
+    final idx = destinations.indexWhere(
+      (ele) =>
+          ele.screen ==
+          state.routerDelegate.currentConfiguration.last.route.name,
+    );
+    _currentIndex = idx == -1 ? null : idx;
+    notifyListeners();
+  }
+
+  void setIndex(int idx) {
+    _currentIndex = idx;
+    notifyListeners();
+  }
+}
diff --git a/lib/router.dart b/lib/router.dart
index 5b2b6d7..d0ab996 100644
--- a/lib/router.dart
+++ b/lib/router.dart
@@ -4,8 +4,10 @@ import 'package:surface/screens/account/profile_edit.dart';
 import 'package:surface/screens/account/publishers/publisher_edit.dart';
 import 'package:surface/screens/account/publishers/publisher_new.dart';
 import 'package:surface/screens/account/publishers/publishers.dart';
+import 'package:surface/screens/album.dart';
 import 'package:surface/screens/auth/login.dart';
 import 'package:surface/screens/auth/register.dart';
+import 'package:surface/screens/chat.dart';
 import 'package:surface/screens/explore.dart';
 import 'package:surface/screens/home.dart';
 import 'package:surface/screens/post/post_detail.dart';
@@ -20,6 +22,7 @@ final appRouter = GoRouter(
       builder: (context, state, child) => AppScaffold(
         body: child,
         showBottomNavigation: true,
+        showDrawer: true,
       ),
       routes: [
         GoRoute(
@@ -37,6 +40,16 @@ final appRouter = GoRouter(
           name: 'account',
           builder: (context, state) => const AccountScreen(),
         ),
+        GoRoute(
+          path: '/chat',
+          name: 'chat',
+          builder: (context, state) => const ChatScreen(),
+        ),
+        GoRoute(
+          path: '/album',
+          name: 'album',
+          builder: (context, state) => const AlbumScreen(),
+        ),
       ],
     ),
     ShellRoute(
@@ -74,6 +87,7 @@ final appRouter = GoRouter(
       builder: (context, state, child) => AppScaffold(
         body: child,
         autoImplyAppBar: true,
+        showDrawer: true,
       ),
       routes: [
         GoRoute(
diff --git a/lib/screens/album.dart b/lib/screens/album.dart
new file mode 100644
index 0000000..4c91e30
--- /dev/null
+++ b/lib/screens/album.dart
@@ -0,0 +1,10 @@
+import 'package:flutter/material.dart';
+
+class AlbumScreen extends StatelessWidget {
+  const AlbumScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return const Placeholder();
+  }
+}
diff --git a/lib/screens/chat.dart b/lib/screens/chat.dart
new file mode 100644
index 0000000..437de3f
--- /dev/null
+++ b/lib/screens/chat.dart
@@ -0,0 +1,10 @@
+import 'package:flutter/material.dart';
+
+class ChatScreen extends StatelessWidget {
+  const ChatScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return const Placeholder();
+  }
+}
diff --git a/lib/widgets/navigation/app_bottom_navigation.dart b/lib/widgets/navigation/app_bottom_navigation.dart
index 71c207a..1a40060 100644
--- a/lib/widgets/navigation/app_bottom_navigation.dart
+++ b/lib/widgets/navigation/app_bottom_navigation.dart
@@ -1,6 +1,8 @@
+import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:go_router/go_router.dart';
-import 'package:surface/widgets/navigation/app_destinations.dart';
+import 'package:provider/provider.dart';
+import 'package:surface/providers/navigation.dart';
 
 class AppBottomNavigationBar extends StatefulWidget {
   const AppBottomNavigationBar({super.key});
@@ -10,23 +12,46 @@ class AppBottomNavigationBar extends StatefulWidget {
 }
 
 class _AppBottomNavigationBarState extends State<AppBottomNavigationBar> {
-  int _currentIndex = 0;
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      context
+          .read<NavigationProvider>()
+          .autoDetectIndex(GoRouter.maybeOf(context));
+    });
+  }
 
   @override
   Widget build(BuildContext context) {
-    return BottomNavigationBar(
-      currentIndex: _currentIndex,
-      type: BottomNavigationBarType.fixed,
-      showUnselectedLabels: false,
-      items: appDestinations.map((ele) {
-        return BottomNavigationBarItem(
-          icon: ele.icon,
-          label: ele.label,
+    final nav = context.watch<NavigationProvider>();
+
+    return ListenableBuilder(
+      listenable: nav,
+      builder: (context, _) {
+        if (!nav.isIndexInRange(0, nav.pinnedDestinationCount)) {
+          return const SizedBox.shrink();
+        }
+
+        final destinations = [
+          ...nav.destinations.where((ele) => ele.isPinned),
+        ];
+
+        return BottomNavigationBar(
+          currentIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
+          type: BottomNavigationBarType.fixed,
+          showUnselectedLabels: false,
+          items: destinations.map((ele) {
+            return BottomNavigationBarItem(
+              icon: ele.icon,
+              label: ele.label.tr(),
+            );
+          }).toList(),
+          onTap: (idx) {
+            nav.setIndex(idx);
+            GoRouter.of(context).goNamed(destinations[idx].screen);
+          },
         );
-      }).toList(),
-      onTap: (idx) {
-        setState(() => _currentIndex = idx);
-        GoRouter.of(context).goNamed(appDestinations[idx].screen);
       },
     );
   }
diff --git a/lib/widgets/navigation/app_destinations.dart b/lib/widgets/navigation/app_destinations.dart
deleted file mode 100644
index 01e371b..0000000
--- a/lib/widgets/navigation/app_destinations.dart
+++ /dev/null
@@ -1,33 +0,0 @@
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/material.dart';
-import 'package:material_symbols_icons/symbols.dart';
-
-class AppNavDestination {
-  final String label;
-  final String screen;
-  final Widget icon;
-
-  AppNavDestination({
-    required this.label,
-    required this.screen,
-    required this.icon,
-  });
-}
-
-List<AppNavDestination> appDestinations = [
-  AppNavDestination(
-    icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
-    screen: 'home',
-    label: tr('screenHome'),
-  ),
-  AppNavDestination(
-    icon: Icon(Symbols.explore, weight: 400, opticalSize: 20),
-    screen: 'explore',
-    label: tr('screenExplore'),
-  ),
-  AppNavDestination(
-    icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
-    screen: 'account',
-    label: tr('screenAccount'),
-  ),
-];
diff --git a/lib/widgets/navigation/app_drawer_navigation.dart b/lib/widgets/navigation/app_drawer_navigation.dart
new file mode 100644
index 0000000..41e94c8
--- /dev/null
+++ b/lib/widgets/navigation/app_drawer_navigation.dart
@@ -0,0 +1,76 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:provider/provider.dart';
+import 'package:styled_widget/styled_widget.dart';
+import 'package:surface/providers/navigation.dart';
+
+class AppNavigationDrawer extends StatefulWidget {
+  const AppNavigationDrawer({super.key});
+
+  @override
+  State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
+}
+
+class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      context
+          .read<NavigationProvider>()
+          .autoDetectIndex(GoRouter.maybeOf(context));
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final nav = context.watch<NavigationProvider>();
+
+    return ListenableBuilder(
+      listenable: nav,
+      builder: (context, _) {
+        final destinations = [
+          ...nav.destinations.where((ele) => ele.isPinned),
+          ...nav.destinations.where((ele) => !ele.isPinned),
+        ];
+
+        return NavigationDrawer(
+          selectedIndex: nav.currentIndex,
+          children: [
+            Column(
+              mainAxisSize: MainAxisSize.min,
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text('Solar Network').bold(),
+                Text('Solar Network 2.0α').fontSize(12).textColor(
+                    Theme.of(context).colorScheme.onSurface.withOpacity(0.5)),
+              ],
+            ).padding(
+              horizontal: 32,
+              vertical: 8,
+            ),
+            ...destinations.where((ele) => ele.isPinned).map((ele) {
+              return NavigationDrawerDestination(
+                icon: ele.icon,
+                label: Text(ele.label).tr(),
+              );
+            }),
+            const Divider(),
+            ...destinations.where((ele) => !ele.isPinned).map((ele) {
+              return NavigationDrawerDestination(
+                icon: ele.icon,
+                label: Text(ele.label).tr(),
+              );
+            }),
+          ],
+          onDestinationSelected: (idx) {
+            nav.setIndex(idx);
+            GoRouter.of(context).goNamed(destinations[idx].screen);
+            Scaffold.of(context).closeDrawer();
+          },
+        );
+      },
+    );
+  }
+}
diff --git a/lib/widgets/navigation/app_scaffold.dart b/lib/widgets/navigation/app_scaffold.dart
index 302f4d3..acf932e 100644
--- a/lib/widgets/navigation/app_scaffold.dart
+++ b/lib/widgets/navigation/app_scaffold.dart
@@ -5,6 +5,7 @@ import 'package:responsive_framework/responsive_framework.dart';
 import 'package:surface/widgets/dialog.dart';
 import 'package:surface/widgets/navigation/app_background.dart';
 import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
+import 'package:surface/widgets/navigation/app_drawer_navigation.dart';
 
 class AppScaffold extends StatelessWidget {
   final PreferredSizeWidget? appBar;
@@ -14,6 +15,7 @@ class AppScaffold extends StatelessWidget {
   final Widget? body;
   final bool autoImplyAppBar;
   final bool showBottomNavigation;
+  final bool showDrawer;
   const AppScaffold({
     super.key,
     this.appBar,
@@ -23,12 +25,14 @@ class AppScaffold extends StatelessWidget {
     this.body,
     this.autoImplyAppBar = false,
     this.showBottomNavigation = false,
+    this.showDrawer = false,
   });
 
   @override
   Widget build(BuildContext context) {
+    final isShowDrawer = showDrawer;
     final isShowBottomNavigation = (showBottomNavigation)
-        ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
+        ? (ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE))
         : false;
 
     final state = GoRouter.maybeOf(context);
@@ -50,6 +54,7 @@ class AppScaffold extends StatelessWidget {
         body: body,
         floatingActionButtonLocation: floatingActionButtonLocation,
         floatingActionButton: floatingActionButton,
+        drawer: isShowDrawer ? AppNavigationDrawer() : null,
         bottomNavigationBar:
             isShowBottomNavigation ? AppBottomNavigationBar() : null,
       ),