
지난 시간에 작성한 코드에 한 가지 이슈가 있다. 로그인 했을 때 홈 화면으로 돌아가는데 다시 설정 탭으로 가면 로그인 창이 뜬다. 로그아웃을 했을 때 로그인한 사용자처럼 돌아다닐 수 있는 것도 문제가 될 수 있다. (앱에 따라 비회원에게 허용되어 있는 부분이 있는 경우도 있지만.)
이를 해결하는 가장 손쉬운 방법은 redirect를 사용하는 거다.
lib/workout_router.dart//앞 부분 생략 final router = GoRouter( navigatorKey: _rootNavigatorKey, redirect: (context, state) { User? user = FirebaseAuth.instance.currentUser; if (user == null) { if (state.uri.path != '/settings/login/registration' && state.uri.path != '/settings/reset_password' && state.uri.path != '/') { return '/settings/login'; } } else { if (state.uri.path == '/settings/login' || state.uri.path == '/settings/login/registration') { return '/settings'; } } }, routes: [ // 생략 ], );
로그인하지 않은 사용자일 경우 회원가입, 비밀번호 변경, 랜딩 페이지가 아닌 곳에 있을 때 로그인 페이지로 이동시킨다. (로그인 페이지에 있을 경우 이미 거기에 있으니 옮기지 않아도 되므로 따로 작성하지 않는다.)
로그인한 사용자일 경우 로그인 또는 회원가입 화면에 있을 때 설정 페이지로 이동시킨다.
SDK 접근에 대하여
여기서는
FirebaseAuthService를 거치지 않고 SDK에 바로 접근했다. router가 SDK에 종속된 것이다.
Service를 사용할 경우 SDK 종속성을 배제할 수 있지만 View 또는 ViewModel에서만 Service에 접근한다는 원칙을 위배하여 흐름이 애매해질 수 있다.
우리는 router는 앱의 backbone에 해당하니 여기서는 SDK 접근을 허용하는 걸로 예외를 두기로 했다.
redirect는 무겁다. route가 변동될 때마다 같이 실행되기 때문에 부하가 발생한다. 따라서 큰 앱의 경우는 이런 오버헤드를 피하기 위해 로그인 여부를 state로 관리한다.
지금까지 우리는 lib 디렉토리 안에 모든 파일을 구분 없이 작성했다. 관리의 편의성을 위해 파일 구조를 다음과 같이 리팩토링하겠다.
❯ tree lib lib ├── firebase_options.dart ├── logics │ └── workout_manager.dart ├── main.dart ├── models │ ├── workout.dart │ └── workout_group.dart ├── pages │ ├── landing_page.dart │ ├── login_page.dart │ ├── profile_page.dart │ ├── registration_page.dart │ ├── settings_page.dart │ ├── workout_guide_page.dart │ ├── workout_home_page.dart │ ├── workout_list_page.dart │ └── workout_shell.dart ├── services │ ├── firebase_auth_service.dart │ └── firebase_storage_service.dart ├── widgets │ ├── dashboard_card.dart │ └── show_snackbar.dart └── workout_router.dart
어디에 넣을지 애매한 녀석들은 일단 빼고 옮기기로 했다. show_snackbar.dart 는 엄밀히 말하면 widgets/ 에 넣기 애매하긴 한데, 나는 루트에 있는 것보다는 저기에 넣는 게 낫다고 판단했다. (수업에서는 루트에 두는 걸로 했지만 이건 내 판단이다.)
작은 앱은 보통 레이어를 기준으로 파일 구조를 설정하고, 큰 앱은 기능을 기준으로 파일 구조를 설정한다고 한다.
지금까지 우리가 만들었던 화면은 정적인 데이터를 기반으로 한다. 이미 만들어져 있는 운동 목록을 적절히 보여주는 식이다. 하지만 앱은 그런 식으로만 작동하지 않는다. 새 글을 작성하면 피드에 글 위젯이 추가되는 앱 같은 건 흔히 있으니 말이다.
그런 의미에서 원하는 운동을 선택 조합하여 나만의 운동 목록을 만드는 화면을 살펴보도록 하겠다. 수업 자료에서 models/my_workout.dart, pages/my_workout_list_page.dart, pages/add_workout_dialog.dart, widgets/workout_tile.dart, widgets/workout_day_selector.dart 를 복사해 온 뒤, 라우트에 페이지를 추가한다.
lib/workout_router.dart// 앞 부분 생략 branches: [ StatefulShellBranch( navigatorKey: _homeNavigatorKey, routes: [ GoRoute( path: '/workout_home', builder: (context, state) => WorkoutHomePage(), routes: [ GoRoute( path: 'my_workout_list', builder: (context, state) { return MyWorkoutListPage(); } ), // 뒷 부분 생략
WorkoutHomePage 의 적절한 위치에 나만의 운동 페이지로 이동할 버튼을 추가한다.
MyWorkoutListPage 에서 플로팅 버튼을 누르면 dialog가 route가 아닌 overlay로 뜨도록 작성한다.
// 앞 부분 생략 floatingActionButton: FloatingActionButton( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, child: Icon(Icons.add), onPressed: () { showDialog(context: context, builder: (context) { return Dialog(child: AddWorkoutDialog(),); }); }, ), // 뒷 부분 생략
AddWorkoutDialog 에 운동명, 이미지, 운동 시간에 대한 property를 추가하고 onChanged 에 적절한 callback function을 작성한다. 이 때, 운동 시간은 텍스트로 입력되지만 숫자이므로 int.parse() 를 해주어야 한다.
이미지 업로드는 프로필 사진 변경에서 했던 것처럼 하면 된다. 다만 백그라운드에서 업로드되는 것과 별개로 프리뷰 이미지는 로컬 이미지를 보여주는 방식으로 하겠다.
Service에 적절한 method를 추가해 주어야 한다.
lib/services/firebase_storage_service.dart// 앞 부분 생략 Future<String> uploadWorkoutImage({ required Uint8List bytes, required String path, required int pickedFileHash, }) async { try { final workoutRef = storageRef.child('my_workout/${pickedFileHash}_workout_image.png'); final metadata = SettableMetadata( contentType: 'image/png', customMetadata: { 'picked-file-path': path, }, ); await workoutRef.putData(bytes, metadata); final downloadUrl = await workoutRef.getDownloadURL(); return downloadUrl; } catch (e) { throw Exception('upload 실패: $e'); } } // 뒷 부분 생략
lib/pages/add_workout_dialog.dart// 앞 부분 생략 class _AddWorkoutDialogState extends State<AddWorkoutDialog> { String? newWorkoutTitle; int? newWorkoutMinutes; String? newWorkoutImageUrl; final FirebaseStorageService _storage = FirebaseStorageService(); final ImagePicker _picker = ImagePicker(); ImageProvider? _previewImage; XFile? _pickedImage; Future<void> _pickImage() async { try { _pickedImage = await _picker.pickImage(source: ImageSource.gallery); if (_pickedImage != null) { _previewImage = FileImage(File(_pickedImage!.path)); } } catch (e) { showSnackBar('$e'); } } Future<String?> uploadWorkout(XFile? pickedFile) async { if (pickedFile == null) { return null; } return await _storage.uploadWorkoutImage( bytes: await pickedFile.readAsBytes(), path: pickedFile.path, pickedFileHash: pickedFile.hashCode, ).catchError((error) { showSnackBar('$error'); }); // 생략: 이하 이미지 컨테이너 Container( width: 80, height: 80, decoration: BoxDecoration( image: _previewImage != null ? DecorationImage( image: _previewImage!, fit: BoxFit.cover, ) : null, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300, width: 1), ), ), // 생략: 이하 운동 추가 버튼 Container( decoration: BoxDecoration( borderRadius: BorderRadius.only( bottomRight: Radius.circular(20.0), bottomLeft: Radius.circular(20.0), ), color: colorScheme.primary, ), height: 50, width: double.infinity, child: TextButton( onPressed: () { uploadWorkout(_pickedImage); }, child: Text( '운동 추가', style: textTheme.titleLarge?.copyWith( color: colorScheme.onPrimary, fontWeight: FontWeight.bold, ), ), ), ), // 뒷 부분 생략
이미지 변경 시 로컬 이미지를 보여주고 최종적으로 운동 추가 버튼을 눌러야 서버에 올라간다.
추가된 운동 내용을 MyWorkoutList state에 추가해야 한다. 그 작업을 하기에 앞서 WorkoutDaySelector widget을 완성해 본다.
ToggleButton은 onPressed 가 정의되어 있어야 작동한다는 것을 유의하자.
lib/widgets/workout_day_selector.dartimport 'package:flutter/material.dart'; class WorkoutDaySelector extends StatefulWidget { const WorkoutDaySelector({super.key}); State<WorkoutDaySelector> createState() => _WorkoutDaySelectorState(); } class _WorkoutDaySelectorState extends State<WorkoutDaySelector> { List<bool> isSelected = List.filled(7, false); void updateIsSelected(int index) { isSelected[index] = !isSelected[index]; } Widget build(BuildContext context) { return ToggleButtons( isSelected: isSelected, onPressed: (index) { setState(() { updateIsSelected(index); }); }, constraints: const BoxConstraints( minHeight: 32, minWidth: 32, ), children: [ Text('월'), Text('화'), Text('수'), Text('목'), Text('금'), Text('토'), Text('일'), ], ); } }
그리고 화면에만 반영되는 게 아니라 실제 데이터에 반영되게 코드를 수정...하기에 앞서...
코드의 유지보수성을 높이고, 여러 화면 간 데이터 공유와 UI 갱신을 효율적으로 처리하기 위해서 State Management Tool을 사용해 보자. BLoC, Riverpid, GetX 등 다양한 것들이 있지만 가장 기본적인 Provider를 사용할 것이다.
상태를 관리한다는 것은 데이터 변화를 추적하고 이를 관리하는 것이다. Provider 를 통해 데이터를 제공하면 그 하위에서 해당 데이터를 사용할 수 있다.
pubspec.yamldependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 audioplayers: ^6.8.1 flex_color_scheme: ^8.4.0 intl: ^0.20.3 go_router: ^17.4.0 shared_preferences: ^2.5.5 firebase_core: ^4.13.0 firebase_auth: ^6.5.7 image_picker: ^1.2.3 firebase_storage: ^13.4.6 provider: ^6.1.5+1
이 녀석은 완성형에 가까운 package이기 때문에 개발자가 Flutter version-up에 따른 코드 수정 외에 업데이트를 하지 않을 것이라고 선언했을 정도로 충분히 안정적이다. 대규모 앱에서는 적합하지 않을 수 있는데 그 경우 Riverpod, BLoC 등을 사용한다.
Provider의 대표적인 상태 공급 도구
구분 ProviderChangeNotifierProviderMultiProvider역할 데이터(값, 객체)를 단순히 하위 트리에 전달 ChangeNotifier의 상태 변화를 감지하여 UI를 갱신여러 개의 Provider를 가독성 좋게 묶어서 전달UI 갱신 여부 데이터가 바뀌어도 자동으로 UI가 안 바뀜 notifyListeners()호출 시 자동으로 UI 갱신내부에 선언한 개별 Provider특성을 따름주요 용도 서비스 클래스, 리포지토리, 고정된 설정값 전달 실시간으로 변하는 앱 상태 관리 앱 전역이나 화면 진입 시 여러 상태를 동시에 주입할 때 그 외에도 더 있지만 이 정도 알면 충분하다.
Provider의 대표적인 상태 소비 도구
구분 Provider.of(context)Consumer위치 function 영역에서 사용 widget 영역에서 사용 특징 buildmethod 안에서 선언하며, listen 옵션을 켜고 끌 수 있음BuildContext를 쓰기 어려운 곳이나, 특정 부분만 콕 집어서 리빌드하고 싶을 때 사용동작 listen: true(기본값)로 설정하면 상태가 바뀔 때buildmethod가 있는 widget 전체가 다시 그려짐Consumer바로 밑에 있는builderfunction 내부만 다시 그려짐
MyWorkout 을 담는 List를 관리하는 Provider를 작성한다.
lib/logics/my_workout_provider.dartimport 'dart:collection'; import 'package:flutter/material.dart'; import '../models/my_workout.dart'; class MyWorkoutProvider extends ChangeNotifier{ final List<MyWorkout> _workouts = [ MyWorkout( name: '어깨 스트레칭', imageURL: 'https://firebasestorage.googleapis.com/v0/b/workouttracker-pre.firebasestorage.app/o/my_workouts%2Fshoulder_stretch.png?alt=media&token=05c3ca57-586e-4c32-8ff7-c2aa88503d4c', minutes: 10, ), MyWorkout( name: '전사 자세', imageURL: 'https://firebasestorage.googleapis.com/v0/b/workouttracker-pre.firebasestorage.app/o/my_workouts%2Fwarrior_pose.png?alt=media&token=c4ab3994-c66d-458c-a53d-fa3e02ee5671', minutes: 15, ), ]; List<MyWorkout> get workouts => UnmodifiableListView(_workouts); void addMyWorkout(MyWorkout workout) { _workouts.add(workout); notifyListeners(); } }
외부에서 List를 직접 수정할 수 없도록 getter에서 UnmodifiableListView() 로 감싼 값을 사용한다. 운동을 추가할 땐 직접 추가하는 게 아니라 addMyWorkout() 을 사용해야 한다.
이 녀석을 사용하려면 이걸 사용할 모든 widget을 포함하고 있는 상위 widget에서 명시해 주어야 하는데, 우리는 lib/main.dart 의 MyApp 에서 해주도록 하겠다.
lib/main.dart// 앞 부분 생략 class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => MyWorkoutProvider()), ], child: MaterialApp.router( // 생략 ), ); } }
그리고 MyWorkoutListPage 에서 Consumer 를 사용하도록 코드를 수정한다.
lib/pages/my_workout_list_page.dart// 앞 부분 생략 Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( title: Text('MyWorkoutList'), ), body: Consumer<MyWorkoutProvider>( builder: (context, myWorkoutProvider, child) { List<MyWorkout> workouts = myWorkoutProvider.workouts; return ListView.builder( // 생략 ); // 뒷 부분 생략
그리고 AddWorkoutDialog 에서 운동 추가 버튼을 누르면 Provider를 통해 운동이 추가되도록 코드를 작성한다.
lib/pages/add_workout_dialog.dart// 앞 부분 생략 child: TextButton( onPressed: () async { newWorkoutImageUrl = await uploadWorkout(_pickedImage); Provider.of<MyWorkoutProvider>( context, listen: false, ).addMyWorkout( MyWorkout( name: newWorkoutTitle!, imageURL: newWorkoutImageUrl!, minutes: newWorkoutMinutes!, ), ); context.pop(); }, child: Text( '운동 추가', style: textTheme.titleLarge?.copyWith( color: colorScheme.onPrimary, fontWeight: FontWeight.bold, ), ), ), // 뒷 부분 생략