일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- db
- 플러터
- Binding
- 마우이
- AnimationController
- MVVM
- JavaScript
- Animation
- 애니메이션
- 바인딩
- 리엑트
- 자바스크립트
- typescript
- HTML
- .NET
- page
- GitHub
- 오류
- MS-SQL
- Maui
- MSSQL
- 파이어베이스
- listview
- 닷넷
- spring boot
- Firebase
- React JS
- 함수
- Flutter
- 깃허브
- Today
- Total
개발노트
75. [Flutter] API 서버 통신에 상태 관리 적용하기 (http, riverpod 패키지 응용) 본문
75. [Flutter] API 서버 통신에 상태 관리 적용하기 (http, riverpod 패키지 응용)
mroh1226 2024. 4. 24. 16:48API 서버 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() 했을 때)
위 그림과 같이 구현하기 위해 이제 소스를 작성해봅니다.
전체소스
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를 반환합니다.
- data: 데이터가 있는 경우, ListView.separated를 반환합니다.
이를 통해 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),
))
],
),
)),
);
},
);
}
- showModalBottomSheet 함수를 사용하여 Bottom Sheet를 화면에 표시합니다. 이 Bottom Sheet는 모달 형태로 화면 하단에서 나타나며 사용자 입력을 받기 위한 용도로 사용됩니다.
- Bottom Sheet의 크기를 조정하고 배경색을 설정하기 위해 Container 위젯을 사용합니다. 여기서 isScrollControlled 옵션을 **true**로 설정하여 Bottom Sheet의 크기가 화면을 벗어날 경우 사용자가 스크롤하여 모든 내용을 볼 수 있도록 합니다.
- Bottom Sheet 내부에는 데이터를 입력할 수 있는 입력 필드가 포함된 Form 위젯이 있습니다. 이 Form은 **_globalKey**를 사용하여 상태를 관리하며, 사용자 입력의 유효성을 검사하기 위해 **TextFormField**를 사용합니다.
- TextFormField: TextFormField는 사용자로부터 텍스트 입력을 받는 입력 필드입니다. 두 개의 TextFormField가 사용되며, 각각 재료명과 설명을 입력 받습니다. 입력값은 각각 **_nameEditingController**와 _detailEditingController 컨트롤러를 통해 관리됩니다.
- _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 서버 연결 소스설명.
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
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
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}');: 만약 서버에서 오류가 발생하면 해당 오류 메시지를 포함한 예외를 던집니다.
'앱 개발 > Flutter' 카테고리의 다른 글
77. [Flutter] GetX 의 Named Route 사용하여, 화면 전환되는bottomNavigationBar만들기 (0) | 2024.08.08 |
---|---|
76. [Flutter] 웹소켓 통신하기 (웹소켓과 http와의 차이) (0) | 2024.08.06 |
74. [Flutter] http 패키지 사용하기 (API 서버 통신) (0) | 2024.04.11 |
73. [Flutter] GoRoute, Stack, OffStage 조합으로 bottomNavigationBar 화면 만들기 (0) | 2024.03.07 |
72. [Flutter] MediaQueryData로 BuildContext 없이 기기 size 구하기(Device Size 가져오기) (0) | 2024.02.28 |