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체크와 함께 비동기 로그인을 처리했습니다. initState와addPostFrameCallback으로 화면 진입 시 데이터를 로드했습니다.AlertDialog로 할 일 추가 다이얼로그를 구현했습니다.RefreshIndicator로 pull-to-refresh 기능을 추가했습니다.shared_preferences로 JWT 토큰을 영구 저장하여 앱 재시작 후에도 로그인 상태를 유지했습니다.
다음 챕터에서는 이 Full-Stack Dart 방식의 장점과 한계를 솔직하게 이야기합니다.