💄 Better bottom navigation

This commit is contained in:
LittleSheep 2024-07-06 20:55:53 +08:00
parent a304b26c96
commit 66ddfea68d
9 changed files with 326 additions and 278 deletions

View File

@ -19,7 +19,6 @@ import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/friend.dart'; import 'package:solian/providers/friend.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/shells/listener_shell.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/translations.dart'; import 'package:solian/translations.dart';
@ -83,15 +82,14 @@ class SolianApp extends StatelessWidget {
routerDelegate: AppRouter.instance.routerDelegate, routerDelegate: AppRouter.instance.routerDelegate,
routeInformationParser: AppRouter.instance.routeInformationParser, routeInformationParser: AppRouter.instance.routeInformationParser,
routeInformationProvider: AppRouter.instance.routeInformationProvider, routeInformationProvider: AppRouter.instance.routeInformationProvider,
backButtonDispatcher: AppRouter.instance.backButtonDispatcher,
translations: SolianMessages(), translations: SolianMessages(),
locale: Get.deviceLocale, locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'), fallbackLocale: const Locale('en', 'US'),
onInit: () => _initializeProviders(context), onInit: () => _initializeProviders(context),
builder: (context, child) { builder: (context, child) {
return ListenerShell( return ScaffoldMessenger(
child: ScaffoldMessenger( child: child ?? const SizedBox(),
child: child ?? Container(),
),
); );
}, },
); );

View File

