개발노트

75. [Flutter] API 서버 통신에 상태 관리 적용하기 (http, riverpod 패키지 응용) 본문

앱 개발/Flutter

75. [Flutter] API 서버 통신에 상태 관리 적용하기 (http, riverpod 패키지 응용)

mroh1226 2024. 4. 24. 16:48
반응형

API 서버 http 통신에 riverpod을 이용하여 상태관리 하도록 만들어 보겠습니다.

  • 이번 시간에는 AsyncNotifierProvider와 http의 GET, POST, DELETE (CRUD) 를 같이 사용하여 어떻게 상태관리 할 수 있는지 포스팅 해보겠습니다.
  • JPA의 save() 메소드로 upsert가 가능하기 때문에 update 즉, PUT는 생략하겠습니다.

[구현할 기능 시나리오]

1. 재료 리스트 화면으로 네이게이션되었을 때 findAll() 로 모든 재료를 받아 ListView의 ListTile에 맵핑합니다.

2. 재료가 담긴 ListTile 을 클릭했을 때 해당 재료의 식별ID로 조회하여 하단 시트가 올라오며, 시트에 정보를 가져옵니다.

3. ListTile 클릭으로 Bottom Sheet에 조회된 재료는 TextField를 수정하여 정보를 업데이트 할 수 있습니다.

4. 우측 상단의 "+"버튼을 눌렀을 때 재료를 추가 할 수 있는 시트가 하단에서나오고 정보를 입력하여 재료를 추가합니다.

    (id의 유무를 확인하여 삽입, 수정 구분하기 위해 버튼의 Text를 Insert, Update로 구분지어 노출합니다.)

5. 재료(ListTile)를 길게 누르면 삭제, Delete 됩니다.

 

만들고자하는 DB 와 API가 Flutter App에 연동된 모습의 일부는 아래와 같습니다. (findAll() 했을 때)

DB -> API Server

 

API Server -> Client App

 

위 그림과 같이 구현하기 위해 이제 소스를 작성해봅니다.


전체소스

ingredient.dart (사용될 모델, 재료)
class Ingredient {
  int? ingredientId; // 재료 ID, null이 될 수 있음
  int categoryId; // 카테고리 ID
  String name; // 재료명
  String detail; // 재료 설명
  String state; // 상태
  DateTime createdAt; // 생성일

  // JSON 데이터를 Ingredient 객체로 변환하는 생성자
  Ingredient.fromJson(Map<String, dynamic> json)
      : ingredientId = json["ingredient_id"], // ingredient_id 키의 값을 ingredientId에 할당
        categoryId = json["category_id"], // category_id 키의 값을 categoryId에 할당
        name = json["name"], // name 키의 값을 name에 할당
        detail = json["detail"], // detail 키의 값을 detail에 할당
        state = json["state"], // state 키의 값을 state에 할당
        createdAt = DateTime.parse(json["created_at"]); // created_at 키의 값을 DateTime으로 변환하여 createdAt에 할당

  // Ingredient 객체를 JSON 형식으로 직렬화하는 메서드
  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = {
      "category_id": categoryId, // categoryId를 category_id 키로 매핑
      "name": name, // name을 name 키로 매핑
      "detail": detail, // detail을 detail 키로 매핑
      "state": state, // state를 state 키로 매핑
      // toIso8601String(): "2024-04-23T15:30:00Z" 형태로 변환하여 created_at 키로 매핑
      "created_at": createdAt.toIso8601String(),
    };

    // ingredientId가 null이 아닌 경우에만 추가
    if (ingredientId != null) {
      data["ingredient_id"] = ingredientId; // ingredientId를 ingredient_id 키로 매핑
    }

