Programming/Dart & Flutter

플러터 앱에 flex_color_scheme 테마 설정하기 (Hive 연동)

찐공log 2024. 3. 5. 22:09

플러터 앱에 테마 배경을 다양한 색상으로 바꿔서 예쁘게 꾸며봅시다.

flex_color_scheme 패키지를 선택한 이유는 많은 사용자들이 사용 중이고 Flutter favorite으로 선정된 앱이기 때문입니다. 😀

 

 

다크모드도 설정할 수 있고, 여러 가지 테마 색상을 지원합니다.

내가 만든 앱을 이용하는 사용자에게 한 가지 고정된 배경 색상만 제공하기보다는 다양한 선택권을 주면 꾸미는 맛도 있고 기분이 좋겠죠? 🥹🥹

 

제가 현재 제작하고 있는 뽀모도로 타이머 앱은 다크모드만 필요하나, 하는 김에 테마까지 지원해 보기로 했습니다.

사용자가 테마 색상을 선택하면 바로 테마 색상이 반영되는 것과 다크모드 기능을 코드로 정리해 볼게요.

 

공식문서: https://pub.dev/packages/flex_color_scheme

 

flex_color_scheme | Flutter package

A Flutter package to use and make beautiful Material design based themes.

pub.dev

 

(시리즈를 의도한 것은 아니지만 이전 글에서 사용했던 프로젝트에 이어서 진행합니다.)

 

 

화면을 스포를 해보자면, 아래와 같은 모습입니다.😁

첫 번째 테마를 고르는 위젯을 선택하면 팝업메뉴가 나타나면서 색상을 선택할 수 있어요.

다크모드도 스위치를 이용해서 바로 변경이 됩니다.

완성된 모습

 

 

 

1. flex_color_scheme 설치

flutter pub add flex_color_scheme

 

현재 프로젝트에 추가된 dependencies들은 아래와 같습니다.

데이터베이스는 로컬 DB인 하이브를 추가해 주었어요. 인스톨은 동일하게 해 주시면 됩니다.

https://pub.dev/packages/hive

 

hive | Dart package

Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256.

pub.dev

하이브공식문서: https://docs.hivedb.dev/#/

 

Hive Docs

 

docs.hivedb.dev

 

사용자가 다크모드나 테마 색상을 설정한 값을 저장하려면 로컬 저장소에 저장하는 것이 빠르고 편리하거든요.

dependencies는 아래와 같습니다.

 

 

 

2. 프로젝트 폴더 구성

기본적으로 lib 폴더 하위에 아래와 같이 폴더들을 구성했어요.

구조를 처음부터 잘 정해놓아야 나중에 코드들이 중구난방으로 흩어지지 않아요.🤣

 

 

사용하지 않는 폴더들도 미리 만들어두었어요. 참고해 주세요.

 

* 간단 설명

const - 상수값들이 저장되어 있는 폴더

controllers - 구현한 컨트롤러를 모아둔 폴더

model - 데이터 모델

pages - 플러터에서 한 화면 전체를 의미하는 페이지인 위젯 모음

services - 추상화 서비스 클래스와 그를 구현하는 클래스의 모음

utils - 유틸리티 폴더.

widgets - 기타 위젯 모음

 

 

 

3.  main 함수

main.dart 파일 안의 main 함수는 이렇게 생겼습니다. 앞전에 파이어베이스와 다국어화를 작업한 코드들이 보이고요.

하이브를 사용하기 위해서 하이브 초기화가 필요합니다.

HiveService는 추상 클래스이며, 이를 구현하는 HiveServiceTheme는  HiveService를 구현한 클래스입니다. 

생성자에 테마 Box 이름(theme_box)을 넣어주고요. Hive에서는 보통 데이터베이스의 table과 비슷한 개념으로 Box라고 합니다.

그다음 init 함수를 호출하여 하이브 박스(theme_box) 오픈하거나 어댑터를 등록합니다.