@ -13,10 +13,10 @@ import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_detail.dart'; import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.dart'; import 'package:solian/screens/realms/realm_view.dart';
import 'package:solian/screens/social.dart'; import 'package:solian/screens/feed.dart';
import 'package:solian/screens/posts/post_publish.dart'; import 'package:solian/screens/posts/post_publish.dart';
import 'package:solian/shells/basic_shell.dart'; import 'package:solian/shells/basic_shell.dart';
import 'package:solian/shells/nav_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_shell.dart'; import 'package:solian/shells/title_shell.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/sidebar/empty_placeholder.dart'; import 'package:solian/widgets/sidebar/empty_placeholder.dart';
@ -25,204 +25,206 @@ abstract class AppRouter {
static GoRouter instance = GoRouter( static GoRouter instance = GoRouter(
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (context, state, child) => NavShell( builder: (context, state, child) => RootShell(
state: state, state: state,
showAppBar: false,
showSidebar: false,
child: child, child: child,
), ),
routes: [ routes: [
ShellRoute( _feedRoute,
builder: (context, state, child) => BasicShell( _chatRoute,
state: state, _realmRoute,
sidebarFirst: true, _accountRoute,
showAppBar: false,
sidebar: const SocialScreen(),
child: child,
),
routes: [
GoRoute(
path: '/',
name: 'social',
builder: (context, state) =>
SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const SocialScreen(),
),
GoRoute(
path: '/posts/view/:alias',
name: 'postDetail',
builder: (context, state) => TitleShell(
state: state,
child: PostDetailScreen(
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/posts/publish',
name: 'postPublishing',
builder: (context, state) {
final arguments = state.extra as PostPublishingArguments?;
return PostPublishingScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
realm: arguments?.realm,
);
},
),
],
),
ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const ChatScreen(),
child: child,
),
routes: [
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) =>
SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const ChatScreen(),
),
GoRoute(
path: '/chat/organize',
name: 'channelOrganizing',
builder: (context, state) {
final arguments = state.extra as ChannelOrganizeArguments?;
return ChannelOrganizeScreen(
edit: arguments?.edit,
realm: arguments?.realm,
);
},
),
GoRoute(
path: '/chat/:alias',
name: 'channelChat',
builder: (context, state) {
return ChannelChatScreen(
alias: state.pathParameters['alias']!,
realm: state.uri.queryParameters['realm'] ?? 'global',
);
},
),
GoRoute(
path: '/chat/:alias/detail',
name: 'channelDetail',
builder: (context, state) {
final arguments = state.extra as ChannelDetailArguments;
return TitleShell(
state: state,
child: ChannelDetailScreen(
channel: arguments.channel,
profile: arguments.profile,
realm: state.uri.queryParameters['realm'] ?? 'global',
),
);
},
),
],
),
ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const RealmListScreen(),
child: child,
),
routes: [
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) =>
SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const RealmListScreen(),
),
GoRoute(
path: '/realms/:alias/detail',
name: 'realmDetail',
builder: (context, state) => TitleShell(
state: state,
child: RealmDetailScreen(
realm: state.extra as Realm,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/realm/organize',
name: 'realmOrganizing',
builder: (context, state) {
final arguments = state.extra as RealmOrganizeArguments?;
return RealmOrganizeScreen(
edit: arguments?.edit,
);
},
),
GoRoute(
path: '/realm/:alias',
name: 'realmView',
builder: (context, state) {
return RealmViewScreen(
alias: state.pathParameters['alias']!,
);
},
),
],
),
ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const AccountScreen(),
child: child,
),
routes: [
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) =>
SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const AccountScreen(),
),
GoRoute(
path: '/account/friend',
name: 'accountFriend',
builder: (context, state) => TitleShell(
state: state,
child: const FriendScreen(),
),
),
GoRoute(
path: '/account/personalize',
name: 'accountPersonalize',
builder: (context, state) => TitleShell(
state: state,
child: const PersonalizeScreen(),
),
),
GoRoute(
path: '/about',
name: 'about',
builder: (context, state) => TitleShell(
state: state,
child: const AboutScreen(),
),
),
],
),
], ],
), ),
], ],
); );
static final ShellRoute _feedRoute = ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const FeedScreen(),
child: child,
),
routes: [
GoRoute(
path: '/',
name: 'feed',
builder: (context, state) => SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const FeedScreen(),
),
GoRoute(
path: '/posts/view/:alias',
name: 'postDetail',
builder: (context, state) => TitleShell(
state: state,
child: PostDetailScreen(
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/posts/publish',
name: 'postPublishing',
builder: (context, state) {
final arguments = state.extra as PostPublishingArguments?;
return PostPublishingScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
realm: arguments?.realm,
);
},
),
],
);
static final ShellRoute _chatRoute = ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const ChatScreen(),
child: child,
),
routes: [
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const ChatScreen(),
),
GoRoute(
path: '/chat/organize',
name: 'channelOrganizing',
builder: (context, state) {
final arguments = state.extra as ChannelOrganizeArguments?;
return ChannelOrganizeScreen(
edit: arguments?.edit,
realm: arguments?.realm,
);
},
),
GoRoute(
path: '/chat/:alias',
name: 'channelChat',
builder: (context, state) {
return ChannelChatScreen(
alias: state.pathParameters['alias']!,
realm: state.uri.queryParameters['realm'] ?? 'global',
);
},
),
GoRoute(
path: '/chat/:alias/detail',
name: 'channelDetail',
builder: (context, state) {
final arguments = state.extra as ChannelDetailArguments;
return TitleShell(
state: state,
child: ChannelDetailScreen(
channel: arguments.channel,
profile: arguments.profile,
realm: state.uri.queryParameters['realm'] ?? 'global',
),
);
},
),
],
);
static final ShellRoute _realmRoute = ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const RealmListScreen(),
child: child,
),
routes: [
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) => SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const RealmListScreen(),
),
GoRoute(
path: '/realms/:alias/detail',
name: 'realmDetail',
builder: (context, state) => TitleShell(
state: state,
child: RealmDetailScreen(
realm: state.extra as Realm,
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/realm/organize',
name: 'realmOrganizing',
builder: (context, state) {
final arguments = state.extra as RealmOrganizeArguments?;
return RealmOrganizeScreen(
edit: arguments?.edit,
);
},
),
GoRoute(
path: '/realm/:alias',
name: 'realmView',
builder: (context, state) {
return RealmViewScreen(
alias: state.pathParameters['alias']!,
);
},
),
],
);
static final ShellRoute _accountRoute = ShellRoute(
builder: (context, state, child) => BasicShell(
state: state,
sidebarFirst: true,
showAppBar: false,
sidebar: const AccountScreen(),
child: child,
),
routes: [
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => SolianTheme.isExtraLargeScreen(context)
? const EmptyPagePlaceholder()
: const AccountScreen(),
),
GoRoute(
path: '/account/friend',
name: 'accountFriend',
builder: (context, state) => TitleShell(
state: state,
child: const FriendScreen(),
),
),
GoRoute(
path: '/account/personalize',
name: 'accountPersonalize',
builder: (context, state) => TitleShell(
state: state,
child: const PersonalizeScreen(),
),
),
GoRoute(
path: '/about',
name: 'about',
builder: (context, state) => TitleShell(
state: state,
child: const AboutScreen(),
),
),
],
);
} }

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/account.dart'; import 'package:solian/providers/account.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SignInPopup extends StatefulWidget { class SignInPopup extends StatefulWidget {
@ -13,13 +15,13 @@ class SignInPopup extends StatefulWidget {
State<SignInPopup> createState() => _SignInPopupState(); State<SignInPopup> createState() => _SignInPopupState();
} }
class _SignInPopupState extends State<SignInPopup> { class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
bool _isBusy = false; bool _isBusy = false;
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
void requestResetPassword(BuildContext context) async { void requestResetPassword() async {
final username = _usernameController.value.text; final username = _usernameController.value.text;
if (username.isEmpty) { if (username.isEmpty) {
context.showErrorDialog('signinResetPasswordHint'.tr); context.showErrorDialog('signinResetPasswordHint'.tr);
@ -49,7 +51,7 @@ class _SignInPopupState extends State<SignInPopup> {
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr); context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
} }
void performAction(BuildContext context) async { void performAction() async {
final AuthProvider provider = Get.find(); final AuthProvider provider = Get.find();
final username = _usernameController.value.text; final username = _usernameController.value.text;
@ -96,6 +98,27 @@ class _SignInPopupState extends State<SignInPopup> {
Navigator.pop(context, true); Navigator.pop(context, true);
} }
@override
void initState() {
protocolHandler.addListener(this);
super.initState();
}
@override
void dispose() {
protocolHandler.removeListener(this);
super.dispose();
}
@override
void onProtocolUrlReceived(String url) {
final uri = url.replaceFirst('solink://', '');
if (uri == 'auth?status=done') {
closeInAppWebView();
performAction();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
@ -144,20 +167,19 @@ class _SignInPopupState extends State<SignInPopup> {
), ),
onTapOutside: (_) => onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(context), onSubmitted: (_) => performAction(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
TextButton( TextButton(
onPressed: onPressed: _isBusy ? null : () => requestResetPassword(),
_isBusy ? null : () => requestResetPassword(context),
style: TextButton.styleFrom(foregroundColor: Colors.grey), style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr), child: Text('forgotPassword'.tr),
), ),
TextButton( TextButton(
onPressed: _isBusy ? null : () => performAction(context), onPressed: _isBusy ? null : () => performAction(),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View File

@ -12,14 +12,14 @@ import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
class SocialScreen extends StatefulWidget { class FeedScreen extends StatefulWidget {
const SocialScreen({super.key}); const FeedScreen({super.key});
@override @override
State<SocialScreen> createState() => _SocialScreenState(); State<FeedScreen> createState() => _FeedScreenState();
} }
class _SocialScreenState extends State<SocialScreen> { class _FeedScreenState extends State<FeedScreen> {
final PagingController<int, Post> _pagingController = final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0); PagingController(firstPageKey: 0);
@ -52,26 +52,7 @@ class _SocialScreenState extends State<SocialScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return Scaffold( return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final value =
await AppRouter.instance.pushNamed('postPublishing');
if (value != null) {
_pagingController.refresh();
}
},
);
}
return Container();
}),
body: Material( body: Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: RefreshIndicator( child: RefreshIndicator(
@ -79,7 +60,7 @@ class _SocialScreenState extends State<SocialScreen> {
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
title: AppBarTitle('social'.tr), title: AppBarTitle('feed'.tr),
centerTitle: false, centerTitle: false,
floating: true, floating: true,
titleSpacing: SolianTheme.titleSpacing(context), titleSpacing: SolianTheme.titleSpacing(context),
@ -87,6 +68,7 @@ class _SocialScreenState extends State<SocialScreen> {
actions: [ actions: [
const BackgroundStateWidget(), const BackgroundStateWidget(),
const NotificationButton(), const NotificationButton(),
const FeedCreationButton(),
SizedBox( SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16, width: SolianTheme.isLargeScreen(context) ? 8 : 16,
), ),
@ -100,3 +82,26 @@ class _SocialScreenState extends State<SocialScreen> {
); );
} }
} }
class FeedCreationButton extends StatelessWidget {
const FeedCreationButton({super.key});
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
return FutureBuilder(
future: auth.isAuthorized,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return IconButton(
icon: const Icon(Icons.add_circle),
onPressed: () {
AppRouter.instance.pushNamed('postPublishing');
},
);
}
return const SizedBox();
});
}
}

View File

@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:url_launcher/url_launcher.dart';
class ListenerShell extends StatefulWidget {
final Widget child;
const ListenerShell({super.key, required this.child});
@override
State<ListenerShell> createState() => _ListenerShellState();
}
class _ListenerShellState extends State<ListenerShell> with ProtocolListener {
@override
void initState() {
protocolHandler.addListener(this);
super.initState();
}
@override
void dispose() {
protocolHandler.removeListener(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
@override
void onProtocolUrlReceived(String url) {
final uri = url.replaceFirst('solink://', '');
if (uri == 'auth?status=done') {
closeInAppWebView();
}
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/navigation/app_navigation.dart'; import 'package:solian/widgets/navigation/app_navigation.dart';
@ -14,7 +13,6 @@ class NavShell extends StatelessWidget {
final bool showSidebar; final bool showSidebar;
final bool showNavigation; final bool showNavigation;
final bool? showBottomNavigation; final bool? showBottomNavigation;
final GoRouterState state;
final Widget child; final Widget child;
final bool sidebarFirst; final bool sidebarFirst;
@ -23,7 +21,6 @@ class NavShell extends StatelessWidget {
const NavShell({ const NavShell({
super.key, super.key,
required this.child, required this.child,
required this.state,
this.showAppBar = true, this.showAppBar = true,
this.showSidebar = true, this.showSidebar = true,
this.showNavigation = true, this.showNavigation = true,
@ -60,7 +57,7 @@ class NavShell extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: showAppBar appBar: showAppBar
? AppBar( ? AppBar(
title: Text(state.topRoute?.name?.tr ?? 'page'.tr), title: Text(routeName ?? 'page'.tr),
centerTitle: false, centerTitle: false,
titleSpacing: canPop ? null : 24, titleSpacing: canPop ? null : 24,
elevation: SolianTheme.isLargeScreen(context) ? 1 : 0, elevation: SolianTheme.isLargeScreen(context) ? 1 : 0,

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/navigation/app_navigation.dart';
import 'package:solian/widgets/navigation/app_navigation_bottom_bar.dart';
import 'package:solian/widgets/navigation/app_navigation_rail.dart';
class RootShell extends StatelessWidget {
final bool showSidebar;
final bool showNavigation;
final bool? showBottomNavigation;
final GoRouterState state;
final Widget child;
const RootShell({
super.key,
required this.state,
required this.child,
this.showSidebar = true,
this.showNavigation = true,
this.showBottomNavigation,
});
@override
Widget build(BuildContext context) {
final routeName = AppRouter
.instance.routerDelegate.currentConfiguration.lastOrNull?.route.name;
final showBottom = showBottomNavigation ??
AppNavigation.destinationPages.contains(routeName);
return Scaffold(
body: SolianTheme.isLargeScreen(context)
? Row(
children: [
if (showNavigation) const AppNavigationRail(),
if (showNavigation)
const VerticalDivider(thickness: 0.3, width: 1),
Expanded(child: child),
],
)
: Stack(
children: [
child,
Positioned(
bottom: 0,
left: 0,
right: 0,
child: const AppNavigationBottomBar()
.animate(target: showBottom ? 0 : 1)
.slideY(
duration: 250.ms,
begin: 0,
end: 1,
curve: Curves.easeInToLinear,
),
),
],
),
);
}
}

View File

@ -10,7 +10,7 @@ class SolianMessages extends Translations {
'next': 'Next', 'next': 'Next',
'reset': 'Reset', 'reset': 'Reset',
'page': 'Page', 'page': 'Page',
'social': 'Social', 'feed': 'Feed',
'chat': 'Chat', 'chat': 'Chat',
'apply': 'Apply', 'apply': 'Apply',
'cancel': 'Cancel', 'cancel': 'Cancel',
@ -263,7 +263,7 @@ class SolianMessages extends Translations {
'edit': '编辑', 'edit': '编辑',
'delete': '删除', 'delete': '删除',
'page': '页面', 'page': '页面',
'social': '社交', 'feed': '资讯',
'chat': '聊天', 'chat': '聊天',
'apply': '应用', 'apply': '应用',
'search': '搜索', 'search': '搜索',

View File

@ -4,9 +4,9 @@ import 'package:get/utils.dart';
abstract class AppNavigation { abstract class AppNavigation {
static List<AppNavigationDestination> destinations = [ static List<AppNavigationDestination> destinations = [
AppNavigationDestination( AppNavigationDestination(
icon: const Icon(Icons.public), icon: const Icon(Icons.feed),
label: 'social'.tr, label: 'feed'.tr,
page: 'social', page: 'feed',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: const Icon(Icons.forum), icon: const Icon(Icons.forum),