    return data; // JSON 맵 반환
  }
}
ingredientScreen.dart (재료 전체 리스트 화면)
class IngredientScreen extends ConsumerStatefulWidget {
  static String routeName = "ingredient";
  static String routeURL = "/ingredient";
  const IngredientScreen({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() =>
      _IngredientScreenState();
}

class _IngredientScreenState extends ConsumerState<IngredientScreen> {
  final TextEditingController _nameEditingController = TextEditingController();
  final TextEditingController _detailEditingController =
      TextEditingController();
  final GlobalKey<FormState> _globalKey = GlobalKey<FormState>();

  void _onAddPressed() {
    // 추가 버튼이 눌렸을 때
    _nameEditingController.text = "";
    _detailEditingController.text = "";
    // 재료 추가를 위한 Bottom Sheet를 엽니다.
    ingredientBottomSheet(null);
  }

  void _onTapDelete(int? ingredientId) {
    // 재료 삭제 버튼이 눌렸을 때
    // Provider를 통해 재료를 삭제합니다.
    ref.read(ingredientProvider.notifier).deleteIngredient(ingredientId);
    setState(() {});
  }

  void _onTapUpsert(int? id) {
    // 재료 추가 또는 수정 버튼이 눌렸을 때
    if (_globalKey.currentState!.validate()) {
      // 유효성 검사를 통과한 경우
      // 입력된 데이터로 재료 객체를 생성합니다.
      Ingredient ingredient = Ingredient(
          ingredientId: id,
          categoryId: 3,
          name: _nameEditingController.text,
          detail: _detailEditingController.text,
          state: '1',
          createdAt: DateTime.now());
      // Provider를 통해 재료를 추가 또는 수정합니다.
      ref.read(ingredientProvider.notifier).upsertIngredient(ingredient);
      // Bottom Sheet를 닫습니다.
      Navigator.pop(context);
    }
  }

  void _onTapFindById(int? ingredientId) async {
    // 특정 재료를 탭하여 조회할 때
    // Provider를 통해 해당 재료를 조회합니다.
    Ingredient selectedIng =
        await ref.read(ingredientProvider.notifier).getIngredient(ingredientId);
    // 조회된 재료의 정보를 입력 폼에 설정합니다.
    _nameEditingController.text = selectedIng.name;
    _detailEditingController.text = selectedIng.detail;
    // 수정을 위한 Bottom Sheet를 엽니다.
    ingredientBottomSheet(selectedIng.ingredientId);
  }

  @override
  Widget build(BuildContext context) {
    // 화면을 빌드합니다.
    return Scaffold(
      resizeToAvoidBottomInset: true,
      appBar: AppBar(
        title: const Text("재료 리스트"),
        centerTitle: true,
        actions: [
          IconButton(
            icon: const Icon(Icons.add_box, size: 40),
            onPressed: _onAddPressed,
          )
        ],
      ),
      body: Center(
        child: Consumer(
          builder: (context, watch, child) {
            // Provider를 통해 재료 목록을 가져옵니다.
            final asyncValue = ref.watch(ingredientProvider);
            return asyncValue.when(
              data: (data) {
                // 데이터가 있는 경우 ListView를 반환합니다.
                return ListView.separated(
                  itemBuilder: (context, index) {
                    final ingredient = data[index];
                    // 재료를 탭하면 수정 폼이 열립니다.
                    // 재료를 길게 누르면 삭제됩니다.
                    return GestureDetector(
                      onTap: () => _onTapFindById(ingredient.ingredientId),
                      onLongPress: () => _onTapDelete(ingredient.ingredientId),
                      child: ListTile(
                        title: Text(
                            '${ingredient.ingredientId}.${ingredient.name}'),
                        subtitle: Text(ingredient.detail),
                      ),
                    );
                  },
                  separatorBuilder: (context, index) => const Divider(),
                  itemCount: data.length,
                );
              },
              error: (error, stackTrace) {
                // 에러가 있는 경우 에러를 보여줍니다.
                return Text(error.toString());
              },
              loading: () {
                // 데이터를 로드 중인 경우 로딩 표시를 보여줍니다.
                return const CircularProgressIndicator();
              },
            );
          },
        ),
      ),
    );
  }
}

위의 코드는 Provider의 상태를 구독하고 해당 상태에 따라 UI를 업데이트하는 Consumer 위젯을 정의하는 부분입니다. Consumer는 Provider를 구독하고, Provider의 상태 변화를 감지하여 UI를 업데이트합니다.

Consumer 위젯:

  • Consumer: Provider를 구독하고, Provider의 상태 변화를 감지하여 UI를 업데이트합니다.

builder 함수:

  • builder: Consumer의 builder 함수는 세 가지 콜백을 인수로 받습니다.
    • context: BuildContext 타입의 인수로, 해당 위젯의 빌드 컨텍스트를 나타냅니다.
    • watch: Provider를 감시하고, 해당 Provider의 값을 읽어올 수 있습니다.
    • child: 자식 위젯입니다. Consumer는 주로 UI를 구성하는 역할을 하기 때문에 자식 위젯은 필요하지 않을 수 있습니다.

Provider 구독 및 상태 처리:

  • final asyncValue = ref.watch(ingredientProvider);: Consumer 내에서 **ingredientProvider**를 watch하여 해당 Provider의 상태를 확인합니다.
  • asyncValue.when(): AsyncValue의 상태를 확인합니다. 상태에 따라 다른 동작을 수행합니다.
    • data: 데이터가 있는 경우, ListView.separated를 반환합니다.
      • itemBuilder: 데이터 리스트를 기반으로 각 항목을 생성합니다. 각 재료 항목을 탭하면 _onTapFindById 메서드가 호출되고, 길게 누르면 _onTapDelete 메서드가 호출됩니다.
      • separatorBuilder: 항목 사이에 구분선을 추가합니다.
      • itemCount: 데이터 리스트의 길이를 반환하여 ListView의 항목 수를 지정합니다.
    • error: 에러가 발생한 경우, 에러 메시지를 Text 위젯으로 반환합니다.
    • loading: 데이터를 로드 중인 경우, CircularProgressIndicator를 반환합니다.

이를 통해 Provider의 상태에 따라 UI를 동적으로 업데이트할 수 있습니다. Provider의 상태가 변경되면 Consumer가 다시 빌드되어 새로운 상태를 반영합니다.

ingredientBottomSheet.dart (재료 입력창, 하단 시트)
  Future<dynamic> ingredientBottomSheet(int? id) {
    // Bottom Sheet를 열 때 사용될 함수입니다.
    return showModalBottomSheet(
      isScrollControlled: true,
      showDragHandle: true,
      context: context,
      builder: (context) {
        return Container(
          width: DeviceSize.deviceWidth / 1.1,
          height: DeviceSize.deviceHeight / 1.1,
          decoration: const BoxDecoration(color: Colors.pink),
          child: Form(
              key: _globalKey,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  children: [
                    TextFormField(
                      controller: _nameEditingController,
                      textAlign: TextAlign.center,
                      validator: (value) {
                        if (value != null && value.isEmpty) {
                          return "빈칸입니다.";
                        } else {
                          return null;
                        }
                      },
                      style: const TextStyle(fontSize: 24),
                      decoration: const InputDecoration(
                          hintText: "재료명을 입력해주세요.", border: InputBorder.none),
                    ),
                    TextFormField(
                      controller: _detailEditingController,
                      validator: (value) {
                        if (value != null && value.isEmpty) {
                          return "빈칸입니다.";
                        } else {
                          return null;
                        }
                      },
                      maxLines: 5,
                      decoration: const InputDecoration(
                          hintText: "설명을 작성해주세요.",
                          border: OutlineInputBorder()),
                    ),
                    const SizedBox(
                      height: 10,
                    ),
                    CupertinoButton(
                        color: Colors.orange,
                        onPressed: () => _onTapUpsert(id),
                        child: Text(
                          id == null ? "Insert" : "Update",
                          style: const TextStyle(
                              fontSize: 24, color: Colors.white),
                        ))
                  ],
                ),
              )),
        );
      },
    );
  }
  1. showModalBottomSheet 함수를 사용하여 Bottom Sheet를 화면에 표시합니다. 이 Bottom Sheet는 모달 형태로 화면 하단에서 나타나며 사용자 입력을 받기 위한 용도로 사용됩니다.
  2. Bottom Sheet의 크기를 조정하고 배경색을 설정하기 위해 Container 위젯을 사용합니다. 여기서 isScrollControlled 옵션을 **true**로 설정하여 Bottom Sheet의 크기가 화면을 벗어날 경우 사용자가 스크롤하여 모든 내용을 볼 수 있도록 합니다.
  3. Bottom Sheet 내부에는 데이터를 입력할 수 있는 입력 필드가 포함된 Form 위젯이 있습니다. 이 Form은 **_globalKey**를 사용하여 상태를 관리하며, 사용자 입력의 유효성을 검사하기 위해 **TextFormField**를 사용합니다.
  4. TextFormField: TextFormField는 사용자로부터 텍스트 입력을 받는 입력 필드입니다. 두 개의 TextFormField가 사용되며, 각각 재료명과 설명을 입력 받습니다. 입력값은 각각 **_nameEditingController**와 _detailEditingController 컨트롤러를 통해 관리됩니다.
  5. _onTapUpsert 함수 호출: 사용자가 버튼을 탭하면 _onTapUpsert 함수가 호출됩니다. 이 함수는 현재 입력값을 사용하여 재료를 추가하거나 수정하고, 이후에는 Bottom Sheet를 닫습니다.
