크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정
#3 — UI 구현과 4플랫폼 빌드
2편에서 Drift 기반의 로컬 DB와 Repository까지 완성했다. 이번 편에서는 Riverpod 컨트롤러를 연결하고, 실제 화면(노트 목록, 에디터)을 만들고, go_router로 화면 전환을 구성하고, GitHub Actions로 iOS/Android/macOS/Windows 4플랫폼 빌드를 자동화한다. 1차 개발의 마지막 단계다.
Riverpod 컨트롤러 — Repository와 UI를 연결하는 층
2편에서 만든 NoteRepository는 순수한 Dart 인터페이스다. 이걸 Flutter 위젯에서 직접 호출할 수도 있지만, 그러면 위젯이 비즈니스 로직과 강하게 결합된다. 중간에 컨트롤러(Controller) 층을 두면 위젯은 “데이터를 보여주기”에만 집중할 수 있다.
노트 목록 컨트롤러
part 'note_list_controller.g.dart';
@riverpod
Stream<List<Note>> noteList(Ref ref) {
return ref.watch(noteRepositoryProvider).watchAll();
}
@riverpod
class NoteListActions extends _$NoteListActions {
@override
void build() {}
Future<void> deleteNote(String id) async {
await ref.read(noteRepositoryProvider).delete(id);
}
}
두 개의 Provider를 분리했다.
noteListProvider: Repository의watchAll()Stream을 구독한다. DB에 노트가 추가/수정/삭제되면 Stream이 새 리스트를 방출하고, 이를 watch하고 있는 위젯이 자동으로 다시 그려진다.NoteListActions: 삭제 같은 명령(command) 을 담당한다. 읽기와 쓰기를 분리하는 CQRS 패턴의 간소화 버전이라고 볼 수 있다.
@riverpod 어노테이션을 붙이면 build_runner가 .g.dart 파일에 Provider 선언 코드를 자동 생성한다. 직접 StreamProvider를 선언하는 것보다 코드가 훨씬 간결하다.
에디터 컨트롤러
part 'editor_controller.g.dart';
@riverpod
Future<Note?> editorNote(Ref ref, String? id) async {
if (id == null) return null;
return ref.watch(noteRepositoryProvider).findById(id);
}
@riverpod
class EditorActions extends _$EditorActions {
@override
void build() {}
Future<String> create({
required NoteType type,
required String title,
required String content,
}) async {
final now = ref.read(clockProvider).now();
final id = ref.read(uuidProvider).v4();
final note = Note(
id: id,
type: type,
title: title,
content: content,
createdAt: now,
updatedAt: now,
);
await ref.read(noteRepositoryProvider).save(note);
return id;
}
Future<void> update(Note original, {required String title, required String content}) async {
final now = ref.read(clockProvider).now();
final updated = original.copyWith(
title: title,
content: content,
updatedAt: now,
);
await ref.read(noteRepositoryProvider).save(updated);
}
}
에디터 컨트롤러도 읽기/쓰기를 분리했다.
editorNoteProvider:id가null이면 새 노트(반환값null), 아니면 기존 노트를 DB에서 조회한다.EditorActions:create와update를 담당한다.clockProvider에서 현재 시각을,uuidProvider에서 고유 ID를 가져온다.
clockProvider와 uuidProvider는 1편에서 설명한 Clock 추상화의 연장선이다. 테스트에서 이 Provider를 Mock으로 교체하면 “2026년 1월 1일에 생성된 노트”처럼 시간을 고정한 테스트를 작성할 수 있다.
노트 목록 화면 — NoteListScreen
앱을 열면 가장 먼저 보이는 화면이다. 노트가 없으면 빈 상태를, 있으면 리스트를 보여준다.
전체 구조
class NoteListScreen extends ConsumerWidget {
const NoteListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(noteListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('노트'),
actions: [
IconButton(
tooltip: '검색',
icon: const Icon(Icons.search),
onPressed: () => context.push('/search'),
),
IconButton(
tooltip: '설정',
icon: const Icon(Icons.settings),
onPressed: () => context.push('/settings'),
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('오류: $e')),
data: (notes) {
if (notes.isEmpty) {
return const _EmptyState();
}
return ListView.builder(
itemCount: notes.length,
itemBuilder: (context, i) => _NoteTile(note: notes[i]),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final picked = await showTypePicker(context);
if (picked == null) return;
if (!context.mounted) return;
context.push('/editor?type=${picked.toJson()}');
},
child: const Icon(Icons.add),
),
);
}
}
핵심 포인트를 짚어보자.
ConsumerWidget: 일반 StatelessWidget 대신 Riverpod의 ConsumerWidget을 사용한다. build 메서드에 WidgetRef ref가 추가되어 Provider를 watch할 수 있다.
ref.watch(noteListProvider): 이 한 줄로 DB의 노트 목록을 구독한다. 반환값은 AsyncValue<List<Note>>인데, .when()으로 loading/error/data 세 가지 상태를 분기 처리한다.
FAB(+버튼): 누르면 showTypePicker로 바텀시트를 띄워 노트 종류를 고른 뒤, 에디터 화면으로 이동한다. context.mounted 체크는 async 작업 후 위젯이 이미 사라졌을 때 context.push를 호출하는 오류를 방지한다.
빈 상태
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.note_alt_outlined, size: 64),
SizedBox(height: 16),
Text('아직 작성된 노트가 없습니다'),
SizedBox(height: 8),
Text('오른쪽 아래 + 버튼으로 추가해보세요'),
],
),
);
}
}
처음 앱을 열면 이 화면이 보인다. 아이콘과 안내 텍스트로 사용자가 다음 행동을 바로 알 수 있게 했다. 단순하지만, 빈 상태를 별도 위젯으로 분리해두면 나중에 애니메이션이나 일러스트를 추가할 때 이 위젯만 수정하면 된다.
스와이프 삭제
class _NoteTile extends ConsumerWidget {
const _NoteTile({required this.note});
final Note note;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: ValueKey(note.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 24),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('노트 삭제'),
content: Text('"${note.title.isEmpty ? '제목 없음' : note.title}" 을(를) 삭제할까요?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('삭제'),
),
],
),
) ??
false;
},
onDismissed: (_) {
ref.read(noteListActionsProvider.notifier).deleteNote(note.id);
},
child: ListTile(
leading: Icon(_iconFor(note.type)),
title: Text(
note.title.isEmpty ? '제목 없음' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
note.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => context.push('/editor?id=${note.id}'),
),
);
}
static IconData _iconFor(NoteType type) {
switch (type) {
case NoteType.plain:
return Icons.notes;
case NoteType.markdown:
return Icons.code;
case NoteType.checklist:
return Icons.checklist;
}
}
}
Dismissible 위젯으로 스와이프 삭제를 구현했다. 몇 가지 설계 포인트:
- 단방향 스와이프:
DismissDirection.endToStart로 오른쪽에서 왼쪽으로만 스와이프 가능. 양방향으로 열면 실수로 삭제하기 쉽다. - 삭제 확인 다이얼로그:
confirmDismiss에서AlertDialog를 띄워 한 번 더 확인한다. 실수 방지 장치. - 실제 삭제: 다이얼로그에서 “삭제”를 누르면
onDismissed가 호출되고,noteListActionsProvider를 통해 Repository의delete를 호출한다. - 노트 타입별 아이콘:
_iconFor로 일반 텍스트/마크다운/체크리스트에 따라 다른 아이콘을 보여준다.
에디터 화면 — EditorScreen
노트를 생성하거나 수정하는 화면이다. 새 노트 생성과 기존 노트 수정을 하나의 화면에서 처리한다.
class EditorScreen extends ConsumerStatefulWidget {
const EditorScreen({
super.key,
required this.noteId,
required this.initialType,
});
final String? noteId;
final NoteType initialType;
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
noteId가null이면 새 노트, 값이 있으면 기존 노트 수정.initialType은 새 노트일 때 어떤 종류(plain/markdown/checklist)인지를 결정한다.
생성/수정 분기
class _EditorScreenState extends ConsumerState<EditorScreen> {
late final TextEditingController _titleCtrl;
String _body = '';
Note? _loaded;
bool _hydrated = false;
late NoteType _type;
// ...
Future<void> _save() async {
final actions = ref.read(editorActionsProvider.notifier);
if (_loaded == null) {
await actions.create(type: _type, title: _titleCtrl.text, content: _body);
} else {
await actions.update(
_loaded!,
title: _titleCtrl.text,
content: _body,
);
}
if (mounted) context.pop();
}
@override
Widget build(BuildContext context) {
final async = ref.watch(editorNoteProvider(widget.noteId));
return Scaffold(
appBar: AppBar(
title: Text(widget.noteId == null ? '새 노트' : '노트 편집'),
actions: [
IconButton(
tooltip: '저장',
icon: const Icon(Icons.check),
onPressed: _save,
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('오류: $e')),
data: (note) {
if (!_hydrated) {
_loaded = note;
_titleCtrl.text = note?.title ?? '';
_body = note?.content ?? '';
_type = note?.type ?? widget.initialType;
_hydrated = true;
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: '제목',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Expanded(
child: _buildEditorFor(_type),
),
],
),
);
},
),
);
}
}
여기서 _hydrated 플래그가 중요하다. editorNoteProvider는 비동기로 DB에서 노트를 가져오는데, Provider가 새 값을 방출할 때마다 build가 다시 호출된다. _hydrated가 없으면 사용자가 제목을 수정하는 도중에 원래 값으로 되돌아가는 문제가 생긴다. 최초 한 번만 DB 데이터로 초기화하고, 이후에는 사용자 입력 상태를 유지한다.
_save() 메서드에서는 _loaded가 null인지 아닌지로 생성/수정을 분기한다:
- 새 노트:
_loaded == null→actions.create()호출 - 기존 노트:
_loaded != null→actions.update()호출,copyWith으로 수정 시간만 갱신
저장 후 context.pop()으로 목록 화면으로 돌아간다. Riverpod Stream이 살아있으니 목록 화면은 자동으로 갱신된다.
go_router — 선언적 화면 전환
화면 간 이동을 go_router로 구성했다.
GoRouter buildRouter() {
return GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const NoteListScreen(),
),
GoRoute(
path: '/editor',
builder: (context, state) {
final typeParam = state.uri.queryParameters['type'] ?? 'plain';
final type = _parseType(typeParam);
return EditorScreen(
noteId: state.uri.queryParameters['id'],
initialType: type,
);
},
),
GoRoute(
path: '/search',
builder: (_, __) => const SearchScreen(),
),
GoRoute(
path: '/settings',
builder: (_, __) => const SettingsScreen(),
),
],
);
}
NoteType _parseType(String raw) {
try {
return NoteType.fromJson(raw);
} on ArgumentError {
return NoteType.plain;
}
}
라우트는 4개다:
| 경로 | 화면 | 파라미터 |
|---|---|---|
/ |
노트 목록 | - |
/editor |
에디터 | id(수정), type(새 노트 종류) |
/search |
검색 | - |
/settings |
설정 | - |
에디터 라우트가 흥미로운데, 쿼리 파라미터로 생성/수정을 구분한다:
- 새 노트:
/editor?type=plain—id가 없으므로noteId는null - 기존 노트 수정:
/editor?id=abc-123—id가 있으므로 해당 노트를 DB에서 로드
_parseType에서 잘못된 값이 들어오면 ArgumentError를 잡아서 기본값 plain으로 처리한다. URL 파라미터는 문자열이라 방어 코드가 필요하다.
Material 3 테마
ThemeData buildLightTheme() {
return ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
);
}
ThemeData buildDarkTheme() {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
);
}
Material 3의 ColorScheme.fromSeed를 사용했다. seed color 하나(indigo)만 지정하면 Material 3 알고리즘이 primary, secondary, surface, background 등 전체 색상 팔레트를 자동으로 생성해준다. 라이트/다크 테마 모두 같은 seed color를 쓰니 톤이 일관된다.
이 테마는 앱 진입점에서 연결된다:
class CrossPlatformNoteApp extends ConsumerWidget {
CrossPlatformNoteApp({super.key});
final _router = buildRouter();
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeControllerProvider);
return MaterialApp.router(
title: 'cross-platform-note',
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: themeMode,
routerConfig: _router,
);
}
}
MaterialApp.router로 go_router와 통합하고, themeMode를 Riverpod Provider로 관리한다. 사용자가 설정에서 테마를 바꾸면 themeModeControllerProvider가 새 값을 방출하고, 앱 전체가 즉시 다시 그려진다.
main.dart — 앱 진입점
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [
sharedPrefsProvider.overrideWith((_) => Future.value(prefs)),
],
child: CrossPlatformNoteApp(),
),
);
}
Flutter 앱의 시작점이다. 주의할 부분이 두 가지 있다.
WidgetsFlutterBinding.ensureInitialized(): main()이 async이면 반드시 호출해야 한다. SharedPreferences.getInstance()가 플랫폼 채널을 사용하는데, 바인딩이 초기화되지 않은 상태에서 호출하면 크래시가 난다.
ProviderScope overrides: SharedPreferences 인스턴스를 미리 생성해서 주입한다. 이렇게 하면 앱 내 어디서든 ref.watch(sharedPrefsProvider)로 동기적으로 접근할 수 있다. 만약 Provider 내부에서 SharedPreferences.getInstance()를 호출하면 첫 접근 시 AsyncLoading 상태를 거쳐야 해서 화면이 깜빡거린다.
통합 테스트 — 실제 앱에서 E2E 검증
단위 테스트는 개별 함수의 동작을 검증하지만, 실제로 화면을 띄워서 탭하고 스와이프하는 플로우를 검증하려면 통합 테스트가 필요하다.
testWidgets('end-to-end: create → list → edit → delete', (tester) async {
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPrefsProvider.overrideWith((_) => Future.value(prefs)),
],
child: CrossPlatformNoteApp(),
),
);
// 1. 빈 상태 확인
await waitFor(tester, find.text('아직 작성된 노트가 없습니다'));
// 2. 새 노트 추가 — + FAB 탭 → TypePicker → 일반 텍스트 선택
await tester.tap(find.byIcon(Icons.add));
await waitFor(tester, find.text('일반 텍스트'));
await tester.tap(find.text('일반 텍스트'));
await waitFor(tester, find.byTooltip('저장'));
// ...
await tester.enterText(find.byType(TextField).first, 'E2E 노트');
await tester.tap(find.byTooltip('저장'));
// 3. 목록에 나타나는지
await waitForListTileText(tester, 'E2E 노트');
// 4. 탭해서 수정
await tester.tap(find.descendant(
of: find.byType(ListTile),
matching: find.text('E2E 노트'),
));
await tester.enterText(find.byType(TextField).first, 'E2E 수정됨');
await tester.tap(find.byTooltip('저장'));
await waitForListTileText(tester, 'E2E 수정됨');
// 5. 스와이프 삭제
await tester.drag(
find.descendant(of: find.byType(ListTile), matching: find.text('E2E 수정됨')),
const Offset(-500, 0),
);
await waitFor(tester, find.text('삭제'));
await tester.tap(find.text('삭제'));
// 6. 다시 빈 상태
await waitFor(tester, find.text('아직 작성된 노트가 없습니다'));
});
이 테스트는 실제 앱의 전체 플로우를 검증한다:
- 앱 시작 → 빈 상태 확인
- FAB 탭 → 노트 종류 선택 → 제목 입력 → 저장
- 목록에 노트 등장 확인
- 노트 탭 → 제목 수정 → 저장 → 수정 반영 확인
- 스와이프 → 삭제 확인 다이얼로그 → 삭제
- 다시 빈 상태 확인
한 가지 주의할 점은 pumpAndSettle 대신 커스텀 waitFor 함수를 사용했다는 것이다. Drift의 Stream 구독이 계속 살아있기 때문에 pumpAndSettle은 타임아웃이 발생한다. waitFor는 특정 위젯이 화면에 나타날 때까지 짧은 간격으로 pump를 반복하는 방식이라, 백그라운드 Stream이 돌고 있어도 문제없다.
GitHub Actions CI — 4플랫폼 자동 빌드
코드를 push할 때마다 테스트를 돌리고, 4개 플랫폼에서 빌드가 되는지 자동으로 확인한다. .github/workflows/ci.yml 전체 구조를 보자.
name: CI
on:
push:
branches: [main, 'feature/**']
pull_request:
branches: [main]
jobs:
test:
name: Unit & Widget Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.41.x'
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test --coverage
테스트 Job
가장 먼저 실행되는 Job이다.
- Flutter SDK 설치 (
subosito/flutter-action—cache: true로 캐시 활성화) - 의존성 설치
- 코드 생성 (
build_runner) — freezed, Drift, Riverpod 생성 파일 필요 - 정적 분석 (
flutter analyze) - 테스트 실행 (
flutter test --coverage)
4플랫폼 빌드 Jobs
테스트가 통과하면(needs: test) 4개 빌드 Job이 병렬로 실행된다.
build-android:
name: Build Android APK
runs-on: ubuntu-latest
needs: test
steps:
# ... Flutter 설정, 코드 생성
- run: flutter build apk --debug
build-ios:
name: Build iOS (no codesign)
runs-on: macos-latest
needs: test
steps:
# ... Flutter 설정, 코드 생성
- run: flutter build ios --debug --no-codesign
build-macos:
name: Build macOS
runs-on: macos-latest
needs: test
steps:
# ... Flutter 설정, 코드 생성
- run: flutter build macos --debug
build-windows:
name: Build Windows
runs-on: windows-latest
needs: test
steps:
# ... Flutter 설정, 코드 생성
- run: flutter build windows --debug
| 플랫폼 | Runner | 빌드 명령 | 비고 |
|---|---|---|---|
| Android | ubuntu-latest | flutter build apk --debug |
JDK 17 필요 (actions/setup-java) |
| iOS | macos-latest | flutter build ios --debug --no-codesign |
서명 없이 빌드만 확인 |
| macOS | macos-latest | flutter build macos --debug |
- |
| Windows | windows-latest | flutter build windows --debug |
- |
몇 가지 설계 결정:
--debug빌드: CI에서는 “빌드가 되는지”만 확인하면 된다.--release는 코드 서명, 최적화 등 추가 설정이 필요하고 시간도 오래 걸린다.- iOS
--no-codesign: Apple 개발자 인증서 없이도 빌드가 통과하도록 한다. 실제 배포할 때만 서명하면 된다. needs: test: 테스트가 실패하면 빌드 Job은 아예 실행되지 않는다. CI 비용을 아끼면서도 빌드가 깨졌을 때 빠르게 알 수 있다.- 각 Job에 코드 생성 포함: GitHub Actions의 각 Job은 독립적인 환경에서 돌아간다. 이전 Job에서 생성한 파일은 공유되지 않으니, 각 빌드 Job에서도
build_runner를 실행해야 한다.
이번 편 정리
| 항목 | 내용 |
|---|---|
| 컨트롤러 | 목록(noteList + NoteListActions), 에디터(editorNote + EditorActions) 읽기/쓰기 분리 |
| 노트 목록 | ConsumerWidget, AsyncValue.when 패턴, 빈 상태, 스와이프 삭제 + 확인 다이얼로그 |
| 에디터 | ConsumerStatefulWidget, _hydrated 플래그로 DB 데이터 1회 초기화, create/update 분기 |
| 라우터 | go_router 4개 라우트, 쿼리 파라미터로 생성/수정 구분 |
| 테마 | Material 3 ColorScheme.fromSeed, 라이트/다크 자동 생성 |
| 진입점 | SharedPreferences 사전 로드, ProviderScope overrides |
| 통합 테스트 | E2E 플로우 (생성 → 목록 → 수정 → 삭제), 커스텀 waitFor 함수 |
| CI | GitHub Actions — 테스트 후 Android/iOS/macOS/Windows 4플랫폼 병렬 빌드 |
여기까지가 1차 개발 완성이다. 로컬에서 노트를 생성하고, 조회하고, 수정하고, 삭제할 수 있으며, 4개 플랫폼에서 빌드가 통과한다.
다음 편에서는 3종 에디터(Plain, Markdown, Checklist)를 각각 구현하고, DB 스키마를 v2로 마이그레이션한다.
← 이전: #2 로컬 DB 설계와 노트 CRUD → 다음: #4 3종 에디터