[Flutter] 크로스플랫폼 노트 앱 개발기 #6 — 테마, 설정, 그리고 2차 개발 마무리

크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정


#6 — 테마, 설정, 그리고 2차 개발 마무리

이번 편에서는 다크/라이트 테마 전환과 에디터 글자 크기 설정을 구현하고, CI를 정비한 뒤, 2차 개발 전체를 돌아본다.




설정 도메인 설계

설정 기능도 notes 기능과 마찬가지로 Clean Architecture의 data/domain/presentation 레이어로 나눴다. 디렉토리 구조는 다음과 같다.

lib/features/settings/
├── data/
│   └── settings_repository.dart     # SharedPreferences 저장/로드
├── domain/
│   ├── app_settings.dart            # 설정 엔티티 (freezed)
│   └── editor_font_size.dart        # 폰트 크기 enum
└── presentation/
    ├── settings_screen.dart          # 설정 화면 UI
    ├── theme_mode_controller.dart    # 테마 모드 Riverpod 컨트롤러
    └── editor_font_size_controller.dart  # 폰트 크기 Riverpod 컨트롤러

에디터 폰트 크기 — EditorFontSize enum

설정값 중 에디터 글자 크기는 enum으로 정의했다. 자유 입력이 아니라 3단계로 제한한 이유는 단순하다 — 사용자가 고민 없이 빠르게 선택할 수 있도록.

enum EditorFontSize {
  small(14, 'small'),
  medium(16, 'medium'),
  large(18, 'large');

  const EditorFontSize(this.points, this._wire);

  final double points;
  final String _wire;

  String toJson() => _wire;

  static EditorFontSize fromJson(String? value) {
    if (value == null) return EditorFontSize.medium;
    return EditorFontSize.values.firstWhere(
      (s) => s._wire == value,
      orElse: () => EditorFontSize.medium,
    );
  }
}

각 enum 값이 실제 포인트 크기(points)와 직렬화용 문자열(_wire)을 함께 들고 있다. fromJson에서 null이나 알 수 없는 값이 오면 기본값 medium으로 떨어지도록 방어 처리했다.

설정 엔티티 — AppSettings

앱의 전체 설정을 하나의 freezed 클래스로 묶었다.

@freezed
class AppSettings with _$AppSettings {
  const factory AppSettings({
    required ThemeMode themeMode,
    required EditorFontSize editorFontSize,
  }) = _AppSettings;

  factory AppSettings.defaults() => const AppSettings(
        themeMode: ThemeMode.system,
        editorFontSize: EditorFontSize.medium,
      );
}

defaults() 팩토리로 기본값을 명시한다. 앱을 처음 설치하면 시스템 테마를 따르고 글자 크기는 보통(16pt)이다. 나중에 설정 항목이 늘어나면 이 클래스에 필드를 추가하면 된다.




SharedPreferences로 설정 저장하기

노트 데이터는 Drift(SQLite)에 저장하지만, 테마 모드나 폰트 크기 같은 단순 설정은 SharedPreferences가 적합하다. key-value 저장소로 가볍고, 별도 스키마 관리가 필요 없다.

class SettingsRepository {
  SettingsRepository(this._prefs);

  static const _keyThemeMode = 'settings.themeMode';
  static const _keyEditorFontSize = 'settings.editorFontSize';

  final SharedPreferences _prefs;

  AppSettings load() {
    return AppSettings(
      themeMode: _parseThemeMode(_prefs.getString(_keyThemeMode)),
      editorFontSize:
          EditorFontSize.fromJson(_prefs.getString(_keyEditorFontSize)),
    );
  }

  Future<void> setThemeMode(ThemeMode mode) async {
    await _prefs.setString(_keyThemeMode, mode.name);
  }

  Future<void> setEditorFontSize(EditorFontSize size) async {
    await _prefs.setString(_keyEditorFontSize, size.toJson());
  }

  static ThemeMode _parseThemeMode(String? v) {
    return switch (v) {
      'light' => ThemeMode.light,
      'dark' => ThemeMode.dark,
      _ => ThemeMode.system,
    };
  }
}

load()는 동기 메서드다. SharedPreferences는 getInstance()를 한 번 호출하면 이후 읽기는 동기로 가능하기 때문이다. 쓰기(setThemeMode, setEditorFontSize)만 비동기다.

_parseThemeMode에서 Dart 3의 switch 표현식을 사용했다. light, dark가 아닌 모든 값(null 포함)은 system으로 떨어진다.




Riverpod으로 테마 상태 관리

