본문 바로가기

개발/Study

[GoToLearn] Flutter Riverpod을 사용한 BMI 계산기

GoToLearn 7주차

GoToLearn 7주차는 Riverpod을 활용하여 BMI 계산기를 만들어보는 과제입니다. 간단하게 한 번 만들어보도록 하겠습니다.

 

BMI란 ?

BMI 계산기를 만들기 전에 BMI가 어떤것인지 먼저 알아보고 진행하도록 하겠습니다.

 

BMI(Body Mass Index)로 체질량 지수를 뜻하고 근육량과 상관없는 키와 몸무게 가지고 계산하는 지수입니다.

BMI의 계산식은 몸무게(kg)을 키(m)의 제곱을 나눈 값으로 식으로 나타내면 bmi = 몸무게 / (키^2)으로 나타낼 수 있습니다.

대한 비만 학회 기준으로 BMI가 18.5 미만이면 저체중, 18.5 ~ 22.9는 정상, 23 ~ 24.9는 비만 전 단계, 25 ~ 29.9는 1단계 비만, 30 ~ 34.9는 2단계 비만, 35. 이상이면 3단계 비만으로 정의한다고합니다.

 

화면 정의 및 구현

간단한 BMI 계산기를 만들기 위해 필요한 화면 요소를 다음과 같이 정의하겠습니다.

  • BMI 입력 안내 표시 (BMI 계산 전)
    • 아이콘
    • 입력 안내 텍스트
  • BMI 결과 표시 (BMI 계산 후)
    • 아이콘 - 비만도 결과에 따라 색 변경
    • 비만도 결과 텍스트
    • BMI 지수 텍스트
  • 키 입력 텍스트 필드
  • 몸무게 입력 텍스트 필드
  • 초기화 버튼
  • 계산 버튼

 

BMI 계산 전 화면 부터 구성해보겠습니다.

class MainPage extends StatefulWidget {
  const MainPage({Key? key}) : super(key: key);

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  final _heightController = TextEditingController();
  final _weightController = TextEditingController();