그리고 ThemeController 객체를 만들면서 서비스 객체를 넘깁니다. ThemeController에서는 이제 서비스 객체를 이용하여 값을 가져오거나 저장할 수가 있어요.

 

컨트롤러의 loadAll 함수를 호출하면 기존 하이브 박스에 저장되어 있는 테마 데이터를 가져옵니다.

main 함수가 처음 실행되면서 사용자가 기존에 설정해 놓았던 테마 데이터를 불러와서 반영을 해주어야겠지요. 

또한 아무런 설정이 없는 상태라도 항상 테마 디폴트 값을 불러오도록 했습니다.

 

runApp 하위에는 실제 테마 선택하는 기능과 다크모드를 구현해 놓은 화면인 TimerApp 위젯이 있습니다.

import 'package:easy_localization/easy_localization.dart';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:pomotimer/services/hive_service.dart';
import 'package:pomotimer/services/hive_service_theme.dart';
import 'package:pomotimer/widgets/theme_popup_menu.dart';
import 'controllers/theme_controller.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  await EasyLocalization.ensureInitialized();
  //하이브 초기화
  await Hive.initFlutter();
  //하이브 서비스 클래스
  final HiveService hiveServiceTheme = HiveServiceTheme('theme_box');
  //박스 및 어댑더 등록 초기화 작업
  await hiveServiceTheme.init();
  //테마 컨트롤러 객체 생성
  final ThemeController themeController = ThemeController(hiveServiceTheme);
  //데이터 로드
  await themeController.loadAll();

  runApp(
    EasyLocalization(
      supportedLocales: const [Locale('en', 'US'), Locale('ko', 'KR')],
      path: 'assets/translations',
      fallbackLocale: const Locale('en', 'US'),
      child: TimerApp(themeController),
    ),
  );
}

 

 

 

4. HiveService 추상 클래스와 구현

1) HiveService abstract class

HiveService 클래스는 말 그대로 저장소 관련 서비스 클래스입니다.

추상 클래스로 선언한 이유는 테마뿐만 아니라 앞으로 다른 수많은 데이터들을 하이브에 저장할 것인데 그때마다 기능별로 확장하여 사용하면서 코드 관리에 용이하도록 하기 위함입니다.

구현해야 할 함수들은 몇 개 없어요. 초기화, 로드, 세이브 함수 셋입니다.

abstract class StorageService {
  // 초기화, 셋업 작업
  Future<void> init();

  //키를 이용하여 값 불러오기
  Future<T> load<T>(String key, T defaultValue);

  //키를 이용하여 값을 저장
  Future<void> save<T>(String key, T value);
}

 

 

 

2) HiveServiceTheme class

HiveService를 구현한 HiveServiceTheme 클래스입니다.

테마 박스 이름을 생성자로 받으며, init 함수 부분에서 openBox를 하고, 어댑터를 등록하는 부분이 있습니다. 

반드시 데이터를 가져오기 전에는 openBox를 해야  box 함수를 호출할 수 있어요.

import 'package:flutter/foundation.dart';
import 'package:pomotimer/services/hive_service_theme_adapters.dart';
import 'hive_service.dart';
import 'package:hive_flutter/hive_flutter.dart';

const bool _debug = !kReleaseMode && true;

class HiveServiceTheme implements HiveService {
  HiveServiceTheme(this.boxName);

  final String boxName;
  late final Box<dynamic> _hiveBox;

  @override
  Future<void> init() async {
    //하이브 객체 어뎁터 등록
    registerHiveAdapters();
    //하이브 박스에 접근하기전 반드시 openBox를 호출
    await Hive.openBox<dynamic>(boxName);
    _hiveBox = Hive.box<dynamic>(boxName);
  }

  //하이브 객체 어뎁터 등록
  void registerHiveAdapters() {
    Hive.registerAdapter(ThemeModeAdapter());
  }