설정을 저장하는 것만으로는 부족하다. 사용자가 테마를 변경하면 앱 전체의 UI가 즉시 반영되어야 한다. 이 역할을 Riverpod 컨트롤러가 담당한다.

SharedPreferences Provider

먼저 SharedPreferences 인스턴스를 Provider로 제공한다.

@Riverpod(keepAlive: true)
Future<SharedPreferences> sharedPrefs(Ref ref) =>
    SharedPreferences.getInstance();

keepAlive: true로 선언하면 앱이 살아있는 동안 Provider가 유지된다. SharedPreferences처럼 앱 전역에서 사용하는 인스턴스는 매번 재생성할 이유가 없다.

실제로는 main.dart에서 미리 초기화한 인스턴스를 override로 주입한다.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();

  runApp(
    ProviderScope(
      overrides: [
        sharedPrefsProvider.overrideWith((_) => Future.value(prefs)),
      ],
      child: CrossPlatformNoteApp(),
    ),
  );
}

main에서 미리 await하고 overrideWith로 넣으면, 이후 어디서든 ref.watch(sharedPrefsProvider).requireValue로 동기 접근이 가능하다. 별도의 로딩 상태 처리가 필요 없어진다.

ThemeModeController

@Riverpod(keepAlive: true)
class ThemeModeController extends _$ThemeModeController {
  @override
  ThemeMode build() {
    final prefs = ref.watch(sharedPrefsProvider).requireValue;
    final repo = SettingsRepository(prefs);
    return repo.load().themeMode;
  }

  Future<void> set(ThemeMode mode) async {
    final prefs = ref.read(sharedPrefsProvider).requireValue;
    final repo = SettingsRepository(prefs);
    await repo.setThemeMode(mode);
    state = mode;
  }
}

build()에서 저장된 테마 모드를 읽어서 초기 상태를 반환한다. set()은 SharedPreferences에 저장한 뒤 state를 갱신한다. state가 변경되면 이 Provider를 watch하고 있는 모든 위젯이 자동으로 리빌드된다.

EditorFontSizeController

폰트 크기 컨트롤러도 동일한 패턴이다.

@Riverpod(keepAlive: true)
class EditorFontSizeController extends _$EditorFontSizeController {
  @override
  EditorFontSize build() {
    final prefs = ref.watch(sharedPrefsProvider).requireValue;
    final repo = SettingsRepository(prefs);
    return repo.load().editorFontSize;
  }

  Future<void> set(EditorFontSize size) async {
    final prefs = ref.read(sharedPrefsProvider).requireValue;
    final repo = SettingsRepository(prefs);
    await repo.setEditorFontSize(size);
    state = size;
  }
}

테마와 폰트 크기를 별도 컨트롤러로 분리한 이유가 있다. 하나의 컨트롤러에 합치면 테마를 변경할 때 폰트 크기를 구독하는 에디터까지 불필요하게 리빌드된다. 관심사별로 나누면 각각 필요한 곳에서만 watch할 수 있다.




테마를 앱에 연결하기

Material 3 테마 정의는 theme.dart에 분리했다.

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,
  );
}

ColorScheme.fromSeed는 하나의 seed color(indigo)로부터 라이트/다크 팔레트를 자동 생성한다. 개별 색상을 일일이 지정할 필요 없이 Material 3의 톤 시스템이 조화로운 배색을 잡아준다.

이 테마를 CrossPlatformNoteApp에서 themeModeControllerProvider와 연결한다.

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의 세 가지 파라미터다:

  • theme — 라이트 테마
  • darkTheme — 다크 테마
  • themeMode — 어떤 테마를 사용할지 결정 (system, light, dark)

ref.watch(themeModeControllerProvider)state 변경을 감지하면 MaterialApp 자체가 리빌드되면서 테마가 즉시 전환된다. 별도의 setState나 콜백 체인 없이 Riverpod의 단방향 데이터 흐름만으로 동작한다.

전체 흐름을 정리하면 이렇다:

사용자가 "다크" 선택
  → ThemeModeController.set(ThemeMode.dark)
    → SharedPreferences에 저장 (앱 재시작 시 유지)
    → state = ThemeMode.dark
      → MaterialApp 리빌드
        → darkTheme 적용
          → 모든 화면이 다크 모드로 전환




설정 화면 UI

설정 화면(SettingsScreen)은 ConsumerWidget으로 구현했다. 라우터에 /settings 경로로 등록되어 있고, 노트 목록 화면의 앱바에서 톱니바퀴 아이콘을 탭하면 진입한다.

