guricode

MVVM 패턴 연습 본문

앱/Flutter&Dart

MVVM 패턴 연습

agentrakugaki 2025. 8. 25. 20:35

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)
  • 한 화면에서 “보여주고 바꾸기”를 모두 한다면 둘 다 같이 써야 한다.