DAY 22 | Flutter 데모 앱 만들기: 사용자가 구성하는 페이지와 Provider

Pt J·3일 전
post-thumbnail

DAY 22 | Flutter 데모 앱 만들기: 사용자가 구성하는 페이지와 Provider

지난 시간에 작성한 코드에 한 가지 이슈가 있다. 로그인 했을 때 홈 화면으로 돌아가는데 다시 설정 탭으로 가면 로그인 창이 뜬다. 로그아웃을 했을 때 로그인한 사용자처럼 돌아다닐 수 있는 것도 문제가 될 수 있다. (앱에 따라 비회원에게 허용되어 있는 부분이 있는 경우도 있지만.)

이를 해결하는 가장 손쉬운 방법은 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.dart

import '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('일'),
      ],
    );
  }
}

그리고 화면에만 반영되는 게 아니라 실제 데이터에 반영되게 코드를 수정...하기에 앞서...

State Management Tool

코드의 유지보수성을 높이고, 여러 화면 간 데이터 공유와 UI 갱신을 효율적으로 처리하기 위해서 State Management Tool을 사용해 보자. BLoC, Riverpid, GetX 등 다양한 것들이 있지만 가장 기본적인 Provider를 사용할 것이다.

상태를 관리한다는 것은 데이터 변화를 추적하고 이를 관리하는 것이다. Provider 를 통해 데이터를 제공하면 그 하위에서 해당 데이터를 사용할 수 있다.

pubspec.yaml

dependencies:
  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 영역에서 사용
특징build method 안에서 선언하며, listen 옵션을 켜고 끌 수 있음BuildContext를 쓰기 어려운 곳이나, 특정 부분만 콕 집어서 리빌드하고 싶을 때 사용
동작listen: true (기본값)로 설정하면 상태가 바뀔 때 build method가 있는 widget 전체가 다시 그려짐Consumer 바로 밑에 있는 builder function 내부만 다시 그려짐

MyWorkout 을 담는 List를 관리하는 Provider를 작성한다.

lib/logics/my_workout_provider.dart

import '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.dartMyApp 에서 해주도록 하겠다.

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,
                ),
              ),
            ),
// 뒷 부분 생략

>>> GitHub Repository at this point (4e20e84)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글