class SettingsScreen extends ConsumerWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(themeModeControllerProvider);
    final fontSize = ref.watch(editorFontSizeControllerProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('설정')),
      body: ListView(
        children: [
          const _SectionHeader('테마'),
          RadioGroup<ThemeMode>(
            groupValue: themeMode,
            onChanged: (v) {
              if (v != null) {
                ref.read(themeModeControllerProvider.notifier).set(v);
              }
            },
            child: Column(
              children: ThemeMode.values
                  .map(
                    (m) => RadioListTile<ThemeMode>(
                      title: Text(_themeLabel(m)),
                      value: m,
                    ),
                  )
                  .toList(),
            ),
          ),
          const _SectionHeader('에디터 글자 크기'),
          RadioGroup<EditorFontSize>(
            groupValue: fontSize,
            onChanged: (v) {
              if (v != null) {
                ref.read(editorFontSizeControllerProvider.notifier).set(v);
              }
            },
            child: Column(
              children: EditorFontSize.values
                  .map(
                    (s) => RadioListTile<EditorFontSize>(
                      title: Text(_fontLabel(s)),
                      subtitle: Text('${s.points.toStringAsFixed(0)}pt'),
                      value: s,
                    ),
                  )
                  .toList(),
            ),
          ),
          // 미리보기 박스
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                border: Border.all(color: Theme.of(context).dividerColor),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '이 크기로 노트를 편집하게 됩니다.\n예시 두 번째 줄.',
                style: TextStyle(fontSize: fontSize.points),
              ),
            ),
          ),
          const _SectionHeader('정보'),
          const ListTile(
            title: Text('버전'),
            subtitle: Text('0.2.0'),
          ),
        ],
      ),
    );
  }
}

화면 구성은 세 섹션이다:

  1. 테마 — 시스템 설정 따름 / 라이트 / 다크를 RadioListTile로 선택
  2. 에디터 글자 크기 — 작게(14pt) / 보통(16pt) / 크게(18pt)를 선택하면, 바로 아래 미리보기 박스의 텍스트 크기가 실시간으로 변한다
  3. 정보 — 현재 버전 표시

RadioGroup 위젯에 groupValueonChanged를 넘기고, RadioListTilevalue만 가진다. Flutter 3.22부터 도입된 이 패턴 덕분에 각 RadioListTilegroupValueonChanged를 반복해서 넣지 않아도 된다.




CI 업그레이드 — actions/checkout@v5

2차 개발을 진행하면서 GitHub Actions CI 워크플로우도 함께 정비했다. actions/checkout을 v4에서 v5로, actions/setup-javav5로 올렸다.

steps:
  - uses: actions/checkout@v5

  - uses: subosito/flutter-action@v2
    with:
      flutter-version: '3.41.x'
      channel: stable
      cache: true

v5의 주요 변경은 Node.js 22 런타임으로의 업그레이드다. GitHub Actions가 Node.js 16/20 런타임의 지원을 단계적으로 종료하고 있어서, v4에 머물면 CI에서 경고가 발생한다. 기능상 차이는 크지 않지만, 경고 없이 깨끗한 빌드 로그를 유지하기 위해 올려뒀다.

CI는 총 5개 잡으로 구성되어 있다:

Job 실행 환경 내용
test ubuntu-latest 의존성 설치 → 코드 생성 → 정적 분석 → 유닛/위젯 테스트
build-android ubuntu-latest APK 빌드 (debug)
build-ios macos-latest iOS 빌드 (no codesign)
build-macos macos-latest macOS 빌드
build-windows windows-latest Windows 빌드

4개 빌드 잡은 test 잡에 의존(needs: test)한다. 테스트가 통과해야만 빌드가 시작되는 구조다.




통합 테스트 — 에디터 플로우 검증

유닛 테스트와 위젯 테스트 외에, 실제 앱을 띄워서 사용자 시나리오를 검증하는 integration test도 추가했다. integration_test/editors_flow_test.dart에 세 가지 시나리오가 들어있다.

마크다운 노트 생성과 미리보기 토글

testWidgets('markdown: create with preview toggle', (tester) async {
  final prefs = await SharedPreferences.getInstance();

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        sharedPrefsProvider.overrideWith((_) => Future.value(prefs)),
      ],
      child: CrossPlatformNoteApp(),
    ),
  );

  await waitFor(tester, find.text('아직 작성된 노트가 없습니다'));

  // 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, '마크다운 노트');
  await tester.enterText(find.byType(TextField).at(1), '# 제목\n**굵게**');

  // 미리보기 ↔ 편집 토글
  await tester.tap(find.text('미리보기'));
  await tester.pump(const Duration(milliseconds: 300));
  expect(find.text('편집'), findsOneWidget);

  // 저장 후 목록에서 마크다운 아이콘 확인
  await tester.tap(find.text('편집'));
  await tester.tap(find.byTooltip('저장'));
  await waitFor(tester, find.text('마크다운 노트'));
  expect(find.byIcon(Icons.code), findsOneWidget);
});

