과거 프로젝트에서 페이지를 관리할 때 MaterialApp의 기본 route를 사용해서 Navigator.pushName(), pushReplacementNamed() 메서드를 사용했다. 이번에는 go_router 라이브러리를 사용해서 페이지 라우팅를 어떻게 하는지 알아보았다.
class _App extends StatelessWidget {
const _App({super.key});
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
MaterialApp.router는 routerConfig Named Parameter를 통해 RouterConfig 클래스의 인스턴스를 받아야 한다.
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) {
return RootScreen();
},
routes: [
GoRoute(
path: 'basic',
builder: (context, state) {
return BasicScreen();
},
),
GoRoute(...),
GoRoute(...),
GoRoute(...),
],
],
);
context.go('same level path');를 실행하면 자신 위에 올리지 않고 상위 레벨의 router부터 스택에 넣은 뒤 이동하기 때문이다. 즉, 같은 레벨에서의 이동은 스택을 바꾼다 라고 생각할 수 있다.context.push('same level path');를 사용하면 스택이 남게 된다.
final router = GoRouter(
routes: [
GoRoute(
path: 'path_param/:id',
builder: (context, state) {
return PathParamScreen();
},
),
GoRoute(
path: 'query_param',
builder: (context, state) {
return QueryParameterScreen();
},
),
],
);
------------- 사용 코드
ListView(
children: [
Text('Path Param : ${GoRouterState.of(context).pathParameters}'),
ElevatedButton(
onPressed: () {
context.go('/path_param/123');
},
child: Text('Go One More'),
),
],
),
ListView(
children: [
Text(
'Query Param : ${GoRouterState.of(context).uri.queryParameters}'),
ElevatedButton(
onPressed: () {
context.push(
Uri(path: '/query_param', queryParameters: {
'name': 'codefactory',
'age': '21',
}).toString(),
);
},
child: Text('Query Parameter'),
),
],
),
redirect를 전체 레벨, 특정 레벨에서 설정할 수 있다.
final router = GoRouter(
redirect: (context, state) {
// return string(path) -> 해당 라우트로 이동한다.
// return null -> 원래 이동하려던 라우트로 이동한다.
if (state.uri.toString() == '/login/private' && !authState) {
return '/login';
}
return null;
},
routes: [
GoRoute(),
GoRoute(),
GoRoute(),
...
],
일부에서 적용하고 싶은면 GoRoute 안에 지정
GoRoute(
path: 'login2',
builder: (context, state) => LoginScreen(),
routes: [
GoRoute(
path: 'private',
builder: (context, state) => PrivateScreen(),
redirect: (context, state) {
if (!authState) {
return '/login2';
}
return null;
},
),
],
),
Performing hot restart...
Syncing files to device iPhone 15 Pro Max...
Restarted application in 282ms.
[GoRouter] Full paths for routes:
=> /
=> /basic
=> /named
=> /push
=> /pop
=> /pop/return
=> /path_param/:id
=> /path_param/:id/:name
=> /query_param
=> /nested/a
=> /nested/b
=> /nested/c
=> /login
=> /login/private
=> /login2
=> /login2/private
=> /transition
=> /transition/detail
known full paths for route names:
named_screen => /named
[GoRouter] setting initial location null
[GoRouter] Using MaterialApp configuration
[GoRouter] going to /basic
[GoRouter] going to /error
[GoRouter] No initial matches: /error
[GoRouter] going to /
[GoRouter] going to /login2
[GoRouter] going to /login2/private
[GoRouter] redirecting to RouteMatchList#a84bc(uri: /login2, matches: [RouteMatch#15de7(route: GoRoute#a4e47(name: null, path: "/")), RouteMatch#532f8(route: GoRoute#1cc06(name: null, path: "login2"))])
[GoRouter] going to /login2/private
[GoRouter] going to /basic
part 'user_me_repository.g.dart';
()
abstract class UserMeRepository {
factory UserMeRepository(Dio dio, {String baseUrl}) = _UserMeRepository;
('/')
({
'accessToken': 'true',
})
Future<UserModel> getMe();
}
final userMeRepositoryProvider = Provider<UserMeRepository>((ref) {
final dio = ref.watch(dioProvider);
return UserMeRepository(dio, baseUrl: 'http://$ip/user/me');
});
abstract class UserModelBase {}
class UserModelLoading extends UserModelBase {}
class UserModelError extends UserModelBase {
final String message;
UserModelError({
required this.message,
});
}
()
class UserModel extends UserModelBase {
final String id;
final String username;
(
fromJson: DataUtils.pathToUrl,
)
final String imageUrl;
UserModel({
required this.id,
required this.username,
required this.imageUrl,
});
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
final userMeProvider =
StateNotifierProvider<UserMeStateNotifier, UserModelBase?>((ref) {
final authRepository = ref.watch(authRepositoryProvider);
final userMeRepository = ref.watch(userMeRepositoryProvider);
final storage = ref.watch(secureStorageProvider);
return UserMeStateNotifier(
authRepository: authRepository,
repository: userMeRepository,
storage: storage,
);
});
class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
final UserMeRepository repository;
// 토큰용
final FlutterSecureStorage storage;
UserMeStateNotifier({
required this.repository,
required this.storage,
}) : super(UserModelLoading()) {
// 내 정보 가져오기
getMe();
}
// 토큰 기반으로 유저 정보 가져오기
Future<void> getMe() async {
final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);
final accessToken = await storage.read(key: ACCESS_TOKEN_KEY);
if (refreshToken == null || accessToken == null) {
state = null;
return;
}
final resp = await repository.getMe();
state = resp;
}
}
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final dio = ref.watch(dioProvider);
return AuthRepository(baseUrl: 'http://$ip/auth', dio: dio);
});
class AuthRepository {
final String baseUrl;
final Dio dio;
AuthRepository({
required this.baseUrl,
required this.dio,
});
Future<LoginResponse> login({
required String username,
required String password,
}) async {
// DataUtils.plainToBase64 = plain Data를 Base64로 인코딩 하는 코드
final serialized = DataUtils.plainToBase64('$username:$password');
final resp = await dio.post(
'$baseUrl/login',
options: Options(
headers: {
'authorization': 'Basic $serialized',
},
),
);
return LoginResponse.fromJson(resp.data);
}
Future<TokenResponse> token() async {
final resp = await dio.post(
'$baseUrl/token',
options: Options(
headers: {
'refreshToken': 'true',
},
),
);
return TokenResponse.fromJson(resp.data);
}
}
class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
final AuthRepository authRepository;
final UserMeRepository repository;
final FlutterSecureStorage storage;
UserMeStateNotifier({
required this.authRepository,
required this.repository,
required this.storage,
}) : super(UserModelLoading()) {
// 내 정보 가져오기
getMe();
}
Future<void> getMe() async {
...
}
Future<UserModelBase> login({
required String username,
required String password,
}) async {
try {
state = UserModelLoading();
// authRepository에서 username, password를 인코딩해서 헤더에 넣고 데이터를 가져오는 로직이 존재함.
final resp = await authRepository.login(
username: username,
password: password,
);
await storage.write(key: REFRESH_TOKEN_KEY, value: resp.refreshToken);
await storage.write(key: ACCESS_TOKEN_KEY, value: resp.accessToken);
// 로그인 후 데이터 저장
final userResp = await repository.getMe();
state = userResp;
return userResp;
} catch (e) {
state = UserModelError(message: '로그인에 실패했습니다.');
return Future.value(state);
}
}
Future<void> logout() async {
state = null;
await Future.wait(
[
storage.delete(key: REFRESH_TOKEN_KEY),
storage.delete(key: ACCESS_TOKEN_KEY),
],
);
}
}
final authProvider = ChangeNotifierProvider<AuthProvider>((ref) {
return AuthProvider(ref: ref);
});
class AuthProvider extends ChangeNotifier {
final Ref ref;
AuthProvider({
required this.ref,
}) {
ref.listen<UserModelBase?>(userMeProvider, (previous, next) {
if (previous != next) {
notifyListeners();
}
});
}
void logout() {
ref.read(userMeProvider.notifier).logout();
}
FutureOr<String?> redirectLogic(BuildContext context, GoRouterState state) {
final UserModelBase? user = ref.read(userMeProvider);
final loggingIn = state.uri.toString() == '/login';
if (user == null) {
return loggingIn ? null : '/login';
}
if (user is UserModel) {
return loggingIn || state.uri.toString() == '/splash' ? '/' : null;
}
if (user is UserModelError) {
return !loggingIn ? '/login' : null;
}
return null;
}

List<GoRoute> get routes => [
GoRoute(
path: '/',
name: RootTab.routeName,
builder: (context, state) => RootTab(),
routes: [
GoRoute(
path: 'restaurant/:rid',
name: RestaurantDetailScreen.routeName,
builder: (_, state) => RestaurantDetailScreen(
id: state.pathParameters['rid']!,
),
),
]),
GoRoute(
path: '/splash',
name: SplashScreen.routeName,
builder: (context, state) => SplashScreen(),
),
GoRoute(
path: '/login',
name: LoginScreen.routeName,
builder: (context, state) => LoginScreen(),
),
];
final routerProvider = Provider<GoRouter>((ref) {
final provider = ref.read(authProvider);
return GoRouter(
routes: provider.routes,
initialLocation: '/splash',
// 상태가 변경됬을 때 리프레쉬를 해야한다. 그 대상 상태
refreshListenable: provider,
// 리다이렉트 룰
redirect: provider.redirectLogic,
);
});
class _App extends ConsumerWidget {
const _App({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
debugShowCheckedModeBanner: true,
routerDelegate: router.routerDelegate,
routeInformationParser: router.routeInformationParser,
routeInformationProvider: router.routeInformationProvider,
);
}
}
routerDelegate: router.routerDelegate,: 앱의 라우팅을 관리하는 객체를 지정한다. 이 routerDelegate는 앱의 현재 상태와 URL 경로를 기반으로 앱의 화면을 업데이트한다..routeInformationParser: router.routeInformationParser,: URL 경로를 RouteInformation 객체로 변환하는 파서를 지정. 이 파서는 앱이 URL을 해석하는 방법을 정의한다.routeInformationProvider: router.routeInformationProvider,: 현재 앱의 상태를 RouteInformation 객체로 변환하는 프로바이더를 지정, 이 프로바이더는 앱의 현재 상태를 URL로 변환하고, 그 역으로 URL을 앱의 상태로 변환하는 역할을 한다.// 메인 레스토랑 스크린
class RestaurantScreen extends StatelessWidget {
Widget build(BuildContext context) {
return PaginationListView(
provider: restaurantProvider,
itemBuilder: <RestaurantModel>(_, index, model) {
return GestureDetector(
onTap: () {
context.goNamed(
RestaurantDetailScreen.routeName,
// 파라미터 지정
pathParameters: {
'rid': model.id,
},
);
},
child: RestaurantCard.fromModel(
model: model,
),
);
},
);
}
}