IngredientsViewModel.dart (API 호출 ViewModel 부분, Riverpod 상태관리)
final ingredientProvider =
    AsyncNotifierProvider<IngredientsViewModel, List<Ingredient>>(
  () => IngredientsViewModel(),
);

class IngredientsViewModel extends AsyncNotifier<List<Ingredient>> {
  final String baseUrl = "http://172.17.7.232:8080/ing";
  final String getAllUrl = "all";
  @override
  FutureOr<List<Ingredient>> build() async {
    List<Ingredient> ingList = await getIngredientList();
    return ingList;
  }

  ////GET////
  //모든 재료 List 조회하기
  Future<List<Ingredient>> getIngredientList() async {
    Response response = await get(Uri.parse('$baseUrl/$getAllUrl'));
    if (response.statusCode == 200) {
      final List ingredients = jsonDecode(utf8.decode(response.bodyBytes));
      //final List ingredients = jsonDecode(response.body);
      return ingredients.map((e) => Ingredient.fromJson(e)).toList();
    } else {
      throw Exception(response.reasonPhrase);
    }
  }

  //id로 재료 조회하기
  Future<Ingredient> getIngredient(int? id) async {
    Response response = await get(Uri.parse('$baseUrl/select/$id'));
    if (response.statusCode == 200) {
      Ingredient ingredient =
          Ingredient.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
      return ingredient;
    } else {
      throw Exception(response.reasonPhrase);
    }
  }