  @override
  Future<T> load<T>(String key, T defaultValue) async {
    try {
      final T loaded = _hiveBox.get(key, defaultValue: defaultValue) as T;
      if (_debug) {
        debugPrint('Hive type   : $key as ${defaultValue.runtimeType}');
        debugPrint('Hive loaded : $key as $loaded with ${loaded.runtimeType}');
      }
      return loaded;
    } catch (e) {
      debugPrint('Hive load (get) ERROR');
      debugPrint(' Error message ...... : $e');
      debugPrint(' Store key .......... : $key');
      debugPrint(' defaultValue ....... : $defaultValue');

      return defaultValue;
    }
  }

  @override
  Future<void> save<T>(String key, T value) async {
    try {
      await _hiveBox.put(key, value);
      if (_debug) {
        debugPrint('Hive type   : $key as ${value.runtimeType}');
        debugPrint('Hive saved  : $key as $value');
      }
    } catch (e) {
      debugPrint('Hive save (put) ERROR');
      debugPrint(' Error message ...... : $e');
      debugPrint(' Store key .......... : $key');
      debugPrint(' Save value ......... : $value');
    }
  }
}

 

 

 

3) ThemeModeAdapter class

하이브에 enum을 저장하려면 어댑터 클래스가 필요합니다.

기본 자료형은 어댑터 클래스를 따로 작성할 필요가 없이 바로 저장이 되나 직접 만든 클래스나 enum과 같은 자료형은 어댑터를 등록해 주어야 해당 자료형을 저장할 수 있습니다.

어댑터를 명령어로 이용하여 자동으로 만들 수도 있지만 지금 ThemeMode enum은 flex_color_scheme에서 제공하는 enum이기 때문에 직접 작성해 줍니다.

import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

class ThemeModeAdapter extends TypeAdapter<ThemeMode> {
  @override
  ThemeMode read(BinaryReader reader) {
    final int index = reader.readInt();
    return ThemeMode.values[index];
  }

  @override
  void write(BinaryWriter writer, ThemeMode obj) {
    writer.writeInt(obj.index);
  }

  @override
  int get typeId => 150;
}

 

어댑터 클래스 작성법은 아래 링크를 참조해 주세요.

https://docs.hivedb.dev/#/custom-objects/type_adapters

 

Hive Docs

 

docs.hivedb.dev

 

 

 

5. Theme Controller 클래스

1) ThemeController class

사용자가 테마를 변경하면 화면이 즉시 변경이 되어야 하는데요.

새로운 테마값이 저장이 되면, 리스너를 이용하여 하위 트리에 변경을 알려서 위젯을 재빌드를 해야 합니다.

아래 TimerApp 위젯에서 리스너를 설정하는 부분을 확인하실 수 있습니다.

 

ThemeController를 listenable에 등록을 하기 위해서는 ChangeNotifier를 믹스인 하면 됩니다.

믹스인은 with라는 키워드를 이용하여 사용할 수 있어요.

https://dart.dev/language/mixins

 

Mixins

Learn how to add to features to a class in Dart.

dart.dev

 

또한 컨트롤러 내부의 _themeMode 변수와 _schemeIndex 변수는 late로 지정해 주었는데, 이유는 loadAll을 호출하면 하이브에서 데이터를 가져와서 초기화를 하기 때문에 늦은 초기화가 필요해서 설정해 주었습니다.

각 변수들의 get과 set함수를 정의한 부분이 있습니다.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pomotimer/services/theme_service.dart';
import '../const/store.dart';

class ThemeController with ChangeNotifier {
  ThemeController(this._themeService);

