Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
Tags
- lifecycle
- 배포
- 자바스크립트
- develop
- npm
- 단축키
- java
- 엡
- abap
- JS
- Flutter
- java 출력
- DART
- Clean Architecture
- firebase
- unity
- printf
- 앱심사
- UI/UX
- ListView
- java 콘솔 출력 차이
- riverpod
- JQ
- 자바 출력 방식
- react
- nodejs
- LLM
- scss
- println
- 자바 포맷 출력
Archives
- Today
- Total
guricode
MVVM 패턴 연습 본문
lib/main.dart
import 'package:flutter/material.dart'; // Flutter 기본 위젯 패키지
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Riverpod의 ProviderScope 사용
import 'package:mvvm_prac/todo_page.dart'; // TodoPage 화면
void main() {
// 앱 시작점. ProviderScope로 트리를 감싸 리버팟 상태관리 활성화
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// MaterialApp 루트. 라우팅/테마 관리
return const MaterialApp(
title: 'Todo A',
home: TodoPage(), // 첫 화면: TodoPage
);
}
}
lib/model/todo.dart
import 'package:meta/meta.dart'; // @immutable 사용을 위해
@immutable // 생성 후 내부 필드가 바뀌지 않는 '값 객체'로 사용
class Todo {
final String id; // 고유 식별자
final String title; // 할 일 제목
final bool done; // 완료 여부
final DateTime createdAt;// 생성 시각
const Todo({
required this.id,
required this.title,
this.done = false, // 기본은 미완료
required this.createdAt,
});
// 일부 필드만 바꾼 새 인스턴스를 반환(불변 패턴 유지)
Todo copyWith({
String? id,
String? title,
bool? done,
DateTime? createdAt,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
done: done ?? this.done,
createdAt: createdAt ?? this.createdAt,
);
}
}
copyWith은 어떻게 사용된건지??
목적: 불변 객체의 일부 필드만 바꾼 새 인스턴스를 손쉽게 생성.
동작: 전달된 인자만 교체하고, 나머지는 기존 값 유지(??).
??의 의미와 한계
- id ?? this.id는 “인자를 안 줬거나 null이면 기존 값 유지”를 뜻함.
- 필드가 non-nullable일 때 적합.
- 필드를 null로 설정해야 하는 모델이면 Wrapped<T> 패턴 같은 별도 수단이 필요.
copyWith 설계에서 nullable 필드는 흔히 문제가 된다.
왜냐하면 단순히 ?? this.field 패턴을 쓰면 “null을 전달하려는 경우”와 “아예 인자를 안 준 경우”를 구분할 수 없기 때문이다.
Todo copyWith({String? title, bool? done}) {
return Todo(
title: title ?? this.title,
done: done ?? this.done,
);
}
- title: null 을 주면 무시되어 기존 값 유지.
- → 필드 자체를 null로 만들 수 없음.
필드가 nullable(String?)일 때
예: String? description.
(1) 기본 패턴 (실패 사례)
Todo copyWith({String? description}) { return Todo( description: description ?? this.description, ); }
- 여기서 description: null을 주면 ??에 의해 기존 값이 들어감.
- → “null로 바꾸기”가 불가능해짐.
(해결방법) Wrapper 타입 이용
class Nullable<T> {
const Nullable._(this.value, this.isSet);
final T? value;
final bool isSet;
static Nullable<T> unset<T>() => Nullable._(null, false);
static Nullable<T> of<T>(T? v) => Nullable._(v, true);
}
Todo copyWith({Nullable<String>? description}) {
return Todo(
description: (description == null || !description.isSet)
? this.description
: description.value,
);
}
- description: Nullable.of("메모") → 값 교체
- description: Nullable.of(null) → null로 교체
- description: null (즉 인자 생략) → 기존 값 유지
(해결방법) 두번째
freezed 패키지 쓰는 게 현실적이다.
@freezed
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
@Default(false) bool done,
required DateTime createdAt,
}) = _Todo;
}
- 자동 생성된 copyWith는 nullable 필드를 안전하게 다룬다.
- todo.copyWith(description: null) 하면 그대로 null이 들어감.
freezed를 쓸때 주의사항
part 'todo.freezed.dart'; // ✅ 코드 생성 파일 연결
part 'todo.g.dart'; // ✅ JSON 직렬화까지 쓰려면 필요
그리고 터미널에서
flutter pub run build_runner build --delete-conflicting-outputs
실행
lib/viewmodel/todo_notifier.dart (Notifier 사용 버전)
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Notifier/Provider API
import 'package:uuid/uuid.dart'; // 고유 id 생성용
import '../model/todo.dart'; // Todo 모델
// UI가 구독할 전역 Provider. 상태 타입은 List<Todo>
final todoProvider =
NotifierProvider<TodoNotifier, List<Todo>>(TodoNotifier.new);//new는 새 인스턴스 생성하는 팩토리함수
// ViewModel 역할. 상태(List<Todo>)를 보유하고 메서드로 변경
class TodoNotifier extends Notifier<List<Todo>> {
static const _uuid = Uuid(); // v4 UUID 생성기
@override
List<Todo> build() => const []; // 초기 상태: 빈 리스트(불변)
// 항목 추가
void add(String title) {
final t = title.trim(); // 공백 제거
if (t.isEmpty) return; // 빈 입력 방지
final todo = Todo(
id: _uuid.v4(), // 고유 id 생성
title: t,
createdAt: DateTime.now(),
);
// 새 리스트로 교체해야 리빌드가 보장됨(참조 변경)
state = [todo, ...state]; // 최신 항목을 위로
}
// 완료 토글
void toggle(String id) {
// 특정 id만 done을 반전시킨 새 리스트로 교체
state = [
for (final x in state)
if (x.id == id) x.copyWith(done: !x.done) else x
];
}
// 항목 삭제
void remove(String id) {
// 해당 id를 제외한 새 리스트 생성
state = state.where((x) => x.id != id).toList(growable: false);
}
// 완료 항목 일괄 삭제
void clearCompleted() {
// done == true 를 제거하고 남긴 새 리스트로 교체
state = state.where((x) => !x.done).toList(growable: false);
}
}
lib/todo_page.dart
import 'package:flutter/material.dart'; // 머티리얼 위젯
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Consumer 위젯, ref API
import 'package:mvvm_prac/model/todo.dart'; // Todo 모델(타일에서 타입 사용)
import 'package:mvvm_prac/viewmodel/todo_notifier.dart'; // todoProvider, VM
// Riverpod을 사용하는 Stateful 위젯. 텍스트 컨트롤러 처리를 위해 Stateful 선택
class TodoPage extends ConsumerStatefulWidget {
const TodoPage({super.key});
@override
ConsumerState<TodoPage> createState() => _TodoPageState();
}
class _TodoPageState extends ConsumerState<TodoPage> {
// 입력 필드 제어용 컨트롤러
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose(); // 컨트롤러 해제로 메모리 누수 방지
super.dispose();
}
void _submit() {
// 1) VM의 add 호출로 상태 변경 트리거
ref.read(todoProvider.notifier).add(_ctrl.text);
// 2) 입력창 비우기
_ctrl.clear();
}
@override
Widget build(BuildContext context) {
// todoProvider를 '구독'. 상태(List<Todo>)가 바뀌면 이 빌드가 다시 호출됨
final todos = ref.watch(todoProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Todo A"),
actions: [
IconButton(
tooltip: '완료항목삭제', // 접근성/힌트
// 완료 항목이 하나라도 있으면 onPressed에 함수 제공 → 버튼 활성화
// 없으면 null → Flutter 규칙상 버튼 비활성화
onPressed: todos.any((t) => t.done)
? () => ref.read(todoProvider.notifier).clearCompleted()
: null,
icon: const Icon(Icons.cleaning_services_outlined),
)
],
),
body: Column(
children: [
// 상단 입력 영역 여백
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// 텍스트필드가 가로를 대부분 차지
Expanded(
child: TextField(
controller: _ctrl, // 현재 텍스트 제어
decoration: const InputDecoration(
hintText: '할 일 입력', // 플레이스홀더
border: OutlineInputBorder(), // 외곽선
),
// 키보드 '완료' 입력 시 _summit 호출
onSubmitted: (_) => _submit(),
),
),
const SizedBox(width: 8), // 필드-버튼 간격
ElevatedButton(
onPressed: _submit, // 버튼 클릭 시 추가
child: const Text('add'),
),
],
),
),
const Divider(height: 0), // 입력부/목록 구분선
// 남은 공간을 스크롤 리스트로 채움
Expanded(
child: ListView.builder(
itemCount: todos.length, // 아이템 개수
itemBuilder: (_, i) => _TodoTile(t: todos[i]), // 각 아이템 위젯
),
),
],
),
);
}
}
// 단일 Todo를 렌더링하는 타일 위젯.
// ConsumerWidget로 ref 접근 가능. 자체 상태는 없음.
class _TodoTile extends ConsumerWidget {
const _TodoTile({required this.t});
final Todo t; // 표시할 Todo 데이터
@override
Widget build(BuildContext context, WidgetRef ref) {
// VM 인스턴스. 읽기 전용 접근(read): 메서드 호출 용도
final vm = ref.read(todoProvider.notifier);
return Dismissible(
key: ValueKey(t.id), // 리스트 변화 시 안정적 식별자
background: Container(
color: Colors.redAccent, // 스와이프 중에 보이는 배경
),
onDismissed: (_) => vm.remove(t.id), // 충분히 스와이프되면 삭제
child: CheckboxListTile(
value: t.done, // 체크 상태
onChanged: (_) => vm.toggle(t.id), // 체크 토글 시 상태 변경
title: Text(
t.title, // 제목 표시
style: TextStyle(
// 완료면 취소선 및 회색 처리로 시각 피드백
decoration: t.done ? TextDecoration.lineThrough : null,
color: t.done ? Colors.grey : null,
),
),
secondary: IconButton(
tooltip: 'delete', // 접근성/힌트
onPressed: () => vm.remove(t.id), // 아이콘 클릭으로 즉시 삭제
icon: const Icon(Icons.delete_outline),
),
),
);
}
}
- UI 표시 → ref.watch(todoProvider)
- 상태 변경 → ref.read(todoProvider.notifier)
- 한 화면에서 “보여주고 바꾸기”를 모두 한다면 둘 다 같이 써야 한다.
'앱 > Flutter&Dart' 카테고리의 다른 글
| [영화정보앱 만들기-2] [트러블 슈팅] Dio 인터셉터 handler.next 누락으로 요청 영구 대기 (4) | 2025.08.27 |
|---|---|
| [영화정보앱 만들기] 의존성 설정,dotenv (2) | 2025.08.26 |
| [TIL] 20250819 - GoRouter,Responsive UI (0) | 2025.08.19 |
| Flutter ThemeExtension (2) | 2025.08.18 |
| Flutter Firebase 시작 정리 - 설치부터 초기화까지 (3) | 2025.08.14 |