  //모든 재료 갯수 가져오기
  Future<int> getIngredientCount() async {
    Response response = await get(Uri.parse('$baseUrl/count'));
    if (response.statusCode == 200) {
      int count = jsonDecode(utf8.decode(response.bodyBytes));
      return count;
    } else {
      throw Exception(response.reasonPhrase);
    }
  }

  ////POST////
  //재료 추가하기, 수정하기
  Future<dynamic> upsertIngredient(Ingredient ingredient) async {
    state = const AsyncValue.loading();
    final Map<String, dynamic> requestBody = ingredient.toJson();
    Response response = await post(Uri.parse('$baseUrl/upsert'),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
        },
        body: jsonEncode(requestBody));
    if (response.statusCode == 200) {
      List<Ingredient> ingList = await getIngredientList();
      state = AsyncValue.data(ingList);
      return jsonDecode(response.body);
    } else {
      throw Exception('Failed to upsert ingredient: ${response.statusCode}');
    }
  }

  ////Delete////
  Future<void> deleteIngredient(int? id) async {
    state = const AsyncValue.loading();
    Response response = await delete(Uri.parse('$baseUrl/delete/$id'));
    if (response.statusCode == 200) {
      List<Ingredient> ingList = await getIngredientList();
      state = AsyncValue.data(ingList);
    } else {
      throw Exception('Failed to delete ingredient: ${response.statusCode}');
    }
  }
}

API 서버 연결 소스설명.

http GET 호출

GET

1. getIngredientList() 메서드: 재료 전체 리스트 조회

  ////GET////
  //모든 재료 List 조회하기
  Future<List<Ingredient>> getIngredientList() async {
    Response response = await get(Uri.parse('$baseUrl/$getAllUrl'));
    if (response.statusCode == 200) {
      final List ingredients = jsonDecode(utf8.decode(response.bodyBytes));
      //final List ingredients = jsonDecode(response.body);
      return ingredients.map((e) => Ingredient.fromJson(e)).toList();
    } else {
      throw Exception(response.reasonPhrase);
    }
  }