  final ThemeService _themeService;
  //하이브에서 기존 테마 관련 값을 로드
  Future<void> loadAll() async {
    _themeMode =
        await _themeService.load(Store.keyThemeMode, Store.defaultThemeMode);
    _schemeIndex = await _themeService.load(
        Store.keySchemeIndex, Store.defaultSchemeIndex);
  }
  //테마모드
  late ThemeMode _themeMode;
  ThemeMode get themeMode => _themeMode;
  //notify는 디폴트값으로 true를 가짐
  void setThemeMode(ThemeMode? value, [bool notify = true]) {
    if (value == null) return;
    if (value == _themeMode) return;
    _themeMode = value;
    //모든 리스너들에게 알림
    if (notify) notifyListeners();
    //하이브에 저장
    unawaited(_themeService.save(Store.keyThemeMode, value));
  }
  //테마 색상 인덱스
  late int _schemeIndex;
  int get schemeIndex => _schemeIndex;
  void setSchemeIndex(int? value, [bool notify = true]) {
    if (value == null) return;
    if (value == _schemeIndex) return;
    _schemeIndex = value;
    if (notify) notifyListeners();
    unawaited(_themeService.save(Store.keySchemeIndex, value));
  }
}

 

 

 

2) Store class

하이브에는 데이터를 키, 밸류의 형식인 map으로 저장이 되는데 key값은 Store 클래스를 따로 만들어서 이곳에서 모든 키 값을 관리합니다.

Store 클래스는 static 변수로만 구성되어 있는 키 값만 가져다 쓰는 클래스인데 인스턴스화를 할 일이 없지요.

아래와 같이 프라이빗 생성자를 지정하면 직접적인 인스턴스화를 막을 수 있어요.

import 'package:flutter/material.dart';

class Store {
  //프라이빗 생성자
  Store._();
  static const String keyThemeMode = 'themeMode';
  static const ThemeMode defaultThemeMode = ThemeMode.system;
  static const String keySchemeIndex = 'schemeIndex';
  static const int defaultSchemeIndex = 40;
}

 

 

 

6. TimerApp 위젯

1) TimerApp Widget

실제 테마 변경 팝업메뉴와 다크모드를 설정하는 화면이고요. 이 코드는 메인 함수 바로 아래에 이어서 작성되어 있어요.

이전에 main 함수에서 TimerApp에 themeController를 넘겨받았습니다.

이 컨트롤러를 이용하여 테마 관련 값을 데이터베이스에서 가져오거나 저장합니다.

 

또한 가장 중요한 부분인 ListenableBuilder가 감싸는 모든 하위 트리에서 themeController의 내부변수에 변경이 일어나면 builder의 함수가 다시 실행됩니다. 이로 인해 바로 테마나, 다크모드가 변경되는 것이고요.

https://api.flutter.dev/flutter/widgets/ListenableBuilder-class.html

 

ListenableBuilder class - widgets library - Dart API

A general-purpose widget for building a widget subtree when a Listenable changes. ListenableBuilder is useful for more complex widgets that wish to listen to changes in other objects as part of a larger build function. To use ListenableBuilder, construct t

api.flutter.dev

 

다크모드는 정말 간단하게 구현이 가능한데요.

SwitchListTile를 이용하여 토글이 true라면 다크모드, false이면 light 모드입니다.

darkModeToggled의 초기화는 컨트롤러에서 가져온 값을 이용하여 넣어주었고요.

토글 하면서 실제로 값을 변경하는 부분은 컨트롤러의 setThemeMode 함수가 담당하고 있습니다.

class TimerApp extends StatelessWidget {
  const TimerApp(this.themeController, {super.key});
  final ThemeController themeController;

