iBetter Books
수정

Ch 05. 할 일 관리 앱 완성

드디어 모든 조각을 합칩니다. 로그인 화면에서 JWT 토큰을 받아 저장하고, 할 일 목록 화면에서 서버와 통신하며 CRUD를 수행합니다. 이번 챕터 끝에서 todo_server와 완전히 연동된 앱이 완성됩니다.

로그인 화면 완성

// 수정: lib/screens/login_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _formKey = GlobalKey<FormState>();
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscurePassword = true;

  @override
  void dispose() {
    _usernameController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _login() async {
    if (!_formKey.currentState!.validate()) return;

    final provider = context.read<TodoProvider>();
    final success = await provider.login(
      _usernameController.text.trim(),
      _passwordController.text,
    );

    if (!success && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(provider.errorMessage ?? '로그인에 실패했습니다.'),
          backgroundColor: Colors.red,
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = context.watch<TodoProvider>().state == LoadingState.loading;

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                const Icon(
                  Icons.check_circle,
                  size: 72,
                  color: Color(0xFF6750A4),
                ),
                const SizedBox(height: 32),
                Text(
                  'Todo App',
                  style: Theme.of(context).textTheme.headlineMedium,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 48),
                TextFormField(
                  controller: _usernameController,
                  enabled: !isLoading,
                  decoration: const InputDecoration(
                    labelText: '아이디',
                    prefixIcon: Icon(Icons.person_outline),
                    border: OutlineInputBorder(),
                  ),
                  validator: (value) {
                    if (value == null || value.isEmpty) return '아이디를 입력하세요.';
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: _passwordController,
                  obscureText: _obscurePassword,
                  enabled: !isLoading,
                  decoration: InputDecoration(
                    labelText: '비밀번호',
                    prefixIcon: const Icon(Icons.lock_outline),
                    border: const OutlineInputBorder(),
                    suffixIcon: IconButton(
                      icon: Icon(
                        _obscurePassword
                            ? Icons.visibility_off
                            : Icons.visibility,
                      ),
                      onPressed: () {
                        setState(() {
                          _obscurePassword = !_obscurePassword;
                        });
                      },
                    ),
                  ),
                  validator: (value) {
                    if (value == null || value.isEmpty) return '비밀번호를 입력하세요.';
                    return null;
                  },
                  onFieldSubmitted: (_) => _login(),
                ),
                const SizedBox(height: 24),
                FilledButton(
                  onPressed: isLoading ? null : _login,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(vertical: 12),
                    child: isLoading
                        ? const SizedBox(
                            height: 20,
                            width: 20,
                            child: CircularProgressIndicator(
                              strokeWidth: 2,
                              color: Colors.white,
                            ),
                          )
                        : const Text('로그인', style: TextStyle(fontSize: 16)),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

mounted 체크는 비동기 작업 완료 후 위젯이 이미 제거된 경우 context 접근을 막습니다. Flutter에서 비동기 작업 후 context를 사용할 때 반드시 if (mounted) 확인이 필요합니다.

할 일 목록 화면 완성

// 수정: lib/screens/todo_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
import '../widgets/todo_list_view.dart';

class TodoScreen extends StatefulWidget {
  const TodoScreen({super.key});

  @override
  State<TodoScreen> createState() => _TodoScreenState();
}

class _TodoScreenState extends State<TodoScreen> {
  @override
  void initState() {
    super.initState();
    // 화면이 처음 표시될 때 할 일 목록 로드
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<TodoProvider>().loadTodos();
    });
  }

  Future<void> _showAddTodoDialog() async {
    final controller = TextEditingController();
    final result = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('할 일 추가'),
        content: TextField(
          controller: controller,
          autofocus: true,
          decoration: const InputDecoration(
            hintText: '할 일을 입력하세요.',
            border: OutlineInputBorder(),
          ),
          onSubmitted: (value) => Navigator.pop(context, value),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('취소'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, controller.text),
            child: const Text('추가'),
          ),
        ],
      ),
    );

    if (result != null && result.trim().isNotEmpty && mounted) {
      await context.read<TodoProvider>().createTodo(result.trim());
    }
    controller.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<TodoProvider>();

    return Scaffold(
      appBar: AppBar(
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('할 일 목록'),
            if (provider.todos.isNotEmpty)
              Text(
                '${provider.todos.where((t) => t.completed).length}/${provider.todos.length} 완료',
                style: Theme.of(context).textTheme.bodySmall,
              ),
          ],
        ),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => context.read<TodoProvider>().loadTodos(),
          ),
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () {
              context.read<TodoProvider>().logout();
            },
          ),
        ],
      ),
      body: _buildBody(provider),
      floatingActionButton: FloatingActionButton(
        onPressed: _showAddTodoDialog,
        child: const Icon(Icons.add),
      ),
    );
  }

  Widget _buildBody(TodoProvider provider) {
    switch (provider.state) {
      case LoadingState.loading:
        return const Center(child: CircularProgressIndicator());

      case LoadingState.error:
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.error_outline, size: 48, color: Colors.red),
              const SizedBox(height: 16),
              Text(
                provider.errorMessage ?? '오류가 발생했습니다.',
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => context.read<TodoProvider>().loadTodos(),
                child: const Text('다시 시도'),
              ),
            ],
          ),
        );

      case LoadingState.success:
      case LoadingState.idle:
        return RefreshIndicator(
          onRefresh: () => context.read<TodoProvider>().loadTodos(),
          child: TodoListView(
            todos: provider.todos,
            onToggle: (todo) => context.read<TodoProvider>().toggleTodo(todo),
            onDelete: (todo) => context.read<TodoProvider>().deleteTodo(todo.id),
          ),
        );
    }
  }
}

