Dogly - a mobile app with Flutter: dark mode

3 minute read

Introduction

The last post was super long, so this one will be quick and simple. I will implement the settings screen with an option to enable or disable dark mode. I do not have planned any other setting feature for now, but having this view will be useful for implementing them in the future. To persist the chosen value I will use the shared_preferences - a plugin to store simple key-value pairs. I could use Isar DB to store the settings also but I would like to try the most commonly used solution.

Let’s get to work!

Data layer

The first thing to do is create the provider for shared preferences in the core. It is the same solution as for the Isar DB in the previous post. It throws the unimplemented error, but it is overridden before running the application.

// ./lib/core/providers.dart
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
  throw UnimplementedError();
});
// ./lib/main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final sharedPreferences = await SharedPreferences.getInstance();
  final appDir = await getApplicationDocumentsDirectory();
  final isar = await Isar.open(
      [DogModelSchema,
      directory: appDir.path);

  runApp(
    ProviderScope(
      overrides: [  
	    sharedPreferencesProvider.overrideWithValue(sharedPreferences),
        appDirProvider.overrideWithValue(appDir),
        isarInstanceProvider.overrideWithValue(isar),
      ],
      child: const MyApp(),
    ),
  );
}

The API interface is simple, it has just two methods for setting and getting the boolean value.

abstract class ISettingsAPI {
  Future<void> setDarkMode(bool mode);
  bool getDarkMode();
}

class SettingsAPI implements ISettingsAPI {
  final SharedPreferences _sharedPreferences;
  SettingsAPI({required SharedPreferences sharedPreferences})
      : _sharedPreferences = sharedPreferences;

  @override
  Future<void> setDarkMode(bool mode) async {
    await _sharedPreferences.setBool('darkMode', mode);
  }

  @override
  bool getDarkMode() {
    return _sharedPreferences.getBool('darkMode') ?? false;
  }
}

The plugin returns null if the key does not exist. I won’t use it in such a way obviously, but Linter won’t let the code work without handling it. The last thing to create is the provider:

final settingsAPIProvider = Provider((ref) {
  return SettingsAPI(sharedPreferences: ref.watch(sharedPreferencesProvider));
});

Presentation layer

The state notifier controller holds the boolean value where false means that dark mode is not enabled. It has just one toggle method for changing this state.

final darkModeProvider = StateNotifierProvider<DarkModeNotifier, bool>((ref) {
  return DarkModeNotifier(settingsAPI: ref.watch(settingsAPIProvider));
});

class DarkModeNotifier extends StateNotifier<bool> {
  final SettingsAPI _settingsAPI;

  DarkModeNotifier({required SettingsAPI settingsAPI})
      : _settingsAPI = settingsAPI,
        super(false) {
    state = _settingsAPI.getDarkMode();
  }

  void toggle() {
    state = !state;
    _settingsAPI.setDarkMode(state);
  }
}

If there were more options, I would create a separate controller for each.

The settings view consumes the value of the previously created provider and shows the toggle button enabled or disabled based on the obtained value. Clicking it triggers the method from the controller.

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final darkMode = ref.watch(darkModeProvider);
    return Scaffold(
        appBar: AppBar(
          title: const Text('Settings 🛠️'),
          centerTitle: true,
        ),
        body: SettingsList(
          sections: [
            SettingsSection(
              title: const Text('Appearance'),
              tiles: [
                SettingsTile.switchTile(
                  title: const Text('Toggle dark mode'),
                  leading: const FaIcon(FontAwesomeIcons.circleHalfStroke),
                  initialValue: darkMode,
                  onToggle: (val) {
                    ref.read(darkModeProvider.notifier).toggle();
                  },
                ),
              ],
            )
          ],
        ));
  }
}

It looks like this in the app:

dark_mode_disabled

dark_mode_enabled

The app also needs to know what mode should be used at the start. Luckily the whole app is also a widget and it just can be the Riverpod consumer.

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final darkMode = ref.watch(darkModeProvider);

    return MaterialApp(
      title: 'Dogly',
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: darkMode ? ThemeMode.dark : ThemeMode.light,
      home: const HomeView(),
    );
  }
}

The dark theme is just the dark-brightness version of the Material 3 amber color scheme.

final ThemeData darkTheme = ThemeData(
  useMaterial3: true,
  colorSchemeSeed: Colors.amber,
  brightness: Brightness.dark,
);

The home page created in the previous post looks like this in the dark mode:

you_dogs_dark

Conclusion

To be honest, the dark mode is must must-have feature in every service I use, so I am happy it is easy-peasy to implement it in Flutter using shared preferences and Riverpod. In the next post, I will create an articles section of the application. Cya there!

References

  1. https://www.matijanovosel.com/blog/dark-mode-in-flutter-using-riverpod