이 메서드는 모든 재료를 가져오기 위해 서버에 GET 요청을 보내는 역할을 합니다.

  • Uri.parse('$baseUrl/$getAllUrl'): 요청할 URL을 생성합니다. baseUrl은 서버의 기본 URL이고, getAllUrl은 모든 재료를 가져오는 데 사용되는 엔드포인트입니다.
  • get(Uri.parse('$baseUrl/$getAllUrl')): 생성된 URL로 GET 요청을 보냅니다.
  • response.statusCode == 200: 서버로부터 받은 응답의 상태 코드가 200인지 확인하여 요청이 성공적으로 처리되었는지 확인합니다.
  • jsonDecode(utf8.decode(response.bodyBytes)): 서버 응답으로부터 받은 데이터를 UTF-8로 디코딩하고, JSON 형식으로 디코딩하여 사용 가능한 데이터로 변환합니다.
  • ingredients.map((e) => Ingredient.fromJson(e)).toList(): JSON 형식으로 디코딩된 데이터를 반복하여 각 항목을 Ingredient 모델 객체로 변환한 다음, 리스트로 변환합니다.

2. getIngredient() 메서드: 식별 ID로 재료 정보 조회

  //id로 재료 조회하기
  Future<Ingredient> getIngredient(int? id) async {
    Response response = await get(Uri.parse('$baseUrl/select/$id'));
    if (response.statusCode == 200) {
      Ingredient ingredient =
          Ingredient.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
      return ingredient;
    } else {
      throw Exception(response.reasonPhrase);
    }
  }

이 메서드는 특정 ID를 가진 재료를 조회하여 해당 재료의 정보를 받아옵니다

  • Future<Ingredient> getIngredient(int? id) async: 이 메서드는 특정 ID를 가진 재료를 조회하는 비동기 함수입니다. 조회된 재료는 Ingredient 객체로 반환됩니다.
  • Response response = await get(Uri.parse('$baseUrl/select/$id')): 해당 URL을 이용하여 서버에 GET 요청을 보냅니다. 이때, **$baseUrl/select/$id**는 특정 ID를 가진 재료를 조회하기 위한 엔드포인트 URL입니다.
  • if (response.statusCode == 200) { ... }: 서버로부터 받은 응답의 상태 코드가 200인지 확인하여 요청이 성공적으로 처리되었는지를 확인합니다.
  • Ingredient.fromJson(jsonDecode(utf8.decode(response.bodyBytes))): 서버 응답으로부터 받은 데이터를 UTF-8로 디코딩하고, JSON 형식으로 디코딩하여 사용 가능한 데이터로 변환합니다. 그리고 이를 사용하여 Ingredient 모델 객체를 생성합니다.
  • return ingredient;: 성공적으로 조회된 재료 객체를 반환합니다.
  • throw Exception(response.reasonPhrase);: 만약 서버에서 오류가 발생하면 해당 오류 메시지를 포함한 예외를 던집니다.

조회된 재료는 Ingredient 객체로 반환되어 사용자에게 표시되거나 다른 처리에 사용될 수 있습니다.


*한글깨짐 현상 발생 시 해결방법

jsonDecode(response.body)에 utf8.decode를 추가하여 한글 깨짐을 예방합니다.

 

jsonDecode(response.body); 👉🏻 jsonDecode(utf8.decode(response.bodyBytes));

POST

http POST 호출

3. upsertIngredient() 메서드: 재료 삽입, 수정

  ////POST////
  //재료 추가하기, 수정하기
  Future<dynamic> upsertIngredient(Ingredient ingredient) async {
    state = const AsyncValue.loading();
    final Map<String, dynamic> requestBody = ingredient.toJson();
    Response response = await post(Uri.parse('$baseUrl/upsert'),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
        },
        body: jsonEncode(requestBody));
    if (response.statusCode == 200) {
      List<Ingredient> ingList = await getIngredientList();
      state = AsyncValue.data(ingList);
      return jsonDecode(response.body);
    } else {
      throw Exception('Failed to upsert ingredient: ${response.statusCode}');
    }
  }