  @override
  Widget build(BuildContext context) {
    bool darkModeToggled = themeController.themeMode == ThemeMode.dark;

    return ListenableBuilder(
        listenable: themeController,
        builder: (BuildContext context, Widget? child) {
          return MaterialApp(
            debugShowCheckedModeBanner: false,
            localizationsDelegates: context.localizationDelegates,
            supportedLocales: context.supportedLocales,
            locale: context.locale,
            //테마모드설정(다크, 라이트)
            themeMode: themeController.themeMode,
            //기본테마설정
            theme: FlexThemeData.light(
              blendLevel: 2,
              appBarElevation: 0.5,
              colors: FlexColor.schemesList[themeController.schemeIndex].light,
            ),
            //다크테마 디테일설정
            darkTheme: FlexThemeData.dark(
              blendLevel: 7,
              appBarElevation: 0.5,
              colors: FlexColor.schemesList[themeController.schemeIndex].dark,
            ),
            home: Row(
              children: [
                const SizedBox(width: 0.01), //트릭!
                Expanded(
                  child: Scaffold(
                    appBar: AppBar(
                      title: const Text('title').tr(),
                    ),
                    body: Center(
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          //테마 선택 팝업메뉴
                          ThemePopupMenu(
                            schemeIndex: themeController.schemeIndex,
                            onChanged: themeController.setSchemeIndex,
                          ),
                          //다크모드 선택 위젯 
                          SwitchListTile(
                            title: const Text('Dark Theme mode'),
                            value: darkModeToggled,
                            onChanged: (value) {
                              darkModeToggled = value;
                              if (darkModeToggled) {
                                themeController.setThemeMode(ThemeMode.dark);
                              } else {
                                themeController.setThemeMode(ThemeMode.light);
                              }
                            },
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          );
        });
  }
}

 

 

한 가지 팁이라면, flex_color_scheme 패키지를 살펴보다 알아낸 사실인데요.

PopupMenu는 원래 기본적으로 왼쪽으로 위치가 됩니다. 그러나 팝업상자가 오른쪽으로 나오도록 하는 트릭이 있어요.

위와 같이 Scaffold를 Expand로 감싼 후 Row위젯에 SizedBox를 0에 가깝게 너비를 줍니다.

그다음에 Expand를 위치시키면 팝업 메뉴가 오른쪽으로 배치되는 신기한 일이 일어납니다.🤣 

 

팝업상자는 본래 좌측에 위치하나, 트릭을 이용하여 우측으로 이동

 

 

 

2) ThemePopupMenu Widget

테마팝업메뉴를 클릭하면 팝업화면이 보이면서 여러 가지 테마 색상이 보입니다.

flex_color_scheme에서 제공하는 색상을 그대로 사용하고요.

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';


class ThemePopupMenu extends StatelessWidget {
  const ThemePopupMenu({
    super.key,
    required this.schemeIndex,
    required this.onChanged,
    this.contentPadding,
  });
  final int schemeIndex;
  final ValueChanged<int> onChanged;
  final EdgeInsetsGeometry? contentPadding;
   
  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final bool isLight = theme.brightness == Brightness.light;
    final ColorScheme colorScheme = theme.colorScheme;

    return PopupMenuButton<int>(
      tooltip: '',
      padding: EdgeInsets.zero,
      onSelected: onChanged,
      itemBuilder: (BuildContext context) => <PopupMenuItem<int>>[
        for (int i = 0; i < FlexColor.schemesList.length; i++)
          PopupMenuItem<int>(
            value: i,
            child: ListTile(
              dense: true,
              leading: Icon(Icons.lens,
                  color: isLight
                      ? FlexColor.schemesList[i].light.primary
                      : FlexColor.schemesList[i].dark.primary,
                  size: 35),
              title: Text(FlexColor.schemesList[i].name),
            ),
          )
      ],
      child: ListTile(
        contentPadding:
            contentPadding ?? const EdgeInsets.symmetric(horizontal: 16),
        title: Text(
          '${FlexColor.schemesList[schemeIndex].name} color scheme',
        ),
        subtitle: Text(FlexColor.schemesList[schemeIndex].description),
        trailing: Icon(
          Icons.lens,
          color: colorScheme.primary,
          size: 40,
        ),
      ),
    );
  }
}

 

 

 

여기까지 코드를 작성한다면 코드 파일은 아래처럼 위치하게 됩니다.

firebase_options.dart 파일은 이전에 파이어베이스 설정하면서 생성된 파일이에요.

 

 

 

7. 구현 모습

시뮬레이터로 돌려보니 기능이 잘 동작하네요.🥹

사진보다는 영상이 직관적이어서 영상으로 올려봤어요.

테마 설정과 다크모드 기능 완료!