이 테스트는 마크다운 노트의 전체 생명주기를 검증한다 — 노트 타입 선택, 제목/본문 입력, 미리보기 토글, 저장, 목록 복귀.

체크리스트 노트 생성

testWidgets('checklist: create with items', (tester) async {
  // ... 앱 실행, 빈 목록 확인

  // 체크리스트 타입으로 노트 생성
  await tester.tap(find.byIcon(Icons.add));
  await tester.tap(find.text('체크리스트'));

  // 제목 입력 후 항목 2개 추가
  await tester.enterText(find.byType(TextField).first, '체크리스트 노트');
  await tester.tap(find.text('항목 추가'));
  await tester.tap(find.text('항목 추가'));
  expect(find.byType(Checkbox), findsNWidgets(2));

  // 저장 후 목록에서 체크리스트 아이콘 확인
  await tester.tap(find.byTooltip('저장'));
  await waitFor(tester, find.text('체크리스트 노트'));
  expect(find.byIcon(Icons.checklist), findsOneWidget);
});

검색 기능 검증

testWidgets('search: finds note by keyword', (tester) async {
  // ... 앱 실행

  // 노트 생성 (제목: '검색테스트', 본문: '본문내용')
  // ... 저장 후 목록 복귀

  // 검색 화면 진입 후 키워드 입력
  await tester.tap(find.byTooltip('검색'));
  await tester.enterText(find.byType(TextField).first, '검색테스트');
  // debounce 대기
  await pumpFor(tester, duration: const Duration(milliseconds: 500));

  // 결과 확인
  expect(
    find.descendant(
      of: find.byType(ListTile),
      matching: find.text('검색테스트'),
    ),
    findsOneWidget,
  );
});

테스트에서 주목할 점은 waitFor 헬퍼다. 통합 테스트에서는 실제 DB 연산과 비동기 처리가 일어나기 때문에, 특정 위젯이 화면에 나타날 때까지 짧은 간격으로 pump를 반복하는 방식으로 타이밍 문제를 해결한다. 검색 테스트에서는 디바운스(500ms)까지 고려해야 해서 pumpFor로 충분한 시간을 확보했다.




2차 개발 전체 정리

이번 2차 개발에서는 노트 앱을 실제로 쓸 수 있는 수준으로 끌어올렸다. 각 편에서 다룬 내용을 정리하면 다음과 같다.

주제 핵심 성과
#4 3종 에디터 일반 텍스트, 마크다운(단축키/미리보기), 체크리스트(드래그 정렬). DB v2 마이그레이션으로 NoteType 컬럼 추가
#5 검색 기능 SQLite FTS5 전문 검색. 제목+본문 통합 검색, 디바운스 적용
#6 테마/설정 다크/라이트 테마 전환, 에디터 폰트 크기 설정, SharedPreferences 영속화, CI v5 업그레이드

1차 개발(#1~#3)에서 “만들어진” 앱이 2차 개발(#4~#6)을 거치면서 “쓸 만한” 앱이 되었다.

  • 3종 에디터: 용도에 맞는 노트 타입을 고를 수 있다
  • 전문 검색: 노트가 쌓여도 빠르게 찾을 수 있다
  • 테마/설정: 사용 환경에 맞게 커스터마이징할 수 있다
  • 통합 테스트: 주요 플로우가 자동으로 검증된다

현재 앱 버전은 0.2.0이다.




다음은 — 동기화

여기까지의 앱은 완전한 오프라인 앱이다. 한 기기에서 쓰는 데에는 문제없지만, 처음에 이 앱을 만든 이유 — 맥, 윈도우, 갤럭시에서 동일한 노트를 보는 것 — 를 아직 해결하지 못했다.

다음 단계에서는 Google Drive를 이용한 동기화를 구현한다. 노트 데이터를 암호화해서 Google Drive에 저장하고, 다른 기기에서 복호화해서 가져오는 방식이다. OAuth 인증, 충돌 해결, 암호화 전략까지 다룰 예정이다.



이전: #5 검색 기능 — SQLite FTS5 → 다음: 동기화 편 (작성 예정)




마지막 수정