위의 코드는 서버에 새로운 재료를 추가하거나 기존 재료를 수정하는 기능을 담당하는 upsertIngredient 메서드를 정의합니다.

  • Future<dynamic> upsertIngredient(Ingredient ingredient) async: 이 메서드는 새로운 재료를 추가하거나 기존 재료를 수정하는 비동기 함수입니다. 메서드 파라미터로는 추가하거나 수정할 재료를 나타내는 Ingredient 객체를 받습니다.
  • state = const AsyncValue.loading();: 메서드 실행 중에 로딩 상태로 변경합니다.
  • final Map<String, dynamic> requestBody = ingredient.toJson();: 재료 객체를 JSON 형식으로 변환하여 요청 본문으로 사용하기 위해 준비합니다.
  • Response response = await post(Uri.parse('$baseUrl/upsert'), ...);: 서버에 POST 요청을 보냅니다. 이때, **$baseUrl/upsert**는 재료를 추가하거나 수정하기 위한 엔드포인트 URL입니다.
  • if (response.statusCode == 200) { ... }: 서버로부터 받은 응답의 상태 코드가 200인지 확인하여 요청이 성공적으로 처리되었는지를 확인합니다.
    • List<Ingredient> ingList = await getIngredientList();: 서버로부터 모든 재료 리스트를 다시 가져옵니다. 이는 재료가 추가되거나 수정된 후 최신 상태의 재료 리스트를 유지하기 위함입니다.
    • state = AsyncValue.data(ingList);: 재료 리스트를 가져온 후 이를 데이터 상태로 변경하여 Provider에 반영합니다.
    • return jsonDecode(response.body);: 성공적으로 처리된 경우 서버에서 받은 응답 데이터를 반환합니다.
  • throw Exception('Failed to upsert ingredient: ${response.statusCode}');: 만약 서버에서 오류가 발생하면 해당 오류 메시지를 포함한 예외를 던집니다.

*state의 상태를 변경시켜줘야, View에서 사용되는 ref.watch() 로 감시하는 값이 변했다라는 것을 Notify 할 수 있습니다.


DELETE

http DELETE 호출

deleteIngredient 메서드: 식별 ID로 재료 삭제

////Delete////
  Future<void> deleteIngredient(int? id) async {
    state = const AsyncValue.loading();
    Response response = await delete(Uri.parse('$baseUrl/delete/$id'));
    if (response.statusCode == 200) {
      List<Ingredient> ingList = await getIngredientList();
      state = AsyncValue.data(ingList);
    } else {
      throw Exception('Failed to delete ingredient: ${response.statusCode}');
    }
  }

이 메서드는 특정 재료를 삭제한 후, 최신 상태의 재료 리스트를 다시 가져와서 Provider의 상태를 업데이트합니다.

  • Future<void> deleteIngredient(int? id) async: 이 메서드는 서버에서 특정 재료를 삭제하는 비동기 함수입니다. 메서드 파라미터로는 삭제할 재료의 ID를 받습니다.
  • state = const AsyncValue.loading();: 메서드 실행 중에 로딩 상태로 변경합니다.
  • Response response = await delete(Uri.parse('$baseUrl/delete/$id'));: 서버에 DELETE 요청을 보냅니다. 이때, **$baseUrl/delete/$id**는 삭제할 재료의 ID를 포함한 엔드포인트 URL입니다.
  • if (response.statusCode == 200) { ... }: 서버로부터 받은 응답의 상태 코드가 200인지 확인하여 요청이 성공적으로 처리되었는지를 확인합니다.
    • List<Ingredient> ingList = await getIngredientList();: 서버로부터 모든 재료 리스트를 다시 가져옵니다. 이는 재료가 삭제된 후 최신 상태의 재료 리스트를 유지하기 위함입니다.
    • state = AsyncValue.data(ingList);: 재료 리스트를 가져온 후 이를 데이터 상태로 변경하여 Provider에 반영합니다.
  • throw Exception('Failed to delete ingredient: ${response.statusCode}');: 만약 서버에서 오류가 발생하면 해당 오류 메시지를 포함한 예외를 던집니다.

 

 

반응형
Comments