  @override
  void dispose() {
    _heightController.dispose();
    _weightController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: const Text('BMI 계산기'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            /// BMI 입력 안내 표시(BMI 계산전)
            Container(
              padding: const EdgeInsets.all(16.0),
              child: const Column(
                children: [
                  Icon(
                    Icons.face,
                    size: 80,
                  ),
                  SizedBox(
                    height: 16.0,
                  ),
                  Text(
                    '키와 몸무게를 입력하여 BMI를 확인하세요 !',
                    style: TextStyle(
                      fontSize: 16.0,
                    ),
                  ),
                ],
              ),
            ),

            /// 키 입력(cm)
            TextField(
              controller: _heightController,
              decoration: const InputDecoration(
                labelText: '키(cm)를 입력해주세요.',
                border: OutlineInputBorder(),
              ),
              keyboardType: const TextInputType.numberWithOptions(decimal: true),
              inputFormatters: [
                FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
              ],
            ),
            const SizedBox(
              height: 16,
            ),

            /// 몸무게 입력(kg)
            TextField(
              controller: _weightController,
              decoration: const InputDecoration(
                labelText: '몸무게(kg)를 입력해주세요.',
                border: OutlineInputBorder(),
              ),
              keyboardType: const TextInputType.numberWithOptions(decimal: true),
              inputFormatters: [
                FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
              ],
            ),
            const SizedBox(
              height: 16,
            ),

            Row(
              children: [
                /// 초기화 버튼
                Expanded(
                  child: OutlinedButton(
                    onPressed: () {},
                    child: const Text('초기화'),
                  ),
                ),
                const SizedBox(
                  width: 16,
                ),
                /// 계산 버튼
                Expanded(
                  child: ElevatedButton(
                    onPressed: () {},
                    child: const Text('계산하기'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

 

위 코드를 통해 BMI 계산 전 초기 화면을 아래와 같이 얻을 수 있습니다.

BMI 계산 전 화면


이번에는 BMI 계산 후 화면을 구성해보겠습니다. BMI 계산 후 화면은 나머지는 다 똑같고, 안내 표시를 결과 표시로 변경만 하도록 하겠습니다.

class MainPage extends StatefulWidget {
  const MainPage({Key? key}) : super(key: key);

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState2 extends State<MainPage2> {
  /// 생략

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: const Text('BMI 계산기'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            /// BMI 입력 안내 표시(BMI 계산후)
            Container(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  Icon(
                    Icons.face,
                    size: 80,
                    color: Colors.blue
                  ),
                  SizedBox(
                    height: 16.0,
                  ),
                  Text(
                    '비만도 결과 : 저체중',
                    style: TextStyle(
                      fontSize: 16.0,
                    ),
                  ),
                  Text(
                    'BMI 지수 : 16.1',
                    style: TextStyle(
                      fontSize: 16.0,
                    ),
                  ),
                ],
              ),
            ),

            /// ...이하 동일
          ],
        ),
      ),
    );
  }
}

 

위 코드로 변경하게 되면 아래와 같은 계산 후 화면을 얻을 수 있습니다.

BMI 계산 후 화면

위 처럼 화면 구성이 완료되었으니 BMI 값을 계산하고 알려줄 수 있는 BMINotifier와 BMI Provider를 구현해보겠습니다.

 

BMI Notifier & Provider 정의

전 주에 배웠던 Riverpod의 Provider중 StateNotifierProvider를 정의해서 사용해보겠습니다.

필요한 기능은 1. 계산(키와 몸무게를 입력 받아 bmi 지수 구하기), 2. 초기화(bmi 지수 초기화) 기능입니다.

 

BmiNotifier를 정의합니다.

BmiNotifier를 확인해보면 double 값을 같는 state입니다. 

초기값을 0.0으로 사용하고 초기화 함수와 계산 함수를 구현해보았습니다. (초기화의 기준은 0.0)

class BmiNotifier extends StateNotifier<double> {
  BmiNotifier() : super(0.0);

  // 초기화
  void reset() {
    state = 0.0;
  }

  // 계산(kg, m)
  void calc(double weight, double height) {
    state = weight / (height * height);
  }
}

 

BmiNotifier를 사용할 수 있도록 Provider를 정의하면 다음과 같습니다. 이로써 bmiNotifierProvider를 사용할 수 있게 되었습니다.

final bmiNotifierProvider = StateNotifierProvider<BmiNotifier, double>(
  (ref) => BmiNotifier(),
);

 

bmiNotifierProvider 사용하기

bmiNotifierProvider를 사용하기 전에 먼저 최상단을 ProviderScope로 감싸줍니다.

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

 

이후 MainApp 위젯을 ConsumerWidget으로 변경하여 WidgetRef ref를 사용할 수 있도록 수정해줍니다.

/// StatefulWidget > ConsumerStatefulWidget
class MainPage extends ConsumerStatefulWidget {
  const MainPage({Key? key}) : super(key: key);

  // State -> ConsumerState
  @override
  ConsumerState<MainPage> createState() => _MainPageState();
}

// State -> ConsumerState
class _MainPageState extends ConsumerState<MainPage> {
  @override
  Widget build(BuildContext context) {
    final bmi = ref.watch(bmiNotifierProvider);
    /// 생략
  }
}

 

이후 bmi 값에 따라 결과가 계산 전/후 화면이 스위칭 되도록 변경해 줍니다. 계산 전/후의 기준은 bmi의 값이 0.0이냐 아니냐에 따라 나뉩니다.

            /// 안내 & 결과
            bmi == 0.0
                ? Container(
                    padding: const EdgeInsets.all(16.0),
                    child: const Column(
                      children: [
                        Icon(
                          Icons.face,
                          size: 80,
                        ),
                        SizedBox(
                          height: 16.0,
                        ),
                        Text(
                          '키와 몸무게를 입력하여 BMI를 확인하세요 !',
                          style: TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                      ],
                    ),
                  )
                : Container(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      children: [
                        Icon(
                          Icons.face,
                          size: 80,
                          Colors.blue,
                        ),
                        SizedBox(
                          height: 16.0,
                        ),
                        Text(
                          '비만도 결과 : 저체중',
                          style: TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                        Text(
                          'BMI 지수 : ${bmi.toStringAsFixed(1)}',
                          style: TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                      ],
                    ),
                  ),

 

이후 초기화 버튼과 계산 버튼의 동작을 bmiProvider의 reset(), calc()로 연결합니다. 또한 초기화 버튼을 선택 시에는 각 텍스트를 초기화 시켜주고, 계산 버튼을 선택시엔 값이 잘 들어왔는지 확인 및 0.0으로 나눌 수 없으니 예외 처리를 추가 해 줍니다.

            Row(
              children: [
                Expanded(
                  child: OutlinedButton(
                    /// 초기화 버튼 선택시 텍스트 초기화 및 bmi reset 처리
                    onPressed: () {
                      _heightController.text = '';
                      _weightController.text = '';
                      ref.read(bmiNotifierProvider.notifier).reset();
                    } ,
                    child: const Text('초기화'),
                  ),
                ),
                const SizedBox(
                  width: 16,
                ),
                Expanded(
                  child: ElevatedButton(
                    /// 계산하기 버튼 선택시 text 값을 가져와 계산 진행
                    onPressed: () {
                      final weight = double.tryParse(_weightController.text);
                      final height = double.tryParse(_heightController.text);
                      
                      /// weight가 double 이외에 다른 자료형이 온 경우 예외 처리
                      if (weight == null || height == null) {
                        return;
                      }
						
                      // height가 0.0인 경우 예외 처리(계산 오류 발생)
                      if (height == 0.0) {
                        return;
                      }
                      
                      ref.read(bmiNotifierProvider.notifier).calc(weight, height / 100);
                    },
                    child: const Text('계산하기'),
                  ),
                ),
              ],
            ),

 

위 까지 처리하면 계산과 초기화까지는 잘 되는 것을 확인할 수 있습니다.

 

이제 bmi 결과 값 표시와 그에 따른 화면 처리를 추가해 주겠습니다. bmi 값에 따라 아이콘의 색을 반환하는 함수와 bmi 결과를 반환하는 함수를 작성해보겠습니다.

  String _getBmiResult(double bmi) {
    if (bmi < 18.5) {
      return '저체중';
    }
    else if (bmi < 23){
      return '정상';
    }
    else if (bmi < 25) {
      return '비만 전 단계';
    }
    else if (bmi < 30) {
      return '비만';
    }
    else if(bmi < 35) {
      return '2단계 비만';
    }
    else {
      return '3단계 비만';
    }
  }

  Color _getBmiColor(double bmi) {
    // 저체중
    if (bmi < 18.5) {
      return Colors.blue;
    }
    // 정상
    else if (bmi < 23){
      return Colors.green;
    }
    // 비만 전 단계
    else if (bmi < 25) {
      return Colors.orange;
    }
    // 비만
    else if (bmi < 30) {
      return Colors.red[200] ?? Colors.red;
    }
    // 2단계 비만
    else if(bmi < 35) {
      return Colors.red[500] ?? Colors.red;
    }
    // 3단계 비만
    else {
      return Colors.red[800] ?? Colors.red;
    }
  }

 

 

이후 BMI 계산 후 화면에 사용하면 BMI 결과에 따른 화면 표시도 완성이 됩니다.

 

                  Container(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      children: [
                        Icon(
                          Icons.face,
                          size: 80,
                          color: _getBmiColor(bmi),
                        ),
                        const SizedBox(
                          height: 16.0,
                        ),
                        Text(
                          '비만도 결과 : ${_getBmiResult(bmi)}',
                          style: const TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                        Text(
                          'BMI 지수 : ${bmi.toStringAsFixed(1)}',
                          style: const TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                      ],
                    ),
                  ),

 

BMI 계산기 결과

BMI를 계산을 하면 아래와 같이 결과들을 얻을 수 있고 완성이 됩니다.

전체 코드

main.dart

import 'package:bmi_calculator/bmi_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BMI CALC',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MainPage(),
    );
  }
}

class MainPage extends ConsumerStatefulWidget {
  const MainPage({Key? key}) : super(key: key);

  @override
  ConsumerState<MainPage> createState() => _MainPageState();
}

class _MainPageState extends ConsumerState<MainPage> {
  final _heightController = TextEditingController();
  final _weightController = TextEditingController();

  @override
  void dispose() {
    _heightController.dispose();
    _weightController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final bmi = ref.watch(bmiNotifierProvider);

    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: const Text('BMI 계산기'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            /// 안내 & 결과
            bmi == 0.0
                ? Container(
                    padding: const EdgeInsets.all(16.0),
                    child: const Column(
                      children: [
                        Icon(
                          Icons.face,
                          size: 80,
                        ),
                        SizedBox(
                          height: 16.0,
                        ),
                        Text(
                          '키와 몸무게를 입력하여 BMI를 확인하세요 !',
                          style: TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                      ],
                    ),
                  )
                : Container(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      children: [
                        Icon(
                          Icons.face,
                          size: 80,
                          color: _getBmiColor(bmi),
                        ),
                        const SizedBox(
                          height: 16.0,
                        ),
                        Text(
                          '비만도 결과 : ${_getBmiResult(bmi)}',
                          style: const TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                        Text(
                          'BMI 지수 : ${bmi.toStringAsFixed(1)}',
                          style: const TextStyle(
                            fontSize: 16.0,
                          ),
                        ),
                      ],
                    ),
                  ),

            /// 키 입력(cm)
            TextField(
              controller: _heightController,
              decoration: const InputDecoration(
                labelText: '키(cm)를 입력해주세요.',
                border: OutlineInputBorder(),
              ),
              keyboardType: const TextInputType.numberWithOptions(decimal: true),
              inputFormatters: [
                FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
              ],
            ),
            const SizedBox(
              height: 16,
            ),

            /// 몸무게 입력(kg)
            TextField(
              controller: _weightController,
              decoration: const InputDecoration(
                labelText: '몸무게(kg)를 입력해주세요.',
                border: OutlineInputBorder(),
              ),
              keyboardType: const TextInputType.numberWithOptions(decimal: true),
              inputFormatters: [
                FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
              ],
            ),
            const SizedBox(
              height: 16,
            ),

            Row(
              children: [
                Expanded(
                  child: OutlinedButton(
                    onPressed: () {
                      _heightController.text = '';
                      _weightController.text = '';
                      ref.read(bmiNotifierProvider.notifier).reset();
                    } ,
                    child: const Text('초기화'),
                  ),
                ),
                const SizedBox(
                  width: 16,
                ),
                Expanded(
                  child: ElevatedButton(
                    onPressed: () {
                      final weight = double.tryParse(_weightController.text);
                      final height = double.tryParse(_heightController.text);

                      if (weight == null || height == null) {
                        return;
                      }

                      if (height == 0.0) {
                        return;
                      }

                      ref.read(bmiNotifierProvider.notifier).calc(weight, height / 100);
                    },
                    child: const Text('계산하기'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _getBmiResult(double bmi) {
    if (bmi < 18.5) {
      return '저체중';
    }
    else if (bmi < 23){
      return '정상';
    }
    else if (bmi < 25) {
      return '비만 전 단계';
    }
    else if (bmi < 30) {
      return '비만';
    }
    else if(bmi < 35) {
      return '2단계 비만';
    }
    else {
      return '3단계 비만';
    }
  }

  Color _getBmiColor(double bmi) {
    // 저체중
    if (bmi < 18.5) {
      return Colors.blue;
    }
    // 정상
    else if (bmi < 23){
      return Colors.green;
    }
    // 비만 전 단계
    else if (bmi < 25) {
      return Colors.orange;
    }
    // 비만
    else if (bmi < 30) {
      return Colors.red[200] ?? Colors.red;
    }
    // 2단계 비만
    else if(bmi < 35) {
      return Colors.red[500] ?? Colors.red;
    }
    // 3단계 비만
    else {
      return Colors.red[800] ?? Colors.red;
    }
  }
}

 

bmi_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

final bmiNotifierProvider = StateNotifierProvider<BmiNotifier, double>(
  (ref) => BmiNotifier(),
);

class BmiNotifier extends StateNotifier<double> {
  BmiNotifier() : super(0.0);

  // 초기화
  void reset() {
    state = 0.0;
  }

  // 계산(kg, m)
  void calc(double weight, double height) {
    state = weight / (height * height);
  }
}

 

후기

BMI 계산기를 통해 전 주에 학습했던 Riverpod에 더욱 익숙해질수 있는 시간을 갖은 것 같습니다. 간단한 앱이라 어려움은 많이 없었지만(? - 화면을 어떻게 할 지에 프로젝트가 커지시간을 많이 쓴듯..) Riverpod을 사용할 때 어떤 구조로 프로젝트를 잡아갈지 공부와 고민이 필요하다고 생각됩니다.