+17
-3
lib/src/features/settings/presentation/screens/theme_editor_screen.dart
+17
-3
lib/src/features/settings/presentation/screens/theme_editor_screen.dart
···
24
24
25
25
class _ThemeEditorScreenState extends ConsumerState<ThemeEditorScreen> {
26
26
late String _name;
27
+
late TextEditingController _nameController;
27
28
late String _basePackId;
28
29
late ThemeRoleOverrides _overrides;
29
30
late TypographyScale _typographyScale;
···
34
35
@override
35
36
void initState() {
36
37
super.initState();
38
+
_nameController = TextEditingController();
37
39
_loadOrCreateDraft();
38
40
}
39
41
42
+
@override
43
+
void dispose() {
44
+
_nameController.dispose();
45
+
super.dispose();
46
+
}
47
+
40
48
Future<void> _loadOrCreateDraft() async {
49
+
await Future.microtask(() {});
41
50
if (widget.customThemeId != null) {
42
51
final repo = ref.read(customThemeRepositoryProvider);
43
52
final existing = await repo.getById(widget.customThemeId!);
···
45
54
setState(() {
46
55
_existingId = existing.id;
47
56
_name = existing.name;
57
+
_nameController.text = _name;
48
58
_basePackId = existing.basePackId;
49
59
_overrides = existing.overrides;
50
60
_typographyScale = existing.typographyScale;
···
57
67
final themeState = ref.read(themeControllerProvider);
58
68
setState(() {
59
69
_name = 'My Custom Theme';
70
+
_nameController.text = _name;
60
71
_basePackId = themeState.currentPackId;
61
72
_overrides = ThemeRoleOverrides.empty;
62
73
_typographyScale = TypographyScale.normal;
···
168
179
labelText: 'Theme Name',
169
180
border: OutlineInputBorder(),
170
181
),
171
-
controller: TextEditingController(text: _name),
182
+
183
+
controller: _nameController,
172
184
onChanged: (value) {
173
-
_name = value;
174
-
_isDirty = true;
185
+
setState(() {
186
+
_name = value;
187
+
_isDirty = true;
188
+
});
175
189
},
176
190
),
177
191
),
+182
test/src/features/search/presentation/search_screen_test.dart
+182
test/src/features/search/presentation/search_screen_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:flutter_test/flutter_test.dart';
4
+
import 'package:lazurite/src/core/domain/post.dart';
5
+
import 'package:lazurite/src/core/utils/pagination.dart';
6
+
import 'package:lazurite/src/features/search/application/search_providers.dart';
7
+
import 'package:lazurite/src/features/search/infrastructure/search_repository.dart';
8
+
import 'package:lazurite/src/features/search/presentation/search_screen.dart';
9
+
import 'package:lazurite/src/features/search/presentation/widgets/search_bar_widget.dart';
10
+
import 'package:lazurite/src/infrastructure/db/app_database.dart' hide Post;
11
+
import 'package:mocktail/mocktail.dart';
12
+
13
+
class MockSearchRepository extends Mock implements SearchRepository {}
14
+
15
+
class TestApp extends StatelessWidget {
16
+
const TestApp({required this.home, super.key});
17
+
18
+
final Widget home;
19
+
20
+
@override
21
+
Widget build(BuildContext context) {
22
+
return MaterialApp(home: home);
23
+
}
24
+
}
25
+
26
+
void main() {
27
+
late MockSearchRepository mockRepository;
28
+
29
+
setUp(() {
30
+
mockRepository = MockSearchRepository();
31
+
32
+
when(() => mockRepository.watchRecentSearches()).thenAnswer((_) => Stream.value([]));
33
+
when(() => mockRepository.saveRecentSearch(any())).thenAnswer((_) async {});
34
+
});
35
+
36
+
Widget createSubject({String? initialQuery}) {
37
+
return ProviderScope(
38
+
overrides: [searchRepositoryProvider.overrideWithValue(mockRepository)],
39
+
child: TestApp(home: SearchScreen(initialQuery: initialQuery)),
40
+
);
41
+
}
42
+
43
+
group('SearchScreen', () {
44
+
testWidgets('shows recent searches when query is empty', (tester) async {
45
+
when(() => mockRepository.watchRecentSearches()).thenAnswer(
46
+
(_) => Stream.value([
47
+
RecentSearche(id: 1, query: 'flutter', searchedAt: DateTime.now()),
48
+
RecentSearche(id: 2, query: 'dart', searchedAt: DateTime.now()),
49
+
]),
50
+
);
51
+
52
+
await tester.pumpWidget(createSubject());
53
+
await tester.pumpAndSettle();
54
+
55
+
expect(find.text('Recent Searches'), findsOneWidget);
56
+
expect(find.text('flutter'), findsOneWidget);
57
+
expect(find.text('dart'), findsOneWidget);
58
+
expect(find.byType(SearchBarWidget), findsOneWidget);
59
+
});
60
+
61
+
testWidgets('shows empty state when no recent searches', (tester) async {
62
+
when(() => mockRepository.watchRecentSearches()).thenAnswer((_) => Stream.value([]));
63
+
64
+
await tester.pumpWidget(createSubject());
65
+
await tester.pumpAndSettle();
66
+
67
+
expect(find.text('Search for posts and people'), findsOneWidget);
68
+
expect(find.text('Type a query to find posts or people'), findsOneWidget);
69
+
});
70
+
71
+
testWidgets('performs search and shows results', (tester) async {
72
+
const query = 'bluesky';
73
+
final posts = [
74
+
Post(
75
+
uri: 'at://did:plc:123/app.bsky.feed.post/1',
76
+
cid: 'cid1',
77
+
author: const Author(did: 'did:plc:123', handle: 'alice.test', displayName: 'Alice'),
78
+
record: {},
79
+
text: 'Hello Bluesky!',
80
+
indexedAt: DateTime.now(),
81
+
),
82
+
];
83
+
final result = PaginatedResult(items: posts, cursor: null);
84
+
85
+
when(() => mockRepository.searchPosts(query, cursor: null)).thenAnswer((_) async => result);
86
+
when(
87
+
() => mockRepository.searchActors(query, cursor: null),
88
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
89
+
90
+
await tester.pumpWidget(createSubject());
91
+
await tester.pumpAndSettle();
92
+
93
+
await tester.enterText(find.byType(TextField), query);
94
+
await tester.testTextInput.receiveAction(TextInputAction.search);
95
+
await tester.pumpAndSettle();
96
+
97
+
expect(find.text('Posts'), findsOneWidget);
98
+
expect(find.text('People'), findsOneWidget);
99
+
100
+
expect(find.text('Hello Bluesky!'), findsOneWidget);
101
+
expect(find.text('Alice'), findsOneWidget);
102
+
103
+
verify(() => mockRepository.searchPosts(query, cursor: null)).called(1);
104
+
});
105
+
106
+
testWidgets('initial query populates search bar and results', (tester) async {
107
+
const query = 'initial';
108
+
when(
109
+
() => mockRepository.searchPosts(query, cursor: null),
110
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
111
+
when(
112
+
() => mockRepository.searchActors(query, cursor: null),
113
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
114
+
115
+
await tester.pumpWidget(createSubject(initialQuery: query));
116
+
await tester.pumpAndSettle();
117
+
118
+
expect(find.text(query), findsOneWidget);
119
+
expect(find.byType(TabBar), findsOneWidget);
120
+
});
121
+
122
+
testWidgets('clearing search returns to recent searches', (tester) async {
123
+
const query = 'test';
124
+
when(
125
+
() => mockRepository.searchPosts(query, cursor: null),
126
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
127
+
when(
128
+
() => mockRepository.searchActors(query, cursor: null),
129
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
130
+
131
+
await tester.pumpWidget(createSubject(initialQuery: query));
132
+
await tester.pumpAndSettle();
133
+
134
+
expect(find.byIcon(Icons.clear), findsOneWidget);
135
+
await tester.tap(find.byIcon(Icons.clear));
136
+
await tester.pumpAndSettle();
137
+
138
+
expect(find.text('Search for posts and people'), findsOneWidget);
139
+
expect(find.byType(TabBar), findsNothing);
140
+
});
141
+
142
+
testWidgets('switches between Posts and People tabs', (tester) async {
143
+
const query = 'test';
144
+
when(
145
+
() => mockRepository.searchPosts(query, cursor: null),
146
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
147
+
when(
148
+
() => mockRepository.searchActors(query, cursor: null),
149
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
150
+
151
+
await tester.pumpWidget(createSubject(initialQuery: query));
152
+
await tester.pumpAndSettle();
153
+
154
+
expect(find.text('No posts found'), findsOneWidget);
155
+
156
+
await tester.tap(find.text('People'));
157
+
await tester.pumpAndSettle();
158
+
159
+
expect(find.text('No people found'), findsOneWidget);
160
+
});
161
+
162
+
testWidgets('retry on error', (tester) async {
163
+
const query = 'error';
164
+
when(() => mockRepository.searchPosts(query, cursor: null)).thenThrow('Network Error');
165
+
when(
166
+
() => mockRepository.searchActors(query, cursor: null),
167
+
).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null));
168
+
169
+
await tester.pumpWidget(createSubject(initialQuery: query));
170
+
await tester.pumpAndSettle();
171
+
172
+
expect(find.text('Error: Network Error'), findsOneWidget);
173
+
expect(find.text('Retry'), findsOneWidget);
174
+
175
+
await tester.tap(find.text('Retry'));
176
+
await tester.pump(); // Start retry
177
+
await tester.pump(const Duration(milliseconds: 100));
178
+
179
+
verify(() => mockRepository.searchPosts(query, cursor: null)).called(greaterThan(1));
180
+
});
181
+
});
182
+
}
+230
test/src/features/settings/presentation/screens/theme_editor_screen_test.dart
+230
test/src/features/settings/presentation/screens/theme_editor_screen_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:flutter_test/flutter_test.dart';
4
+
import 'package:go_router/go_router.dart';
5
+
import 'package:lazurite/src/app/providers.dart';
6
+
import 'package:lazurite/src/app/theme_controller.dart';
7
+
import 'package:lazurite/src/app/theming/custom_theme_draft.dart';
8
+
import 'package:lazurite/src/app/theming/theme_pack.dart';
9
+
import 'package:lazurite/src/app/theming/theme_spec.dart';
10
+
import 'package:lazurite/src/app/theming/theme_variant.dart';
11
+
import 'package:lazurite/src/features/settings/presentation/screens/theme_editor_screen.dart';
12
+
import 'package:lazurite/src/features/settings/presentation/widgets/color_role_picker.dart';
13
+
import 'package:lazurite/src/infrastructure/db/daos/local_settings_dao.dart';
14
+
import 'package:lazurite/src/infrastructure/theming/custom_theme_repository.dart';
15
+
import 'package:mocktail/mocktail.dart';
16
+
17
+
class MockCustomThemeRepository extends Mock implements CustomThemeRepository {}
18
+
19
+
class MockLocalSettingsDao extends Mock implements LocalSettingsDao {}
20
+
21
+
class TestApp extends StatelessWidget {
22
+
const TestApp({required this.home, super.key});
23
+
24
+
final Widget home;
25
+
26
+
@override
27
+
Widget build(BuildContext context) {
28
+
return MaterialApp.router(
29
+
routerConfig: GoRouter(
30
+
routes: [
31
+
GoRoute(
32
+
path: '/',
33
+
builder: (context, state) => Scaffold(
34
+
body: Center(
35
+
child: TextButton(
36
+
onPressed: () => context.push('/editor'),
37
+
child: const Text('Push'),
38
+
),
39
+
),
40
+
),
41
+
),
42
+
GoRoute(path: '/editor', builder: (context, state) => home),
43
+
],
44
+
),
45
+
);
46
+
}
47
+
}
48
+
49
+
void main() {
50
+
late MockCustomThemeRepository mockRepository;
51
+
late MockLocalSettingsDao mockSettingsDao;
52
+
53
+
const testPack = ThemePack(
54
+
id: 'test_pack',
55
+
name: 'Test Pack',
56
+
variants: [
57
+
ThemeVariant(
58
+
id: 'test_variant',
59
+
name: 'Test Variant',
60
+
brightness: Brightness.dark,
61
+
spec: ThemeSpec(
62
+
primary: Colors.blue,
63
+
secondary: Colors.teal,
64
+
tertiary: Colors.amber,
65
+
surface: Colors.black,
66
+
surfaceContainerLow: Colors.black12,
67
+
surfaceContainerHigh: Colors.black26,
68
+
outlineVariant: Colors.grey,
69
+
),
70
+
derivedScheme: ColorScheme.dark(),
71
+
),
72
+
],
73
+
);
74
+
75
+
setUp(() {
76
+
mockRepository = MockCustomThemeRepository();
77
+
mockSettingsDao = MockLocalSettingsDao();
78
+
79
+
registerFallbackValue(
80
+
CustomThemeDraft.create(
81
+
name: 'fallback',
82
+
basePackId: 'fallback',
83
+
overrides: ThemeRoleOverrides.empty,
84
+
),
85
+
);
86
+
87
+
when(() => mockRepository.getById(any())).thenAnswer((invocation) async {
88
+
final id = invocation.positionalArguments.first as String;
89
+
return CustomThemeDraft.create(
90
+
name: 'Generic Draft',
91
+
basePackId: 'test_pack',
92
+
overrides: ThemeRoleOverrides.empty,
93
+
).copyWith(id: id);
94
+
});
95
+
96
+
when(() => mockSettingsDao.get(ThemeSettingsKeys.themeMode)).thenAnswer((_) async => 'dark');
97
+
when(
98
+
() => mockSettingsDao.get(ThemeSettingsKeys.themePackId),
99
+
).thenAnswer((_) async => 'test_pack');
100
+
when(() => mockSettingsDao.get(ThemeSettingsKeys.customThemeId)).thenAnswer((_) async => null);
101
+
when(
102
+
() => mockSettingsDao.get(ThemeSettingsKeys.dynamicColorEnabled),
103
+
).thenAnswer((_) async => 'false');
104
+
105
+
when(() => mockSettingsDao.set(any(), any())).thenAnswer((_) async {});
106
+
when(() => mockSettingsDao.remove(any())).thenAnswer((_) async => 1);
107
+
});
108
+
109
+
Widget createSubject({String? customThemeId}) {
110
+
return ProviderScope(
111
+
overrides: [
112
+
customThemeRepositoryProvider.overrideWithValue(mockRepository),
113
+
localSettingsDaoProvider.overrideWithValue(mockSettingsDao),
114
+
availableThemePacksProvider.overrideWith((ref) => [testPack]),
115
+
],
116
+
child: TestApp(home: ThemeEditorScreen(customThemeId: customThemeId)),
117
+
);
118
+
}
119
+
120
+
group('ThemeEditorScreen', () {
121
+
testWidgets('reset clears overrides', (tester) async {
122
+
await tester.pumpWidget(createSubject());
123
+
await tester.tap(find.text('Push'));
124
+
await tester.pumpAndSettle();
125
+
126
+
expect(find.text('My Custom Theme'), findsOneWidget);
127
+
expect(find.text('Test Pack'), findsOneWidget);
128
+
expect(find.byType(ColorRolePicker), findsNWidgets(7));
129
+
});
130
+
131
+
testWidgets('loads default state for new theme', (tester) async {
132
+
await tester.pumpWidget(createSubject());
133
+
await tester.tap(find.text('Push'));
134
+
await tester.pumpAndSettle();
135
+
136
+
expect(find.text('My Custom Theme'), findsOneWidget);
137
+
expect(find.text('Test Pack'), findsOneWidget);
138
+
expect(find.byType(ColorRolePicker), findsNWidgets(7));
139
+
});
140
+
141
+
testWidgets('loads existing theme data', (tester) async {
142
+
final existingDraft = CustomThemeDraft.create(
143
+
name: 'Existing Theme',
144
+
basePackId: 'test_pack',
145
+
overrides: const ThemeRoleOverrides(primary: Colors.red),
146
+
).copyWith(id: 'existing_id');
147
+
148
+
when(() => mockRepository.getById('existing_id')).thenAnswer((_) async => existingDraft);
149
+
150
+
await tester.pumpWidget(createSubject(customThemeId: 'existing_id'));
151
+
await tester.tap(find.text('Push'));
152
+
await tester.pumpAndSettle();
153
+
154
+
expect(find.text('Existing Theme'), findsOneWidget);
155
+
});
156
+
157
+
testWidgets('updates override when color picked', (tester) async {
158
+
await tester.pumpWidget(createSubject());
159
+
await tester.tap(find.text('Push'));
160
+
await tester.pumpAndSettle();
161
+
162
+
await tester.tap(find.text('Primary'));
163
+
await tester.pumpAndSettle();
164
+
165
+
expect(find.text('Select Color'), findsOneWidget);
166
+
});
167
+
168
+
testWidgets('save creates new theme and sets it', (tester) async {
169
+
when(
170
+
() => mockRepository.save(any()),
171
+
).thenAnswer((_) async => const ValidationResult.valid());
172
+
173
+
await tester.pumpWidget(createSubject());
174
+
await tester.tap(find.text('Push'));
175
+
await tester.pumpAndSettle();
176
+
177
+
await tester.tap(find.text('Save'));
178
+
await tester.pumpAndSettle();
179
+
180
+
verify(() => mockRepository.save(any(that: isA<CustomThemeDraft>()))).called(1);
181
+
182
+
verify(() => mockSettingsDao.set(ThemeSettingsKeys.customThemeId, any())).called(1);
183
+
184
+
expect(find.byType(ThemeEditorScreen), findsNothing);
185
+
});
186
+
187
+
testWidgets('save updates existing theme', (tester) async {
188
+
final existingDraft = CustomThemeDraft.create(
189
+
name: 'Existing',
190
+
basePackId: 'test_pack',
191
+
overrides: ThemeRoleOverrides.empty,
192
+
).copyWith(id: 'existing_id');
193
+
194
+
when(() => mockRepository.getById('existing_id')).thenAnswer((_) async => existingDraft);
195
+
when(
196
+
() => mockRepository.save(any()),
197
+
).thenAnswer((_) async => const ValidationResult.valid());
198
+
199
+
await tester.pumpWidget(createSubject(customThemeId: 'existing_id'));
200
+
await tester.tap(find.text('Push'));
201
+
await tester.pumpAndSettle();
202
+
203
+
await tester.enterText(find.byType(TextField), 'Updated Name');
204
+
await tester.pump();
205
+
206
+
await tester.tap(find.text('Save'));
207
+
await tester.pump();
208
+
await tester.pump();
209
+
210
+
verify(() => mockRepository.save(any())).called(1);
211
+
});
212
+
213
+
testWidgets('shows error snackbar on save failure', (tester) async {
214
+
when(
215
+
() => mockRepository.save(any()),
216
+
).thenAnswer((_) async => const ValidationResult.invalid('Save failed'));
217
+
218
+
await tester.pumpWidget(createSubject());
219
+
await tester.tap(find.text('Push'));
220
+
await tester.pumpAndSettle();
221
+
222
+
await tester.tap(find.text('Save'));
223
+
await tester.pump();
224
+
await tester.pump();
225
+
226
+
expect(find.text('Save failed'), findsOneWidget);
227
+
expect(find.byType(ThemeEditorScreen), findsOneWidget);
228
+
});
229
+
});
230
+
}