mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:flutter/material.dart';
2import 'package:flutter_riverpod/flutter_riverpod.dart';
3import 'package:flutter_test/flutter_test.dart';
4import 'package:lazurite/src/app/theme_controller.dart';
5import 'package:lazurite/src/app/theming/packs/oxocarbon_theme_pack.dart';
6import 'package:lazurite/src/app/theming/theme_variant.dart';
7import 'package:lazurite/src/infrastructure/db/daos/local_settings_dao.dart';
8import 'package:mocktail/mocktail.dart';
9
10class MockLocalSettingsDao extends Mock implements LocalSettingsDao {}
11
12/// Test-specific ThemeController that avoids ThemeFactory.buildThemeData()
13/// which triggers Google Fonts loading requiring TestWidgetsFlutterBinding.
14///
15/// This version also skips async loading to avoid Riverpod lifecycle issues.
16class TestThemeController extends ThemeController {
17 @override
18 ThemeState build() => _buildTestState(ThemeMode.dark, 'oxocarbon', false);
19
20 @override
21 Future<void> setThemeMode(ThemeMode mode) async {
22 state = _buildTestState(mode, state.currentPackId, state.dynamicColorEnabled);
23 await ref.read(localSettingsDaoProvider).set(ThemeSettingsKeys.themeMode, mode.name);
24 }
25
26 @override
27 Future<void> setThemePack(String packId) async {
28 final resolved = _resolvePackId(packId);
29 if (resolved == state.currentPackId) return;
30 state = _buildTestState(state.themeMode, resolved, state.dynamicColorEnabled);
31 await ref.read(localSettingsDaoProvider).set(ThemeSettingsKeys.themePackId, resolved);
32 }
33
34 @override
35 ThemeData buildThemeData(ThemeVariant variant) {
36 return ThemeData(
37 useMaterial3: true,
38 brightness: variant.brightness,
39 primaryColor: variant.brightness == Brightness.dark ? Colors.red : Colors.blue,
40 );
41 }
42
43 @override
44 ThemeData buildThemeDataFromScheme(ColorScheme scheme) {
45 return ThemeData(
46 useMaterial3: true,
47 brightness: scheme.brightness,
48 colorScheme: scheme,
49 primaryColor: Colors.green,
50 );
51 }
52
53 ThemeState _buildTestState(ThemeMode mode, String packId, bool dynamicColorEnabled) =>
54 ThemeState(
55 themeMode: mode,
56 currentPackId: packId,
57 dynamicColorEnabled: dynamicColorEnabled,
58 lightTheme: ThemeData.light(useMaterial3: true),
59 darkTheme: ThemeData.dark(useMaterial3: true),
60 );
61
62 String _resolvePackId(String? packId) {
63 if (packId == null) return 'oxocarbon';
64 final packs = ref.read(availableThemePacksProvider);
65 return packs.where((p) => p.id == packId).firstOrNull?.id ?? 'oxocarbon';
66 }
67}
68
69void main() {
70 late MockLocalSettingsDao mockDao;
71 late ProviderContainer container;
72
73 setUp(() {
74 mockDao = MockLocalSettingsDao();
75 when(() => mockDao.get(any())).thenAnswer((_) async => null);
76 when(() => mockDao.set(any(), any())).thenAnswer((_) async {});
77 });
78
79 tearDown(() {
80 container.dispose();
81 });
82
83 ProviderContainer createContainer() {
84 return ProviderContainer(
85 overrides: [
86 localSettingsDaoProvider.overrideWithValue(mockDao),
87 availableThemePacksProvider.overrideWithValue([oxocarbonPack]),
88 themeControllerProvider.overrideWith(TestThemeController.new),
89 ],
90 );
91 }
92
93 group('ThemeController', () {
94 test('defaults to oxocarbon pack and dark mode', () {
95 container = createContainer();
96
97 final state = container.read(themeControllerProvider);
98
99 expect(state.themeMode, ThemeMode.dark);
100 expect(state.currentPackId, 'oxocarbon');
101 expect(state.dynamicColorEnabled, false);
102 expect(state.lightTheme, isA<ThemeData>());
103 expect(state.darkTheme, isA<ThemeData>());
104 });
105
106 test('setThemeMode updates state', () async {
107 container = createContainer();
108 final notifier = container.read(themeControllerProvider.notifier);
109
110 await notifier.setThemeMode(ThemeMode.light);
111
112 expect(container.read(themeControllerProvider).themeMode, ThemeMode.light);
113 });
114
115 test('setThemeMode persists to database', () async {
116 container = createContainer();
117 final notifier = container.read(themeControllerProvider.notifier);
118
119 await notifier.setThemeMode(ThemeMode.light);
120
121 verify(() => mockDao.set(ThemeSettingsKeys.themeMode, 'light')).called(1);
122 });
123
124 test('setThemeMode to system mode persists correctly', () async {
125 container = createContainer();
126 final notifier = container.read(themeControllerProvider.notifier);
127
128 await notifier.setThemeMode(ThemeMode.system);
129
130 expect(container.read(themeControllerProvider).themeMode, ThemeMode.system);
131 verify(() => mockDao.set(ThemeSettingsKeys.themeMode, 'system')).called(1);
132 });
133
134 test('setThemePack with same pack is no-op', () async {
135 container = createContainer();
136 final notifier = container.read(themeControllerProvider.notifier);
137
138 await notifier.setThemePack('oxocarbon');
139
140 expect(container.read(themeControllerProvider).currentPackId, 'oxocarbon');
141 verifyNever(() => mockDao.set(ThemeSettingsKeys.themePackId, any()));
142 });
143
144 test('setThemePack falls back to default for unknown pack', () async {
145 container = createContainer();
146 final notifier = container.read(themeControllerProvider.notifier);
147
148 await notifier.setThemePack('nonexistent');
149
150 expect(container.read(themeControllerProvider).currentPackId, 'oxocarbon');
151 });
152
153 test('toggle switches between light and dark modes', () async {
154 container = createContainer();
155 final notifier = container.read(themeControllerProvider.notifier);
156
157 notifier.toggle();
158 await Future<void>.delayed(const Duration(milliseconds: 10));
159 expect(container.read(themeControllerProvider).themeMode, ThemeMode.light);
160
161 notifier.toggle();
162 await Future<void>.delayed(const Duration(milliseconds: 10));
163 expect(container.read(themeControllerProvider).themeMode, ThemeMode.dark);
164 });
165
166 test('toggle persists mode changes', () async {
167 container = createContainer();
168 final notifier = container.read(themeControllerProvider.notifier);
169
170 notifier.toggle();
171 await Future<void>.delayed(const Duration(milliseconds: 10));
172
173 verify(() => mockDao.set(ThemeSettingsKeys.themeMode, 'light')).called(1);
174 });
175
176 group('Dynamic Colors', () {
177 test('toggleDynamicColor updates state and persists', () async {
178 container = createContainer();
179 final notifier = container.read(themeControllerProvider.notifier);
180
181 await notifier.toggleDynamicColor();
182
183 expect(container.read(themeControllerProvider).dynamicColorEnabled, true);
184 verify(() => mockDao.set(ThemeSettingsKeys.dynamicColorEnabled, 'true')).called(1);
185
186 await notifier.toggleDynamicColor();
187
188 expect(container.read(themeControllerProvider).dynamicColorEnabled, false);
189 verify(() => mockDao.set(ThemeSettingsKeys.dynamicColorEnabled, 'false')).called(1);
190 });
191
192 test('uses dynamic colors when enabled and schemes available', () async {
193 container = createContainer();
194 final notifier = container.read(themeControllerProvider.notifier);
195
196 await notifier.toggleDynamicColor();
197
198 final lightScheme = ColorScheme.fromSeed(
199 seedColor: Colors.purple,
200 brightness: Brightness.light,
201 );
202 final darkScheme = ColorScheme.fromSeed(
203 seedColor: Colors.purple,
204 brightness: Brightness.dark,
205 );
206
207 notifier.setSystemSchemes(lightScheme, darkScheme);
208
209 final state = container.read(themeControllerProvider);
210 expect(
211 state.lightTheme.primaryColor,
212 Colors.green,
213 reason: 'Should use buildThemeDataFromScheme',
214 );
215 expect(state.darkTheme.primaryColor, Colors.green);
216 });
217
218 test('falls back to pack colors if schemes unavailable', () async {
219 container = createContainer();
220 final notifier = container.read(themeControllerProvider.notifier);
221
222 await notifier.toggleDynamicColor();
223
224 final state = container.read(themeControllerProvider);
225 expect(state.darkTheme.primaryColor, Colors.red);
226 });
227
228 test('falls back to pack colors if dynamic color disabled', () async {
229 container = createContainer();
230 final notifier = container.read(themeControllerProvider.notifier);
231
232 final lightScheme = ColorScheme.fromSeed(
233 seedColor: Colors.purple,
234 brightness: Brightness.light,
235 );
236 final darkScheme = ColorScheme.fromSeed(
237 seedColor: Colors.purple,
238 brightness: Brightness.dark,
239 );
240 notifier.setSystemSchemes(lightScheme, darkScheme);
241
242 final state = container.read(themeControllerProvider);
243 expect(state.dynamicColorEnabled, false);
244 expect(state.lightTheme.primaryColor, isNot(Colors.green));
245 });
246 });
247 });
248
249 group('ThemeState', () {
250 test('copyWith creates modified copy', () {
251 final state = ThemeState(
252 themeMode: ThemeMode.dark,
253 currentPackId: 'oxocarbon',
254 lightTheme: ThemeData.light(),
255 darkTheme: ThemeData.dark(),
256 );
257
258 final copied = state.copyWith(themeMode: ThemeMode.light);
259
260 expect(copied.themeMode, ThemeMode.light);
261 expect(copied.currentPackId, 'oxocarbon');
262 });
263
264 test('copyWith preserves unchanged values', () {
265 final lightTheme = ThemeData.light();
266 final darkTheme = ThemeData.dark();
267 final state = ThemeState(
268 themeMode: ThemeMode.dark,
269 currentPackId: 'oxocarbon',
270 lightTheme: lightTheme,
271 darkTheme: darkTheme,
272 );
273
274 final copied = state.copyWith(themeMode: ThemeMode.light);
275
276 expect(copied.lightTheme, same(lightTheme));
277 expect(copied.darkTheme, same(darkTheme));
278 });
279 });
280
281 group('ThemeSettingsKeys', () {
282 test('has correct key values', () {
283 expect(ThemeSettingsKeys.themeMode, 'themeMode');
284 expect(ThemeSettingsKeys.themePackId, 'themePackId');
285 expect(ThemeSettingsKeys.dynamicColorEnabled, 'dynamicColorEnabled');
286 });
287 });
288}