WidgetsBinding.instance.addPostFrameCallback은 첫 번째 프레임이 그려진 직후에 실행됩니다. initState() 안에서 직접 context.read()를 호출하면 안 되므로 이 패턴을 사용합니다. RefreshIndicator는 목록을 아래로 당기면 새로 고침하는 "pull-to-refresh" 기능을 무료로 제공합니다.

JWT 토큰 영구 저장

앱을 재시작해도 로그인 상태를 유지하려면 토큰을 로컬에 저장해야 합니다. shared_preferences 패키지를 사용합니다.

# 수정: pubspec.yamldependencies:  flutter:    sdk: flutter  http: ^1.2.0  provider: ^6.1.0  shared_preferences: ^2.2.0
// 수정: lib/providers/todo_provider.dart (토큰 저장 추가)
import 'package:shared_preferences/shared_preferences.dart';

class TodoProvider extends ChangeNotifier {
  // ...

  static const _tokenKey = 'auth_token';

  Future<void> loadSavedToken() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString(_tokenKey);
    if (saved != null) {
      _token = saved;
      _api.setToken(saved);
      notifyListeners();
    }
  }

  Future<bool> login(String username, String password) async {
    _setLoading();
    try {
      final token = await _api.login(username, password);
      _token = token;
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString(_tokenKey, token);
      _state = LoadingState.idle;
      notifyListeners();
      return true;
    } on ApiException catch (e) {
      _setError(e.message);
      return false;
    } catch (e) {
      _setError('서버에 연결할 수 없습니다.');
      return false;
    }
  }

  void logout() {
    _api.clearToken();
    _token = null;
    _todos = [];
    _state = LoadingState.idle;
    SharedPreferences.getInstance().then(
      (prefs) => prefs.remove(_tokenKey),
    );
    notifyListeners();
  }
}

앱 시작 시 저장된 토큰을 불러옵니다.

// 수정: lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const TodoApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) {
        final provider = TodoProvider();
        provider.loadSavedToken();
        return provider;
      },
      child: MaterialApp(
        // ...
      ),
    );
  }
}

WidgetsFlutterBinding.ensureInitialized()main()에서 async를 사용하거나 플러그인을 초기화하기 전에 반드시 호출해야 합니다.

앱 실행 및 동작 확인

서버를 먼저 실행합니다.

# PART 06 todo_server 실행cd todo_serverdart_frog dev

그다음 Flutter 앱을 실행합니다.

cd todo_appflutter run -d chrome

로그인 → 할 일 추가 → 완료 체크 → 삭제 순서로 동작을 확인합니다. 서버를 재시작해도 SQLite에 저장된 데이터가 그대로인지, 앱을 새로 고침하면 목록이 다시 불러와지는지 확인합니다.

이번 챕터 정리

  • 로그인 화면에서 mounted 체크와 함께 비동기 로그인을 처리했습니다.
  • initStateaddPostFrameCallback으로 화면 진입 시 데이터를 로드했습니다.
  • AlertDialog로 할 일 추가 다이얼로그를 구현했습니다.
  • RefreshIndicator로 pull-to-refresh 기능을 추가했습니다.
  • shared_preferences로 JWT 토큰을 영구 저장하여 앱 재시작 후에도 로그인 상태를 유지했습니다.

다음 챕터에서는 이 Full-Stack Dart 방식의 장점과 한계를 솔직하게 이야기합니다.