91 files changed
+8010
-7227
ios/Podfile.lock
+1
-1
@@ -36,6 +36,6 @@ SPEC CHECKSUMS:
36
objectbox_flutter_libs: bfbfc0486c06b0b29b130ca1ad9467e8314f32f1
37
sherpa_onnx_ios: 385d34da9ade98f8e012d9b518f1440bd8ba3636
38
39
-PODFILE CHECKSUM: c0915517eae1a513480956f02c7781054ca94770
39
+PODFILE CHECKSUM: 6629f972df894304143a32a9a28db974b8bab794
40
41
COCOAPODS: 1.16.2
lib/app.dart
+9
-9
@@ -13,12 +13,12 @@ import 'features/chat/views/chat_screen.dart';
13
import 'features/conversations/views/chat_history_screen.dart';
14
import 'features/mcp/views/mcp_tools_screen.dart';
15
import 'features/on_device/views/model_manager_screen.dart';
16
-import 'features/onboarding/screens/onboarding_language_screen.dart';
17
-import 'features/onboarding/screens/onboarding_model_download_screen.dart';
18
-import 'features/onboarding/screens/onboarding_notification_permission_screen.dart';
19
-import 'features/onboarding/screens/onboarding_server_setup_screen.dart';
20
-import 'features/onboarding/screens/onboarding_server_type_screen.dart';
21
-import 'features/onboarding/screens/onboarding_theme_screen.dart';
16
+import 'features/onboarding/views/onboarding_language_screen.dart';
17
+import 'features/onboarding/views/onboarding_model_download_screen.dart';
18
+import 'features/onboarding/views/onboarding_notification_permission_screen.dart';
19
+import 'features/onboarding/views/onboarding_server_setup_screen.dart';
20
+import 'features/onboarding/views/onboarding_server_type_screen.dart';
21
+import 'features/onboarding/views/onboarding_theme_screen.dart';
22
import 'features/personas/views/create_persona_screen.dart';
23
import 'features/personas/views/persona_list_screen.dart';
24
import 'features/servers/data/models/server.dart';
@@ -26,8 +26,8 @@ import 'features/servers/views/add_server_screen.dart';
26
import 'features/servers/views/server_list_screen.dart';
27
import 'features/tts/views/tts_model_manager_screen.dart';
28
import 'features/settings/views/settings_screen.dart';
29
-import 'features/sidebar/sidebar_drawer.dart';
30
-import 'features/sidebar/sidebar_widget.dart';
29
+import 'features/sidebar/views/components/sidebar_widget.dart';
30
+import 'features/sidebar/views/sidebar_drawer.dart';
31
32
final _rootNavigatorKey = GlobalKey<NavigatorState>();
33
final _shellNavigatorKey = GlobalKey<NavigatorState>();
@@ -131,7 +131,7 @@ final routerProvider = Provider<GoRouter>((ref) {
131
GoRoute(
132
path: AppRoutes.settings,
133
pageBuilder: (context, state) =>
134
- const NoTransitionPage(child: SettingsViews()),
134
+ const NoTransitionPage(child: SettingsScreen()),
135
),
136
GoRoute(
137
path: AppRoutes.chatHistory,
lib/core/components/metadata_chip.dart
renamed
lib/core/components/model_picker/model_list.dart
renamed
+5
-5
@@ -1,13 +1,13 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
-import 'package:localmind/features/chat/providers/model_loading_providers.dart';
3
+import 'package:localmind/core/providers/model_loading_providers.dart';
4
+import 'package:localmind/core/providers/model_picker/model_picker_providers.dart';
5
+import 'package:localmind/core/providers/model_selection_providers.dart';
6
import 'package:localmind/core/models/enums.dart';
7
import 'package:localmind/core/providers/service_providers.dart';
8
import 'package:localmind/core/theme/colors.dart';
7
-import 'package:localmind/features/chat/providers/model_selection_providers.dart';
8
-import 'package:localmind/features/models/data/models/model_info.dart';
9
-import 'package:localmind/features/models/providers/model_picker_providers.dart';
10
-import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/core/models/model_info.dart';
10
+import 'package:localmind/core/providers/server_providers.dart';
11
import 'package:localmind/l10n/app_localizations.dart';
12
import 'model_tile.dart';
13
lib/core/components/model_picker/model_picker_header.dart
new
+104
@@ -0,0 +1,104 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+import 'thinking_indicator.dart';
5
+
6
+class ModelPickerHeader extends StatelessWidget {
7
+ const ModelPickerHeader({
8
+ super.key,
9
+ required this.isDark,
10
+ required this.isThinking,
11
+ required this.modelLoading,
12
+ this.onRefresh,
13
+ });
14
+
15
+ final bool isDark;
16
+ final bool isThinking;
17
+ final dynamic modelLoading;
18
+ final VoidCallback? onRefresh;
19
+
20
+ @override
21
+ Widget build(BuildContext context) {
22
+ final l10n = AppLocalizations.of(context)!;
23
+
24
+ return Row(
25
+ children: [
26
+ Expanded(
27
+ child: Column(
28
+ crossAxisAlignment: CrossAxisAlignment.start,
29
+ children: [
30
+ Row(
31
+ children: [
32
+ Text(
33
+ l10n.select_model_title,
34
+ style: TextStyle(
35
+ fontSize: 20,
36
+ fontWeight: FontWeight.bold,
37
+ color: isDark ? Colors.white : Colors.black,
38
+ ),
39
+ ),
40
+ if (isThinking) ...[
41
+ const SizedBox(width: 8),
42
+ ThinkingIndicator(isDark: isDark),
43
+ ],
44
+ ],
45
+ ),
46
+ if (modelLoading.isLoading)
47
+ Padding(
48
+ padding: const EdgeInsets.only(top: 8),
49
+ child: ModelLoadingBar(
50
+ isDark: isDark,
51
+ modelId: modelLoading.modelId,
52
+ progress: modelLoading.progress,
53
+ ),
54
+ ),
55
+ ],
56
+ ),
57
+ ),
58
+ if (onRefresh != null)
59
+ IconButton(
60
+ icon: const Icon(Icons.refresh, size: 20),
61
+ onPressed: onRefresh,
62
+ tooltip: l10n.refresh_models,
63
+ ),
64
+ ],
65
+ );
66
+ }
67
+}
68
+
69
+class ModelLoadingBar extends StatelessWidget {
70
+ const ModelLoadingBar({
71
+ super.key,
72
+ required this.isDark,
73
+ required this.modelId,
74
+ this.progress,
75
+ });
76
+
77
+ final bool isDark;
78
+ final String? modelId;
79
+ final double? progress;
80
+
81
+ @override
82
+ Widget build(BuildContext context) {
83
+ final l10n = AppLocalizations.of(context)!;
84
+
85
+ return Column(
86
+ crossAxisAlignment: CrossAxisAlignment.start,
87
+ children: [
88
+ Text(
89
+ l10n.loading_model(modelId ?? 'model'),
90
+ style: TextStyle(
91
+ fontSize: 12,
92
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
93
+ ),
94
+ ),
95
+ const SizedBox(height: 4),
96
+ LinearProgressIndicator(
97
+ value: progress,
98
+ backgroundColor:
99
+ isDark ? AppColors.darkBorder : AppColors.lightBorder,
100
+ ),
101
+ ],
102
+ );
103
+ }
104
+}
lib/core/components/model_picker/model_picker_sheet.dart
renamed
+10
-113
@@ -1,16 +1,17 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/providers/model_loading_providers.dart';
5
+import 'package:localmind/core/providers/on_device_providers.dart';
6
+import 'package:localmind/core/providers/model_selection_providers.dart';
7
+import 'package:localmind/core/providers/server_providers.dart';
8
import 'package:localmind/core/theme/colors.dart';
5
-import 'package:localmind/features/chat/providers/chat_providers.dart';
6
-import 'package:localmind/features/on_device/components/on_device_picker_section.dart';
7
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
8
-import 'package:localmind/features/servers/providers/server_providers.dart';
9
import 'package:localmind/l10n/app_localizations.dart';
10
-import '../components/model_list.dart';
11
-import '../components/model_search_field.dart';
12
-import '../components/no_server_state.dart';
13
-import '../components/thinking_indicator.dart';
10
+import 'model_list.dart';
11
+import 'model_picker_header.dart';
12
+import 'model_search_field.dart';
13
+import 'no_server_state.dart';
14
+import 'on_device_picker_section.dart';
15
16
class ModelPickerSheet extends ConsumerWidget {
17
const ModelPickerSheet({super.key});
@@ -78,12 +79,10 @@ class ModelPickerSheet extends ConsumerWidget {
79
),
80
),
81
const SizedBox(height: 16),
81
- _ModelPickerHeader(
82
+ ModelPickerHeader(
83
isDark: isDark,
84
isThinking: isThinking,
85
modelLoading: modelLoading,
85
- activeServer: activeServer,
86
- serverId: activeServer?.id,
86
onRefresh: activeServer != null
87
? () {
88
ref.invalidate(availableModelsProvider(activeServer.id));
@@ -124,105 +123,3 @@ class ModelPickerSheet extends ConsumerWidget {
123
);
124
}
125
}
127
-
128
-class _ModelPickerHeader extends StatelessWidget {
129
- const _ModelPickerHeader({
130
- required this.isDark,
131
- required this.isThinking,
132
- required this.modelLoading,
133
- required this.activeServer,
134
- this.serverId,
135
- this.onRefresh,
136
- });
137
-
138
- final bool isDark;
139
- final bool isThinking;
140
- final dynamic modelLoading;
141
- final dynamic activeServer;
142
- final String? serverId;
143
- final VoidCallback? onRefresh;
144
-
145
- @override
146
- Widget build(BuildContext context) {
147
- final l10n = AppLocalizations.of(context)!;
148
-
149
- return Row(
150
- children: [
151
- Expanded(
152
- child: Column(
153
- crossAxisAlignment: CrossAxisAlignment.start,
154
- children: [
155
- Row(
156
- children: [
157
- Text(
158
- l10n.select_model_title,
159
- style: TextStyle(
160
- fontSize: 20,
161
- fontWeight: FontWeight.bold,
162
- color: isDark ? Colors.white : Colors.black,
163
- ),
164
- ),
165
- if (isThinking) ...[
166
- const SizedBox(width: 8),
167
- ThinkingIndicator(isDark: isDark),
168
- ],
169
- ],
170
- ),
171
- if (modelLoading.isLoading)
172
- Padding(
173
- padding: const EdgeInsets.only(top: 8),
174
- child: _ModelLoadingBar(
175
- isDark: isDark,
176
- modelId: modelLoading.modelId,
177
- progress: modelLoading.progress,
178
- ),
179
- ),
180
- ],
181
- ),
182
- ),
183
- if (onRefresh != null)
184
- IconButton(
185
- icon: const Icon(Icons.refresh, size: 20),
186
- onPressed: onRefresh,
187
- tooltip: l10n.refresh_models,
188
- ),
189
- ],
190
- );
191
- }
192
-}
193
-
194
-class _ModelLoadingBar extends StatelessWidget {
195
- const _ModelLoadingBar({
196
- required this.isDark,
197
- required this.modelId,
198
- this.progress,
199
- });
200
-
201
- final bool isDark;
202
- final String? modelId;
203
- final double? progress;
204
-
205
- @override
206
- Widget build(BuildContext context) {
207
- final l10n = AppLocalizations.of(context)!;
208
-
209
- return Column(
210
- crossAxisAlignment: CrossAxisAlignment.start,
211
- children: [
212
- Text(
213
- l10n.loading_model(modelId ?? 'model'),
214
- style: TextStyle(
215
- fontSize: 12,
216
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
217
- ),
218
- ),
219
- const SizedBox(height: 4),
220
- LinearProgressIndicator(
221
- value: progress,
222
- backgroundColor:
223
- isDark ? AppColors.darkBorder : AppColors.lightBorder,
224
- ),
225
- ],
226
- );
227
- }
228
-}
lib/core/components/model_picker/model_search_field.dart
renamed
+1
-1
@@ -1,7 +1,7 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/providers/model_picker/model_picker_providers.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
4
-import '../providers/model_picker_providers.dart';
5
6
class ModelSearchField extends ConsumerWidget {
7
const ModelSearchField({super.key});
lib/core/components/model_picker/model_tile.dart
renamed
+2
-2
@@ -1,9 +1,9 @@
1
import 'package:flutter/material.dart';
2
import 'package:localmind/core/models/enums.dart';
3
import 'package:localmind/core/theme/colors.dart';
4
-import 'package:localmind/features/models/data/models/model_info.dart';
4
+import 'package:localmind/core/models/model_info.dart';
5
import 'package:localmind/l10n/app_localizations.dart';
6
-import 'metadata_chip.dart';
6
+import 'package:localmind/core/components/metadata_chip.dart';
7
8
class ModelTile extends StatelessWidget {
9
const ModelTile({
lib/core/components/model_picker/no_server_state.dart
renamed
lib/core/components/model_picker/on_device_picker_section.dart
renamed
+6
-6
@@ -4,20 +4,20 @@ import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
import 'package:go_router/go_router.dart';
6
import 'package:localmind/core/device/device_memory_service.dart';
7
+import 'package:localmind/core/models/model_info.dart';
8
import 'package:localmind/core/models/enums.dart';
9
+import 'package:localmind/core/providers/foreground_download_providers.dart';
10
+import 'package:localmind/core/providers/model_picker/model_picker_providers.dart';
11
+import 'package:localmind/core/providers/model_selection_providers.dart';
12
import 'package:localmind/core/providers/app_providers.dart';
13
import 'package:localmind/core/providers/device_info_providers.dart';
14
+import 'package:localmind/core/providers/on_device_providers.dart';
15
import 'package:localmind/core/theme/colors.dart';
11
-import 'package:localmind/features/chat/providers/model_selection_providers.dart';
12
-import 'package:localmind/features/models/data/models/model_info.dart';
13
-import 'package:localmind/features/models/providers/model_picker_providers.dart';
16
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
17
import 'package:localmind/features/on_device/data/models/download_status.dart';
16
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
17
-import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
18
import 'package:localmind/l10n/app_localizations.dart';
19
import 'package:localmind/core/routes/app_routes.dart';
20
-import 'package:localmind/features/models/components/metadata_chip.dart';
20
+import 'package:localmind/core/components/metadata_chip.dart';
21
22
class OnDevicePickerSection extends ConsumerWidget {
23
const OnDevicePickerSection({
lib/core/components/model_picker/thinking_indicator.dart
renamed
lib/core/components/server/server_icon_picker.dart
renamed
lib/core/components/tts/tts_player_bar.dart
renamed
+1
-1
@@ -2,7 +2,7 @@ import 'dart:math' as math;
2
import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
-import '../../providers/tts_providers.dart' as tts;
5
+import 'package:localmind/features/tts/providers/tts_providers.dart' as tts;
6
7
class TtsPlayerBar extends ConsumerWidget {
8
final EdgeInsetsGeometry margin;
lib/core/models/model_info.dart
renamed
+2
-2
@@ -1,5 +1,5 @@
1
-import '../../../../core/models/enums.dart';
2
-import '../../../on_device/data/models/on_device_model.dart';
1
+import 'enums.dart';
2
+import 'on_device_model_types.dart';
3
4
class ModelInfo {
5
final String id;
lib/core/models/on_device_model_types.dart
new
+3
@@ -0,0 +1,3 @@
1
+enum OnDeviceModelRuntime { gemma, llamaCpp }
2
+
3
+enum OnDeviceModelFormat { litertlm, task, binary, gguf }
lib/core/providers/chat_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/chat/providers/chat_providers.dart';
lib/core/providers/conversation_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/conversations/providers/conversation_providers.dart';
lib/core/providers/foreground_download_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
lib/core/providers/model_loading_providers.dart
new
+56
@@ -0,0 +1,56 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import '../../../core/providers/model_selection_providers.dart';
3
+
4
+class ModelLoadingState {
5
+ final bool isLoading;
6
+ final String? modelId;
7
+ final double? progress;
8
+
9
+ const ModelLoadingState({
10
+ this.isLoading = false,
11
+ this.modelId,
12
+ this.progress,
13
+ });
14
+
15
+ ModelLoadingState copyWith({
16
+ bool? isLoading,
17
+ String? modelId,
18
+ double? progress,
19
+ }) {
20
+ return ModelLoadingState(
21
+ isLoading: isLoading ?? this.isLoading,
22
+ modelId: modelId ?? this.modelId,
23
+ progress: progress ?? this.progress,
24
+ );
25
+ }
26
+}
27
+
28
+final modelLoadingProvider =
29
+ NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
30
+ return ModelLoadingNotifier();
31
+});
32
+
33
+class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
34
+ @override
35
+ ModelLoadingState build() => const ModelLoadingState();
36
+
37
+ void setLoading(String modelId, {double? progress}) {
38
+ state = ModelLoadingState(
39
+ isLoading: true,
40
+ modelId: modelId,
41
+ progress: progress,
42
+ );
43
+ }
44
+
45
+ void updateProgress(double progress) {
46
+ state = state.copyWith(progress: progress);
47
+ }
48
+
49
+ void setLoaded() {
50
+ state = const ModelLoadingState();
51
+ }
52
+}
53
+
54
+final modelThinkingProvider = Provider<bool>((ref) {
55
+ return ref.watch(isStreamingProvider);
56
+});
lib/core/providers/model_picker/model_picker_providers.dart
new
+13
@@ -0,0 +1,13 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+
3
+final modelSearchQueryProvider = NotifierProvider<ModelSearchNotifier, String>(
4
+ ModelSearchNotifier.new,
5
+);
6
+
7
+class ModelSearchNotifier extends Notifier<String> {
8
+ @override
9
+ String build() => '';
10
+
11
+ void setQuery(String q) => state = q;
12
+ void clear() => state = '';
13
+}
lib/core/providers/model_selection_providers.dart
renamed
+3
-3
@@ -2,10 +2,10 @@ import 'dart:async';
2
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:localmind/core/models/enums.dart';
5
+import 'package:localmind/core/providers/on_device_providers.dart';
6
import 'package:localmind/core/providers/service_providers.dart';
6
-import 'package:localmind/features/models/data/models/model_info.dart';
7
-import 'package:localmind/features/servers/providers/server_providers.dart';
8
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7
+import 'package:localmind/core/providers/server_providers.dart';
8
+import 'package:localmind/core/models/model_info.dart';
9
10
final selectedModelProvider =
11
NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
lib/core/providers/on_device_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/on_device/providers/on_device_providers.dart';
lib/core/providers/personas_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/personas/providers/personas_providers.dart';
lib/core/providers/server_providers.dart
new
+1
@@ -0,0 +1 @@
1
+export 'package:localmind/features/servers/providers/server_providers.dart';
lib/core/providers/service_providers.dart
+1
-2
@@ -1,7 +1,7 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import 'package:dio/dio.dart';
3
import '../constants/app_constants.dart';
4
-import '../../features/servers/data/server_api_service.dart';
4
+import '../../features/servers/data/repositories/server_api_service.dart';
5
6
final dioProvider = Provider<Dio>((ref) {
7
final dio = Dio(
@@ -96,4 +96,3 @@ class RedirectInterceptor extends Interceptor {
96
);
97
}
98
}
99
-
lib/core/repositories/server/server_repository.dart
renamed
+1
-1
@@ -1,4 +1,4 @@
1
-import '../../../features/models/data/models/model_info.dart';
1
+import '../../models/model_info.dart';
2
import '../../../features/servers/data/models/server.dart';
3
4
abstract class ServerRepository {
lib/features/chat/providers/chat_notifier.dart
+7
-8
@@ -6,19 +6,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:localmind/core/logger/app_logger.dart';
7
import 'package:localmind/core/models/enums.dart';
8
import 'package:localmind/core/providers/app_providers.dart';
9
-import 'package:localmind/core/providers/review_prompt_providers.dart';
9
import 'package:localmind/core/providers/chat_background_service_provider.dart';
10
+import 'package:localmind/core/providers/conversation_providers.dart' as conv;
11
+import 'package:localmind/core/providers/on_device_providers.dart';
12
+import 'package:localmind/core/providers/personas_providers.dart';
13
+import 'package:localmind/core/providers/review_prompt_providers.dart';
14
+import 'package:localmind/core/providers/server_providers.dart';
15
import 'package:localmind/core/providers/storage_providers.dart';
16
import 'package:localmind/core/services/message_save_service.dart';
17
import 'package:localmind/core/storage/entities.dart';
18
import 'package:localmind/features/conversations/data/models/conversation.dart';
15
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
16
- as conv;
17
-import 'package:localmind/features/models/data/models/model_info.dart';
18
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
19
-import 'package:localmind/features/personas/providers/personas_providers.dart';
19
+import 'package:localmind/core/models/model_info.dart';
20
import 'package:localmind/features/servers/data/models/server.dart';
21
-import 'package:localmind/features/servers/providers/server_providers.dart';
21
import 'package:localmind/objectbox.g.dart';
22
import '../data/chat_service.dart';
23
import '../data/models/message.dart' hide ToolCallData;
@@ -28,7 +27,7 @@ import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
27
import 'chat_mcp_providers.dart';
28
import 'chat_params_providers.dart';
29
import 'chat_service_providers.dart';
31
-import 'model_selection_providers.dart';
30
+import '../../../core/providers/model_selection_providers.dart';
31
import 'tooling_providers.dart';
32
33
class PendingToolApproval {
lib/features/chat/providers/chat_params_providers.dart
+2
-3
@@ -1,8 +1,7 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import 'package:localmind/core/providers/app_providers.dart';
3
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
4
- as conv;
5
-import 'package:localmind/features/personas/providers/personas_providers.dart';
3
+import 'package:localmind/core/providers/conversation_providers.dart' as conv;
4
+import 'package:localmind/core/providers/personas_providers.dart';
5
import '../data/models/chat_parameters.dart';
6
7
final chatParamsProvider = Provider<ChatParameters>((ref) {
lib/features/chat/providers/chat_providers.dart
+1
-1
@@ -2,5 +2,5 @@ export 'chat_notifier.dart';
2
export 'chat_params_providers.dart';
3
export 'chat_service_providers.dart';
4
export 'model_loading_providers.dart';
5
-export 'model_selection_providers.dart';
5
+export '../../../core/providers/model_selection_providers.dart';
6
export 'smart_reply_providers.dart';
lib/features/chat/providers/chat_service_providers.dart
+2
-2
@@ -1,10 +1,10 @@
1
import 'package:dio/dio.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/providers/on_device_providers.dart';
5
import 'package:localmind/core/providers/service_providers.dart';
6
+import 'package:localmind/core/providers/server_providers.dart';
7
import 'package:localmind/features/servers/data/models/server.dart';
6
-import 'package:localmind/features/servers/providers/server_providers.dart';
7
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
8
import 'package:localmind/features/on_device/data/on_device_chat_service.dart';
9
import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
10
import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
lib/features/chat/providers/model_loading_providers.dart
+1
-56
@@ -1,56 +1 @@
1
-import 'package:flutter_riverpod/flutter_riverpod.dart';
2
-import 'model_selection_providers.dart';
3
-
4
-class ModelLoadingState {
5
- final bool isLoading;
6
- final String? modelId;
7
- final double? progress;
8
-
9
- const ModelLoadingState({
10
- this.isLoading = false,
11
- this.modelId,
12
- this.progress,
13
- });
14
-
15
- ModelLoadingState copyWith({
16
- bool? isLoading,
17
- String? modelId,
18
- double? progress,
19
- }) {
20
- return ModelLoadingState(
21
- isLoading: isLoading ?? this.isLoading,
22
- modelId: modelId ?? this.modelId,
23
- progress: progress ?? this.progress,
24
- );
25
- }
26
-}
27
-
28
-final modelLoadingProvider =
29
- NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
30
- return ModelLoadingNotifier();
31
-});
32
-
33
-class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
34
- @override
35
- ModelLoadingState build() => const ModelLoadingState();
36
-
37
- void setLoading(String modelId, {double? progress}) {
38
- state = ModelLoadingState(
39
- isLoading: true,
40
- modelId: modelId,
41
- progress: progress,
42
- );
43
- }
44
-
45
- void updateProgress(double progress) {
46
- state = state.copyWith(progress: progress);
47
- }
48
-
49
- void setLoaded() {
50
- state = const ModelLoadingState();
51
- }
52
-}
53
-
54
-final modelThinkingProvider = Provider<bool>((ref) {
55
- return ref.watch(isStreamingProvider);
56
-});
1
+export 'package:localmind/core/providers/model_loading_providers.dart';
lib/features/chat/providers/smart_reply_providers.dart
+3
-4
@@ -3,13 +3,12 @@ import 'dart:async';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:localmind/core/models/enums.dart';
5
import 'package:localmind/core/providers/app_providers.dart';
6
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7
- as conv;
8
-import 'package:localmind/features/servers/providers/server_providers.dart';
6
+import 'package:localmind/core/providers/conversation_providers.dart' as conv;
7
+import 'package:localmind/core/providers/server_providers.dart';
8
import 'chat_notifier.dart';
9
import 'chat_params_providers.dart';
10
import 'chat_service_providers.dart';
12
-import 'model_selection_providers.dart';
11
+import '../../../core/providers/model_selection_providers.dart';
12
13
final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
14
final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
lib/features/chat/views/chat_screen.dart
+4
-926
@@ -1,933 +1,11 @@
1
-import 'dart:convert';
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/features/chat/views/components/chat_screen_content.dart';
3
3
-import 'package:flutter/material.dart';
4
-import 'package:flutter_riverpod/flutter_riverpod.dart';
5
-import 'package:go_router/go_router.dart';
6
-import 'package:hugeicons/hugeicons.dart';
7
-import 'package:shadcn_ui/shadcn_ui.dart';
8
-import 'package:localmind/core/theme/colors.dart';
9
-import 'package:localmind/core/models/enums.dart';
10
-import 'package:localmind/core/providers/app_providers.dart';
11
-import 'package:localmind/core/routes/app_routes.dart';
12
-import 'package:localmind/core/utils/system_insets.dart';
13
-import 'package:localmind/l10n/app_localizations.dart';
14
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
15
- as conv;
16
-import 'package:localmind/features/models/screens/model_picker_sheet.dart';
17
-import 'package:localmind/features/personas/providers/personas_providers.dart';
18
-import 'package:localmind/features/servers/providers/server_providers.dart';
19
-import 'package:localmind/features/servers/data/models/server.dart';
20
-import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
21
-import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
22
-import '../data/models/message.dart';
23
-import '../providers/chat_mcp_providers.dart';
24
-import '../providers/chat_providers.dart';
25
-import 'components/chat_auto_scroll_controller.dart';
26
-import 'components/chat_input_bar.dart';
27
-import 'components/chat_settings_sheet.dart';
28
-import 'components/edit_message_dialog.dart';
29
-import 'components/notification_permission_banner.dart';
30
-import 'components/message_list/message_list.dart';
31
-import 'components/message_list/empty_state.dart';
32
-import 'components/message_list/corrupted_state.dart';
33
-import 'components/top_bar/model_top_bar.dart';
34
-import 'components/top_bar/connection_banner.dart';
35
-import 'components/top_bar/persona_indicator.dart';
36
-import 'components/top_bar/smart_reply_chips.dart';
37
-
38
-class ChatScreen extends ConsumerStatefulWidget {
4
+class ChatScreen extends StatelessWidget {
5
const ChatScreen({super.key});
6
41
- @override
42
- ConsumerState<ChatScreen> createState() => _ChatScreenState();
43
-}
44
-
45
-class _ChatScreenState extends ConsumerState<ChatScreen>
46
- with WidgetsBindingObserver {
47
- final ScrollController _scrollController = ScrollController();
48
- final FocusNode _inputFocusNode = FocusNode();
49
- bool _isApprovalDialogOpen = false;
50
- late final ChatAutoScrollController _autoScroll;
51
-
52
- @override
53
- void initState() {
54
- super.initState();
55
- WidgetsBinding.instance.addObserver(this);
56
- _inputFocusNode.addListener(() => setState(() {}));
57
- _scrollController.addListener(
58
- () => _autoScroll.onScrollChanged(_scrollController),
59
- );
60
- _autoScroll = ChatAutoScrollController();
61
- }
62
-
63
- @override
64
- void didChangeAppLifecycleState(AppLifecycleState state) {
65
- if (state == AppLifecycleState.inactive ||
66
- state == AppLifecycleState.paused ||
67
- state == AppLifecycleState.hidden ||
68
- state == AppLifecycleState.detached) {
69
- ref.read(chatProvider.notifier).checkpointStreamingMessage(flush: true);
70
- }
71
- }
72
-
73
- @override
74
- void dispose() {
75
- WidgetsBinding.instance.removeObserver(this);
76
- _scrollController.dispose();
77
- _inputFocusNode.dispose();
78
- super.dispose();
79
- }
80
-
7
@override
8
Widget build(BuildContext context) {
83
- ref.watch(autoSelectFirstLoadedModelProvider);
84
-
85
- ref.listen<PendingToolApproval?>(
86
- chatProvider.select((s) => s.pendingToolApproval),
87
- (previous, next) {
88
- if (next != null && !_isApprovalDialogOpen) {
89
- _showToolApprovalDialog(context, next);
90
- }
91
- },
92
- );
93
-
94
- return _ChatBody(
95
- scrollController: _scrollController,
96
- inputFocusNode: _inputFocusNode,
97
- autoScroll: _autoScroll,
98
- onModelPicker: () => _showModelPicker(context),
99
- onMenuAction: (action) => _handleMenuAction(action, context),
100
- );
101
- }
102
-
103
- void _showModelPicker(BuildContext context) {
104
- showModalBottomSheet(
105
- context: context,
106
- isScrollControlled: true,
107
- useSafeArea: true,
108
- builder: (context) => const ModelPickerSheet(),
109
- );
110
- }
111
-
112
- void _handleMenuAction(String action, BuildContext context) {
113
- switch (action) {
114
- case 'new_chat':
115
- ref.read(chatProvider.notifier).startNewConversation();
116
- case 'persona':
117
- _showPersonaPicker(context);
118
- case 'remove_persona':
119
- final activeConv = ref.read(conv.activeConversationProvider);
120
- if (activeConv != null) {
121
- ref
122
- .read(conv.conversationsProvider.notifier)
123
- .updatePersona(activeConv.id, null, null);
124
- }
125
- case 'clear':
126
- showDialog(
127
- context: context,
128
- builder: (context) {
129
- final dlgL10n = AppLocalizations.of(context)!;
130
- return AlertDialog(
131
- title: Text(dlgL10n.clear_conversation_title),
132
- content: Text(dlgL10n.clear_conversation_body),
133
- actions: [
134
- TextButton(
135
- onPressed: () => Navigator.pop(context),
136
- child: Text(dlgL10n.cancel),
137
- ),
138
- TextButton(
139
- onPressed: () {
140
- Navigator.pop(context);
141
- ref.read(chatProvider.notifier).clearConversation();
142
- },
143
- child: Text(dlgL10n.clear),
144
- ),
145
- ],
146
- );
147
- },
148
- );
149
- }
150
- }
151
-
152
- void _showPersonaPicker(BuildContext context) {
153
- final personasAsync = ref.read(personasNotifierProvider);
154
- final personas = personasAsync.value ?? [];
155
- final isDark = Theme.of(context).brightness == Brightness.dark;
156
- final activeConv = ref.read(conv.activeConversationProvider);
157
- final currentPersonaId = activeConv?.personaId;
158
-
159
- showModalBottomSheet(
160
- context: context,
161
- isScrollControlled: true,
162
- builder: (ctx) {
163
- final sheetL10n = AppLocalizations.of(context)!;
164
- return SafeArea(
165
- child: Container(
166
- padding: const EdgeInsets.symmetric(vertical: 16),
167
- child: Column(
168
- mainAxisSize: MainAxisSize.min,
169
- children: [
170
- Center(
171
- child: Container(
172
- width: 40,
173
- height: 4,
174
- decoration: BoxDecoration(
175
- color: isDark ? Colors.grey[600] : Colors.grey[300],
176
- borderRadius: BorderRadius.circular(2),
177
- ),
178
- ),
179
- ),
180
- const SizedBox(height: 16),
181
- Text(
182
- sheetL10n.select_persona,
183
- style: TextStyle(
184
- fontSize: 18,
185
- fontWeight: FontWeight.bold,
186
- color: isDark ? Colors.white : Colors.black,
187
- ),
188
- ),
189
- const SizedBox(height: 8),
190
- Flexible(
191
- child: ListView.builder(
192
- shrinkWrap: true,
193
- itemCount: personas.length,
194
- itemBuilder: (context, index) {
195
- final p = personas[index];
196
- final isSelected = p.id == currentPersonaId;
197
- final accent = isDark
198
- ? AppColors.darkAccent
199
- : AppColors.lightAccent;
200
- return ListTile(
201
- leading: Text(
202
- p.emoji,
203
- style: const TextStyle(fontSize: 22),
204
- ),
205
- title: Text(
206
- p.name,
207
- style: TextStyle(
208
- fontWeight: isSelected
209
- ? FontWeight.w600
210
- : FontWeight.normal,
211
- color: isDark ? Colors.white : Colors.black,
212
- ),
213
- ),
214
- subtitle: p.description != null
215
- ? Text(
216
- p.description!,
217
- style: TextStyle(
218
- fontSize: 12,
219
- color: isDark
220
- ? AppColors.darkMutedText
221
- : AppColors.lightMutedText,
222
- ),
223
- maxLines: 1,
224
- overflow: TextOverflow.ellipsis,
225
- )
226
- : null,
227
- trailing: isSelected
228
- ? Icon(Icons.check_circle, color: accent)
229
- : null,
230
- onTap: () {
231
- if (activeConv != null) {
232
- ref
233
- .read(conv.conversationsProvider.notifier)
234
- .updatePersona(
235
- activeConv.id,
236
- p.id,
237
- p.systemPrompt,
238
- );
239
- }
240
- Navigator.pop(ctx);
241
- },
242
- );
243
- },
244
- ),
245
- ),
246
- ],
247
- ),
248
- ),
249
- );
250
- },
251
- );
252
- }
253
-
254
- void _showToolApprovalDialog(
255
- BuildContext context,
256
- PendingToolApproval approval,
257
- ) {
258
- setState(() => _isApprovalDialogOpen = true);
259
- final l10n = AppLocalizations.of(context)!;
260
-
261
- showShadDialog<bool>(
262
- context: context,
263
- barrierDismissible: false,
264
- builder: (context) => ShadDialog.alert(
265
- title: Text('${l10n.execute_tool_title}: ${approval.toolCall.name}?'),
266
- description: Column(
267
- crossAxisAlignment: CrossAxisAlignment.start,
268
- mainAxisSize: MainAxisSize.min,
269
- children: [
270
- Text(l10n.execute_tool_request_desc),
271
- const SizedBox(height: 8),
272
- Container(
273
- width: double.infinity,
274
- padding: const EdgeInsets.all(8),
275
- decoration: BoxDecoration(
276
- color: Theme.of(context).colorScheme.surfaceContainer,
277
- borderRadius: BorderRadius.circular(8),
278
- ),
279
- child: SingleChildScrollView(
280
- scrollDirection: Axis.horizontal,
281
- child: Text(
282
- const JsonEncoder.withIndent(
283
- ' ',
284
- ).convert(approval.toolCall.arguments),
285
- style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
286
- ),
287
- ),
288
- ),
289
- ],
290
- ),
291
- actions: [
292
- ShadButton.outline(
293
- child: Text(l10n.reject),
294
- onPressed: () => Navigator.of(context).pop(false),
295
- ),
296
- ShadButton(
297
- child: Text(l10n.approve),
298
- onPressed: () => Navigator.of(context).pop(true),
299
- ),
300
- ],
301
- ),
302
- ).then((value) {
303
- if (mounted) setState(() => _isApprovalDialogOpen = false);
304
- ref.read(chatProvider.notifier).approveTool(value ?? false);
305
- });
306
- }
307
-}
308
-
309
-class _ChatBody extends ConsumerWidget {
310
- const _ChatBody({
311
- required this.scrollController,
312
- required this.inputFocusNode,
313
- required this.autoScroll,
314
- required this.onModelPicker,
315
- required this.onMenuAction,
316
- });
317
-
318
- final ScrollController scrollController;
319
- final FocusNode inputFocusNode;
320
- final ChatAutoScrollController autoScroll;
321
- final VoidCallback onModelPicker;
322
- final void Function(String) onMenuAction;
323
-
324
- @override
325
- Widget build(BuildContext context, WidgetRef ref) {
326
- final theme = Theme.of(context);
327
- final isDark = theme.brightness == Brightness.dark;
328
- final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
329
- final messages = ref.watch(chatProvider.select((s) => s.messages));
330
- final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
331
- final streamingLength = ref.watch(
332
- chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
333
- );
334
- final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
335
- final selectedModel = ref.watch(selectedModelProvider);
336
- final connectionStatus = ref.watch(connectionStatusProvider);
337
- final activeServer = ref.watch(activeServerProvider);
338
- final activeConversation = ref.watch(conv.activeConversationProvider);
339
- final personaId = activeConversation?.personaId;
340
- final persona = personaId != null
341
- ? ref.watch(personaByIdProvider(personaId))
342
- : null;
343
- final keyboardBottomInset = bottomKeyboardInset(context);
344
- final systemBottomInset = bottomSystemInset(context);
345
- final effectiveBottomInset = keyboardBottomInset > 0
346
- ? 0.0
347
- : systemBottomInset;
348
-
349
- final needsScroll = autoScroll.checkAndUpdate(
350
- messageCount: messages.length,
351
- streamingLength: streamingLength,
352
- isStreaming: isStreaming,
353
- );
354
- if (needsScroll) {
355
- autoScroll.scheduleAutoScroll(
356
- controller: scrollController,
357
- streaming: isStreaming,
358
- );
359
- }
360
-
361
- return Column(
362
- children: [
363
- ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
364
- _ScreenAppBar(
365
- activeServer: activeServer,
366
- connectionStatus: connectionStatus,
367
- isDark: isDark,
368
- persona: persona,
369
- onMenuAction: onMenuAction,
370
- onPersonaPicker: () => _openPersonaPicker(context, ref),
371
- ),
372
- const NotificationPermissionBanner(),
373
- if (connectionStatus == ConnectionStatus.disconnected ||
374
- connectionStatus == ConnectionStatus.error)
375
- ConnectionBanner(status: connectionStatus),
376
- if (persona != null)
377
- PersonaIndicator(
378
- persona: persona,
379
- onTap: () => _openPersonaPicker(context, ref),
380
- onRemove: () {
381
- final activeConv = ref.read(conv.activeConversationProvider);
382
- if (activeConv != null) {
383
- ref
384
- .read(conv.conversationsProvider.notifier)
385
- .updatePersona(activeConv.id, null, null);
386
- }
387
- },
388
- ),
389
- const TtsPlayerBar(),
390
- Expanded(
391
- child: Stack(
392
- children: [
393
- _MessageArea(
394
- isLoading: isLoading,
395
- messages: messages,
396
- activeConversation: activeConversation,
397
- errorMessage: errorMessage,
398
- selectedModel: selectedModel,
399
- isStreaming: isStreaming,
400
- scrollController: scrollController,
401
- effectiveBottomInset: effectiveBottomInset,
402
- keyboardBottomInset: keyboardBottomInset,
403
- onModelPicker: onModelPicker,
404
- ),
405
- _ChatBottomBar(
406
- isStreaming: isStreaming,
407
- keyboardBottomInset: keyboardBottomInset,
408
- inputFocusNode: inputFocusNode,
409
- ),
410
- ],
411
- ),
412
- ),
413
- ],
414
- );
415
- }
416
-}
417
-
418
-void _openPersonaPicker(BuildContext context, WidgetRef ref) {
419
- final personasAsync = ref.read(personasNotifierProvider);
420
- final personas = personasAsync.value ?? [];
421
- final isDark = Theme.of(context).brightness == Brightness.dark;
422
- final activeConv = ref.read(conv.activeConversationProvider);
423
- final currentPersonaId = activeConv?.personaId;
424
-
425
- showModalBottomSheet(
426
- context: context,
427
- isScrollControlled: true,
428
- builder: (ctx) {
429
- final sheetL10n = AppLocalizations.of(context)!;
430
- return SafeArea(
431
- child: Container(
432
- padding: const EdgeInsets.symmetric(vertical: 16),
433
- child: Column(
434
- mainAxisSize: MainAxisSize.min,
435
- children: [
436
- Center(
437
- child: Container(
438
- width: 40,
439
- height: 4,
440
- decoration: BoxDecoration(
441
- color: isDark ? Colors.grey[600] : Colors.grey[300],
442
- borderRadius: BorderRadius.circular(2),
443
- ),
444
- ),
445
- ),
446
- const SizedBox(height: 16),
447
- Text(
448
- sheetL10n.select_persona,
449
- style: TextStyle(
450
- fontSize: 18,
451
- fontWeight: FontWeight.bold,
452
- color: isDark ? Colors.white : Colors.black,
453
- ),
454
- ),
455
- const SizedBox(height: 8),
456
- Flexible(
457
- child: ListView.builder(
458
- shrinkWrap: true,
459
- itemCount: personas.length,
460
- itemBuilder: (context, index) {
461
- final p = personas[index];
462
- final isSelected = p.id == currentPersonaId;
463
- final accent = isDark
464
- ? AppColors.darkAccent
465
- : AppColors.lightAccent;
466
- return ListTile(
467
- leading: Text(
468
- p.emoji,
469
- style: const TextStyle(fontSize: 22),
470
- ),
471
- title: Text(
472
- p.name,
473
- style: TextStyle(
474
- fontWeight: isSelected
475
- ? FontWeight.w600
476
- : FontWeight.normal,
477
- color: isDark ? Colors.white : Colors.black,
478
- ),
479
- ),
480
- subtitle: p.description != null
481
- ? Text(
482
- p.description!,
483
- style: TextStyle(
484
- fontSize: 12,
485
- color: isDark
486
- ? AppColors.darkMutedText
487
- : AppColors.lightMutedText,
488
- ),
489
- maxLines: 1,
490
- overflow: TextOverflow.ellipsis,
491
- )
492
- : null,
493
- trailing: isSelected
494
- ? Icon(Icons.check_circle, color: accent)
495
- : null,
496
- onTap: () {
497
- if (activeConv != null) {
498
- ref
499
- .read(conv.conversationsProvider.notifier)
500
- .updatePersona(
501
- activeConv.id,
502
- p.id,
503
- p.systemPrompt,
504
- );
505
- }
506
- Navigator.pop(ctx);
507
- },
508
- );
509
- },
510
- ),
511
- ),
512
- ],
513
- ),
514
- ),
515
- );
516
- },
517
- ).then((_) {});
518
-}
519
-
520
-class _MessageArea extends ConsumerWidget {
521
- const _MessageArea({
522
- required this.isLoading,
523
- required this.messages,
524
- required this.activeConversation,
525
- this.errorMessage,
526
- required this.selectedModel,
527
- required this.isStreaming,
528
- required this.scrollController,
529
- required this.effectiveBottomInset,
530
- required this.keyboardBottomInset,
531
- required this.onModelPicker,
532
- });
533
-
534
- final bool isLoading;
535
- final List<Message> messages;
536
- final dynamic activeConversation;
537
- final String? errorMessage;
538
- final dynamic selectedModel;
539
- final bool isStreaming;
540
- final ScrollController scrollController;
541
- final double effectiveBottomInset;
542
- final double keyboardBottomInset;
543
- final VoidCallback onModelPicker;
544
-
545
- @override
546
- Widget build(BuildContext context, WidgetRef ref) {
547
- final l10n = AppLocalizations.of(context)!;
548
-
549
- if (isLoading) {
550
- return const Center(child: CircularProgressIndicator(strokeWidth: 2));
551
- }
552
-
553
- if (messages.isEmpty && activeConversation != null) {
554
- return CorruptedChatState(
555
- conversation: activeConversation,
556
- errorMessage: errorMessage,
557
- onStartNewChat: () =>
558
- ref.read(chatProvider.notifier).startNewConversation(),
559
- );
560
- }
561
-
562
- if (messages.isEmpty) {
563
- return EmptyState(
564
- onQuickPrompt: (prompt) =>
565
- ref.read(chatProvider.notifier).sendMessage(prompt),
566
- quickPrompts: [
567
- l10n.quick_write,
568
- l10n.quick_explain,
569
- l10n.quick_debug,
570
- l10n.quick_async,
571
- ],
572
- recentConversations: ref.watch(conv.recentConversationsProvider),
573
- onSeeAll: () => context.push(AppRoutes.chatHistory),
574
- selectedModel: selectedModel,
575
- onModelTap: onModelPicker,
576
- selectedPersona: ref.watch(selectedPersonaProvider),
577
- onPersonaTap: () => _openPersonaPickerForPreselection(context, ref),
578
- );
579
- }
580
-
581
- return MessageList(
582
- scrollController: scrollController,
583
- messages: messages,
584
- isStreaming: isStreaming,
585
- onRetry: (messageId) =>
586
- ref.read(chatProvider.notifier).retryMessage(messageId),
587
- onDelete: (messageId) =>
588
- ref.read(chatProvider.notifier).deleteMessage(messageId),
589
- onEdit: (messageId, currentContent) async {
590
- final newContent = await EditMessageDialog.show(
591
- context,
592
- initialContent: currentContent,
593
- );
594
- if (newContent != null && newContent != currentContent) {
595
- await ref
596
- .read(chatProvider.notifier)
597
- .editMessage(messageId, newContent);
598
- }
599
- },
600
- hasSmartReplies:
601
- !isStreaming &&
602
- keyboardBottomInset == 0 &&
603
- (ref.watch(smartRepliesProvider).asData?.value.isNotEmpty ?? false),
604
- bottomInset: effectiveBottomInset,
605
- );
606
- }
607
-}
608
-
609
-void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
610
- final personasAsync = ref.read(personasNotifierProvider);
611
- final personas = personasAsync.value ?? [];
612
- final isDark = Theme.of(context).brightness == Brightness.dark;
613
- final currentPersona = ref.watch(selectedPersonaProvider);
614
-
615
- showModalBottomSheet(
616
- context: context,
617
- isScrollControlled: true,
618
- builder: (ctx) {
619
- final sheetL10n = AppLocalizations.of(context)!;
620
- return SafeArea(
621
- child: Container(
622
- padding: const EdgeInsets.symmetric(vertical: 16),
623
- child: Column(
624
- mainAxisSize: MainAxisSize.min,
625
- children: [
626
- Center(
627
- child: Container(
628
- width: 40,
629
- height: 4,
630
- decoration: BoxDecoration(
631
- color: isDark ? Colors.grey[600] : Colors.grey[300],
632
- borderRadius: BorderRadius.circular(2),
633
- ),
634
- ),
635
- ),
636
- const SizedBox(height: 16),
637
- Text(
638
- sheetL10n.select_persona,
639
- style: TextStyle(
640
- fontSize: 18,
641
- fontWeight: FontWeight.bold,
642
- color: isDark ? Colors.white : Colors.black,
643
- ),
644
- ),
645
- const SizedBox(height: 8),
646
- Flexible(
647
- child: ListView.builder(
648
- shrinkWrap: true,
649
- itemCount: personas.length,
650
- itemBuilder: (context, index) {
651
- final p = personas[index];
652
- final isSelected = p.id == currentPersona?.id;
653
- final accent = isDark
654
- ? AppColors.darkAccent
655
- : AppColors.lightAccent;
656
- return ListTile(
657
- leading: Text(
658
- p.emoji,
659
- style: const TextStyle(fontSize: 22),
660
- ),
661
- title: Text(
662
- p.name,
663
- style: TextStyle(
664
- fontWeight: isSelected
665
- ? FontWeight.w600
666
- : FontWeight.normal,
667
- color: isDark ? Colors.white : Colors.black,
668
- ),
669
- ),
670
- subtitle: p.description != null
671
- ? Text(
672
- p.description!,
673
- style: TextStyle(
674
- fontSize: 12,
675
- color: isDark
676
- ? AppColors.darkMutedText
677
- : AppColors.lightMutedText,
678
- ),
679
- maxLines: 1,
680
- overflow: TextOverflow.ellipsis,
681
- )
682
- : null,
683
- trailing: isSelected
684
- ? Icon(Icons.check_circle, color: accent)
685
- : null,
686
- onTap: () {
687
- ref.read(selectedPersonaProvider.notifier).select(p);
688
- Navigator.pop(ctx);
689
- },
690
- );
691
- },
692
- ),
693
- ),
694
- ],
695
- ),
696
- ),
697
- );
698
- },
699
- ).then((_) {});
700
-}
701
-
702
-class _ScreenAppBar extends ConsumerWidget {
703
- const _ScreenAppBar({
704
- required this.activeServer,
705
- required this.connectionStatus,
706
- required this.isDark,
707
- required this.persona,
708
- required this.onMenuAction,
709
- required this.onPersonaPicker,
710
- });
711
-
712
- final dynamic activeServer;
713
- final ConnectionStatus connectionStatus;
714
- final bool isDark;
715
- final dynamic persona;
716
- final void Function(String) onMenuAction;
717
- final VoidCallback onPersonaPicker;
718
-
719
- @override
720
- Widget build(BuildContext context, WidgetRef ref) {
721
- final l10n = AppLocalizations.of(context)!;
722
- final settings = ref.watch(settingsProvider);
723
- final mcpConfig = ref.watch(chatMcpConfigProvider);
724
- final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
725
-
726
- return Container(
727
- height: 56,
728
- padding: const EdgeInsets.symmetric(horizontal: 4),
729
- child: Row(
730
- children: [
731
- ShadResponsiveBuilder(
732
- builder: (context, breakpoint) {
733
- final isDesktop =
734
- breakpoint >= ShadTheme.of(context).breakpoints.md;
735
- if (isDesktop) return const SizedBox.shrink();
736
- return IconButton(
737
- icon: const Icon(Icons.menu),
738
- onPressed: () => Scaffold.of(context).openDrawer(),
739
- );
740
- },
741
- ),
742
- const SizedBox(width: 8),
743
- Expanded(
744
- child: Row(
745
- children: [
746
- if (activeServer != null) ...[
747
- _buildServerIcon(context, activeServer as Server?),
748
- const SizedBox(width: 8),
749
- ],
750
- Text(
751
- activeServer?.name ?? l10n.chat_title,
752
- style: const TextStyle(
753
- fontSize: 18,
754
- fontWeight: FontWeight.bold,
755
- ),
756
- ),
757
- const SizedBox(width: 8),
758
- Container(
759
- width: 8,
760
- height: 8,
761
- decoration: BoxDecoration(
762
- shape: BoxShape.circle,
763
- color: connectionStatus == ConnectionStatus.connected
764
- ? Colors.green
765
- : Colors.grey,
766
- ),
767
- ),
768
- ],
769
- ),
770
- ),
771
- Stack(
772
- children: [
773
- IconButton(
774
- icon: HugeIcon(
775
- icon: HugeIcons.strokeRoundedFilterHorizontal,
776
- size: 24,
777
- color: isDark ? Colors.white70 : Colors.black87,
778
- ),
779
- onPressed: () =>
780
- showChatSettingsSheet(context, initialTab: 'parameters'),
781
- tooltip: l10n.chat_parameters_tooltip,
782
- ),
783
- PositionedDirectional(
784
- top: 4,
785
- end: 0,
786
- child: Container(
787
- padding: const EdgeInsets.all(2),
788
- decoration: BoxDecoration(
789
- color: isMcpEnabled ? Colors.green : Colors.grey,
790
- shape: BoxShape.circle,
791
- ),
792
- child: const HugeIcon(
793
- icon: HugeIcons.strokeRoundedTools,
794
- size: 10,
795
- color: Colors.white,
796
- ),
797
- ),
798
- ),
799
- ],
800
- ),
801
- PopupMenuButton<String>(
802
- icon: const Icon(Icons.more_vert),
803
- onSelected: onMenuAction,
804
- itemBuilder: (context) => [
805
- PopupMenuItem(
806
- value: 'new_chat',
807
- child: ListTile(
808
- leading: const Icon(Icons.add),
809
- title: Text(l10n.nav_new_chat),
810
- contentPadding: EdgeInsets.zero,
811
- ),
812
- ),
813
- PopupMenuItem(
814
- value: 'persona',
815
- child: ListTile(
816
- leading: Icon(
817
- persona != null
818
- ? Icons.swap_horiz
819
- : Icons.smart_toy_outlined,
820
- ),
821
- title: Text(
822
- persona != null ? l10n.change_persona : l10n.set_persona,
823
- ),
824
- contentPadding: EdgeInsets.zero,
825
- ),
826
- ),
827
- if (persona != null)
828
- PopupMenuItem(
829
- value: 'remove_persona',
830
- child: ListTile(
831
- leading: const Icon(Icons.person_remove_outlined),
832
- title: Text(l10n.remove_persona),
833
- contentPadding: EdgeInsets.zero,
834
- ),
835
- ),
836
- PopupMenuItem(
837
- value: 'clear',
838
- child: ListTile(
839
- leading: const Icon(Icons.delete_outline),
840
- title: Text(l10n.clear_conversation),
841
- contentPadding: EdgeInsets.zero,
842
- ),
843
- ),
844
- ],
845
- ),
846
- ],
847
- ),
848
- );
849
- }
850
-}
851
-
852
-Widget _buildServerIcon(BuildContext context, Server? server) {
853
- if (server == null) return const SizedBox.shrink();
854
-
855
- final iconData = server.iconName != null
856
- ? getHugeIconByName(server.iconName)
857
- : getDefaultServerIcon(server.type.name);
858
-
859
- if (iconData == null) return const Icon(Icons.dns, size: 18);
860
-
861
- return HugeIcon(
862
- icon: iconData.icon,
863
- size: 18,
864
- color: Theme.of(context).colorScheme.primary,
865
- );
866
-}
867
-
868
-class _ChatBottomBar extends ConsumerWidget {
869
- const _ChatBottomBar({
870
- required this.isStreaming,
871
- required this.keyboardBottomInset,
872
- required this.inputFocusNode,
873
- });
874
-
875
- final bool isStreaming;
876
- final double keyboardBottomInset;
877
- final FocusNode inputFocusNode;
878
-
879
- @override
880
- Widget build(BuildContext context, WidgetRef ref) {
881
- return Positioned(
882
- bottom: 0,
883
- left: 0,
884
- right: 0,
885
- child: Container(
886
- decoration: BoxDecoration(
887
- gradient: LinearGradient(
888
- begin: Alignment.topCenter,
889
- end: Alignment.bottomCenter,
890
- colors: [
891
- Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.1),
892
- Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
893
- Theme.of(context).scaffoldBackgroundColor,
894
- ],
895
- stops: const [0.0, 0.7, 1.0],
896
- ),
897
- ),
898
- child: Column(
899
- mainAxisSize: MainAxisSize.min,
900
- children: [
901
- if (!isStreaming && keyboardBottomInset == 0) ...[
902
- const SizedBox(height: 4),
903
- _SmartReplyChipsWrapper(),
904
- ],
905
- ChatInputBar(
906
- focusNode: inputFocusNode,
907
- isStreaming: isStreaming,
908
- onSend: (message, {attachments}) {
909
- ref
910
- .read(chatProvider.notifier)
911
- .sendMessage(message, attachments: attachments);
912
- },
913
- onStop: () => ref.read(chatProvider.notifier).cancelStream(),
914
- ),
915
- ],
916
- ),
917
- ),
918
- );
919
- }
920
-}
921
-
922
-class _SmartReplyChipsWrapper extends ConsumerWidget {
923
- const _SmartReplyChipsWrapper();
924
-
925
- @override
926
- Widget build(BuildContext context, WidgetRef ref) {
927
- return SmartReplyChips(
928
- onSend: (message) {
929
- ref.read(chatProvider.notifier).sendMessage(message);
930
- },
931
- );
9
+ return const ChatScreenContent();
10
}
11
}
lib/features/chat/views/components/chat_screen_content.dart
new
+932
@@ -0,0 +1,932 @@
1
+import 'dart:convert';
2
+
3
+import 'package:flutter/material.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:go_router/go_router.dart';
6
+import 'package:hugeicons/hugeicons.dart';
7
+import 'package:shadcn_ui/shadcn_ui.dart';
8
+import 'package:localmind/core/theme/colors.dart';
9
+import 'package:localmind/core/components/model_picker/model_picker_sheet.dart';
10
+import 'package:localmind/core/components/server/server_icon_picker.dart';
11
+import 'package:localmind/core/components/tts/tts_player_bar.dart';
12
+import 'package:localmind/core/models/enums.dart';
13
+import 'package:localmind/core/providers/app_providers.dart';
14
+import 'package:localmind/core/providers/conversation_providers.dart' as conv;
15
+import 'package:localmind/core/providers/personas_providers.dart';
16
+import 'package:localmind/core/providers/server_providers.dart';
17
+import 'package:localmind/core/routes/app_routes.dart';
18
+import 'package:localmind/core/utils/system_insets.dart';
19
+import 'package:localmind/l10n/app_localizations.dart';
20
+import 'package:localmind/features/servers/data/models/server.dart';
21
+import '../../data/models/message.dart';
22
+import '../../providers/chat_mcp_providers.dart';
23
+import '../../providers/chat_providers.dart';
24
+import 'chat_auto_scroll_controller.dart';
25
+import 'chat_input_bar.dart';
26
+import 'chat_settings_sheet.dart';
27
+import 'edit_message_dialog.dart';
28
+import 'notification_permission_banner.dart';
29
+import 'message_list/message_list.dart';
30
+import 'message_list/empty_state.dart';
31
+import 'message_list/corrupted_state.dart';
32
+import 'top_bar/model_top_bar.dart';
33
+import 'top_bar/connection_banner.dart';
34
+import 'top_bar/persona_indicator.dart';
35
+import 'top_bar/smart_reply_chips.dart';
36
+
37
+class ChatScreenContent extends ConsumerStatefulWidget {
38
+ const ChatScreenContent({super.key});
39
+
40
+ @override
41
+ ConsumerState<ChatScreenContent> createState() => _ChatScreenContentState();
42
+}
43
+
44
+class _ChatScreenContentState extends ConsumerState<ChatScreenContent>
45
+ with WidgetsBindingObserver {
46
+ final ScrollController _scrollController = ScrollController();
47
+ final FocusNode _inputFocusNode = FocusNode();
48
+ bool _isApprovalDialogOpen = false;
49
+ late final ChatAutoScrollController _autoScroll;
50
+
51
+ @override
52
+ void initState() {
53
+ super.initState();
54
+ WidgetsBinding.instance.addObserver(this);
55
+ _inputFocusNode.addListener(() => setState(() {}));
56
+ _scrollController.addListener(
57
+ () => _autoScroll.onScrollChanged(_scrollController),
58
+ );
59
+ _autoScroll = ChatAutoScrollController();
60
+ }
61
+
62
+ @override
63
+ void didChangeAppLifecycleState(AppLifecycleState state) {
64
+ if (state == AppLifecycleState.inactive ||
65
+ state == AppLifecycleState.paused ||
66
+ state == AppLifecycleState.hidden ||
67
+ state == AppLifecycleState.detached) {
68
+ ref.read(chatProvider.notifier).checkpointStreamingMessage(flush: true);
69
+ }
70
+ }
71
+
72
+ @override
73
+ void dispose() {
74
+ WidgetsBinding.instance.removeObserver(this);
75
+ _scrollController.dispose();
76
+ _inputFocusNode.dispose();
77
+ super.dispose();
78
+ }
79
+
80
+ @override
81
+ Widget build(BuildContext context) {
82
+ ref.watch(autoSelectFirstLoadedModelProvider);
83
+
84
+ ref.listen<PendingToolApproval?>(
85
+ chatProvider.select((s) => s.pendingToolApproval),
86
+ (previous, next) {
87
+ if (next != null && !_isApprovalDialogOpen) {
88
+ _showToolApprovalDialog(context, next);
89
+ }
90
+ },
91
+ );
92
+
93
+ return _ChatBody(
94
+ scrollController: _scrollController,
95
+ inputFocusNode: _inputFocusNode,
96
+ autoScroll: _autoScroll,
97
+ onModelPicker: () => _showModelPicker(context),
98
+ onMenuAction: (action) => _handleMenuAction(action, context),
99
+ );
100
+ }
101
+
102
+ void _showModelPicker(BuildContext context) {
103
+ showModalBottomSheet(
104
+ context: context,
105
+ isScrollControlled: true,
106
+ useSafeArea: true,
107
+ builder: (context) => const ModelPickerSheet(),
108
+ );
109
+ }
110
+
111
+ void _handleMenuAction(String action, BuildContext context) {
112
+ switch (action) {
113
+ case 'new_chat':
114
+ ref.read(chatProvider.notifier).startNewConversation();
115
+ case 'persona':
116
+ _showPersonaPicker(context);
117
+ case 'remove_persona':
118
+ final activeConv = ref.read(conv.activeConversationProvider);
119
+ if (activeConv != null) {
120
+ ref
121
+ .read(conv.conversationsProvider.notifier)
122
+ .updatePersona(activeConv.id, null, null);
123
+ }
124
+ case 'clear':
125
+ showDialog(
126
+ context: context,
127
+ builder: (context) {
128
+ final dlgL10n = AppLocalizations.of(context)!;
129
+ return AlertDialog(
130
+ title: Text(dlgL10n.clear_conversation_title),
131
+ content: Text(dlgL10n.clear_conversation_body),
132
+ actions: [
133
+ TextButton(
134
+ onPressed: () => Navigator.pop(context),
135
+ child: Text(dlgL10n.cancel),
136
+ ),
137
+ TextButton(
138
+ onPressed: () {
139
+ Navigator.pop(context);
140
+ ref.read(chatProvider.notifier).clearConversation();
141
+ },
142
+ child: Text(dlgL10n.clear),
143
+ ),
144
+ ],
145
+ );
146
+ },
147
+ );
148
+ }
149
+ }
150
+
151
+ void _showPersonaPicker(BuildContext context) {
152
+ final personasAsync = ref.read(personasNotifierProvider);
153
+ final personas = personasAsync.value ?? [];
154
+ final isDark = Theme.of(context).brightness == Brightness.dark;
155
+ final activeConv = ref.read(conv.activeConversationProvider);
156
+ final currentPersonaId = activeConv?.personaId;
157
+
158
+ showModalBottomSheet(
159
+ context: context,
160
+ isScrollControlled: true,
161
+ builder: (ctx) {
162
+ final sheetL10n = AppLocalizations.of(context)!;
163
+ return SafeArea(
164
+ child: Container(
165
+ padding: const EdgeInsets.symmetric(vertical: 16),
166
+ child: Column(
167
+ mainAxisSize: MainAxisSize.min,
168
+ children: [
169
+ Center(
170
+ child: Container(
171
+ width: 40,
172
+ height: 4,
173
+ decoration: BoxDecoration(
174
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
175
+ borderRadius: BorderRadius.circular(2),
176
+ ),
177
+ ),
178
+ ),
179
+ const SizedBox(height: 16),
180
+ Text(
181
+ sheetL10n.select_persona,
182
+ style: TextStyle(
183
+ fontSize: 18,
184
+ fontWeight: FontWeight.bold,
185
+ color: isDark ? Colors.white : Colors.black,
186
+ ),
187
+ ),
188
+ const SizedBox(height: 8),
189
+ Flexible(
190
+ child: ListView.builder(
191
+ shrinkWrap: true,
192
+ itemCount: personas.length,
193
+ itemBuilder: (context, index) {
194
+ final p = personas[index];
195
+ final isSelected = p.id == currentPersonaId;
196
+ final accent = isDark
197
+ ? AppColors.darkAccent
198
+ : AppColors.lightAccent;
199
+ return ListTile(
200
+ leading: Text(
201
+ p.emoji,
202
+ style: const TextStyle(fontSize: 22),
203
+ ),
204
+ title: Text(
205
+ p.name,
206
+ style: TextStyle(
207
+ fontWeight: isSelected
208
+ ? FontWeight.w600
209
+ : FontWeight.normal,
210
+ color: isDark ? Colors.white : Colors.black,
211
+ ),
212
+ ),
213
+ subtitle: p.description != null
214
+ ? Text(
215
+ p.description!,
216
+ style: TextStyle(
217
+ fontSize: 12,
218
+ color: isDark
219
+ ? AppColors.darkMutedText
220
+ : AppColors.lightMutedText,
221
+ ),
222
+ maxLines: 1,
223
+ overflow: TextOverflow.ellipsis,
224
+ )
225
+ : null,
226
+ trailing: isSelected
227
+ ? Icon(Icons.check_circle, color: accent)
228
+ : null,
229
+ onTap: () {
230
+ if (activeConv != null) {
231
+ ref
232
+ .read(conv.conversationsProvider.notifier)
233
+ .updatePersona(
234
+ activeConv.id,
235
+ p.id,
236
+ p.systemPrompt,
237
+ );
238
+ }
239
+ Navigator.pop(ctx);
240
+ },
241
+ );
242
+ },
243
+ ),
244
+ ),
245
+ ],
246
+ ),
247
+ ),
248
+ );
249
+ },
250
+ );
251
+ }
252
+
253
+ void _showToolApprovalDialog(
254
+ BuildContext context,
255
+ PendingToolApproval approval,
256
+ ) {
257
+ setState(() => _isApprovalDialogOpen = true);
258
+ final l10n = AppLocalizations.of(context)!;
259
+
260
+ showShadDialog<bool>(
261
+ context: context,
262
+ barrierDismissible: false,
263
+ builder: (context) => ShadDialog.alert(
264
+ title: Text('${l10n.execute_tool_title}: ${approval.toolCall.name}?'),
265
+ description: Column(
266
+ crossAxisAlignment: CrossAxisAlignment.start,
267
+ mainAxisSize: MainAxisSize.min,
268
+ children: [
269
+ Text(l10n.execute_tool_request_desc),
270
+ const SizedBox(height: 8),
271
+ Container(
272
+ width: double.infinity,
273
+ padding: const EdgeInsets.all(8),
274
+ decoration: BoxDecoration(
275
+ color: Theme.of(context).colorScheme.surfaceContainer,
276
+ borderRadius: BorderRadius.circular(8),
277
+ ),
278
+ child: SingleChildScrollView(
279
+ scrollDirection: Axis.horizontal,
280
+ child: Text(
281
+ const JsonEncoder.withIndent(
282
+ ' ',
283
+ ).convert(approval.toolCall.arguments),
284
+ style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
285
+ ),
286
+ ),
287
+ ),
288
+ ],
289
+ ),
290
+ actions: [
291
+ ShadButton.outline(
292
+ child: Text(l10n.reject),
293
+ onPressed: () => Navigator.of(context).pop(false),
294
+ ),
295
+ ShadButton(
296
+ child: Text(l10n.approve),
297
+ onPressed: () => Navigator.of(context).pop(true),
298
+ ),
299
+ ],
300
+ ),
301
+ ).then((value) {
302
+ if (mounted) setState(() => _isApprovalDialogOpen = false);
303
+ ref.read(chatProvider.notifier).approveTool(value ?? false);
304
+ });
305
+ }
306
+}
307
+
308
+class _ChatBody extends ConsumerWidget {
309
+ const _ChatBody({
310
+ required this.scrollController,
311
+ required this.inputFocusNode,
312
+ required this.autoScroll,
313
+ required this.onModelPicker,
314
+ required this.onMenuAction,
315
+ });
316
+
317
+ final ScrollController scrollController;
318
+ final FocusNode inputFocusNode;
319
+ final ChatAutoScrollController autoScroll;
320
+ final VoidCallback onModelPicker;
321
+ final void Function(String) onMenuAction;
322
+
323
+ @override
324
+ Widget build(BuildContext context, WidgetRef ref) {
325
+ final theme = Theme.of(context);
326
+ final isDark = theme.brightness == Brightness.dark;
327
+ final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
328
+ final messages = ref.watch(chatProvider.select((s) => s.messages));
329
+ final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
330
+ final streamingLength = ref.watch(
331
+ chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
332
+ );
333
+ final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
334
+ final selectedModel = ref.watch(selectedModelProvider);
335
+ final connectionStatus = ref.watch(connectionStatusProvider);
336
+ final activeServer = ref.watch(activeServerProvider);
337
+ final activeConversation = ref.watch(conv.activeConversationProvider);
338
+ final personaId = activeConversation?.personaId;
339
+ final persona = personaId != null
340
+ ? ref.watch(personaByIdProvider(personaId))
341
+ : null;
342
+ final keyboardBottomInset = bottomKeyboardInset(context);
343
+ final systemBottomInset = bottomSystemInset(context);
344
+ final effectiveBottomInset = keyboardBottomInset > 0
345
+ ? 0.0
346
+ : systemBottomInset;
347
+
348
+ final needsScroll = autoScroll.checkAndUpdate(
349
+ messageCount: messages.length,
350
+ streamingLength: streamingLength,
351
+ isStreaming: isStreaming,
352
+ );
353
+ if (needsScroll) {
354
+ autoScroll.scheduleAutoScroll(
355
+ controller: scrollController,
356
+ streaming: isStreaming,
357
+ );
358
+ }
359
+
360
+ return Column(
361
+ children: [
362
+ ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
363
+ _ScreenAppBar(
364
+ activeServer: activeServer,
365
+ connectionStatus: connectionStatus,
366
+ isDark: isDark,
367
+ persona: persona,
368
+ onMenuAction: onMenuAction,
369
+ onPersonaPicker: () => _openPersonaPicker(context, ref),
370
+ ),
371
+ const NotificationPermissionBanner(),
372
+ if (connectionStatus == ConnectionStatus.disconnected ||
373
+ connectionStatus == ConnectionStatus.error)
374
+ ConnectionBanner(status: connectionStatus),
375
+ if (persona != null)
376
+ PersonaIndicator(
377
+ persona: persona,
378
+ onTap: () => _openPersonaPicker(context, ref),
379
+ onRemove: () {
380
+ final activeConv = ref.read(conv.activeConversationProvider);
381
+ if (activeConv != null) {
382
+ ref
383
+ .read(conv.conversationsProvider.notifier)
384
+ .updatePersona(activeConv.id, null, null);
385
+ }
386
+ },
387
+ ),
388
+ const TtsPlayerBar(),
389
+ Expanded(
390
+ child: Stack(
391
+ children: [
392
+ _MessageArea(
393
+ isLoading: isLoading,
394
+ messages: messages,
395
+ activeConversation: activeConversation,
396
+ errorMessage: errorMessage,
397
+ selectedModel: selectedModel,
398
+ isStreaming: isStreaming,
399
+ scrollController: scrollController,
400
+ effectiveBottomInset: effectiveBottomInset,
401
+ keyboardBottomInset: keyboardBottomInset,
402
+ onModelPicker: onModelPicker,
403
+ ),
404
+ _ChatBottomBar(
405
+ isStreaming: isStreaming,
406
+ keyboardBottomInset: keyboardBottomInset,
407
+ inputFocusNode: inputFocusNode,
408
+ ),
409
+ ],
410
+ ),
411
+ ),
412
+ ],
413
+ );
414
+ }
415
+}
416
+
417
+void _openPersonaPicker(BuildContext context, WidgetRef ref) {
418
+ final personasAsync = ref.read(personasNotifierProvider);
419
+ final personas = personasAsync.value ?? [];
420
+ final isDark = Theme.of(context).brightness == Brightness.dark;
421
+ final activeConv = ref.read(conv.activeConversationProvider);
422
+ final currentPersonaId = activeConv?.personaId;
423
+
424
+ showModalBottomSheet(
425
+ context: context,
426
+ isScrollControlled: true,
427
+ builder: (ctx) {
428
+ final sheetL10n = AppLocalizations.of(context)!;
429
+ return SafeArea(
430
+ child: Container(
431
+ padding: const EdgeInsets.symmetric(vertical: 16),
432
+ child: Column(
433
+ mainAxisSize: MainAxisSize.min,
434
+ children: [
435
+ Center(
436
+ child: Container(
437
+ width: 40,
438
+ height: 4,
439
+ decoration: BoxDecoration(
440
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
441
+ borderRadius: BorderRadius.circular(2),
442
+ ),
443
+ ),
444
+ ),
445
+ const SizedBox(height: 16),
446
+ Text(
447
+ sheetL10n.select_persona,
448
+ style: TextStyle(
449
+ fontSize: 18,
450
+ fontWeight: FontWeight.bold,
451
+ color: isDark ? Colors.white : Colors.black,
452
+ ),
453
+ ),
454
+ const SizedBox(height: 8),
455
+ Flexible(
456
+ child: ListView.builder(
457
+ shrinkWrap: true,
458
+ itemCount: personas.length,
459
+ itemBuilder: (context, index) {
460
+ final p = personas[index];
461
+ final isSelected = p.id == currentPersonaId;
462
+ final accent = isDark
463
+ ? AppColors.darkAccent
464
+ : AppColors.lightAccent;
465
+ return ListTile(
466
+ leading: Text(
467
+ p.emoji,
468
+ style: const TextStyle(fontSize: 22),
469
+ ),
470
+ title: Text(
471
+ p.name,
472
+ style: TextStyle(
473
+ fontWeight: isSelected
474
+ ? FontWeight.w600
475
+ : FontWeight.normal,
476
+ color: isDark ? Colors.white : Colors.black,
477
+ ),
478
+ ),
479
+ subtitle: p.description != null
480
+ ? Text(
481
+ p.description!,
482
+ style: TextStyle(
483
+ fontSize: 12,
484
+ color: isDark
485
+ ? AppColors.darkMutedText
486
+ : AppColors.lightMutedText,
487
+ ),
488
+ maxLines: 1,
489
+ overflow: TextOverflow.ellipsis,
490
+ )
491
+ : null,
492
+ trailing: isSelected
493
+ ? Icon(Icons.check_circle, color: accent)
494
+ : null,
495
+ onTap: () {
496
+ if (activeConv != null) {
497
+ ref
498
+ .read(conv.conversationsProvider.notifier)
499
+ .updatePersona(
500
+ activeConv.id,
501
+ p.id,
502
+ p.systemPrompt,
503
+ );
504
+ }
505
+ Navigator.pop(ctx);
506
+ },
507
+ );
508
+ },
509
+ ),
510
+ ),
511
+ ],
512
+ ),
513
+ ),
514
+ );
515
+ },
516
+ ).then((_) {});
517
+}
518
+
519
+class _MessageArea extends ConsumerWidget {
520
+ const _MessageArea({
521
+ required this.isLoading,
522
+ required this.messages,
523
+ required this.activeConversation,
524
+ this.errorMessage,
525
+ required this.selectedModel,
526
+ required this.isStreaming,
527
+ required this.scrollController,
528
+ required this.effectiveBottomInset,
529
+ required this.keyboardBottomInset,
530
+ required this.onModelPicker,
531
+ });
532
+
533
+ final bool isLoading;
534
+ final List<Message> messages;
535
+ final dynamic activeConversation;
536
+ final String? errorMessage;
537
+ final dynamic selectedModel;
538
+ final bool isStreaming;
539
+ final ScrollController scrollController;
540
+ final double effectiveBottomInset;
541
+ final double keyboardBottomInset;
542
+ final VoidCallback onModelPicker;
543
+
544
+ @override
545
+ Widget build(BuildContext context, WidgetRef ref) {
546
+ final l10n = AppLocalizations.of(context)!;
547
+
548
+ if (isLoading) {
549
+ return const Center(child: CircularProgressIndicator(strokeWidth: 2));
550
+ }
551
+
552
+ if (messages.isEmpty && activeConversation != null) {
553
+ return CorruptedChatState(
554
+ conversation: activeConversation,
555
+ errorMessage: errorMessage,
556
+ onStartNewChat: () =>
557
+ ref.read(chatProvider.notifier).startNewConversation(),
558
+ );
559
+ }
560
+
561
+ if (messages.isEmpty) {
562
+ return EmptyState(
563
+ onQuickPrompt: (prompt) =>
564
+ ref.read(chatProvider.notifier).sendMessage(prompt),
565
+ quickPrompts: [
566
+ l10n.quick_write,
567
+ l10n.quick_explain,
568
+ l10n.quick_debug,
569
+ l10n.quick_async,
570
+ ],
571
+ recentConversations: ref.watch(conv.recentConversationsProvider),
572
+ onSeeAll: () => context.push(AppRoutes.chatHistory),
573
+ selectedModel: selectedModel,
574
+ onModelTap: onModelPicker,
575
+ selectedPersona: ref.watch(selectedPersonaProvider),
576
+ onPersonaTap: () => _openPersonaPickerForPreselection(context, ref),
577
+ );
578
+ }
579
+
580
+ return MessageList(
581
+ scrollController: scrollController,
582
+ messages: messages,
583
+ isStreaming: isStreaming,
584
+ onRetry: (messageId) =>
585
+ ref.read(chatProvider.notifier).retryMessage(messageId),
586
+ onDelete: (messageId) =>
587
+ ref.read(chatProvider.notifier).deleteMessage(messageId),
588
+ onEdit: (messageId, currentContent) async {
589
+ final newContent = await EditMessageDialog.show(
590
+ context,
591
+ initialContent: currentContent,
592
+ );
593
+ if (newContent != null && newContent != currentContent) {
594
+ await ref
595
+ .read(chatProvider.notifier)
596
+ .editMessage(messageId, newContent);
597
+ }
598
+ },
599
+ hasSmartReplies:
600
+ !isStreaming &&
601
+ keyboardBottomInset == 0 &&
602
+ (ref.watch(smartRepliesProvider).asData?.value.isNotEmpty ?? false),
603
+ bottomInset: effectiveBottomInset,
604
+ );
605
+ }
606
+}
607
+
608
+void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
609
+ final personasAsync = ref.read(personasNotifierProvider);
610
+ final personas = personasAsync.value ?? [];
611
+ final isDark = Theme.of(context).brightness == Brightness.dark;
612
+ final currentPersona = ref.watch(selectedPersonaProvider);
613
+
614
+ showModalBottomSheet(
615
+ context: context,
616
+ isScrollControlled: true,
617
+ builder: (ctx) {
618
+ final sheetL10n = AppLocalizations.of(context)!;
619
+ return SafeArea(
620
+ child: Container(
621
+ padding: const EdgeInsets.symmetric(vertical: 16),
622
+ child: Column(
623
+ mainAxisSize: MainAxisSize.min,
624
+ children: [
625
+ Center(
626
+ child: Container(
627
+ width: 40,
628
+ height: 4,
629
+ decoration: BoxDecoration(
630
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
631
+ borderRadius: BorderRadius.circular(2),
632
+ ),
633
+ ),
634
+ ),
635
+ const SizedBox(height: 16),
636
+ Text(
637
+ sheetL10n.select_persona,
638
+ style: TextStyle(
639
+ fontSize: 18,
640
+ fontWeight: FontWeight.bold,
641
+ color: isDark ? Colors.white : Colors.black,
642
+ ),
643
+ ),
644
+ const SizedBox(height: 8),
645
+ Flexible(
646
+ child: ListView.builder(
647
+ shrinkWrap: true,
648
+ itemCount: personas.length,
649
+ itemBuilder: (context, index) {
650
+ final p = personas[index];
651
+ final isSelected = p.id == currentPersona?.id;
652
+ final accent = isDark
653
+ ? AppColors.darkAccent
654
+ : AppColors.lightAccent;
655
+ return ListTile(
656
+ leading: Text(
657
+ p.emoji,
658
+ style: const TextStyle(fontSize: 22),
659
+ ),
660
+ title: Text(
661
+ p.name,
662
+ style: TextStyle(
663
+ fontWeight: isSelected
664
+ ? FontWeight.w600
665
+ : FontWeight.normal,
666
+ color: isDark ? Colors.white : Colors.black,
667
+ ),
668
+ ),
669
+ subtitle: p.description != null
670
+ ? Text(
671
+ p.description!,
672
+ style: TextStyle(
673
+ fontSize: 12,
674
+ color: isDark
675
+ ? AppColors.darkMutedText
676
+ : AppColors.lightMutedText,
677
+ ),
678
+ maxLines: 1,
679
+ overflow: TextOverflow.ellipsis,
680
+ )
681
+ : null,
682
+ trailing: isSelected
683
+ ? Icon(Icons.check_circle, color: accent)
684
+ : null,
685
+ onTap: () {
686
+ ref.read(selectedPersonaProvider.notifier).select(p);
687
+ Navigator.pop(ctx);
688
+ },
689
+ );
690
+ },
691
+ ),
692
+ ),
693
+ ],
694
+ ),
695
+ ),
696
+ );
697
+ },
698
+ ).then((_) {});
699
+}
700
+
701
+class _ScreenAppBar extends ConsumerWidget {
702
+ const _ScreenAppBar({
703
+ required this.activeServer,
704
+ required this.connectionStatus,
705
+ required this.isDark,
706
+ required this.persona,
707
+ required this.onMenuAction,
708
+ required this.onPersonaPicker,
709
+ });
710
+
711
+ final dynamic activeServer;
712
+ final ConnectionStatus connectionStatus;
713
+ final bool isDark;
714
+ final dynamic persona;
715
+ final void Function(String) onMenuAction;
716
+ final VoidCallback onPersonaPicker;
717
+
718
+ @override
719
+ Widget build(BuildContext context, WidgetRef ref) {
720
+ final l10n = AppLocalizations.of(context)!;
721
+ final settings = ref.watch(settingsProvider);
722
+ final mcpConfig = ref.watch(chatMcpConfigProvider);
723
+ final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
724
+
725
+ return Container(
726
+ height: 56,
727
+ padding: const EdgeInsets.symmetric(horizontal: 4),
728
+ child: Row(
729
+ children: [
730
+ ShadResponsiveBuilder(
731
+ builder: (context, breakpoint) {
732
+ final isDesktop =
733
+ breakpoint >= ShadTheme.of(context).breakpoints.md;
734
+ if (isDesktop) return const SizedBox.shrink();
735
+ return IconButton(
736
+ icon: const Icon(Icons.menu),
737
+ onPressed: () => Scaffold.of(context).openDrawer(),
738
+ );
739
+ },
740
+ ),
741
+ const SizedBox(width: 8),
742
+ Expanded(
743
+ child: Row(
744
+ children: [
745
+ if (activeServer != null) ...[
746
+ _buildServerIcon(context, activeServer as Server?),
747
+ const SizedBox(width: 8),
748
+ ],
749
+ Text(
750
+ activeServer?.name ?? l10n.chat_title,
751
+ style: const TextStyle(
752
+ fontSize: 18,
753
+ fontWeight: FontWeight.bold,
754
+ ),
755
+ ),
756
+ const SizedBox(width: 8),
757
+ Container(
758
+ width: 8,
759
+ height: 8,
760
+ decoration: BoxDecoration(
761
+ shape: BoxShape.circle,
762
+ color: connectionStatus == ConnectionStatus.connected
763
+ ? Colors.green
764
+ : Colors.grey,
765
+ ),
766
+ ),
767
+ ],
768
+ ),
769
+ ),
770
+ Stack(
771
+ children: [
772
+ IconButton(
773
+ icon: HugeIcon(
774
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
775
+ size: 24,
776
+ color: isDark ? Colors.white70 : Colors.black87,
777
+ ),
778
+ onPressed: () =>
779
+ showChatSettingsSheet(context, initialTab: 'parameters'),
780
+ tooltip: l10n.chat_parameters_tooltip,
781
+ ),
782
+ PositionedDirectional(
783
+ top: 4,
784
+ end: 0,
785
+ child: Container(
786
+ padding: const EdgeInsets.all(2),
787
+ decoration: BoxDecoration(
788
+ color: isMcpEnabled ? Colors.green : Colors.grey,
789
+ shape: BoxShape.circle,
790
+ ),
791
+ child: const HugeIcon(
792
+ icon: HugeIcons.strokeRoundedTools,
793
+ size: 10,
794
+ color: Colors.white,
795
+ ),
796
+ ),
797
+ ),
798
+ ],
799
+ ),
800
+ PopupMenuButton<String>(
801
+ icon: const Icon(Icons.more_vert),
802
+ onSelected: onMenuAction,
803
+ itemBuilder: (context) => [
804
+ PopupMenuItem(
805
+ value: 'new_chat',
806
+ child: ListTile(
807
+ leading: const Icon(Icons.add),
808
+ title: Text(l10n.nav_new_chat),
809
+ contentPadding: EdgeInsets.zero,
810
+ ),
811
+ ),
812
+ PopupMenuItem(
813
+ value: 'persona',
814
+ child: ListTile(
815
+ leading: Icon(
816
+ persona != null
817
+ ? Icons.swap_horiz
818
+ : Icons.smart_toy_outlined,
819
+ ),
820
+ title: Text(
821
+ persona != null ? l10n.change_persona : l10n.set_persona,
822
+ ),
823
+ contentPadding: EdgeInsets.zero,
824
+ ),
825
+ ),
826
+ if (persona != null)
827
+ PopupMenuItem(
828
+ value: 'remove_persona',
829
+ child: ListTile(
830
+ leading: const Icon(Icons.person_remove_outlined),
831
+ title: Text(l10n.remove_persona),
832
+ contentPadding: EdgeInsets.zero,
833
+ ),
834
+ ),
835
+ PopupMenuItem(
836
+ value: 'clear',
837
+ child: ListTile(
838
+ leading: const Icon(Icons.delete_outline),
839
+ title: Text(l10n.clear_conversation),
840
+ contentPadding: EdgeInsets.zero,
841
+ ),
842
+ ),
843
+ ],
844
+ ),
845
+ ],
846
+ ),
847
+ );
848
+ }
849
+}
850
+
851
+Widget _buildServerIcon(BuildContext context, Server? server) {
852
+ if (server == null) return const SizedBox.shrink();
853
+
854
+ final iconData = server.iconName != null
855
+ ? getHugeIconByName(server.iconName)
856
+ : getDefaultServerIcon(server.type.name);
857
+
858
+ if (iconData == null) return const Icon(Icons.dns, size: 18);
859
+
860
+ return HugeIcon(
861
+ icon: iconData.icon,
862
+ size: 18,
863
+ color: Theme.of(context).colorScheme.primary,
864
+ );
865
+}
866
+
867
+class _ChatBottomBar extends ConsumerWidget {
868
+ const _ChatBottomBar({
869
+ required this.isStreaming,
870
+ required this.keyboardBottomInset,
871
+ required this.inputFocusNode,
872
+ });
873
+
874
+ final bool isStreaming;
875
+ final double keyboardBottomInset;
876
+ final FocusNode inputFocusNode;
877
+
878
+ @override
879
+ Widget build(BuildContext context, WidgetRef ref) {
880
+ return Positioned(
881
+ bottom: 0,
882
+ left: 0,
883
+ right: 0,
884
+ child: Container(
885
+ decoration: BoxDecoration(
886
+ gradient: LinearGradient(
887
+ begin: Alignment.topCenter,
888
+ end: Alignment.bottomCenter,
889
+ colors: [
890
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.1),
891
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
892
+ Theme.of(context).scaffoldBackgroundColor,
893
+ ],
894
+ stops: const [0.0, 0.7, 1.0],
895
+ ),
896
+ ),
897
+ child: Column(
898
+ mainAxisSize: MainAxisSize.min,
899
+ children: [
900
+ if (!isStreaming && keyboardBottomInset == 0) ...[
901
+ const SizedBox(height: 4),
902
+ _SmartReplyChipsWrapper(),
903
+ ],
904
+ ChatInputBar(
905
+ focusNode: inputFocusNode,
906
+ isStreaming: isStreaming,
907
+ onSend: (message, {attachments}) {
908
+ ref
909
+ .read(chatProvider.notifier)
910
+ .sendMessage(message, attachments: attachments);
911
+ },
912
+ onStop: () => ref.read(chatProvider.notifier).cancelStream(),
913
+ ),
914
+ ],
915
+ ),
916
+ ),
917
+ );
918
+ }
919
+}
920
+
921
+class _SmartReplyChipsWrapper extends ConsumerWidget {
922
+ const _SmartReplyChipsWrapper();
923
+
924
+ @override
925
+ Widget build(BuildContext context, WidgetRef ref) {
926
+ return SmartReplyChips(
927
+ onSend: (message) {
928
+ ref.read(chatProvider.notifier).sendMessage(message);
929
+ },
930
+ );
931
+ }
932
+}
lib/features/models/data/repositories/model_cache.dart
renamed
+1
-1
@@ -1,4 +1,4 @@
1
-import 'models/model_info.dart';
1
+import 'package:localmind/core/models/model_info.dart';
2
3
class ModelCache {
4
final _cache = <String, _CacheEntry>{};
lib/features/models/providers/model_picker_providers.dart
+1
-13
@@ -1,13 +1 @@
1
-import 'package:flutter_riverpod/flutter_riverpod.dart';
2
-
3
-final modelSearchQueryProvider = NotifierProvider<ModelSearchNotifier, String>(
4
- ModelSearchNotifier.new,
5
-);
6
-
7
-class ModelSearchNotifier extends Notifier<String> {
8
- @override
9
- String build() => '';
10
-
11
- void setQuery(String q) => state = q;
12
- void clear() => state = '';
13
-}
1
+export 'package:localmind/core/providers/model_picker/model_picker_providers.dart';
lib/features/models/views/model_picker_sheet.dart
new
+12
@@ -0,0 +1,12 @@
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/core/components/model_picker/model_picker_sheet.dart'
3
+ as core;
4
+
5
+class ModelPickerSheet extends StatelessWidget {
6
+ const ModelPickerSheet({super.key});
7
+
8
+ @override
9
+ Widget build(BuildContext context) {
10
+ return const core.ModelPickerSheet();
11
+ }
12
+}
lib/features/on_device/data/models/on_device_model.dart
+2
-3
@@ -1,10 +1,9 @@
1
import 'package:flutter_gemma/flutter_gemma.dart';
2
3
import '../../../../core/models/enums.dart';
4
+import '../../../../core/models/on_device_model_types.dart';
5
5
-enum OnDeviceModelRuntime { gemma, llamaCpp }
6
-
7
-enum OnDeviceModelFormat { litertlm, task, binary, gguf }
6
+export '../../../../core/models/on_device_model_types.dart';
7
8
enum OnDeviceImportedSource { localFile, huggingFace }
9
lib/features/on_device/data/repositories/imported_gguf_model_repository.dart
renamed
+1
-2
@@ -6,8 +6,7 @@ import 'package:dio/dio.dart';
6
import 'package:path/path.dart' as p;
7
import 'package:path_provider/path_provider.dart';
8
import 'package:shared_preferences/shared_preferences.dart';
9
-
10
-import 'models/on_device_model.dart';
9
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10
11
class ImportedGgufModelMetadata {
12
final String id;
lib/features/on_device/providers/on_device_providers.dart
+1
-1
@@ -11,7 +11,7 @@ import '../../../core/providers/chat_background_service_provider.dart';
11
import '../../../core/providers/review_prompt_providers.dart';
12
import '../../../core/providers/service_providers.dart';
13
import '../../../core/providers/storage_providers.dart';
14
-import '../data/imported_gguf_model_repository.dart';
14
+import '../data/repositories/imported_gguf_model_repository.dart';
15
import '../data/models/on_device_model.dart';
16
import '../data/notification_permission_service.dart';
17
import '../data/on_device_gemma_service.dart';
lib/features/on_device/views/components/backend_selector.dart
renamed
+1
-1
@@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
7
-import '../../../core/providers/app_providers.dart';
7
+import 'package:localmind/core/providers/app_providers.dart';
8
9
class BackendSelector extends ConsumerWidget {
10
const BackendSelector({super.key});
lib/features/on_device/views/components/memory_stats_panel.dart
renamed
lib/features/on_device/views/components/model_card.dart
renamed
lib/features/on_device/views/model_manager_screen.dart
+6
-1102
@@ -6,14 +6,16 @@ import 'package:flutter/services.dart';
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
7
import 'package:localmind/core/models/enums.dart';
8
import 'package:localmind/core/providers/app_providers.dart';
9
+import 'package:localmind/core/providers/foreground_download_providers.dart';
10
+import 'package:localmind/core/providers/on_device_providers.dart';
11
import 'package:localmind/l10n/app_localizations.dart';
12
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
11
-import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
12
-import 'package:localmind/features/on_device/providers/on_device_providers.dart';
13
-import 'package:localmind/features/on_device/components/memory_stats_panel.dart';
14
-import 'package:localmind/features/on_device/components/model_card.dart';
13
+import 'package:localmind/features/on_device/views/components/memory_stats_panel.dart';
14
+import 'package:localmind/features/on_device/views/components/model_card.dart';
15
import 'package:url_launcher/url_launcher.dart';
16
17
+part 'model_manager_screen_parts.dart';
18
+
19
enum _ImportAction { localFile, huggingFace }
20
21
class OnDeviceModelManagerScreen extends ConsumerStatefulWidget {
@@ -203,1101 +205,3 @@ class _OnDeviceModelManagerScreenState
205
}
206
}
207
}
206
-
207
-class _ManagerHeader extends StatelessWidget {
208
- const _ManagerHeader({
209
- required this.l10n,
210
- required this.theme,
211
- required this.onImportLocalGguf,
212
- required this.onImportFromHuggingFace,
213
- });
214
-
215
- final AppLocalizations l10n;
216
- final ThemeData theme;
217
- final VoidCallback onImportLocalGguf;
218
- final VoidCallback onImportFromHuggingFace;
219
-
220
- @override
221
- Widget build(BuildContext context) {
222
- final topPadding = MediaQuery.of(context).padding.top;
223
-
224
- return Container(
225
- padding: EdgeInsets.only(
226
- left: 16,
227
- right: 16,
228
- top: topPadding + 8,
229
- bottom: 16,
230
- ),
231
- decoration: BoxDecoration(
232
- color: theme.brightness == Brightness.dark
233
- ? const Color(0xFF0A0A0A)
234
- : const Color(0xFFFAFAFA),
235
- border: Border(
236
- bottom: BorderSide(
237
- color: theme.brightness == Brightness.dark
238
- ? const Color(0xFF2A2A2A)
239
- : const Color(0xFFE5E5E5),
240
- ),
241
- ),
242
- ),
243
- child: Row(
244
- children: [
245
- Builder(
246
- builder: (context) => IconButton(
247
- icon: const Icon(Icons.menu),
248
- onPressed: () => Scaffold.of(context).openDrawer(),
249
- ),
250
- ),
251
- const SizedBox(width: 8),
252
- Expanded(
253
- child: Text(
254
- l10n.on_device_models_title,
255
- style: TextStyle(
256
- fontSize: 20,
257
- fontWeight: FontWeight.bold,
258
- color: theme.brightness == Brightness.dark
259
- ? Colors.white
260
- : Colors.black,
261
- ),
262
- ),
263
- ),
264
- if (defaultTargetPlatform == TargetPlatform.android ||
265
- defaultTargetPlatform == TargetPlatform.iOS)
266
- PopupMenuButton<_ImportAction>(
267
- tooltip: l10n.gguf_import_action,
268
- onSelected: (value) {
269
- switch (value) {
270
- case _ImportAction.localFile:
271
- onImportLocalGguf();
272
- case _ImportAction.huggingFace:
273
- onImportFromHuggingFace();
274
- }
275
- },
276
- itemBuilder: (context) => [
277
- PopupMenuItem<_ImportAction>(
278
- value: _ImportAction.localFile,
279
- child: ListTile(
280
- contentPadding: EdgeInsets.zero,
281
- leading: const Icon(Icons.folder_open_outlined),
282
- title: Text(l10n.gguf_import_local_title),
283
- subtitle: Text(l10n.gguf_import_local_subtitle),
284
- ),
285
- ),
286
- PopupMenuItem<_ImportAction>(
287
- value: _ImportAction.huggingFace,
288
- child: ListTile(
289
- contentPadding: EdgeInsets.zero,
290
- leading: const Icon(Icons.cloud_download_outlined),
291
- title: Text(l10n.gguf_import_huggingface_title),
292
- subtitle: Text(l10n.gguf_import_huggingface_subtitle),
293
- ),
294
- ),
295
- ],
296
- child: Container(
297
- padding: const EdgeInsets.symmetric(
298
- horizontal: 12,
299
- vertical: 8,
300
- ),
301
- decoration: BoxDecoration(
302
- borderRadius: BorderRadius.circular(10),
303
- border: Border.all(
304
- color: theme.colorScheme.outline.withValues(alpha: 0.4),
305
- ),
306
- ),
307
- child: Row(
308
- mainAxisSize: MainAxisSize.min,
309
- children: [
310
- Icon(
311
- Icons.add_circle_outline,
312
- size: 18,
313
- color: theme.colorScheme.primary,
314
- ),
315
- const SizedBox(width: 8),
316
- Text(l10n.gguf_import_action),
317
- ],
318
- ),
319
- ),
320
- ),
321
- ],
322
- ),
323
- );
324
- }
325
-}
326
-
327
-class _ImportOverviewCard extends StatelessWidget {
328
- const _ImportOverviewCard({
329
- required this.importedModels,
330
- required this.onImportLocalGguf,
331
- required this.onImportFromHuggingFace,
332
- });
333
-
334
- final List<OnDeviceModel> importedModels;
335
- final VoidCallback onImportLocalGguf;
336
- final VoidCallback onImportFromHuggingFace;
337
-
338
- @override
339
- Widget build(BuildContext context) {
340
- final theme = Theme.of(context);
341
- final l10n = AppLocalizations.of(context)!;
342
- final localImports = importedModels
343
- .where((model) => model.isImportedFromLocalFile)
344
- .length;
345
- final huggingFaceImports = importedModels
346
- .where((model) => model.isImportedFromHuggingFace)
347
- .length;
348
-
349
- return Container(
350
- margin: const EdgeInsets.only(bottom: 16),
351
- padding: const EdgeInsets.all(16),
352
- decoration: BoxDecoration(
353
- color: theme.colorScheme.primary.withValues(alpha: 0.05),
354
- borderRadius: BorderRadius.circular(16),
355
- border: Border.all(
356
- color: theme.colorScheme.primary.withValues(alpha: 0.15),
357
- ),
358
- ),
359
- child: Column(
360
- crossAxisAlignment: CrossAxisAlignment.start,
361
- children: [
362
- Row(
363
- crossAxisAlignment: CrossAxisAlignment.start,
364
- children: [
365
- Container(
366
- padding: const EdgeInsets.all(10),
367
- decoration: BoxDecoration(
368
- color: theme.colorScheme.primary.withValues(alpha: 0.1),
369
- borderRadius: BorderRadius.circular(12),
370
- ),
371
- child: Icon(
372
- Icons.memory_outlined,
373
- color: theme.colorScheme.primary,
374
- ),
375
- ),
376
- const SizedBox(width: 12),
377
- Expanded(
378
- child: Column(
379
- crossAxisAlignment: CrossAxisAlignment.start,
380
- children: [
381
- Text(
382
- l10n.gguf_overview_title,
383
- style: theme.textTheme.titleMedium?.copyWith(
384
- fontWeight: FontWeight.bold,
385
- ),
386
- ),
387
- const SizedBox(height: 4),
388
- Text(
389
- l10n.gguf_overview_subtitle,
390
- style: theme.textTheme.bodySmall?.copyWith(
391
- color: theme.colorScheme.onSurface.withValues(
392
- alpha: 0.7,
393
- ),
394
- ),
395
- ),
396
- ],
397
- ),
398
- ),
399
- ],
400
- ),
401
- const SizedBox(height: 12),
402
- Wrap(
403
- spacing: 8,
404
- runSpacing: 8,
405
- children: [
406
- _SummaryPill(
407
- icon: Icons.layers_outlined,
408
- label:
409
- '${importedModels.length} ${l10n.gguf_imported_count_label}',
410
- ),
411
- _SummaryPill(
412
- icon: Icons.folder_open_outlined,
413
- label: '$localImports ${l10n.gguf_local_files_label}',
414
- ),
415
- _SummaryPill(
416
- icon: Icons.cloud_download_outlined,
417
- label: '$huggingFaceImports ${l10n.gguf_huggingface_label}',
418
- ),
419
- ],
420
- ),
421
- const SizedBox(height: 12),
422
- Wrap(
423
- spacing: 10,
424
- runSpacing: 10,
425
- children: [
426
- OutlinedButton.icon(
427
- onPressed: onImportLocalGguf,
428
- icon: const Icon(Icons.folder_open_outlined),
429
- label: Text(l10n.gguf_import_local_title),
430
- ),
431
- OutlinedButton.icon(
432
- onPressed: onImportFromHuggingFace,
433
- icon: const Icon(Icons.cloud_download_outlined),
434
- label: Text(l10n.gguf_import_huggingface_title),
435
- ),
436
- ],
437
- ),
438
- ],
439
- ),
440
- );
441
- }
442
-}
443
-
444
-class _SummaryPill extends StatelessWidget {
445
- const _SummaryPill({required this.icon, required this.label});
446
-
447
- final IconData icon;
448
- final String label;
449
-
450
- @override
451
- Widget build(BuildContext context) {
452
- final theme = Theme.of(context);
453
- return Container(
454
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
455
- decoration: BoxDecoration(
456
- color: theme.colorScheme.surface,
457
- borderRadius: BorderRadius.circular(999),
458
- border: Border.all(
459
- color: theme.colorScheme.outline.withValues(alpha: 0.18),
460
- ),
461
- ),
462
- child: Row(
463
- mainAxisSize: MainAxisSize.min,
464
- children: [
465
- Icon(icon, size: 14, color: theme.colorScheme.primary),
466
- const SizedBox(width: 6),
467
- Text(
468
- label,
469
- style: theme.textTheme.labelMedium?.copyWith(
470
- fontWeight: FontWeight.w600,
471
- ),
472
- ),
473
- ],
474
- ),
475
- );
476
- }
477
-}
478
-
479
-class _SectionHeader extends StatelessWidget {
480
- const _SectionHeader({required this.title, required this.subtitle});
481
-
482
- final String title;
483
- final String subtitle;
484
-
485
- @override
486
- Widget build(BuildContext context) {
487
- final theme = Theme.of(context);
488
- return Padding(
489
- padding: const EdgeInsets.only(bottom: 12),
490
- child: Column(
491
- crossAxisAlignment: CrossAxisAlignment.start,
492
- children: [
493
- Text(
494
- title,
495
- style: theme.textTheme.titleMedium?.copyWith(
496
- fontWeight: FontWeight.bold,
497
- ),
498
- ),
499
- const SizedBox(height: 4),
500
- Text(
501
- subtitle,
502
- style: theme.textTheme.bodySmall?.copyWith(
503
- color: theme.colorScheme.onSurface.withValues(alpha: 0.65),
504
- ),
505
- ),
506
- ],
507
- ),
508
- );
509
- }
510
-}
511
-
512
-class _EmptyImportedModelsCard extends StatelessWidget {
513
- const _EmptyImportedModelsCard({
514
- required this.onImportLocalGguf,
515
- required this.onImportFromHuggingFace,
516
- });
517
-
518
- final VoidCallback onImportLocalGguf;
519
- final VoidCallback onImportFromHuggingFace;
520
-
521
- @override
522
- Widget build(BuildContext context) {
523
- final theme = Theme.of(context);
524
- final l10n = AppLocalizations.of(context)!;
525
- return Container(
526
- margin: const EdgeInsets.only(bottom: 16),
527
- padding: const EdgeInsets.all(18),
528
- decoration: BoxDecoration(
529
- color: theme.colorScheme.surface,
530
- borderRadius: BorderRadius.circular(16),
531
- border: Border.all(
532
- color: theme.colorScheme.outline.withValues(alpha: 0.18),
533
- ),
534
- ),
535
- child: Column(
536
- crossAxisAlignment: CrossAxisAlignment.start,
537
- children: [
538
- Text(
539
- l10n.gguf_no_imported_title,
540
- style: theme.textTheme.titleSmall?.copyWith(
541
- fontWeight: FontWeight.w700,
542
- ),
543
- ),
544
- const SizedBox(height: 6),
545
- Text(
546
- l10n.gguf_no_imported_subtitle,
547
- style: theme.textTheme.bodySmall?.copyWith(
548
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
549
- ),
550
- ),
551
- const SizedBox(height: 12),
552
- Wrap(
553
- spacing: 10,
554
- runSpacing: 10,
555
- children: [
556
- OutlinedButton.icon(
557
- onPressed: onImportLocalGguf,
558
- icon: const Icon(Icons.folder_open_outlined),
559
- label: Text(l10n.gguf_import_local_title),
560
- ),
561
- OutlinedButton.icon(
562
- onPressed: onImportFromHuggingFace,
563
- icon: const Icon(Icons.cloud_download_outlined),
564
- label: Text(l10n.gguf_import_huggingface_title),
565
- ),
566
- ],
567
- ),
568
- ],
569
- ),
570
- );
571
- }
572
-}
573
-
574
-class _HuggingFaceGgufImportDialog extends ConsumerStatefulWidget {
575
- const _HuggingFaceGgufImportDialog();
576
-
577
- @override
578
- ConsumerState<_HuggingFaceGgufImportDialog> createState() =>
579
- _HuggingFaceGgufImportDialogState();
580
-}
581
-
582
-class _HuggingFaceGgufImportDialogState
583
- extends ConsumerState<_HuggingFaceGgufImportDialog> {
584
- static final Uri _huggingFaceModelsUri = Uri.parse(
585
- 'https://huggingface.co/models?search=gguf',
586
- );
587
-
588
- final TextEditingController _urlController = TextEditingController();
589
- CancelToken? _cancelToken;
590
- bool _isImporting = false;
591
- String? _error;
592
- int _receivedBytes = 0;
593
- int _totalBytes = 0;
594
-
595
- @override
596
- void dispose() {
597
- _cancelToken?.cancel();
598
- _urlController.dispose();
599
- super.dispose();
600
- }
601
-
602
- @override
603
- Widget build(BuildContext context) {
604
- final theme = Theme.of(context);
605
- final l10n = AppLocalizations.of(context)!;
606
- final hasHuggingFaceToken = ref.watch(
607
- settingsProvider.select(
608
- (settings) => settings.huggingFaceToken?.trim().isNotEmpty == true,
609
- ),
610
- );
611
- final input = _urlController.text.trim();
612
- final preview = _ImportUrlPreview.fromInput(input);
613
- final progress = _totalBytes > 0 ? _receivedBytes / _totalBytes : null;
614
- final dialogWidth = MediaQuery.of(context).size.width > 560
615
- ? 520.0
616
- : MediaQuery.of(context).size.width * 0.82;
617
-
618
- return AlertDialog(
619
- titlePadding: const EdgeInsets.fromLTRB(24, 22, 24, 0),
620
- contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
621
- actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
622
- title: Row(
623
- children: [
624
- Container(
625
- padding: const EdgeInsets.all(10),
626
- decoration: BoxDecoration(
627
- color: theme.colorScheme.primary.withValues(alpha: 0.10),
628
- borderRadius: BorderRadius.circular(12),
629
- ),
630
- child: Icon(
631
- Icons.cloud_download_outlined,
632
- color: theme.colorScheme.primary,
633
- ),
634
- ),
635
- const SizedBox(width: 12),
636
- Expanded(child: Text(l10n.gguf_import_huggingface_dialog_title)),
637
- ],
638
- ),
639
- content: SizedBox(
640
- width: dialogWidth,
641
- child: SingleChildScrollView(
642
- child: Column(
643
- mainAxisSize: MainAxisSize.min,
644
- crossAxisAlignment: CrossAxisAlignment.start,
645
- children: [
646
- Text(
647
- l10n.gguf_import_huggingface_dialog_subtitle,
648
- style: theme.textTheme.bodySmall?.copyWith(
649
- color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
650
- ),
651
- ),
652
- const SizedBox(height: 14),
653
- TextField(
654
- controller: _urlController,
655
- enabled: !_isImporting,
656
- maxLines: 2,
657
- minLines: 1,
658
- keyboardType: TextInputType.url,
659
- textInputAction: TextInputAction.done,
660
- onChanged: (_) {
661
- if (_error != null) {
662
- setState(() => _error = null);
663
- } else {
664
- setState(() {});
665
- }
666
- },
667
- onSubmitted: (_) {
668
- if (!_isImporting) {
669
- _startImport();
670
- }
671
- },
672
- decoration: InputDecoration(
673
- labelText: l10n.gguf_url_or_repo_path,
674
- hintText:
675
- 'owner/repo/blob/main/model.gguf\nhttps://huggingface.co/owner/repo/resolve/main/model.gguf',
676
- border: const OutlineInputBorder(),
677
- prefixIcon: const Icon(Icons.link_rounded),
678
- ),
679
- ),
680
- const SizedBox(height: 10),
681
- Wrap(
682
- spacing: 8,
683
- runSpacing: 8,
684
- children: [
685
- OutlinedButton.icon(
686
- onPressed: _isImporting ? null : _pasteFromClipboard,
687
- icon: const Icon(Icons.content_paste_rounded, size: 18),
688
- label: Text(l10n.paste),
689
- ),
690
- OutlinedButton.icon(
691
- onPressed: _isImporting || input.isEmpty
692
- ? null
693
- : _clearInput,
694
- icon: const Icon(Icons.close_rounded, size: 18),
695
- label: Text(l10n.clear_huggingface_token),
696
- ),
697
- OutlinedButton.icon(
698
- onPressed: _isImporting
699
- ? null
700
- : () => _openHuggingFace(context),
701
- icon: const Icon(Icons.open_in_new_rounded, size: 18),
702
- label: Text(l10n.gguf_browse),
703
- ),
704
- ],
705
- ),
706
- if (preview != null) ...[
707
- const SizedBox(height: 14),
708
- _ImportPreviewCard(preview: preview),
709
- ],
710
- const SizedBox(height: 12),
711
- Container(
712
- width: double.infinity,
713
- padding: const EdgeInsets.all(12),
714
- decoration: BoxDecoration(
715
- color: theme.colorScheme.surface,
716
- borderRadius: BorderRadius.circular(14),
717
- border: Border.all(
718
- color: theme.colorScheme.outline.withValues(alpha: 0.18),
719
- ),
720
- ),
721
- child: Row(
722
- crossAxisAlignment: CrossAxisAlignment.start,
723
- children: [
724
- Icon(
725
- hasHuggingFaceToken
726
- ? Icons.verified_user_outlined
727
- : Icons.info_outline,
728
- size: 18,
729
- color: hasHuggingFaceToken
730
- ? Colors.green
731
- : theme.colorScheme.primary,
732
- ),
733
- const SizedBox(width: 8),
734
- Expanded(
735
- child: Column(
736
- crossAxisAlignment: CrossAxisAlignment.start,
737
- children: [
738
- Text(
739
- hasHuggingFaceToken
740
- ? l10n.gguf_huggingface_token_ready
741
- : l10n.gguf_huggingface_token_optional,
742
- style: theme.textTheme.labelLarge?.copyWith(
743
- fontWeight: FontWeight.w700,
744
- ),
745
- ),
746
- const SizedBox(height: 4),
747
- Text(
748
- hasHuggingFaceToken
749
- ? l10n.gguf_huggingface_token_ready_desc
750
- : l10n.gguf_huggingface_token_optional_desc,
751
- style: theme.textTheme.bodySmall,
752
- ),
753
- ],
754
- ),
755
- ),
756
- ],
757
- ),
758
- ),
759
- if (_isImporting) ...[
760
- const SizedBox(height: 16),
761
- Container(
762
- width: double.infinity,
763
- padding: const EdgeInsets.all(14),
764
- decoration: BoxDecoration(
765
- color: theme.colorScheme.primary.withValues(alpha: 0.06),
766
- borderRadius: BorderRadius.circular(16),
767
- border: Border.all(
768
- color: theme.colorScheme.primary.withValues(alpha: 0.14),
769
- ),
770
- ),
771
- child: Column(
772
- crossAxisAlignment: CrossAxisAlignment.start,
773
- children: [
774
- Row(
775
- children: [
776
- Expanded(
777
- child: Text(
778
- preview?.fileName ?? l10n.gguf_downloading,
779
- maxLines: 1,
780
- overflow: TextOverflow.ellipsis,
781
- style: theme.textTheme.labelLarge?.copyWith(
782
- fontWeight: FontWeight.w700,
783
- ),
784
- ),
785
- ),
786
- const SizedBox(width: 8),
787
- Container(
788
- padding: const EdgeInsets.symmetric(
789
- horizontal: 10,
790
- vertical: 5,
791
- ),
792
- decoration: BoxDecoration(
793
- color: theme.colorScheme.primary.withValues(
794
- alpha: 0.12,
795
- ),
796
- borderRadius: BorderRadius.circular(999),
797
- ),
798
- child: Text(
799
- progress == null
800
- ? l10n.gguf_preparing
801
- : '${(progress * 100).toStringAsFixed(1)}%',
802
- style: theme.textTheme.labelMedium?.copyWith(
803
- color: theme.colorScheme.primary,
804
- fontWeight: FontWeight.w700,
805
- ),
806
- ),
807
- ),
808
- ],
809
- ),
810
- const SizedBox(height: 12),
811
- _ImportProgressBar(progress: progress),
812
- const SizedBox(height: 10),
813
- Text(
814
- progress != null
815
- ? '${_formatBytes(_receivedBytes)} / ${_formatBytes(_totalBytes)}'
816
- : l10n.gguf_preparing_download,
817
- style: theme.textTheme.bodySmall?.copyWith(
818
- color: theme.colorScheme.onSurface.withValues(
819
- alpha: 0.70,
820
- ),
821
- fontWeight: FontWeight.w600,
822
- ),
823
- ),
824
- ],
825
- ),
826
- ),
827
- ],
828
- if (_error != null) ...[
829
- const SizedBox(height: 12),
830
- Container(
831
- width: double.infinity,
832
- padding: const EdgeInsets.all(12),
833
- decoration: BoxDecoration(
834
- color: Colors.red.withValues(alpha: 0.08),
835
- borderRadius: BorderRadius.circular(14),
836
- border: Border.all(
837
- color: Colors.red.withValues(alpha: 0.18),
838
- ),
839
- ),
840
- child: Row(
841
- crossAxisAlignment: CrossAxisAlignment.start,
842
- children: [
843
- const Icon(
844
- Icons.error_outline_rounded,
845
- color: Colors.red,
846
- size: 18,
847
- ),
848
- const SizedBox(width: 8),
849
- Expanded(
850
- child: Text(
851
- _error!,
852
- style: theme.textTheme.bodySmall?.copyWith(
853
- color: Colors.red.shade700,
854
- fontWeight: FontWeight.w600,
855
- ),
856
- ),
857
- ),
858
- ],
859
- ),
860
- ),
861
- ],
862
- ],
863
- ),
864
- ),
865
- ),
866
- actions: [
867
- TextButton(
868
- onPressed: _isImporting
869
- ? () {
870
- _cancelToken?.cancel();
871
- Navigator.of(context).pop();
872
- }
873
- : () => Navigator.of(context).pop(),
874
- child: Text(_isImporting ? l10n.gguf_cancel_import : l10n.cancel),
875
- ),
876
- ElevatedButton.icon(
877
- onPressed: _isImporting || input.isEmpty ? null : _startImport,
878
- icon: const Icon(Icons.download_rounded, size: 18),
879
- label: Text(l10n.gguf_import_action),
880
- ),
881
- ],
882
- );
883
- }
884
-
885
- Future<void> _pasteFromClipboard() async {
886
- final data = await Clipboard.getData('text/plain');
887
- final text = data?.text?.trim();
888
- if (!mounted) return;
889
- if (text == null || text.isEmpty) {
890
- ScaffoldMessenger.of(context).showSnackBar(
891
- SnackBar(content: Text(AppLocalizations.of(context)!.clipboard_empty)),
892
- );
893
- return;
894
- }
895
- setState(() {
896
- _urlController.text = text;
897
- _error = null;
898
- });
899
- }
900
-
901
- void _clearInput() {
902
- setState(() {
903
- _urlController.clear();
904
- _error = null;
905
- });
906
- }
907
-
908
- Future<void> _openHuggingFace(BuildContext context) async {
909
- final messenger = ScaffoldMessenger.of(context);
910
- final launched = await launchUrl(
911
- _huggingFaceModelsUri,
912
- mode: LaunchMode.externalApplication,
913
- );
914
- if (!launched && context.mounted) {
915
- messenger.showSnackBar(
916
- SnackBar(
917
- content: Text(
918
- AppLocalizations.of(context)!.could_not_open_huggingface,
919
- ),
920
- ),
921
- );
922
- }
923
- }
924
-
925
- Future<void> _startImport() async {
926
- final sourceUrl = _urlController.text.trim();
927
- if (sourceUrl.isEmpty) {
928
- setState(() {
929
- _error = AppLocalizations.of(context)!.gguf_paste_url_error;
930
- });
931
- return;
932
- }
933
-
934
- setState(() {
935
- _isImporting = true;
936
- _error = null;
937
- _receivedBytes = 0;
938
- _totalBytes = 0;
939
- });
940
-
941
- _cancelToken = CancelToken();
942
-
943
- try {
944
- final huggingFaceToken = ref.read(
945
- settingsProvider.select((settings) => settings.huggingFaceToken),
946
- );
947
- final model = await ref
948
- .read(importedGgufModelsProvider.notifier)
949
- .importModelFromHuggingFaceUrl(
950
- sourceUrl,
951
- huggingFaceToken: huggingFaceToken,
952
- cancelToken: _cancelToken,
953
- onProgress: (receivedBytes, totalBytes) {
954
- if (!mounted) return;
955
- setState(() {
956
- _receivedBytes = receivedBytes;
957
- _totalBytes = totalBytes > 0 ? totalBytes : 0;
958
- });
959
- },
960
- );
961
-
962
- if (!mounted) return;
963
- Navigator.of(context).pop(model);
964
- } catch (e) {
965
- if (!mounted) return;
966
- setState(() {
967
- _isImporting = false;
968
- _error = _friendlyError(e);
969
- });
970
- }
971
- }
972
-
973
- String _friendlyError(Object error) {
974
- final l10n = AppLocalizations.of(context)!;
975
- final message = error.toString();
976
- final normalized = message
977
- .replaceFirst('HttpException: ', '')
978
- .replaceFirst('FormatException: ', '')
979
- .replaceFirst('Exception: ', '')
980
- .replaceFirst('FileSystemException: ', '');
981
- switch (normalized) {
982
- case 'GGUF import canceled.':
983
- return l10n.gguf_import_canceled;
984
- case 'Enter a Hugging Face GGUF URL.':
985
- return l10n.gguf_enter_huggingface_url;
986
- case 'Only official Hugging Face GGUF URLs are supported.':
987
- return l10n.gguf_only_official_huggingface_urls;
988
- case 'Use an HTTPS Hugging Face URL for GGUF import.':
989
- return l10n.gguf_use_https_url;
990
- case 'The Hugging Face URL must point directly to a .gguf file.':
991
- return l10n.gguf_url_must_point_to_file;
992
- case 'Unable to determine the GGUF file name.':
993
- return l10n.gguf_unable_to_detect_file_name;
994
- case 'The downloaded GGUF file was empty or missing.':
995
- return l10n.gguf_download_empty;
996
- case 'Selected model file does not exist':
997
- return l10n.gguf_selected_file_missing;
998
- default:
999
- return normalized;
1000
- }
1001
- }
1002
-
1003
- String _formatBytes(int bytes) {
1004
- if (bytes <= 0) return '0 B';
1005
- final kb = bytes / 1024;
1006
- if (kb < 1024) return '${kb.toStringAsFixed(0)} KB';
1007
- final mb = kb / 1024;
1008
- if (mb < 1024) return '${mb.toStringAsFixed(1)} MB';
1009
- final gb = mb / 1024;
1010
- return '${gb.toStringAsFixed(2)} GB';
1011
- }
1012
-}
1013
-
1014
-class _ImportPreviewCard extends StatelessWidget {
1015
- const _ImportPreviewCard({required this.preview});
1016
-
1017
- final _ImportUrlPreview preview;
1018
-
1019
- @override
1020
- Widget build(BuildContext context) {
1021
- final theme = Theme.of(context);
1022
- final l10n = AppLocalizations.of(context)!;
1023
-
1024
- return Container(
1025
- width: double.infinity,
1026
- padding: const EdgeInsets.all(12),
1027
- decoration: BoxDecoration(
1028
- color: theme.colorScheme.surfaceContainerHighest.withValues(
1029
- alpha: 0.35,
1030
- ),
1031
- borderRadius: BorderRadius.circular(14),
1032
- border: Border.all(
1033
- color: theme.colorScheme.outline.withValues(alpha: 0.18),
1034
- ),
1035
- ),
1036
- child: Column(
1037
- crossAxisAlignment: CrossAxisAlignment.start,
1038
- children: [
1039
- Wrap(
1040
- spacing: 8,
1041
- runSpacing: 8,
1042
- children: [
1043
- _PreviewPill(
1044
- icon: preview.isBlobLink
1045
- ? Icons.sync_alt_rounded
1046
- : Icons.link_rounded,
1047
- label: preview.isBlobLink
1048
- ? l10n.gguf_blob_link
1049
- : preview.inputStyleLabel,
1050
- ),
1051
- if (preview.fileName != null)
1052
- _PreviewPill(
1053
- icon: Icons.insert_drive_file_outlined,
1054
- label: preview.fileName!,
1055
- ),
1056
- ],
1057
- ),
1058
- const SizedBox(height: 10),
1059
- if (preview.repoPath != null)
1060
- _PreviewRow(
1061
- label: l10n.gguf_repository_label,
1062
- value: preview.repoPath!,
1063
- ),
1064
- if (preview.normalizedDisplay != null)
1065
- _PreviewRow(
1066
- label: l10n.gguf_detected_path_label,
1067
- value: preview.normalizedDisplay!,
1068
- ),
1069
- ],
1070
- ),
1071
- );
1072
- }
1073
-}
1074
-
1075
-class _PreviewPill extends StatelessWidget {
1076
- const _PreviewPill({required this.icon, required this.label});
1077
-
1078
- final IconData icon;
1079
- final String label;
1080
-
1081
- @override
1082
- Widget build(BuildContext context) {
1083
- final theme = Theme.of(context);
1084
- return Container(
1085
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1086
- decoration: BoxDecoration(
1087
- color: theme.colorScheme.primary.withValues(alpha: 0.08),
1088
- borderRadius: BorderRadius.circular(999),
1089
- ),
1090
- child: Row(
1091
- mainAxisSize: MainAxisSize.min,
1092
- children: [
1093
- Icon(icon, size: 14, color: theme.colorScheme.primary),
1094
- const SizedBox(width: 6),
1095
- Text(
1096
- label,
1097
- style: theme.textTheme.labelMedium?.copyWith(
1098
- color: theme.colorScheme.primary,
1099
- fontWeight: FontWeight.w700,
1100
- ),
1101
- ),
1102
- ],
1103
- ),
1104
- );
1105
- }
1106
-}
1107
-
1108
-class _PreviewRow extends StatelessWidget {
1109
- const _PreviewRow({required this.label, required this.value});
1110
-
1111
- final String label;
1112
- final String value;
1113
-
1114
- @override
1115
- Widget build(BuildContext context) {
1116
- final theme = Theme.of(context);
1117
- return Padding(
1118
- padding: const EdgeInsets.only(top: 4),
1119
- child: Column(
1120
- crossAxisAlignment: CrossAxisAlignment.start,
1121
- children: [
1122
- Text(
1123
- label,
1124
- style: theme.textTheme.labelSmall?.copyWith(
1125
- color: theme.colorScheme.onSurface.withValues(alpha: 0.62),
1126
- fontWeight: FontWeight.w700,
1127
- ),
1128
- ),
1129
- const SizedBox(height: 2),
1130
- Text(
1131
- value,
1132
- style: theme.textTheme.bodySmall?.copyWith(
1133
- fontWeight: FontWeight.w600,
1134
- ),
1135
- ),
1136
- ],
1137
- ),
1138
- );
1139
- }
1140
-}
1141
-
1142
-class _ImportProgressBar extends StatelessWidget {
1143
- const _ImportProgressBar({required this.progress});
1144
-
1145
- final double? progress;
1146
-
1147
- @override
1148
- Widget build(BuildContext context) {
1149
- final theme = Theme.of(context);
1150
- final trackColor = theme.colorScheme.primary.withValues(alpha: 0.12);
1151
- final fillColor = theme.colorScheme.primary;
1152
- final normalizedProgress = progress?.clamp(0.0, 1.0);
1153
-
1154
- return ClipRRect(
1155
- borderRadius: BorderRadius.circular(999),
1156
- child: SizedBox(
1157
- height: 10,
1158
- child: LinearProgressIndicator(
1159
- value: normalizedProgress?.toDouble(),
1160
- backgroundColor: trackColor,
1161
- valueColor: AlwaysStoppedAnimation<Color>(fillColor),
1162
- ),
1163
- ),
1164
- );
1165
- }
1166
-}
1167
-
1168
-class _ImportUrlPreview {
1169
- const _ImportUrlPreview({
1170
- required this.inputStyleLabel,
1171
- required this.isBlobLink,
1172
- this.repoPath,
1173
- this.fileName,
1174
- this.normalizedDisplay,
1175
- });
1176
-
1177
- final String inputStyleLabel;
1178
- final bool isBlobLink;
1179
- final String? repoPath;
1180
- final String? fileName;
1181
- final String? normalizedDisplay;
1182
-
1183
- static _ImportUrlPreview? fromInput(String input) {
1184
- final trimmed = input.trim();
1185
- if (trimmed.isEmpty) return null;
1186
-
1187
- final hasScheme =
1188
- trimmed.startsWith('https://') || trimmed.startsWith('http://');
1189
- final displayUri = hasScheme ? Uri.tryParse(trimmed) : null;
1190
- final pathSource = displayUri != null && displayUri.pathSegments.isNotEmpty
1191
- ? displayUri.pathSegments
1192
- : trimmed.split('/').where((part) => part.isNotEmpty).toList();
1193
-
1194
- if (pathSource.isEmpty) {
1195
- return const _ImportUrlPreview(
1196
- inputStyleLabel: 'Manual path',
1197
- isBlobLink: false,
1198
- );
1199
- }
1200
-
1201
- final blobIndex = pathSource.indexOf('blob');
1202
- final resolveIndex = pathSource.indexOf('resolve');
1203
- final fileName =
1204
- pathSource.isNotEmpty && pathSource.last.toLowerCase().endsWith('.gguf')
1205
- ? pathSource.last
1206
- : null;
1207
- final repoPath = pathSource.length >= 2
1208
- ? '${pathSource[0]}/${pathSource[1]}'
1209
- : null;
1210
-
1211
- String? normalizedDisplay;
1212
- if (blobIndex != -1) {
1213
- final normalized = [...pathSource]..[blobIndex] = 'resolve';
1214
- normalizedDisplay = normalized.join('/');
1215
- } else if (resolveIndex != -1 || fileName != null) {
1216
- normalizedDisplay = pathSource.join('/');
1217
- }
1218
-
1219
- return _ImportUrlPreview(
1220
- inputStyleLabel: hasScheme ? 'Direct URL' : 'Repo path',
1221
- isBlobLink: blobIndex != -1,
1222
- repoPath: repoPath,
1223
- fileName: fileName,
1224
- normalizedDisplay: normalizedDisplay,
1225
- );
1226
- }
1227
-}
1228
-
1229
-class _EngineStatusCard extends StatelessWidget {
1230
- const _EngineStatusCard({
1231
- required this.engineState,
1232
- required this.models,
1233
- required this.l10n,
1234
- required this.theme,
1235
- });
1236
-
1237
- final OnDeviceEngineState engineState;
1238
- final List<OnDeviceModel> models;
1239
- final AppLocalizations l10n;
1240
- final ThemeData theme;
1241
-
1242
- @override
1243
- Widget build(BuildContext context) {
1244
- if (engineState.status == OnDeviceEngineStatus.loaded) {
1245
- final loadedId = engineState.loadedModelId;
1246
- final loadedName = loadedId == null
1247
- ? 'Unknown'
1248
- : models.where((m) => m.id == loadedId).map((m) => m.name).followedBy(
1249
- [loadedId],
1250
- ).first;
1251
-
1252
- return Container(
1253
- padding: const EdgeInsets.all(12),
1254
- margin: const EdgeInsets.only(bottom: 16),
1255
- decoration: BoxDecoration(
1256
- color: Colors.green.withValues(alpha: 0.1),
1257
- borderRadius: BorderRadius.circular(8),
1258
- border: Border.all(color: Colors.green),
1259
- ),
1260
- child: Row(
1261
- children: [
1262
- const Icon(Icons.check_circle, color: Colors.green, size: 20),
1263
- const SizedBox(width: 8),
1264
- Expanded(
1265
- child: Text(
1266
- l10n.model_loaded(
1267
- loadedName,
1268
- engineState.backend?.name ?? 'CPU',
1269
- ),
1270
- style: const TextStyle(color: Colors.green),
1271
- ),
1272
- ),
1273
- ],
1274
- ),
1275
- );
1276
- }
1277
-
1278
- if (engineState.status == OnDeviceEngineStatus.loading) {
1279
- return const Padding(
1280
- padding: EdgeInsets.only(bottom: 16),
1281
- child: LinearProgressIndicator(),
1282
- );
1283
- }
1284
-
1285
- if (engineState.status == OnDeviceEngineStatus.error) {
1286
- return Container(
1287
- padding: const EdgeInsets.all(12),
1288
- margin: const EdgeInsets.only(bottom: 16),
1289
- decoration: BoxDecoration(
1290
- color: Colors.red.withValues(alpha: 0.1),
1291
- borderRadius: BorderRadius.circular(8),
1292
- border: Border.all(color: Colors.red),
1293
- ),
1294
- child: Text(
1295
- engineState.error ?? l10n.unknown_error,
1296
- style: const TextStyle(color: Colors.red),
1297
- ),
1298
- );
1299
- }
1300
-
1301
- return const SizedBox.shrink();
1302
- }
1303
-}
lib/features/on_device/views/model_manager_screen_parts.dart
new
+1099
@@ -0,0 +1,1099 @@
1
+part of 'model_manager_screen.dart';
2
+
3
+class _ManagerHeader extends StatelessWidget {
4
+ const _ManagerHeader({
5
+ required this.l10n,
6
+ required this.theme,
7
+ required this.onImportLocalGguf,
8
+ required this.onImportFromHuggingFace,
9
+ });
10
+
11
+ final AppLocalizations l10n;
12
+ final ThemeData theme;
13
+ final VoidCallback onImportLocalGguf;
14
+ final VoidCallback onImportFromHuggingFace;
15
+
16
+ @override
17
+ Widget build(BuildContext context) {
18
+ final topPadding = MediaQuery.of(context).padding.top;
19
+
20
+ return Container(
21
+ padding: EdgeInsets.only(
22
+ left: 16,
23
+ right: 16,
24
+ top: topPadding + 8,
25
+ bottom: 16,
26
+ ),
27
+ decoration: BoxDecoration(
28
+ color: theme.brightness == Brightness.dark
29
+ ? const Color(0xFF0A0A0A)
30
+ : const Color(0xFFFAFAFA),
31
+ border: Border(
32
+ bottom: BorderSide(
33
+ color: theme.brightness == Brightness.dark
34
+ ? const Color(0xFF2A2A2A)
35
+ : const Color(0xFFE5E5E5),
36
+ ),
37
+ ),
38
+ ),
39
+ child: Row(
40
+ children: [
41
+ Builder(
42
+ builder: (context) => IconButton(
43
+ icon: const Icon(Icons.menu),
44
+ onPressed: () => Scaffold.of(context).openDrawer(),
45
+ ),
46
+ ),
47
+ const SizedBox(width: 8),
48
+ Expanded(
49
+ child: Text(
50
+ l10n.on_device_models_title,
51
+ style: TextStyle(
52
+ fontSize: 20,
53
+ fontWeight: FontWeight.bold,
54
+ color: theme.brightness == Brightness.dark
55
+ ? Colors.white
56
+ : Colors.black,
57
+ ),
58
+ ),
59
+ ),
60
+ if (defaultTargetPlatform == TargetPlatform.android ||
61
+ defaultTargetPlatform == TargetPlatform.iOS)
62
+ PopupMenuButton<_ImportAction>(
63
+ tooltip: l10n.gguf_import_action,
64
+ onSelected: (value) {
65
+ switch (value) {
66
+ case _ImportAction.localFile:
67
+ onImportLocalGguf();
68
+ case _ImportAction.huggingFace:
69
+ onImportFromHuggingFace();
70
+ }
71
+ },
72
+ itemBuilder: (context) => [
73
+ PopupMenuItem<_ImportAction>(
74
+ value: _ImportAction.localFile,
75
+ child: ListTile(
76
+ contentPadding: EdgeInsets.zero,
77
+ leading: const Icon(Icons.folder_open_outlined),
78
+ title: Text(l10n.gguf_import_local_title),
79
+ subtitle: Text(l10n.gguf_import_local_subtitle),
80
+ ),
81
+ ),
82
+ PopupMenuItem<_ImportAction>(
83
+ value: _ImportAction.huggingFace,
84
+ child: ListTile(
85
+ contentPadding: EdgeInsets.zero,
86
+ leading: const Icon(Icons.cloud_download_outlined),
87
+ title: Text(l10n.gguf_import_huggingface_title),
88
+ subtitle: Text(l10n.gguf_import_huggingface_subtitle),
89
+ ),
90
+ ),
91
+ ],
92
+ child: Container(
93
+ padding: const EdgeInsets.symmetric(
94
+ horizontal: 12,
95
+ vertical: 8,
96
+ ),
97
+ decoration: BoxDecoration(
98
+ borderRadius: BorderRadius.circular(10),
99
+ border: Border.all(
100
+ color: theme.colorScheme.outline.withValues(alpha: 0.4),
101
+ ),
102
+ ),
103
+ child: Row(
104
+ mainAxisSize: MainAxisSize.min,
105
+ children: [
106
+ Icon(
107
+ Icons.add_circle_outline,
108
+ size: 18,
109
+ color: theme.colorScheme.primary,
110
+ ),
111
+ const SizedBox(width: 8),
112
+ Text(l10n.gguf_import_action),
113
+ ],
114
+ ),
115
+ ),
116
+ ),
117
+ ],
118
+ ),
119
+ );
120
+ }
121
+}
122
+
123
+class _ImportOverviewCard extends StatelessWidget {
124
+ const _ImportOverviewCard({
125
+ required this.importedModels,
126
+ required this.onImportLocalGguf,
127
+ required this.onImportFromHuggingFace,
128
+ });
129
+
130
+ final List<OnDeviceModel> importedModels;
131
+ final VoidCallback onImportLocalGguf;
132
+ final VoidCallback onImportFromHuggingFace;
133
+
134
+ @override
135
+ Widget build(BuildContext context) {
136
+ final theme = Theme.of(context);
137
+ final l10n = AppLocalizations.of(context)!;
138
+ final localImports = importedModels
139
+ .where((model) => model.isImportedFromLocalFile)
140
+ .length;
141
+ final huggingFaceImports = importedModels
142
+ .where((model) => model.isImportedFromHuggingFace)
143
+ .length;
144
+
145
+ return Container(
146
+ margin: const EdgeInsets.only(bottom: 16),
147
+ padding: const EdgeInsets.all(16),
148
+ decoration: BoxDecoration(
149
+ color: theme.colorScheme.primary.withValues(alpha: 0.05),
150
+ borderRadius: BorderRadius.circular(16),
151
+ border: Border.all(
152
+ color: theme.colorScheme.primary.withValues(alpha: 0.15),
153
+ ),
154
+ ),
155
+ child: Column(
156
+ crossAxisAlignment: CrossAxisAlignment.start,
157
+ children: [
158
+ Row(
159
+ crossAxisAlignment: CrossAxisAlignment.start,
160
+ children: [
161
+ Container(
162
+ padding: const EdgeInsets.all(10),
163
+ decoration: BoxDecoration(
164
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
165
+ borderRadius: BorderRadius.circular(12),
166
+ ),
167
+ child: Icon(
168
+ Icons.memory_outlined,
169
+ color: theme.colorScheme.primary,
170
+ ),
171
+ ),
172
+ const SizedBox(width: 12),
173
+ Expanded(
174
+ child: Column(
175
+ crossAxisAlignment: CrossAxisAlignment.start,
176
+ children: [
177
+ Text(
178
+ l10n.gguf_overview_title,
179
+ style: theme.textTheme.titleMedium?.copyWith(
180
+ fontWeight: FontWeight.bold,
181
+ ),
182
+ ),
183
+ const SizedBox(height: 4),
184
+ Text(
185
+ l10n.gguf_overview_subtitle,
186
+ style: theme.textTheme.bodySmall?.copyWith(
187
+ color: theme.colorScheme.onSurface.withValues(
188
+ alpha: 0.7,
189
+ ),
190
+ ),
191
+ ),
192
+ ],
193
+ ),
194
+ ),
195
+ ],
196
+ ),
197
+ const SizedBox(height: 12),
198
+ Wrap(
199
+ spacing: 8,
200
+ runSpacing: 8,
201
+ children: [
202
+ _SummaryPill(
203
+ icon: Icons.layers_outlined,
204
+ label:
205
+ '${importedModels.length} ${l10n.gguf_imported_count_label}',
206
+ ),
207
+ _SummaryPill(
208
+ icon: Icons.folder_open_outlined,
209
+ label: '$localImports ${l10n.gguf_local_files_label}',
210
+ ),
211
+ _SummaryPill(
212
+ icon: Icons.cloud_download_outlined,
213
+ label: '$huggingFaceImports ${l10n.gguf_huggingface_label}',
214
+ ),
215
+ ],
216
+ ),
217
+ const SizedBox(height: 12),
218
+ Wrap(
219
+ spacing: 10,
220
+ runSpacing: 10,
221
+ children: [
222
+ OutlinedButton.icon(
223
+ onPressed: onImportLocalGguf,
224
+ icon: const Icon(Icons.folder_open_outlined),
225
+ label: Text(l10n.gguf_import_local_title),
226
+ ),
227
+ OutlinedButton.icon(
228
+ onPressed: onImportFromHuggingFace,
229
+ icon: const Icon(Icons.cloud_download_outlined),
230
+ label: Text(l10n.gguf_import_huggingface_title),
231
+ ),
232
+ ],
233
+ ),
234
+ ],
235
+ ),
236
+ );
237
+ }
238
+}
239
+
240
+class _SummaryPill extends StatelessWidget {
241
+ const _SummaryPill({required this.icon, required this.label});
242
+
243
+ final IconData icon;
244
+ final String label;
245
+
246
+ @override
247
+ Widget build(BuildContext context) {
248
+ final theme = Theme.of(context);
249
+ return Container(
250
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
251
+ decoration: BoxDecoration(
252
+ color: theme.colorScheme.surface,
253
+ borderRadius: BorderRadius.circular(999),
254
+ border: Border.all(
255
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
256
+ ),
257
+ ),
258
+ child: Row(
259
+ mainAxisSize: MainAxisSize.min,
260
+ children: [
261
+ Icon(icon, size: 14, color: theme.colorScheme.primary),
262
+ const SizedBox(width: 6),
263
+ Text(
264
+ label,
265
+ style: theme.textTheme.labelMedium?.copyWith(
266
+ fontWeight: FontWeight.w600,
267
+ ),
268
+ ),
269
+ ],
270
+ ),
271
+ );
272
+ }
273
+}
274
+
275
+class _SectionHeader extends StatelessWidget {
276
+ const _SectionHeader({required this.title, required this.subtitle});
277
+
278
+ final String title;
279
+ final String subtitle;
280
+
281
+ @override
282
+ Widget build(BuildContext context) {
283
+ final theme = Theme.of(context);
284
+ return Padding(
285
+ padding: const EdgeInsets.only(bottom: 12),
286
+ child: Column(
287
+ crossAxisAlignment: CrossAxisAlignment.start,
288
+ children: [
289
+ Text(
290
+ title,
291
+ style: theme.textTheme.titleMedium?.copyWith(
292
+ fontWeight: FontWeight.bold,
293
+ ),
294
+ ),
295
+ const SizedBox(height: 4),
296
+ Text(
297
+ subtitle,
298
+ style: theme.textTheme.bodySmall?.copyWith(
299
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.65),
300
+ ),
301
+ ),
302
+ ],
303
+ ),
304
+ );
305
+ }
306
+}
307
+
308
+class _EmptyImportedModelsCard extends StatelessWidget {
309
+ const _EmptyImportedModelsCard({
310
+ required this.onImportLocalGguf,
311
+ required this.onImportFromHuggingFace,
312
+ });
313
+
314
+ final VoidCallback onImportLocalGguf;
315
+ final VoidCallback onImportFromHuggingFace;
316
+
317
+ @override
318
+ Widget build(BuildContext context) {
319
+ final theme = Theme.of(context);
320
+ final l10n = AppLocalizations.of(context)!;
321
+ return Container(
322
+ margin: const EdgeInsets.only(bottom: 16),
323
+ padding: const EdgeInsets.all(18),
324
+ decoration: BoxDecoration(
325
+ color: theme.colorScheme.surface,
326
+ borderRadius: BorderRadius.circular(16),
327
+ border: Border.all(
328
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
329
+ ),
330
+ ),
331
+ child: Column(
332
+ crossAxisAlignment: CrossAxisAlignment.start,
333
+ children: [
334
+ Text(
335
+ l10n.gguf_no_imported_title,
336
+ style: theme.textTheme.titleSmall?.copyWith(
337
+ fontWeight: FontWeight.w700,
338
+ ),
339
+ ),
340
+ const SizedBox(height: 6),
341
+ Text(
342
+ l10n.gguf_no_imported_subtitle,
343
+ style: theme.textTheme.bodySmall?.copyWith(
344
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
345
+ ),
346
+ ),
347
+ const SizedBox(height: 12),
348
+ Wrap(
349
+ spacing: 10,
350
+ runSpacing: 10,
351
+ children: [
352
+ OutlinedButton.icon(
353
+ onPressed: onImportLocalGguf,
354
+ icon: const Icon(Icons.folder_open_outlined),
355
+ label: Text(l10n.gguf_import_local_title),
356
+ ),
357
+ OutlinedButton.icon(
358
+ onPressed: onImportFromHuggingFace,
359
+ icon: const Icon(Icons.cloud_download_outlined),
360
+ label: Text(l10n.gguf_import_huggingface_title),
361
+ ),
362
+ ],
363
+ ),
364
+ ],
365
+ ),
366
+ );
367
+ }
368
+}
369
+
370
+class _HuggingFaceGgufImportDialog extends ConsumerStatefulWidget {
371
+ const _HuggingFaceGgufImportDialog();
372
+
373
+ @override
374
+ ConsumerState<_HuggingFaceGgufImportDialog> createState() =>
375
+ _HuggingFaceGgufImportDialogState();
376
+}
377
+
378
+class _HuggingFaceGgufImportDialogState
379
+ extends ConsumerState<_HuggingFaceGgufImportDialog> {
380
+ static final Uri _huggingFaceModelsUri = Uri.parse(
381
+ 'https://huggingface.co/models?search=gguf',
382
+ );
383
+
384
+ final TextEditingController _urlController = TextEditingController();
385
+ CancelToken? _cancelToken;
386
+ bool _isImporting = false;
387
+ String? _error;
388
+ int _receivedBytes = 0;
389
+ int _totalBytes = 0;
390
+
391
+ @override
392
+ void dispose() {
393
+ _cancelToken?.cancel();
394
+ _urlController.dispose();
395
+ super.dispose();
396
+ }
397
+
398
+ @override
399
+ Widget build(BuildContext context) {
400
+ final theme = Theme.of(context);
401
+ final l10n = AppLocalizations.of(context)!;
402
+ final hasHuggingFaceToken = ref.watch(
403
+ settingsProvider.select(
404
+ (settings) => settings.huggingFaceToken?.trim().isNotEmpty == true,
405
+ ),
406
+ );
407
+ final input = _urlController.text.trim();
408
+ final preview = _ImportUrlPreview.fromInput(input);
409
+ final progress = _totalBytes > 0 ? _receivedBytes / _totalBytes : null;
410
+ final dialogWidth = MediaQuery.of(context).size.width > 560
411
+ ? 520.0
412
+ : MediaQuery.of(context).size.width * 0.82;
413
+
414
+ return AlertDialog(
415
+ titlePadding: const EdgeInsets.fromLTRB(24, 22, 24, 0),
416
+ contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
417
+ actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
418
+ title: Row(
419
+ children: [
420
+ Container(
421
+ padding: const EdgeInsets.all(10),
422
+ decoration: BoxDecoration(
423
+ color: theme.colorScheme.primary.withValues(alpha: 0.10),
424
+ borderRadius: BorderRadius.circular(12),
425
+ ),
426
+ child: Icon(
427
+ Icons.cloud_download_outlined,
428
+ color: theme.colorScheme.primary,
429
+ ),
430
+ ),
431
+ const SizedBox(width: 12),
432
+ Expanded(child: Text(l10n.gguf_import_huggingface_dialog_title)),
433
+ ],
434
+ ),
435
+ content: SizedBox(
436
+ width: dialogWidth,
437
+ child: SingleChildScrollView(
438
+ child: Column(
439
+ mainAxisSize: MainAxisSize.min,
440
+ crossAxisAlignment: CrossAxisAlignment.start,
441
+ children: [
442
+ Text(
443
+ l10n.gguf_import_huggingface_dialog_subtitle,
444
+ style: theme.textTheme.bodySmall?.copyWith(
445
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
446
+ ),
447
+ ),
448
+ const SizedBox(height: 14),
449
+ TextField(
450
+ controller: _urlController,
451
+ enabled: !_isImporting,
452
+ maxLines: 2,
453
+ minLines: 1,
454
+ keyboardType: TextInputType.url,
455
+ textInputAction: TextInputAction.done,
456
+ onChanged: (_) {
457
+ if (_error != null) {
458
+ setState(() => _error = null);
459
+ } else {
460
+ setState(() {});
461
+ }
462
+ },
463
+ onSubmitted: (_) {
464
+ if (!_isImporting) {
465
+ _startImport();
466
+ }
467
+ },
468
+ decoration: InputDecoration(
469
+ labelText: l10n.gguf_url_or_repo_path,
470
+ hintText:
471
+ 'owner/repo/blob/main/model.gguf\nhttps://huggingface.co/owner/repo/resolve/main/model.gguf',
472
+ border: const OutlineInputBorder(),
473
+ prefixIcon: const Icon(Icons.link_rounded),
474
+ ),
475
+ ),
476
+ const SizedBox(height: 10),
477
+ Wrap(
478
+ spacing: 8,
479
+ runSpacing: 8,
480
+ children: [
481
+ OutlinedButton.icon(
482
+ onPressed: _isImporting ? null : _pasteFromClipboard,
483
+ icon: const Icon(Icons.content_paste_rounded, size: 18),
484
+ label: Text(l10n.paste),
485
+ ),
486
+ OutlinedButton.icon(
487
+ onPressed: _isImporting || input.isEmpty
488
+ ? null
489
+ : _clearInput,
490
+ icon: const Icon(Icons.close_rounded, size: 18),
491
+ label: Text(l10n.clear_huggingface_token),
492
+ ),
493
+ OutlinedButton.icon(
494
+ onPressed: _isImporting
495
+ ? null
496
+ : () => _openHuggingFace(context),
497
+ icon: const Icon(Icons.open_in_new_rounded, size: 18),
498
+ label: Text(l10n.gguf_browse),
499
+ ),
500
+ ],
501
+ ),
502
+ if (preview != null) ...[
503
+ const SizedBox(height: 14),
504
+ _ImportPreviewCard(preview: preview),
505
+ ],
506
+ const SizedBox(height: 12),
507
+ Container(
508
+ width: double.infinity,
509
+ padding: const EdgeInsets.all(12),
510
+ decoration: BoxDecoration(
511
+ color: theme.colorScheme.surface,
512
+ borderRadius: BorderRadius.circular(14),
513
+ border: Border.all(
514
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
515
+ ),
516
+ ),
517
+ child: Row(
518
+ crossAxisAlignment: CrossAxisAlignment.start,
519
+ children: [
520
+ Icon(
521
+ hasHuggingFaceToken
522
+ ? Icons.verified_user_outlined
523
+ : Icons.info_outline,
524
+ size: 18,
525
+ color: hasHuggingFaceToken
526
+ ? Colors.green
527
+ : theme.colorScheme.primary,
528
+ ),
529
+ const SizedBox(width: 8),
530
+ Expanded(
531
+ child: Column(
532
+ crossAxisAlignment: CrossAxisAlignment.start,
533
+ children: [
534
+ Text(
535
+ hasHuggingFaceToken
536
+ ? l10n.gguf_huggingface_token_ready
537
+ : l10n.gguf_huggingface_token_optional,
538
+ style: theme.textTheme.labelLarge?.copyWith(
539
+ fontWeight: FontWeight.w700,
540
+ ),
541
+ ),
542
+ const SizedBox(height: 4),
543
+ Text(
544
+ hasHuggingFaceToken
545
+ ? l10n.gguf_huggingface_token_ready_desc
546
+ : l10n.gguf_huggingface_token_optional_desc,
547
+ style: theme.textTheme.bodySmall,
548
+ ),
549
+ ],
550
+ ),
551
+ ),
552
+ ],
553
+ ),
554
+ ),
555
+ if (_isImporting) ...[
556
+ const SizedBox(height: 16),
557
+ Container(
558
+ width: double.infinity,
559
+ padding: const EdgeInsets.all(14),
560
+ decoration: BoxDecoration(
561
+ color: theme.colorScheme.primary.withValues(alpha: 0.06),
562
+ borderRadius: BorderRadius.circular(16),
563
+ border: Border.all(
564
+ color: theme.colorScheme.primary.withValues(alpha: 0.14),
565
+ ),
566
+ ),
567
+ child: Column(
568
+ crossAxisAlignment: CrossAxisAlignment.start,
569
+ children: [
570
+ Row(
571
+ children: [
572
+ Expanded(
573
+ child: Text(
574
+ preview?.fileName ?? l10n.gguf_downloading,
575
+ maxLines: 1,
576
+ overflow: TextOverflow.ellipsis,
577
+ style: theme.textTheme.labelLarge?.copyWith(
578
+ fontWeight: FontWeight.w700,
579
+ ),
580
+ ),
581
+ ),
582
+ const SizedBox(width: 8),
583
+ Container(
584
+ padding: const EdgeInsets.symmetric(
585
+ horizontal: 10,
586
+ vertical: 5,
587
+ ),
588
+ decoration: BoxDecoration(
589
+ color: theme.colorScheme.primary.withValues(
590
+ alpha: 0.12,
591
+ ),
592
+ borderRadius: BorderRadius.circular(999),
593
+ ),
594
+ child: Text(
595
+ progress == null
596
+ ? l10n.gguf_preparing
597
+ : '${(progress * 100).toStringAsFixed(1)}%',
598
+ style: theme.textTheme.labelMedium?.copyWith(
599
+ color: theme.colorScheme.primary,
600
+ fontWeight: FontWeight.w700,
601
+ ),
602
+ ),
603
+ ),
604
+ ],
605
+ ),
606
+ const SizedBox(height: 12),
607
+ _ImportProgressBar(progress: progress),
608
+ const SizedBox(height: 10),
609
+ Text(
610
+ progress != null
611
+ ? '${_formatBytes(_receivedBytes)} / ${_formatBytes(_totalBytes)}'
612
+ : l10n.gguf_preparing_download,
613
+ style: theme.textTheme.bodySmall?.copyWith(
614
+ color: theme.colorScheme.onSurface.withValues(
615
+ alpha: 0.70,
616
+ ),
617
+ fontWeight: FontWeight.w600,
618
+ ),
619
+ ),
620
+ ],
621
+ ),
622
+ ),
623
+ ],
624
+ if (_error != null) ...[
625
+ const SizedBox(height: 12),
626
+ Container(
627
+ width: double.infinity,
628
+ padding: const EdgeInsets.all(12),
629
+ decoration: BoxDecoration(
630
+ color: Colors.red.withValues(alpha: 0.08),
631
+ borderRadius: BorderRadius.circular(14),
632
+ border: Border.all(
633
+ color: Colors.red.withValues(alpha: 0.18),
634
+ ),
635
+ ),
636
+ child: Row(
637
+ crossAxisAlignment: CrossAxisAlignment.start,
638
+ children: [
639
+ const Icon(
640
+ Icons.error_outline_rounded,
641
+ color: Colors.red,
642
+ size: 18,
643
+ ),
644
+ const SizedBox(width: 8),
645
+ Expanded(
646
+ child: Text(
647
+ _error!,
648
+ style: theme.textTheme.bodySmall?.copyWith(
649
+ color: Colors.red.shade700,
650
+ fontWeight: FontWeight.w600,
651
+ ),
652
+ ),
653
+ ),
654
+ ],
655
+ ),
656
+ ),
657
+ ],
658
+ ],
659
+ ),
660
+ ),
661
+ ),
662
+ actions: [
663
+ TextButton(
664
+ onPressed: _isImporting
665
+ ? () {
666
+ _cancelToken?.cancel();
667
+ Navigator.of(context).pop();
668
+ }
669
+ : () => Navigator.of(context).pop(),
670
+ child: Text(_isImporting ? l10n.gguf_cancel_import : l10n.cancel),
671
+ ),
672
+ ElevatedButton.icon(
673
+ onPressed: _isImporting || input.isEmpty ? null : _startImport,
674
+ icon: const Icon(Icons.download_rounded, size: 18),
675
+ label: Text(l10n.gguf_import_action),
676
+ ),
677
+ ],
678
+ );
679
+ }
680
+
681
+ Future<void> _pasteFromClipboard() async {
682
+ final data = await Clipboard.getData('text/plain');
683
+ final text = data?.text?.trim();
684
+ if (!mounted) return;
685
+ if (text == null || text.isEmpty) {
686
+ ScaffoldMessenger.of(context).showSnackBar(
687
+ SnackBar(content: Text(AppLocalizations.of(context)!.clipboard_empty)),
688
+ );
689
+ return;
690
+ }
691
+ setState(() {
692
+ _urlController.text = text;
693
+ _error = null;
694
+ });
695
+ }
696
+
697
+ void _clearInput() {
698
+ setState(() {
699
+ _urlController.clear();
700
+ _error = null;
701
+ });
702
+ }
703
+
704
+ Future<void> _openHuggingFace(BuildContext context) async {
705
+ final messenger = ScaffoldMessenger.of(context);
706
+ final launched = await launchUrl(
707
+ _huggingFaceModelsUri,
708
+ mode: LaunchMode.externalApplication,
709
+ );
710
+ if (!launched && context.mounted) {
711
+ messenger.showSnackBar(
712
+ SnackBar(
713
+ content: Text(
714
+ AppLocalizations.of(context)!.could_not_open_huggingface,
715
+ ),
716
+ ),
717
+ );
718
+ }
719
+ }
720
+
721
+ Future<void> _startImport() async {
722
+ final sourceUrl = _urlController.text.trim();
723
+ if (sourceUrl.isEmpty) {
724
+ setState(() {
725
+ _error = AppLocalizations.of(context)!.gguf_paste_url_error;
726
+ });
727
+ return;
728
+ }
729
+
730
+ setState(() {
731
+ _isImporting = true;
732
+ _error = null;
733
+ _receivedBytes = 0;
734
+ _totalBytes = 0;
735
+ });
736
+
737
+ _cancelToken = CancelToken();
738
+
739
+ try {
740
+ final huggingFaceToken = ref.read(
741
+ settingsProvider.select((settings) => settings.huggingFaceToken),
742
+ );
743
+ final model = await ref
744
+ .read(importedGgufModelsProvider.notifier)
745
+ .importModelFromHuggingFaceUrl(
746
+ sourceUrl,
747
+ huggingFaceToken: huggingFaceToken,
748
+ cancelToken: _cancelToken,
749
+ onProgress: (receivedBytes, totalBytes) {
750
+ if (!mounted) return;
751
+ setState(() {
752
+ _receivedBytes = receivedBytes;
753
+ _totalBytes = totalBytes > 0 ? totalBytes : 0;
754
+ });
755
+ },
756
+ );
757
+
758
+ if (!mounted) return;
759
+ Navigator.of(context).pop(model);
760
+ } catch (e) {
761
+ if (!mounted) return;
762
+ setState(() {
763
+ _isImporting = false;
764
+ _error = _friendlyError(e);
765
+ });
766
+ }
767
+ }
768
+
769
+ String _friendlyError(Object error) {
770
+ final l10n = AppLocalizations.of(context)!;
771
+ final message = error.toString();
772
+ final normalized = message
773
+ .replaceFirst('HttpException: ', '')
774
+ .replaceFirst('FormatException: ', '')
775
+ .replaceFirst('Exception: ', '')
776
+ .replaceFirst('FileSystemException: ', '');
777
+ switch (normalized) {
778
+ case 'GGUF import canceled.':
779
+ return l10n.gguf_import_canceled;
780
+ case 'Enter a Hugging Face GGUF URL.':
781
+ return l10n.gguf_enter_huggingface_url;
782
+ case 'Only official Hugging Face GGUF URLs are supported.':
783
+ return l10n.gguf_only_official_huggingface_urls;
784
+ case 'Use an HTTPS Hugging Face URL for GGUF import.':
785
+ return l10n.gguf_use_https_url;
786
+ case 'The Hugging Face URL must point directly to a .gguf file.':
787
+ return l10n.gguf_url_must_point_to_file;
788
+ case 'Unable to determine the GGUF file name.':
789
+ return l10n.gguf_unable_to_detect_file_name;
790
+ case 'The downloaded GGUF file was empty or missing.':
791
+ return l10n.gguf_download_empty;
792
+ case 'Selected model file does not exist':
793
+ return l10n.gguf_selected_file_missing;
794
+ default:
795
+ return normalized;
796
+ }
797
+ }
798
+
799
+ String _formatBytes(int bytes) {
800
+ if (bytes <= 0) return '0 B';
801
+ final kb = bytes / 1024;
802
+ if (kb < 1024) return '${kb.toStringAsFixed(0)} KB';
803
+ final mb = kb / 1024;
804
+ if (mb < 1024) return '${mb.toStringAsFixed(1)} MB';
805
+ final gb = mb / 1024;
806
+ return '${gb.toStringAsFixed(2)} GB';
807
+ }
808
+}
809
+
810
+class _ImportPreviewCard extends StatelessWidget {
811
+ const _ImportPreviewCard({required this.preview});
812
+
813
+ final _ImportUrlPreview preview;
814
+
815
+ @override
816
+ Widget build(BuildContext context) {
817
+ final theme = Theme.of(context);
818
+ final l10n = AppLocalizations.of(context)!;
819
+
820
+ return Container(
821
+ width: double.infinity,
822
+ padding: const EdgeInsets.all(12),
823
+ decoration: BoxDecoration(
824
+ color: theme.colorScheme.surfaceContainerHighest.withValues(
825
+ alpha: 0.35,
826
+ ),
827
+ borderRadius: BorderRadius.circular(14),
828
+ border: Border.all(
829
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
830
+ ),
831
+ ),
832
+ child: Column(
833
+ crossAxisAlignment: CrossAxisAlignment.start,
834
+ children: [
835
+ Wrap(
836
+ spacing: 8,
837
+ runSpacing: 8,
838
+ children: [
839
+ _PreviewPill(
840
+ icon: preview.isBlobLink
841
+ ? Icons.sync_alt_rounded
842
+ : Icons.link_rounded,
843
+ label: preview.isBlobLink
844
+ ? l10n.gguf_blob_link
845
+ : preview.inputStyleLabel,
846
+ ),
847
+ if (preview.fileName != null)
848
+ _PreviewPill(
849
+ icon: Icons.insert_drive_file_outlined,
850
+ label: preview.fileName!,
851
+ ),
852
+ ],
853
+ ),
854
+ const SizedBox(height: 10),
855
+ if (preview.repoPath != null)
856
+ _PreviewRow(
857
+ label: l10n.gguf_repository_label,
858
+ value: preview.repoPath!,
859
+ ),
860
+ if (preview.normalizedDisplay != null)
861
+ _PreviewRow(
862
+ label: l10n.gguf_detected_path_label,
863
+ value: preview.normalizedDisplay!,
864
+ ),
865
+ ],
866
+ ),
867
+ );
868
+ }
869
+}
870
+
871
+class _PreviewPill extends StatelessWidget {
872
+ const _PreviewPill({required this.icon, required this.label});
873
+
874
+ final IconData icon;
875
+ final String label;
876
+
877
+ @override
878
+ Widget build(BuildContext context) {
879
+ final theme = Theme.of(context);
880
+ return Container(
881
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
882
+ decoration: BoxDecoration(
883
+ color: theme.colorScheme.primary.withValues(alpha: 0.08),
884
+ borderRadius: BorderRadius.circular(999),
885
+ ),
886
+ child: Row(
887
+ mainAxisSize: MainAxisSize.min,
888
+ children: [
889
+ Icon(icon, size: 14, color: theme.colorScheme.primary),
890
+ const SizedBox(width: 6),
891
+ Text(
892
+ label,
893
+ style: theme.textTheme.labelMedium?.copyWith(
894
+ color: theme.colorScheme.primary,
895
+ fontWeight: FontWeight.w700,
896
+ ),
897
+ ),
898
+ ],
899
+ ),
900
+ );
901
+ }
902
+}
903
+
904
+class _PreviewRow extends StatelessWidget {
905
+ const _PreviewRow({required this.label, required this.value});
906
+
907
+ final String label;
908
+ final String value;
909
+
910
+ @override
911
+ Widget build(BuildContext context) {
912
+ final theme = Theme.of(context);
913
+ return Padding(
914
+ padding: const EdgeInsets.only(top: 4),
915
+ child: Column(
916
+ crossAxisAlignment: CrossAxisAlignment.start,
917
+ children: [
918
+ Text(
919
+ label,
920
+ style: theme.textTheme.labelSmall?.copyWith(
921
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.62),
922
+ fontWeight: FontWeight.w700,
923
+ ),
924
+ ),
925
+ const SizedBox(height: 2),
926
+ Text(
927
+ value,
928
+ style: theme.textTheme.bodySmall?.copyWith(
929
+ fontWeight: FontWeight.w600,
930
+ ),
931
+ ),
932
+ ],
933
+ ),
934
+ );
935
+ }
936
+}
937
+
938
+class _ImportProgressBar extends StatelessWidget {
939
+ const _ImportProgressBar({required this.progress});
940
+
941
+ final double? progress;
942
+
943
+ @override
944
+ Widget build(BuildContext context) {
945
+ final theme = Theme.of(context);
946
+ final trackColor = theme.colorScheme.primary.withValues(alpha: 0.12);
947
+ final fillColor = theme.colorScheme.primary;
948
+ final normalizedProgress = progress?.clamp(0.0, 1.0);
949
+
950
+ return ClipRRect(
951
+ borderRadius: BorderRadius.circular(999),
952
+ child: SizedBox(
953
+ height: 10,
954
+ child: LinearProgressIndicator(
955
+ value: normalizedProgress?.toDouble(),
956
+ backgroundColor: trackColor,
957
+ valueColor: AlwaysStoppedAnimation<Color>(fillColor),
958
+ ),
959
+ ),
960
+ );
961
+ }
962
+}
963
+
964
+class _ImportUrlPreview {
965
+ const _ImportUrlPreview({
966
+ required this.inputStyleLabel,
967
+ required this.isBlobLink,
968
+ this.repoPath,
969
+ this.fileName,
970
+ this.normalizedDisplay,
971
+ });
972
+
973
+ final String inputStyleLabel;
974
+ final bool isBlobLink;
975
+ final String? repoPath;
976
+ final String? fileName;
977
+ final String? normalizedDisplay;
978
+
979
+ static _ImportUrlPreview? fromInput(String input) {
980
+ final trimmed = input.trim();
981
+ if (trimmed.isEmpty) return null;
982
+
983
+ final hasScheme =
984
+ trimmed.startsWith('https://') || trimmed.startsWith('http://');
985
+ final displayUri = hasScheme ? Uri.tryParse(trimmed) : null;
986
+ final pathSource = displayUri != null && displayUri.pathSegments.isNotEmpty
987
+ ? displayUri.pathSegments
988
+ : trimmed.split('/').where((part) => part.isNotEmpty).toList();
989
+
990
+ if (pathSource.isEmpty) {
991
+ return const _ImportUrlPreview(
992
+ inputStyleLabel: 'Manual path',
993
+ isBlobLink: false,
994
+ );
995
+ }
996
+
997
+ final blobIndex = pathSource.indexOf('blob');
998
+ final resolveIndex = pathSource.indexOf('resolve');
999
+ final fileName =
1000
+ pathSource.isNotEmpty && pathSource.last.toLowerCase().endsWith('.gguf')
1001
+ ? pathSource.last
1002
+ : null;
1003
+ final repoPath = pathSource.length >= 2
1004
+ ? '${pathSource[0]}/${pathSource[1]}'
1005
+ : null;
1006
+
1007
+ String? normalizedDisplay;
1008
+ if (blobIndex != -1) {
1009
+ final normalized = [...pathSource]..[blobIndex] = 'resolve';
1010
+ normalizedDisplay = normalized.join('/');
1011
+ } else if (resolveIndex != -1 || fileName != null) {
1012
+ normalizedDisplay = pathSource.join('/');
1013
+ }
1014
+
1015
+ return _ImportUrlPreview(
1016
+ inputStyleLabel: hasScheme ? 'Direct URL' : 'Repo path',
1017
+ isBlobLink: blobIndex != -1,
1018
+ repoPath: repoPath,
1019
+ fileName: fileName,
1020
+ normalizedDisplay: normalizedDisplay,
1021
+ );
1022
+ }
1023
+}
1024
+
1025
+class _EngineStatusCard extends StatelessWidget {
1026
+ const _EngineStatusCard({
1027
+ required this.engineState,
1028
+ required this.models,
1029
+ required this.l10n,
1030
+ required this.theme,
1031
+ });
1032
+
1033
+ final OnDeviceEngineState engineState;
1034
+ final List<OnDeviceModel> models;
1035
+ final AppLocalizations l10n;
1036
+ final ThemeData theme;
1037
+
1038
+ @override
1039
+ Widget build(BuildContext context) {
1040
+ if (engineState.status == OnDeviceEngineStatus.loaded) {
1041
+ final loadedId = engineState.loadedModelId;
1042
+ final loadedName = loadedId == null
1043
+ ? 'Unknown'
1044
+ : models.where((m) => m.id == loadedId).map((m) => m.name).followedBy(
1045
+ [loadedId],
1046
+ ).first;
1047
+
1048
+ return Container(
1049
+ padding: const EdgeInsets.all(12),
1050
+ margin: const EdgeInsets.only(bottom: 16),
1051
+ decoration: BoxDecoration(
1052
+ color: Colors.green.withValues(alpha: 0.1),
1053
+ borderRadius: BorderRadius.circular(8),
1054
+ border: Border.all(color: Colors.green),
1055
+ ),
1056
+ child: Row(
1057
+ children: [
1058
+ const Icon(Icons.check_circle, color: Colors.green, size: 20),
1059
+ const SizedBox(width: 8),
1060
+ Expanded(
1061
+ child: Text(
1062
+ l10n.model_loaded(
1063
+ loadedName,
1064
+ engineState.backend?.name ?? 'CPU',
1065
+ ),
1066
+ style: const TextStyle(color: Colors.green),
1067
+ ),
1068
+ ),
1069
+ ],
1070
+ ),
1071
+ );
1072
+ }
1073
+
1074
+ if (engineState.status == OnDeviceEngineStatus.loading) {
1075
+ return const Padding(
1076
+ padding: EdgeInsets.only(bottom: 16),
1077
+ child: LinearProgressIndicator(),
1078
+ );
1079
+ }
1080
+
1081
+ if (engineState.status == OnDeviceEngineStatus.error) {
1082
+ return Container(
1083
+ padding: const EdgeInsets.all(12),
1084
+ margin: const EdgeInsets.only(bottom: 16),
1085
+ decoration: BoxDecoration(
1086
+ color: Colors.red.withValues(alpha: 0.1),
1087
+ borderRadius: BorderRadius.circular(8),
1088
+ border: Border.all(color: Colors.red),
1089
+ ),
1090
+ child: Text(
1091
+ engineState.error ?? l10n.unknown_error,
1092
+ style: const TextStyle(color: Colors.red),
1093
+ ),
1094
+ );
1095
+ }
1096
+
1097
+ return const SizedBox.shrink();
1098
+ }
1099
+}
lib/features/onboarding/views/components/model_download_components.dart
renamed
+24
-225
@@ -1,224 +1,22 @@
1
-import 'dart:async';
1
import 'dart:io';
2
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
-import 'package:go_router/go_router.dart';
5
+import 'package:localmind/core/device/device_memory_service.dart';
6
+import 'package:localmind/core/providers/foreground_download_providers.dart';
7
+import 'package:localmind/features/on_device/data/models/download_progress_info.dart';
8
+import 'package:localmind/features/on_device/data/models/download_status.dart';
9
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10
+import 'package:localmind/l10n/app_localizations.dart';
11
import 'package:shadcn_ui/shadcn_ui.dart';
8
-import 'package:cue/cue.dart';
12
10
-import '../../../core/models/enums.dart';
11
-import '../../../core/routes/app_routes.dart';
12
-import '../../../l10n/app_localizations.dart';
13
-import '../../on_device/data/models/on_device_model.dart';
14
-import '../../on_device/data/models/download_status.dart';
15
-import '../../on_device/data/models/download_progress_info.dart';
16
-import '../../on_device/providers/on_device_providers.dart';
17
-import '../../on_device/providers/foreground_download_providers.dart';
18
-import '../../servers/data/models/server.dart';
19
-import '../../servers/providers/server_providers.dart';
20
-import '../../../core/providers/device_info_providers.dart';
21
-import '../../../core/device/device_memory_service.dart';
13
+class ModelDownloadMemoryInfo extends StatelessWidget {
14
+ const ModelDownloadMemoryInfo({super.key, required this.memoryAsync});
15
23
-class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
24
- const OnboardingModelDownloadScreen({super.key});
25
-
26
- @override
27
- ConsumerState<OnboardingModelDownloadScreen> createState() =>
28
- _OnboardingModelDownloadScreenState();
29
-}
30
-
31
-class _OnboardingModelDownloadScreenState
32
- extends ConsumerState<OnboardingModelDownloadScreen> {
33
- bool _isCreatingServer = false;
16
+ final AsyncValue<DeviceMemoryInfo> memoryAsync;
17
18
@override
19
Widget build(BuildContext context) {
37
- final l10n = AppLocalizations.of(context)!;
38
- final theme = Theme.of(context);
39
- final models = ref.watch(onDeviceModelsProvider);
40
- final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
41
- final downloadStates = ref.watch(foregroundDownloadNotifierProvider);
42
- final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
43
-
44
- return Scaffold(
45
- appBar: AppBar(title: Text(l10n.download_model_title)),
46
- body: SafeArea(
47
- child: Cue.onMount(
48
- motion: .smooth(),
49
- child: Column(
50
- children: [
51
- Expanded(
52
- child: ListView(
53
- padding: const EdgeInsets.symmetric(
54
- horizontal: 24.0,
55
- vertical: 16.0,
56
- ),
57
- children: [
58
- Actor(
59
- acts: [.fadeIn(), .slideY(from: 0.08)],
60
- child: Column(
61
- crossAxisAlignment: CrossAxisAlignment.stretch,
62
- children: [
63
- Text(
64
- l10n.download_model_desc,
65
- style: theme.textTheme.bodyMedium?.copyWith(
66
- color: theme.colorScheme.onSurface.withValues(
67
- alpha: 0.7,
68
- ),
69
- ),
70
- ),
71
- const SizedBox(height: 24),
72
- if (!Platform.isAndroid && !Platform.isIOS)
73
- Container(
74
- padding: const EdgeInsets.all(12),
75
- margin: const EdgeInsets.only(bottom: 16),
76
- decoration: BoxDecoration(
77
- color: Colors.orange.withValues(alpha: 0.1),
78
- borderRadius: BorderRadius.circular(8),
79
- border: Border.all(color: Colors.orange),
80
- ),
81
- child: Row(
82
- children: [
83
- const Icon(
84
- Icons.info_outline,
85
- color: Colors.orange,
86
- size: 20,
87
- ),
88
- const SizedBox(width: 8),
89
- Expanded(
90
- child: Text(
91
- l10n.on_device_android_only,
92
- style: const TextStyle(
93
- color: Colors.orange,
94
- ),
95
- ),
96
- ),
97
- ],
98
- ),
99
- ),
100
- _buildMemoryInfo(context, ref, deviceMemoryAsync),
101
- ],
102
- ),
103
- ),
104
- Actor(
105
- delay: 60.ms,
106
- acts: [.fadeIn(), .slideY(from: 0.08)],
107
- child: Column(
108
- crossAxisAlignment: CrossAxisAlignment.stretch,
109
- children: models.map((model) {
110
- final isDownloaded = downloadedModelsAsync.when(
111
- data: (set) => set.contains(model.id),
112
- loading: () => false,
113
- error: (_, _) => false,
114
- );
115
- final progressInfo = downloadStates[model.id];
116
-
117
- return Padding(
118
- padding: const EdgeInsets.only(bottom: 12),
119
- child: _ModelCard(
120
- model: model,
121
- isDownloaded: isDownloaded,
122
- progressInfo: progressInfo,
123
- theme: theme,
124
- deviceMemory: deviceMemoryAsync.value,
125
- ),
126
- );
127
- }).toList(),
128
- ),
129
- ),
130
- ],
131
- ),
132
- ),
133
- Actor(
134
- delay: 120.ms,
135
- acts: [.fadeIn(), .slideY(from: 0.08)],
136
- child: Padding(
137
- padding: const EdgeInsets.symmetric(
138
- horizontal: 16,
139
- vertical: 8,
140
- ),
141
- child: Column(
142
- crossAxisAlignment: CrossAxisAlignment.stretch,
143
- children: [
144
- if (_isCreatingServer)
145
- const Center(child: CircularProgressIndicator())
146
- else
147
- ShadButton(
148
- width: double.infinity,
149
- onPressed: _canContinue()
150
- ? _createOnDeviceServer
151
- : null,
152
- child: Text(
153
- l10n.continue_action,
154
- style: const TextStyle(
155
- fontSize: 16,
156
- fontWeight: FontWeight.w600,
157
- ),
158
- ),
159
- ),
160
- ],
161
- ),
162
- ),
163
- ),
164
- ],
165
- ),
166
- ),
167
- ),
168
- );
169
- }
170
-
171
- bool _canContinue() {
172
- final downloadedSet = ref.read(downloadedModelsProvider).whenData((s) => s);
173
- return downloadedSet.hasValue && downloadedSet.value!.isNotEmpty;
174
- }
175
-
176
- Future<void> _createOnDeviceServer() async {
177
- setState(() => _isCreatingServer = true);
178
-
179
- try {
180
- final server = Server(
181
- id: DateTime.now().millisecondsSinceEpoch.toString(),
182
- name: 'On-Device',
183
- type: ServerType.onDevice,
184
- host: '',
185
- port: 0,
186
- isDefault: true,
187
- createdAt: DateTime.now(),
188
- lastConnectedAt: DateTime.now(),
189
- status: ConnectionStatus.connected,
190
- iconName: 'strokeRoundedSmartPhone01',
191
- );
192
-
193
- await ref.read(serversProvider.notifier).addServer(server);
194
- await ref.read(serversProvider.notifier).setDefault(server.id);
195
- ref.read(activeServerProvider.notifier).setActiveServer(server);
196
-
197
- if (mounted) {
198
- context.push(AppRoutes.onboardingTheme);
199
- }
200
- } catch (e) {
201
- if (mounted) {
202
- ScaffoldMessenger.of(context).showSnackBar(
203
- SnackBar(
204
- content: Text(
205
- AppLocalizations.of(context)!.error_with_message(e.toString()),
206
- ),
207
- ),
208
- );
209
- }
210
- } finally {
211
- if (mounted) {
212
- setState(() => _isCreatingServer = false);
213
- }
214
- }
215
- }
216
-
217
- Widget _buildMemoryInfo(
218
- BuildContext context,
219
- WidgetRef ref,
220
- AsyncValue<DeviceMemoryInfo> memoryAsync,
221
- ) {
20
final l10n = AppLocalizations.of(context)!;
21
return memoryAsync.when(
22
data: (info) {
@@ -261,11 +59,6 @@ class _OnboardingModelDownloadScreenState
59
}
60
61
class _MemoryStat extends StatelessWidget {
264
- final String label;
265
- final String value;
266
- final IconData icon;
267
- final Color? color;
268
-
62
const _MemoryStat({
63
required this.label,
64
required this.value,
@@ -273,6 +66,11 @@ class _MemoryStat extends StatelessWidget {
66
this.color,
67
});
68
69
+ final String label;
70
+ final String value;
71
+ final IconData icon;
72
+ final Color? color;
73
+
74
@override
75
Widget build(BuildContext context) {
76
final theme = Theme.of(context);
@@ -303,21 +101,22 @@ class _MemoryStat extends StatelessWidget {
101
}
102
}
103
306
-class _ModelCard extends ConsumerWidget {
307
- final OnDeviceModel model;
308
- final bool isDownloaded;
309
- final DownloadProgressInfo? progressInfo;
310
- final ThemeData theme;
311
- final DeviceMemoryInfo? deviceMemory;
312
-
313
- const _ModelCard({
104
+class ModelDownloadCard extends ConsumerWidget {
105
+ const ModelDownloadCard({
106
+ super.key,
107
required this.model,
108
required this.isDownloaded,
316
- this.progressInfo,
109
required this.theme,
110
+ this.progressInfo,
111
this.deviceMemory,
112
});
113
114
+ final OnDeviceModel model;
115
+ final bool isDownloaded;
116
+ final DownloadProgressInfo? progressInfo;
117
+ final ThemeData theme;
118
+ final DeviceMemoryInfo? deviceMemory;
119
+
120
@override
121
Widget build(BuildContext context, WidgetRef ref) {
122
final l10n = AppLocalizations.of(context)!;
lib/features/onboarding/views/components/onboarding_server_type_screen_content.dart
renamed
+10
-11
@@ -5,22 +5,21 @@ import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
import 'package:cue/cue.dart';
7
import 'package:url_launcher/url_launcher.dart';
8
+import 'package:localmind/core/models/enums.dart';
9
+import 'package:localmind/core/providers/app_providers.dart';
10
+import 'package:localmind/core/routes/app_routes.dart';
11
+import 'package:localmind/l10n/app_localizations.dart';
12
9
-import '../../../core/models/enums.dart';
10
-import '../../../core/providers/app_providers.dart';
11
-import '../../../core/routes/app_routes.dart';
12
-import '../../../l10n/app_localizations.dart';
13
-
14
-class OnboardingServerTypeScreen extends ConsumerStatefulWidget {
15
- const OnboardingServerTypeScreen({super.key});
13
+class OnboardingServerTypeScreenContent extends ConsumerStatefulWidget {
14
+ const OnboardingServerTypeScreenContent({super.key});
15
16
@override
18
- ConsumerState<OnboardingServerTypeScreen> createState() =>
19
- _OnboardingServerTypeScreenState();
17
+ ConsumerState<OnboardingServerTypeScreenContent> createState() =>
18
+ _OnboardingServerTypeScreenContentState();
19
}
20
22
-class _OnboardingServerTypeScreenState
23
- extends ConsumerState<OnboardingServerTypeScreen> {
21
+class _OnboardingServerTypeScreenContentState
22
+ extends ConsumerState<OnboardingServerTypeScreenContent> {
23
ServerType? _selectedType;
24
static final Uri _repoUrl = Uri.parse(
25
'https://github.com/abdulmominsakib/localmind',
lib/features/onboarding/views/onboarding_language_screen.dart
renamed
lib/features/onboarding/views/onboarding_model_download_screen.dart
new
+213
@@ -0,0 +1,213 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:go_router/go_router.dart';
7
+import 'package:shadcn_ui/shadcn_ui.dart';
8
+import 'package:cue/cue.dart';
9
+
10
+import '../../../core/models/enums.dart';
11
+import '../../../core/routes/app_routes.dart';
12
+import '../../../l10n/app_localizations.dart';
13
+import '../../on_device/providers/on_device_providers.dart';
14
+import '../../on_device/providers/foreground_download_providers.dart';
15
+import '../../servers/data/models/server.dart';
16
+import '../../servers/providers/server_providers.dart';
17
+import '../../../core/providers/device_info_providers.dart';
18
+import 'components/model_download_components.dart';
19
+
20
+class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
21
+ const OnboardingModelDownloadScreen({super.key});
22
+
23
+ @override
24
+ ConsumerState<OnboardingModelDownloadScreen> createState() =>
25
+ _OnboardingModelDownloadScreenState();
26
+}
27
+
28
+class _OnboardingModelDownloadScreenState
29
+ extends ConsumerState<OnboardingModelDownloadScreen> {
30
+ bool _isCreatingServer = false;
31
+
32
+ @override
33
+ Widget build(BuildContext context) {
34
+ final l10n = AppLocalizations.of(context)!;
35
+ final theme = Theme.of(context);
36
+ final models = ref.watch(onDeviceModelsProvider);
37
+ final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
38
+ final downloadStates = ref.watch(foregroundDownloadNotifierProvider);
39
+ final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
40
+
41
+ return Scaffold(
42
+ appBar: AppBar(title: Text(l10n.download_model_title)),
43
+ body: SafeArea(
44
+ child: Cue.onMount(
45
+ motion: .smooth(),
46
+ child: Column(
47
+ children: [
48
+ Expanded(
49
+ child: ListView(
50
+ padding: const EdgeInsets.symmetric(
51
+ horizontal: 24.0,
52
+ vertical: 16.0,
53
+ ),
54
+ children: [
55
+ Actor(
56
+ acts: [.fadeIn(), .slideY(from: 0.08)],
57
+ child: Column(
58
+ crossAxisAlignment: CrossAxisAlignment.stretch,
59
+ children: [
60
+ Text(
61
+ l10n.download_model_desc,
62
+ style: theme.textTheme.bodyMedium?.copyWith(
63
+ color: theme.colorScheme.onSurface.withValues(
64
+ alpha: 0.7,
65
+ ),
66
+ ),
67
+ ),
68
+ const SizedBox(height: 24),
69
+ if (!Platform.isAndroid && !Platform.isIOS)
70
+ Container(
71
+ padding: const EdgeInsets.all(12),
72
+ margin: const EdgeInsets.only(bottom: 16),
73
+ decoration: BoxDecoration(
74
+ color: Colors.orange.withValues(alpha: 0.1),
75
+ borderRadius: BorderRadius.circular(8),
76
+ border: Border.all(color: Colors.orange),
77
+ ),
78
+ child: Row(
79
+ children: [
80
+ const Icon(
81
+ Icons.info_outline,
82
+ color: Colors.orange,
83
+ size: 20,
84
+ ),
85
+ const SizedBox(width: 8),
86
+ Expanded(
87
+ child: Text(
88
+ l10n.on_device_android_only,
89
+ style: const TextStyle(
90
+ color: Colors.orange,
91
+ ),
92
+ ),
93
+ ),
94
+ ],
95
+ ),
96
+ ),
97
+ ModelDownloadMemoryInfo(memoryAsync: deviceMemoryAsync),
98
+ ],
99
+ ),
100
+ ),
101
+ Actor(
102
+ delay: 60.ms,
103
+ acts: [.fadeIn(), .slideY(from: 0.08)],
104
+ child: Column(
105
+ crossAxisAlignment: CrossAxisAlignment.stretch,
106
+ children: models.map((model) {
107
+ final isDownloaded = downloadedModelsAsync.when(
108
+ data: (set) => set.contains(model.id),
109
+ loading: () => false,
110
+ error: (_, _) => false,
111
+ );
112
+ final progressInfo = downloadStates[model.id];
113
+
114
+ return Padding(
115
+ padding: const EdgeInsets.only(bottom: 12),
116
+ child: ModelDownloadCard(
117
+ model: model,
118
+ isDownloaded: isDownloaded,
119
+ progressInfo: progressInfo,
120
+ theme: theme,
121
+ deviceMemory: deviceMemoryAsync.value,
122
+ ),
123
+ );
124
+ }).toList(),
125
+ ),
126
+ ),
127
+ ],
128
+ ),
129
+ ),
130
+ Actor(
131
+ delay: 120.ms,
132
+ acts: [.fadeIn(), .slideY(from: 0.08)],
133
+ child: Padding(
134
+ padding: const EdgeInsets.symmetric(
135
+ horizontal: 16,
136
+ vertical: 8,
137
+ ),
138
+ child: Column(
139
+ crossAxisAlignment: CrossAxisAlignment.stretch,
140
+ children: [
141
+ if (_isCreatingServer)
142
+ const Center(child: CircularProgressIndicator())
143
+ else
144
+ ShadButton(
145
+ width: double.infinity,
146
+ onPressed: _canContinue()
147
+ ? _createOnDeviceServer
148
+ : null,
149
+ child: Text(
150
+ l10n.continue_action,
151
+ style: const TextStyle(
152
+ fontSize: 16,
153
+ fontWeight: FontWeight.w600,
154
+ ),
155
+ ),
156
+ ),
157
+ ],
158
+ ),
159
+ ),
160
+ ),
161
+ ],
162
+ ),
163
+ ),
164
+ ),
165
+ );
166
+ }
167
+
168
+ bool _canContinue() {
169
+ final downloadedSet = ref.read(downloadedModelsProvider).whenData((s) => s);
170
+ return downloadedSet.hasValue && downloadedSet.value!.isNotEmpty;
171
+ }
172
+
173
+ Future<void> _createOnDeviceServer() async {
174
+ setState(() => _isCreatingServer = true);
175
+
176
+ try {
177
+ final server = Server(
178
+ id: DateTime.now().millisecondsSinceEpoch.toString(),
179
+ name: 'On-Device',
180
+ type: ServerType.onDevice,
181
+ host: '',
182
+ port: 0,
183
+ isDefault: true,
184
+ createdAt: DateTime.now(),
185
+ lastConnectedAt: DateTime.now(),
186
+ status: ConnectionStatus.connected,
187
+ iconName: 'strokeRoundedSmartPhone01',
188
+ );
189
+
190
+ await ref.read(serversProvider.notifier).addServer(server);
191
+ await ref.read(serversProvider.notifier).setDefault(server.id);
192
+ ref.read(activeServerProvider.notifier).setActiveServer(server);
193
+
194
+ if (mounted) {
195
+ context.push(AppRoutes.onboardingTheme);
196
+ }
197
+ } catch (e) {
198
+ if (mounted) {
199
+ ScaffoldMessenger.of(context).showSnackBar(
200
+ SnackBar(
201
+ content: Text(
202
+ AppLocalizations.of(context)!.error_with_message(e.toString()),
203
+ ),
204
+ ),
205
+ );
206
+ }
207
+ } finally {
208
+ if (mounted) {
209
+ setState(() => _isCreatingServer = false);
210
+ }
211
+ }
212
+ }
213
+}
lib/features/onboarding/views/onboarding_notification_permission_screen.dart
renamed
lib/features/onboarding/views/onboarding_server_setup_screen.dart
renamed
lib/features/onboarding/views/onboarding_server_type_screen.dart
new
+11
@@ -0,0 +1,11 @@
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/features/onboarding/views/components/onboarding_server_type_screen_content.dart';
3
+
4
+class OnboardingServerTypeScreen extends StatelessWidget {
5
+ const OnboardingServerTypeScreen({super.key});
6
+
7
+ @override
8
+ Widget build(BuildContext context) {
9
+ return const OnboardingServerTypeScreenContent();
10
+ }
11
+}
lib/features/onboarding/views/onboarding_theme_screen.dart
renamed
lib/features/personas/views/components/create_persona_screen_content.dart
new
+466
@@ -0,0 +1,466 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:go_router/go_router.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+import 'package:localmind/features/personas/data/models/persona.dart';
6
+import 'package:localmind/features/personas/providers/personas_providers.dart';
7
+
8
+class CreatePersonaScreenContent extends ConsumerStatefulWidget {
9
+ final Persona? editPersona;
10
+
11
+ const CreatePersonaScreenContent({super.key, this.editPersona});
12
+
13
+ @override
14
+ ConsumerState<CreatePersonaScreenContent> createState() =>
15
+ _CreatePersonaScreenContentState();
16
+}
17
+
18
+class _CreatePersonaScreenContentState
19
+ extends ConsumerState<CreatePersonaScreenContent> {
20
+ final _formKey = GlobalKey<FormState>();
21
+ late TextEditingController _nameController;
22
+ late TextEditingController _descriptionController;
23
+ late TextEditingController _promptController;
24
+ late TextEditingController _tempController;
25
+ late TextEditingController _topPController;
26
+
27
+ late String _selectedEmoji;
28
+ String? _selectedCategory;
29
+ bool _showAdvanced = false;
30
+ bool _isSaving = false;
31
+ bool _showPreview = false;
32
+
33
+ bool get _isEditing => widget.editPersona != null;
34
+
35
+ static const _emojis = [
36
+ '🤖',
37
+ '🧑💻',
38
+ '📐',
39
+ '✍️',
40
+ '📚',
41
+ '✏️',
42
+ '📋',
43
+ '🎯',
44
+ '🔍',
45
+ '💡',
46
+ '🎨',
47
+ '🧪',
48
+ '🔬',
49
+ '📝',
50
+ '🧠',
51
+ '🎭',
52
+ '🎵',
53
+ '📊',
54
+ '🌐',
55
+ '💬',
56
+ '🛠️',
57
+ '🎓',
58
+ '📖',
59
+ '🧪',
60
+ '🎮',
61
+ '🗺️',
62
+ '🤝',
63
+ '⚡',
64
+ ];
65
+
66
+ static const _categories = ['General', 'Coding', 'Education', 'Creative'];
67
+
68
+ @override
69
+ void initState() {
70
+ super.initState();
71
+ final p = widget.editPersona;
72
+ _nameController = TextEditingController(text: p?.name ?? '');
73
+ _descriptionController = TextEditingController(text: p?.description ?? '');
74
+ _promptController = TextEditingController(text: p?.systemPrompt ?? '');
75
+ _selectedEmoji = p?.emoji ?? '🤖';
76
+ _selectedCategory = p?.category;
77
+
78
+ final params = p?.preferredParams;
79
+ _tempController = TextEditingController(
80
+ text: params != null && params['temperature'] != null
81
+ ? params['temperature'].toString()
82
+ : '',
83
+ );
84
+ _topPController = TextEditingController(
85
+ text: params != null && params['topP'] != null
86
+ ? params['topP'].toString()
87
+ : '',
88
+ );
89
+ }
90
+
91
+ @override
92
+ void dispose() {
93
+ _nameController.dispose();
94
+ _descriptionController.dispose();
95
+ _promptController.dispose();
96
+ _tempController.dispose();
97
+ _topPController.dispose();
98
+ super.dispose();
99
+ }
100
+
101
+ Future<void> _save() async {
102
+ if (!_formKey.currentState!.validate()) return;
103
+
104
+ setState(() => _isSaving = true);
105
+
106
+ final notifier = ref.read(personasNotifierProvider.notifier);
107
+ final name = _nameController.text.trim();
108
+ final prompt = _promptController.text.trim();
109
+ final desc = _descriptionController.text.trim();
110
+
111
+ Map<String, dynamic>? params;
112
+ final temp = double.tryParse(_tempController.text.trim());
113
+ final topP = double.tryParse(_topPController.text.trim());
114
+ if (temp != null || topP != null) {
115
+ params = {};
116
+ if (temp != null) params['temperature'] = temp;
117
+ if (topP != null) params['topP'] = topP;
118
+ }
119
+
120
+ try {
121
+ if (_isEditing) {
122
+ await notifier.updatePersona(
123
+ widget.editPersona!.copyWith(
124
+ name: name,
125
+ emoji: _selectedEmoji,
126
+ systemPrompt: prompt,
127
+ description: desc.isNotEmpty ? desc : null,
128
+ category: _selectedCategory,
129
+ preferredParams: params,
130
+ ),
131
+ );
132
+ } else {
133
+ await notifier.createPersona(
134
+ name: name,
135
+ emoji: _selectedEmoji,
136
+ systemPrompt: prompt,
137
+ description: desc.isNotEmpty ? desc : null,
138
+ category: _selectedCategory,
139
+ preferredParams: params,
140
+ );
141
+ }
142
+
143
+ if (mounted) {
144
+ final l10n = AppLocalizations.of(context)!;
145
+ context.pop();
146
+ ScaffoldMessenger.of(context).showSnackBar(
147
+ SnackBar(
148
+ content: Text(_isEditing ? l10n.persona_updated : l10n.persona_created),
149
+ ),
150
+ );
151
+ }
152
+ } catch (e) {
153
+ if (mounted) {
154
+ ScaffoldMessenger.of(context).showSnackBar(
155
+ SnackBar(
156
+ content: Text(AppLocalizations.of(context)!.error_with_message(e.toString())),
157
+ backgroundColor: Colors.red,
158
+ ),
159
+ );
160
+ }
161
+ } finally {
162
+ if (mounted) setState(() => _isSaving = false);
163
+ }
164
+ }
165
+
166
+ @override
167
+ Widget build(BuildContext context) {
168
+ final l10n = AppLocalizations.of(context)!;
169
+ final theme = Theme.of(context);
170
+ final isDark = theme.brightness == Brightness.dark;
171
+
172
+ return Scaffold(
173
+ appBar: AppBar(
174
+ title: Text(_isEditing ? l10n.edit_persona : l10n.create_persona),
175
+ leading: IconButton(
176
+ icon: const Icon(Icons.close),
177
+ onPressed: () => context.pop(),
178
+ ),
179
+ actions: [
180
+ TextButton(
181
+ onPressed: _isSaving ? null : _save,
182
+ child: _isSaving
183
+ ? const SizedBox(
184
+ width: 16,
185
+ height: 16,
186
+ child: CircularProgressIndicator(strokeWidth: 2),
187
+ )
188
+ : Text(_isEditing ? l10n.save : l10n.create_persona_button),
189
+ ),
190
+ ],
191
+ ),
192
+ body: Form(
193
+ key: _formKey,
194
+ child: ListView(
195
+ padding: const EdgeInsets.all(16),
196
+ children: [
197
+ Text(
198
+ l10n.emoji_label,
199
+ style: TextStyle(
200
+ fontSize: 14,
201
+ fontWeight: FontWeight.w500,
202
+ color: isDark ? Colors.white : Colors.black,
203
+ ),
204
+ ),
205
+ const SizedBox(height: 8),
206
+ SizedBox(
207
+ height: 48,
208
+ child: ListView.separated(
209
+ scrollDirection: Axis.horizontal,
210
+ itemCount: _emojis.length,
211
+ separatorBuilder: (_, _) => const SizedBox(width: 4),
212
+ itemBuilder: (context, index) {
213
+ final emoji = _emojis[index];
214
+ final isSelected = emoji == _selectedEmoji;
215
+ return GestureDetector(
216
+ onTap: () => setState(() => _selectedEmoji = emoji),
217
+ child: Container(
218
+ width: 40,
219
+ height: 40,
220
+ decoration: BoxDecoration(
221
+ color: isSelected
222
+ ? (isDark
223
+ ? const Color(
224
+ 0xFF3B82F6,
225
+ ).withValues(alpha: 0.2)
226
+ : const Color(
227
+ 0xFF2563EB,
228
+ ).withValues(alpha: 0.15))
229
+ : (isDark
230
+ ? const Color(0xFF1F1F1F)
231
+ : const Color(0xFFF5F5F5)),
232
+ borderRadius: BorderRadius.circular(8),
233
+ border: Border.all(
234
+ color: isSelected
235
+ ? (isDark
236
+ ? const Color(0xFF3B82F6)
237
+ : const Color(0xFF2563EB))
238
+ : (isDark
239
+ ? const Color(0xFF3A3A3A)
240
+ : const Color(0xFFE5E5E5)),
241
+ ),
242
+ ),
243
+ child: Center(
244
+ child: Text(
245
+ emoji,
246
+ style: const TextStyle(fontSize: 20),
247
+ ),
248
+ ),
249
+ ),
250
+ );
251
+ },
252
+ ),
253
+ ),
254
+ const SizedBox(height: 20),
255
+
256
+ TextFormField(
257
+ controller: _nameController,
258
+ decoration: InputDecoration(
259
+ labelText: l10n.name_label,
260
+ hintText: l10n.my_persona_hint,
261
+ ),
262
+ validator: (v) {
263
+ if (v == null || v.trim().isEmpty) return l10n.name_required;
264
+ if (v.trim().length > 50) return l10n.name_max_50;
265
+ return null;
266
+ },
267
+ textInputAction: TextInputAction.next,
268
+ maxLength: 50,
269
+ ),
270
+ const SizedBox(height: 12),
271
+
272
+ DropdownButtonFormField<String>(
273
+ initialValue: _selectedCategory,
274
+ decoration: InputDecoration(labelText: l10n.category_label),
275
+ items: _categories
276
+ .map((c) => DropdownMenuItem(value: c, child: Text(c)))
277
+ .toList(),
278
+ onChanged: (v) => setState(() => _selectedCategory = v),
279
+ ),
280
+ const SizedBox(height: 12),
281
+
282
+ TextFormField(
283
+ controller: _descriptionController,
284
+ decoration: InputDecoration(
285
+ labelText: l10n.description_optional,
286
+ hintText: l10n.description_hint,
287
+ ),
288
+ maxLength: 200,
289
+ textInputAction: TextInputAction.next,
290
+ ),
291
+ const SizedBox(height: 12),
292
+
293
+ Row(
294
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
295
+ children: [
296
+ Text(
297
+ l10n.system_prompt,
298
+ style: TextStyle(
299
+ fontSize: 14,
300
+ fontWeight: FontWeight.w500,
301
+ color: isDark ? Colors.white : Colors.black,
302
+ ),
303
+ ),
304
+ Row(
305
+ children: [
306
+ Text(
307
+ l10n.character_count_max(_promptController.text.length),
308
+ style: TextStyle(
309
+ fontSize: 12,
310
+ color: _promptController.text.length > 4000
311
+ ? Colors.red
312
+ : (isDark
313
+ ? const Color(0xFF666666)
314
+ : const Color(0xFF999999)),
315
+ ),
316
+ ),
317
+ const SizedBox(width: 8),
318
+ IconButton(
319
+ icon: Icon(
320
+ _showPreview ? Icons.edit : Icons.visibility,
321
+ size: 18,
322
+ ),
323
+ tooltip: _showPreview ? l10n.edit : l10n.preview,
324
+ onPressed: () =>
325
+ setState(() => _showPreview = !_showPreview),
326
+ ),
327
+ ],
328
+ ),
329
+ ],
330
+ ),
331
+ const SizedBox(height: 4),
332
+ if (_showPreview)
333
+ Container(
334
+ width: double.infinity,
335
+ padding: const EdgeInsets.all(12),
336
+ decoration: BoxDecoration(
337
+ color: isDark
338
+ ? const Color(0xFF1F1F1F)
339
+ : const Color(0xFFF5F5F5),
340
+ borderRadius: BorderRadius.circular(8),
341
+ border: Border.all(
342
+ color: isDark
343
+ ? const Color(0xFF3A3A3A)
344
+ : const Color(0xFFE5E5E5),
345
+ ),
346
+ ),
347
+ child: Text(
348
+ _promptController.text.isEmpty
349
+ ? l10n.no_prompt_placeholder
350
+ : _promptController.text,
351
+ style: TextStyle(
352
+ fontSize: 14,
353
+ color: _promptController.text.isEmpty
354
+ ? (isDark
355
+ ? const Color(0xFF555555)
356
+ : const Color(0xFFBBBBBB))
357
+ : (isDark ? Colors.white : Colors.black),
358
+ ),
359
+ ),
360
+ )
361
+ else
362
+ TextFormField(
363
+ controller: _promptController,
364
+ decoration: InputDecoration(
365
+ hintText: l10n.prompt_hint,
366
+ alignLabelWithHint: true,
367
+ ),
368
+ maxLines: 8,
369
+ minLines: 4,
370
+ maxLength: 4000,
371
+ validator: (v) {
372
+ if (v == null || v.trim().isEmpty) {
373
+ return l10n.prompt_required;
374
+ }
375
+ if (v.trim().length > 4000) return l10n.prompt_max_chars;
376
+ return null;
377
+ },
378
+ onChanged: (_) => setState(() {}),
379
+ ),
380
+ const SizedBox(height: 16),
381
+
382
+ GestureDetector(
383
+ onTap: () => setState(() => _showAdvanced = !_showAdvanced),
384
+ child: Row(
385
+ children: [
386
+ Icon(
387
+ _showAdvanced ? Icons.expand_less : Icons.expand_more,
388
+ color: isDark
389
+ ? const Color(0xFF888888)
390
+ : const Color(0xFF999999),
391
+ ),
392
+ const SizedBox(width: 4),
393
+ Text(
394
+ l10n.advanced_settings,
395
+ style: TextStyle(
396
+ fontSize: 14,
397
+ fontWeight: FontWeight.w500,
398
+ color: isDark
399
+ ? const Color(0xFF888888)
400
+ : const Color(0xFF999999),
401
+ ),
402
+ ),
403
+ ],
404
+ ),
405
+ ),
406
+ if (_showAdvanced) ...[
407
+ const SizedBox(height: 12),
408
+ Row(
409
+ children: [
410
+ Expanded(
411
+ child: TextFormField(
412
+ controller: _tempController,
413
+ decoration: InputDecoration(
414
+ labelText: l10n.temperature_label,
415
+ hintText: l10n.temp_hint,
416
+ ),
417
+ keyboardType: const TextInputType.numberWithOptions(
418
+ decimal: true,
419
+ ),
420
+ validator: (v) {
421
+ if (v == null || v.trim().isEmpty) return null;
422
+ final val = double.tryParse(v.trim());
423
+ if (val == null || val < 0 || val > 2) return l10n.range_0_2;
424
+ return null;
425
+ },
426
+ ),
427
+ ),
428
+ const SizedBox(width: 12),
429
+ Expanded(
430
+ child: TextFormField(
431
+ controller: _topPController,
432
+ decoration: InputDecoration(
433
+ labelText: l10n.top_p_label,
434
+ hintText: l10n.top_p_hint,
435
+ ),
436
+ keyboardType: const TextInputType.numberWithOptions(
437
+ decimal: true,
438
+ ),
439
+ validator: (v) {
440
+ if (v == null || v.trim().isEmpty) return null;
441
+ final val = double.tryParse(v.trim());
442
+ if (val == null || val < 0 || val > 1) return l10n.range_0_1;
443
+ return null;
444
+ },
445
+ ),
446
+ ),
447
+ ],
448
+ ),
449
+ ],
450
+ const SizedBox(height: 32),
451
+ ElevatedButton(
452
+ onPressed: _isSaving ? null : _save,
453
+ child: _isSaving
454
+ ? const SizedBox(
455
+ width: 20,
456
+ height: 20,
457
+ child: CircularProgressIndicator(strokeWidth: 2),
458
+ )
459
+ : Text(_isEditing ? l10n.save_changes : l10n.create_persona_button),
460
+ ),
461
+ ],
462
+ ),
463
+ ),
464
+ );
465
+ }
466
+}
lib/features/personas/views/components/persona_list_screen_content.dart
new
+501
@@ -0,0 +1,501 @@
1
+import "package:localmind/core/theme/colors.dart";
2
+import 'package:flutter/material.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:go_router/go_router.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+import 'package:localmind/core/routes/app_routes.dart';
7
+import 'package:localmind/features/personas/data/models/persona.dart';
8
+import 'package:localmind/features/personas/providers/personas_providers.dart';
9
+
10
+class PersonaListScreenContent extends ConsumerWidget {
11
+ const PersonaListScreenContent({super.key});
12
+
13
+ @override
14
+ Widget build(BuildContext context, WidgetRef ref) {
15
+ final l10n = AppLocalizations.of(context)!;
16
+ final filteredPersonas = ref.watch(filteredPersonasProvider);
17
+ final builtIn = filteredPersonas.where((p) => p.isBuiltIn).toList();
18
+ final userCreated = filteredPersonas.where((p) => !p.isBuiltIn).toList();
19
+ final selectedCategory = ref.watch(personaCategoryFilterProvider);
20
+ final theme = Theme.of(context);
21
+ final isDark = theme.brightness == Brightness.dark;
22
+ final topPadding = MediaQuery.of(context).padding.top;
23
+
24
+ return Stack(
25
+ children: [
26
+ Column(
27
+ children: [
28
+ Container(
29
+ padding: EdgeInsets.only(
30
+ left: 16,
31
+ right: 16,
32
+ top: topPadding + 8,
33
+ bottom: 16,
34
+ ),
35
+ decoration: BoxDecoration(
36
+ color: isDark
37
+ ? const Color(0xFF0A0A0A)
38
+ : const Color(0xFFFAFAFA),
39
+ border: Border(
40
+ bottom: BorderSide(
41
+ color: isDark
42
+ ? const Color(0xFF2A2A2A)
43
+ : const Color(0xFFE5E5E5),
44
+ ),
45
+ ),
46
+ ),
47
+ child: Row(
48
+ children: [
49
+ Builder(
50
+ builder: (context) => IconButton(
51
+ icon: const Icon(Icons.menu),
52
+ onPressed: () => Scaffold.of(context).openDrawer(),
53
+ ),
54
+ ),
55
+ const SizedBox(width: 8),
56
+ Text(
57
+ l10n.personas_title,
58
+ style: TextStyle(
59
+ fontSize: 20,
60
+ fontWeight: FontWeight.bold,
61
+ color: isDark ? Colors.white : Colors.black,
62
+ ),
63
+ ),
64
+ ],
65
+ ),
66
+ ),
67
+ SizedBox(
68
+ height: 44,
69
+ child: ListView(
70
+ scrollDirection: Axis.horizontal,
71
+ padding: const EdgeInsets.symmetric(horizontal: 12),
72
+ children:
73
+ [
74
+ l10n.all,
75
+ l10n.persona_category_general,
76
+ l10n.persona_category_coding,
77
+ l10n.persona_category_education,
78
+ l10n.persona_category_creative,
79
+ ].map((cat) {
80
+ final isActive =
81
+ selectedCategory == (cat == l10n.all ? null : cat);
82
+ return Padding(
83
+ padding: const EdgeInsets.symmetric(
84
+ horizontal: 4,
85
+ vertical: 6,
86
+ ),
87
+ child: FilterChip(
88
+ label: Text(
89
+ cat,
90
+ style: TextStyle(
91
+ fontSize: 13,
92
+ fontWeight: isActive
93
+ ? FontWeight.w600
94
+ : FontWeight.normal,
95
+ color: isActive
96
+ ? (isDark ? Colors.white : Colors.white)
97
+ : (isDark
98
+ ? const Color(0xFFAAAAAA)
99
+ : const Color(0xFF666666)),
100
+ ),
101
+ ),
102
+ selected: isActive,
103
+ onSelected: (_) {
104
+ ref
105
+ .read(personaCategoryFilterProvider.notifier)
106
+ .setCategory(cat == l10n.all ? null : cat);
107
+ },
108
+ selectedColor: isDark
109
+ ? const Color(0xFF3B82F6)
110
+ : const Color(0xFF2563EB),
111
+ backgroundColor: isDark
112
+ ? const Color(0xFF1F1F1F)
113
+ : const Color(0xFFF5F5F5),
114
+ side: BorderSide(
115
+ color: isActive
116
+ ? (isDark
117
+ ? const Color(0xFF3B82F6)
118
+ : const Color(0xFF2563EB))
119
+ : (isDark
120
+ ? const Color(0xFF3A3A3A)
121
+ : const Color(0xFFE5E5E5)),
122
+ ),
123
+ showCheckmark: false,
124
+ shape: RoundedRectangleBorder(
125
+ borderRadius: BorderRadius.circular(20),
126
+ ),
127
+ padding: const EdgeInsets.symmetric(horizontal: 8),
128
+ materialTapTargetSize:
129
+ MaterialTapTargetSize.shrinkWrap,
130
+ ),
131
+ );
132
+ }).toList(),
133
+ ),
134
+ ),
135
+ Expanded(
136
+ child: filteredPersonas.isEmpty
137
+ ? _EmptyState(isDark: isDark, l10n: l10n)
138
+ : ListView(
139
+ padding: const EdgeInsets.symmetric(
140
+ horizontal: 16,
141
+ vertical: 8,
142
+ ),
143
+ children: [
144
+ if (builtIn.isNotEmpty) ...[
145
+ _SectionLabel(label: l10n.persona_builtin_section, isDark: isDark),
146
+ ...builtIn.map(
147
+ (p) => _PersonaCard(
148
+ persona: p,
149
+ isDark: isDark,
150
+ l10n: l10n,
151
+ onLongPress: () => _showActions(
152
+ context,
153
+ ref,
154
+ p,
155
+ isBuiltIn: true,
156
+ ),
157
+ ),
158
+ ),
159
+ const SizedBox(height: 12),
160
+ ],
161
+ if (userCreated.isNotEmpty) ...[
162
+ _SectionLabel(label: l10n.persona_my_section, isDark: isDark),
163
+ ...userCreated.map(
164
+ (p) => _PersonaCard(
165
+ persona: p,
166
+ isDark: isDark,
167
+ l10n: l10n,
168
+ onTap: () => context.push(
169
+ AppRoutes.createPersona,
170
+ extra: p,
171
+ ),
172
+ onLongPress: () => _showActions(
173
+ context,
174
+ ref,
175
+ p,
176
+ isBuiltIn: false,
177
+ ),
178
+ ),
179
+ ),
180
+ ],
181
+ const SizedBox(height: 80),
182
+ ],
183
+ ),
184
+ ),
185
+ ],
186
+ ),
187
+ PositionedDirectional(
188
+ bottom: 24,
189
+ end: 24,
190
+ child: FloatingActionButton(
191
+ onPressed: () => context.push(AppRoutes.createPersona),
192
+ child: const Icon(Icons.add),
193
+ ),
194
+ ),
195
+ ],
196
+ );
197
+ }
198
+
199
+ void _showActions(
200
+ BuildContext context,
201
+ WidgetRef ref,
202
+ Persona persona, {
203
+ required bool isBuiltIn,
204
+ }) {
205
+ showModalBottomSheet(
206
+ context: context,
207
+ builder: (ctx) {
208
+ final sheetL10n = AppLocalizations.of(ctx)!;
209
+ return SafeArea(
210
+ child: Column(
211
+ mainAxisSize: MainAxisSize.min,
212
+ children: [
213
+ if (!isBuiltIn) ...[
214
+ ListTile(
215
+ leading: const Icon(Icons.edit),
216
+ title: Text(sheetL10n.edit),
217
+ onTap: () {
218
+ Navigator.pop(ctx);
219
+ context.push(AppRoutes.createPersona, extra: persona);
220
+ },
221
+ ),
222
+ ListTile(
223
+ leading: const Icon(Icons.delete_outline, color: Colors.red),
224
+ title: Text(
225
+ sheetL10n.delete,
226
+ style: const TextStyle(color: Colors.red),
227
+ ),
228
+ onTap: () {
229
+ Navigator.pop(ctx);
230
+ showDialog(
231
+ context: context,
232
+ builder: (dCtx) {
233
+ final dlgL10n = AppLocalizations.of(dCtx)!;
234
+ return AlertDialog(
235
+ title: Text(dlgL10n.delete_persona_title(persona.name)),
236
+ content: Text(dlgL10n.delete_persona_body),
237
+ actions: [
238
+ TextButton(
239
+ onPressed: () => Navigator.pop(dCtx),
240
+ child: Text(dlgL10n.cancel),
241
+ ),
242
+ TextButton(
243
+ onPressed: () {
244
+ ref
245
+ .read(personasNotifierProvider.notifier)
246
+ .deletePersona(persona.id);
247
+ Navigator.pop(dCtx);
248
+ },
249
+ style: TextButton.styleFrom(
250
+ foregroundColor: Colors.red,
251
+ ),
252
+ child: Text(dlgL10n.delete),
253
+ ),
254
+ ],
255
+ );
256
+ },
257
+ );
258
+ },
259
+ ),
260
+ ],
261
+ ListTile(
262
+ leading: const Icon(Icons.copy),
263
+ title: Text(sheetL10n.clone_edit),
264
+ onTap: () {
265
+ Navigator.pop(ctx);
266
+ final clone = ref
267
+ .read(personasNotifierProvider.notifier)
268
+ .clonePersona(persona.id);
269
+ clone.then((c) {
270
+ if (!context.mounted) return;
271
+ context.push(AppRoutes.createPersona, extra: c);
272
+ });
273
+ },
274
+ ),
275
+ const SizedBox(height: 8),
276
+ ],
277
+ ),
278
+ );
279
+ },
280
+ );
281
+ }
282
+}
283
+
284
+class _SectionLabel extends StatelessWidget {
285
+ const _SectionLabel({required this.label, required this.isDark});
286
+ final String label;
287
+ final bool isDark;
288
+
289
+ @override
290
+ Widget build(BuildContext context) {
291
+ return Padding(
292
+ padding: const EdgeInsets.only(bottom: 8),
293
+ child: Text(
294
+ label,
295
+ style: TextStyle(
296
+ fontSize: 11,
297
+ fontWeight: FontWeight.w600,
298
+ letterSpacing: 0.8,
299
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
300
+ ),
301
+ ),
302
+ );
303
+ }
304
+}
305
+
306
+class _PersonaCard extends StatelessWidget {
307
+ const _PersonaCard({
308
+ required this.persona,
309
+ required this.isDark,
310
+ required this.l10n,
311
+ this.onTap,
312
+ this.onLongPress,
313
+ });
314
+
315
+ final Persona persona;
316
+ final bool isDark;
317
+ final AppLocalizations l10n;
318
+ final VoidCallback? onTap;
319
+ final VoidCallback? onLongPress;
320
+
321
+ @override
322
+ Widget build(BuildContext context) {
323
+ return GestureDetector(
324
+ onTap: onTap,
325
+ onLongPress: onLongPress,
326
+ child: Container(
327
+ margin: const EdgeInsets.only(bottom: 8),
328
+ padding: const EdgeInsets.all(12),
329
+ decoration: BoxDecoration(
330
+ color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
331
+ borderRadius: BorderRadius.circular(10),
332
+ border: Border.all(
333
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
334
+ ),
335
+ ),
336
+ child: Row(
337
+ children: [
338
+ Container(
339
+ width: 44,
340
+ height: 44,
341
+ decoration: BoxDecoration(
342
+ color: isDark
343
+ ? const Color(0xFF2A2A2A)
344
+ : const Color(0xFFEDEDED),
345
+ borderRadius: BorderRadius.circular(10),
346
+ ),
347
+ child: Center(
348
+ child: Text(
349
+ persona.emoji,
350
+ style: const TextStyle(fontSize: 22),
351
+ ),
352
+ ),
353
+ ),
354
+ const SizedBox(width: 12),
355
+ Expanded(
356
+ child: Column(
357
+ crossAxisAlignment: CrossAxisAlignment.start,
358
+ children: [
359
+ Row(
360
+ children: [
361
+ Expanded(
362
+ child: Text(
363
+ persona.name,
364
+ style: TextStyle(
365
+ fontSize: 15,
366
+ fontWeight: FontWeight.w600,
367
+ color: isDark ? Colors.white : Colors.black,
368
+ ),
369
+ maxLines: 1,
370
+ overflow: TextOverflow.ellipsis,
371
+ ),
372
+ ),
373
+ if (persona.isBuiltIn)
374
+ Container(
375
+ padding: const EdgeInsets.symmetric(
376
+ horizontal: 6,
377
+ vertical: 2,
378
+ ),
379
+ decoration: BoxDecoration(
380
+ color: isDark
381
+ ? const Color(
382
+ 0xFF3B82F6,
383
+ ).withValues(alpha: 0.15)
384
+ : const Color(
385
+ 0xFF2563EB,
386
+ ).withValues(alpha: 0.1),
387
+ borderRadius: BorderRadius.circular(4),
388
+ ),
389
+ child: Text(
390
+ l10n.builtin_badge,
391
+ style: TextStyle(
392
+ fontSize: 10,
393
+ fontWeight: FontWeight.w600,
394
+ color: isDark
395
+ ? const Color(0xFF60A5FA)
396
+ : const Color(0xFF2563EB),
397
+ ),
398
+ ),
399
+ ),
400
+ ],
401
+ ),
402
+ const SizedBox(height: 2),
403
+ Text(
404
+ persona.description ??
405
+ persona.systemPrompt.substring(
406
+ 0,
407
+ persona.systemPrompt.length.clamp(0, 60),
408
+ ),
409
+ style: TextStyle(
410
+ fontSize: 13,
411
+ color: isDark
412
+ ? const Color(0xFF888888)
413
+ : const Color(0xFF999999),
414
+ ),
415
+ maxLines: 1,
416
+ overflow: TextOverflow.ellipsis,
417
+ ),
418
+ if (persona.category != null) ...[
419
+ const SizedBox(height: 4),
420
+ Container(
421
+ padding: const EdgeInsets.symmetric(
422
+ horizontal: 6,
423
+ vertical: 2,
424
+ ),
425
+ decoration: BoxDecoration(
426
+ color: isDark
427
+ ? const Color(0xFF2A2A2A)
428
+ : const Color(0xFFE8E8E8),
429
+ borderRadius: BorderRadius.circular(4),
430
+ ),
431
+ child: Text(
432
+ persona.category!,
433
+ style: TextStyle(
434
+ fontSize: 10,
435
+ color: isDark
436
+ ? const Color(0xFFAAAAAA)
437
+ : const Color(0xFF777777),
438
+ ),
439
+ ),
440
+ ),
441
+ ],
442
+ ],
443
+ ),
444
+ ),
445
+ const SizedBox(width: 8),
446
+ Icon(
447
+ Icons.chevron_right,
448
+ size: 20,
449
+ color: isDark ? const Color(0xFF555555) : const Color(0xFFCCCCCC),
450
+ ),
451
+ ],
452
+ ),
453
+ ),
454
+ );
455
+ }
456
+}
457
+
458
+class _EmptyState extends StatelessWidget {
459
+ const _EmptyState({required this.isDark, required this.l10n});
460
+ final bool isDark;
461
+ final AppLocalizations l10n;
462
+
463
+ @override
464
+ Widget build(BuildContext context) {
465
+ return Center(
466
+ child: Padding(
467
+ padding: const EdgeInsets.all(32),
468
+ child: Column(
469
+ mainAxisAlignment: MainAxisAlignment.center,
470
+ children: [
471
+ Icon(
472
+ Icons.smart_toy_outlined,
473
+ size: 64,
474
+ color: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
475
+ ),
476
+ const SizedBox(height: 16),
477
+ Text(
478
+ l10n.no_personas_found,
479
+ style: TextStyle(
480
+ fontSize: 18,
481
+ fontWeight: FontWeight.w500,
482
+ color: isDark ? Colors.white : Colors.black,
483
+ ),
484
+ ),
485
+ const SizedBox(height: 8),
486
+ Text(
487
+ l10n.no_personas_desc,
488
+ style: TextStyle(
489
+ fontSize: 14,
490
+ color: isDark
491
+ ? const Color(0xFF888888)
492
+ : const Color(0xFF999999),
493
+ ),
494
+ textAlign: TextAlign.center,
495
+ ),
496
+ ],
497
+ ),
498
+ ),
499
+ );
500
+ }
501
+}
lib/features/personas/views/create_persona_screen.dart
+6
-457
@@ -1,465 +1,14 @@
1
-import 'package:flutter/material.dart';
2
-import 'package:flutter_riverpod/flutter_riverpod.dart';
3
-import 'package:go_router/go_router.dart';
4
-import 'package:localmind/l10n/app_localizations.dart';
5
-import '../data/models/persona.dart';
6
-import '../providers/personas_providers.dart';
7
-
8
-class CreatePersonaScreen extends ConsumerStatefulWidget {
9
- final Persona? editPersona;
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/features/personas/data/models/persona.dart';
3
+import 'package:localmind/features/personas/views/components/create_persona_screen_content.dart';
4
5
+class CreatePersonaScreen extends StatelessWidget {
6
const CreatePersonaScreen({super.key, this.editPersona});
7
13
- @override
14
- ConsumerState<CreatePersonaScreen> createState() =>
15
- _CreatePersonaScreenState();
16
-}
17
-
18
-class _CreatePersonaScreenState extends ConsumerState<CreatePersonaScreen> {
19
- final _formKey = GlobalKey<FormState>();
20
- late TextEditingController _nameController;
21
- late TextEditingController _descriptionController;
22
- late TextEditingController _promptController;
23
- late TextEditingController _tempController;
24
- late TextEditingController _topPController;
25
-
26
- late String _selectedEmoji;
27
- String? _selectedCategory;
28
- bool _showAdvanced = false;
29
- bool _isSaving = false;
30
- bool _showPreview = false;
31
-
32
- bool get _isEditing => widget.editPersona != null;
33
-
34
- static const _emojis = [
35
- '🤖',
36
- '🧑💻',
37
- '📐',
38
- '✍️',
39
- '📚',
40
- '✏️',
41
- '📋',
42
- '🎯',
43
- '🔍',
44
- '💡',
45
- '🎨',
46
- '🧪',
47
- '🔬',
48
- '📝',
49
- '🧠',
50
- '🎭',
51
- '🎵',
52
- '📊',
53
- '🌐',
54
- '💬',
55
- '🛠️',
56
- '🎓',
57
- '📖',
58
- '🧪',
59
- '🎮',
60
- '🗺️',
61
- '🤝',
62
- '⚡',
63
- ];
64
-
65
- static const _categories = ['General', 'Coding', 'Education', 'Creative'];
66
-
67
- @override
68
- void initState() {
69
- super.initState();
70
- final p = widget.editPersona;
71
- _nameController = TextEditingController(text: p?.name ?? '');
72
- _descriptionController = TextEditingController(text: p?.description ?? '');
73
- _promptController = TextEditingController(text: p?.systemPrompt ?? '');
74
- _selectedEmoji = p?.emoji ?? '🤖';
75
- _selectedCategory = p?.category;
76
-
77
- final params = p?.preferredParams;
78
- _tempController = TextEditingController(
79
- text: params != null && params['temperature'] != null
80
- ? params['temperature'].toString()
81
- : '',
82
- );
83
- _topPController = TextEditingController(
84
- text: params != null && params['topP'] != null
85
- ? params['topP'].toString()
86
- : '',
87
- );
88
- }
89
-
90
- @override
91
- void dispose() {
92
- _nameController.dispose();
93
- _descriptionController.dispose();
94
- _promptController.dispose();
95
- _tempController.dispose();
96
- _topPController.dispose();
97
- super.dispose();
98
- }
99
-
100
- Future<void> _save() async {
101
- if (!_formKey.currentState!.validate()) return;
102
-
103
- setState(() => _isSaving = true);
104
-
105
- final notifier = ref.read(personasNotifierProvider.notifier);
106
- final name = _nameController.text.trim();
107
- final prompt = _promptController.text.trim();
108
- final desc = _descriptionController.text.trim();
109
-
110
- Map<String, dynamic>? params;
111
- final temp = double.tryParse(_tempController.text.trim());
112
- final topP = double.tryParse(_topPController.text.trim());
113
- if (temp != null || topP != null) {
114
- params = {};
115
- if (temp != null) params['temperature'] = temp;
116
- if (topP != null) params['topP'] = topP;
117
- }
118
-
119
- try {
120
- if (_isEditing) {
121
- await notifier.updatePersona(
122
- widget.editPersona!.copyWith(
123
- name: name,
124
- emoji: _selectedEmoji,
125
- systemPrompt: prompt,
126
- description: desc.isNotEmpty ? desc : null,
127
- category: _selectedCategory,
128
- preferredParams: params,
129
- ),
130
- );
131
- } else {
132
- await notifier.createPersona(
133
- name: name,
134
- emoji: _selectedEmoji,
135
- systemPrompt: prompt,
136
- description: desc.isNotEmpty ? desc : null,
137
- category: _selectedCategory,
138
- preferredParams: params,
139
- );
140
- }
141
-
142
- if (mounted) {
143
- final l10n = AppLocalizations.of(context)!;
144
- context.pop();
145
- ScaffoldMessenger.of(context).showSnackBar(
146
- SnackBar(
147
- content: Text(_isEditing ? l10n.persona_updated : l10n.persona_created),
148
- ),
149
- );
150
- }
151
- } catch (e) {
152
- if (mounted) {
153
- ScaffoldMessenger.of(context).showSnackBar(
154
- SnackBar(
155
- content: Text(AppLocalizations.of(context)!.error_with_message(e.toString())),
156
- backgroundColor: Colors.red,
157
- ),
158
- );
159
- }
160
- } finally {
161
- if (mounted) setState(() => _isSaving = false);
162
- }
163
- }
8
+ final Persona? editPersona;
9
10
@override
11
Widget build(BuildContext context) {
167
- final l10n = AppLocalizations.of(context)!;
168
- final theme = Theme.of(context);
169
- final isDark = theme.brightness == Brightness.dark;
170
-
171
- return Scaffold(
172
- appBar: AppBar(
173
- title: Text(_isEditing ? l10n.edit_persona : l10n.create_persona),
174
- leading: IconButton(
175
- icon: const Icon(Icons.close),
176
- onPressed: () => context.pop(),
177
- ),
178
- actions: [
179
- TextButton(
180
- onPressed: _isSaving ? null : _save,
181
- child: _isSaving
182
- ? const SizedBox(
183
- width: 16,
184
- height: 16,
185
- child: CircularProgressIndicator(strokeWidth: 2),
186
- )
187
- : Text(_isEditing ? l10n.save : l10n.create_persona_button),
188
- ),
189
- ],
190
- ),
191
- body: Form(
192
- key: _formKey,
193
- child: ListView(
194
- padding: const EdgeInsets.all(16),
195
- children: [
196
- Text(
197
- l10n.emoji_label,
198
- style: TextStyle(
199
- fontSize: 14,
200
- fontWeight: FontWeight.w500,
201
- color: isDark ? Colors.white : Colors.black,
202
- ),
203
- ),
204
- const SizedBox(height: 8),
205
- SizedBox(
206
- height: 48,
207
- child: ListView.separated(
208
- scrollDirection: Axis.horizontal,
209
- itemCount: _emojis.length,
210
- separatorBuilder: (_, _) => const SizedBox(width: 4),
211
- itemBuilder: (context, index) {
212
- final emoji = _emojis[index];
213
- final isSelected = emoji == _selectedEmoji;
214
- return GestureDetector(
215
- onTap: () => setState(() => _selectedEmoji = emoji),
216
- child: Container(
217
- width: 40,
218
- height: 40,
219
- decoration: BoxDecoration(
220
- color: isSelected
221
- ? (isDark
222
- ? const Color(
223
- 0xFF3B82F6,
224
- ).withValues(alpha: 0.2)
225
- : const Color(
226
- 0xFF2563EB,
227
- ).withValues(alpha: 0.15))
228
- : (isDark
229
- ? const Color(0xFF1F1F1F)
230
- : const Color(0xFFF5F5F5)),
231
- borderRadius: BorderRadius.circular(8),
232
- border: Border.all(
233
- color: isSelected
234
- ? (isDark
235
- ? const Color(0xFF3B82F6)
236
- : const Color(0xFF2563EB))
237
- : (isDark
238
- ? const Color(0xFF3A3A3A)
239
- : const Color(0xFFE5E5E5)),
240
- ),
241
- ),
242
- child: Center(
243
- child: Text(
244
- emoji,
245
- style: const TextStyle(fontSize: 20),
246
- ),
247
- ),
248
- ),
249
- );
250
- },
251
- ),
252
- ),
253
- const SizedBox(height: 20),
254
-
255
- TextFormField(
256
- controller: _nameController,
257
- decoration: InputDecoration(
258
- labelText: l10n.name_label,
259
- hintText: l10n.my_persona_hint,
260
- ),
261
- validator: (v) {
262
- if (v == null || v.trim().isEmpty) return l10n.name_required;
263
- if (v.trim().length > 50) return l10n.name_max_50;
264
- return null;
265
- },
266
- textInputAction: TextInputAction.next,
267
- maxLength: 50,
268
- ),
269
- const SizedBox(height: 12),
270
-
271
- DropdownButtonFormField<String>(
272
- initialValue: _selectedCategory,
273
- decoration: InputDecoration(labelText: l10n.category_label),
274
- items: _categories
275
- .map((c) => DropdownMenuItem(value: c, child: Text(c)))
276
- .toList(),
277
- onChanged: (v) => setState(() => _selectedCategory = v),
278
- ),
279
- const SizedBox(height: 12),
280
-
281
- TextFormField(
282
- controller: _descriptionController,
283
- decoration: InputDecoration(
284
- labelText: l10n.description_optional,
285
- hintText: l10n.description_hint,
286
- ),
287
- maxLength: 200,
288
- textInputAction: TextInputAction.next,
289
- ),
290
- const SizedBox(height: 12),
291
-
292
- Row(
293
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
294
- children: [
295
- Text(
296
- l10n.system_prompt,
297
- style: TextStyle(
298
- fontSize: 14,
299
- fontWeight: FontWeight.w500,
300
- color: isDark ? Colors.white : Colors.black,
301
- ),
302
- ),
303
- Row(
304
- children: [
305
- Text(
306
- l10n.character_count_max(_promptController.text.length),
307
- style: TextStyle(
308
- fontSize: 12,
309
- color: _promptController.text.length > 4000
310
- ? Colors.red
311
- : (isDark
312
- ? const Color(0xFF666666)
313
- : const Color(0xFF999999)),
314
- ),
315
- ),
316
- const SizedBox(width: 8),
317
- IconButton(
318
- icon: Icon(
319
- _showPreview ? Icons.edit : Icons.visibility,
320
- size: 18,
321
- ),
322
- tooltip: _showPreview ? l10n.edit : l10n.preview,
323
- onPressed: () =>
324
- setState(() => _showPreview = !_showPreview),
325
- ),
326
- ],
327
- ),
328
- ],
329
- ),
330
- const SizedBox(height: 4),
331
- if (_showPreview)
332
- Container(
333
- width: double.infinity,
334
- padding: const EdgeInsets.all(12),
335
- decoration: BoxDecoration(
336
- color: isDark
337
- ? const Color(0xFF1F1F1F)
338
- : const Color(0xFFF5F5F5),
339
- borderRadius: BorderRadius.circular(8),
340
- border: Border.all(
341
- color: isDark
342
- ? const Color(0xFF3A3A3A)
343
- : const Color(0xFFE5E5E5),
344
- ),
345
- ),
346
- child: Text(
347
- _promptController.text.isEmpty
348
- ? l10n.no_prompt_placeholder
349
- : _promptController.text,
350
- style: TextStyle(
351
- fontSize: 14,
352
- color: _promptController.text.isEmpty
353
- ? (isDark
354
- ? const Color(0xFF555555)
355
- : const Color(0xFFBBBBBB))
356
- : (isDark ? Colors.white : Colors.black),
357
- ),
358
- ),
359
- )
360
- else
361
- TextFormField(
362
- controller: _promptController,
363
- decoration: InputDecoration(
364
- hintText: l10n.prompt_hint,
365
- alignLabelWithHint: true,
366
- ),
367
- maxLines: 8,
368
- minLines: 4,
369
- maxLength: 4000,
370
- validator: (v) {
371
- if (v == null || v.trim().isEmpty) {
372
- return l10n.prompt_required;
373
- }
374
- if (v.trim().length > 4000) return l10n.prompt_max_chars;
375
- return null;
376
- },
377
- onChanged: (_) => setState(() {}),
378
- ),
379
- const SizedBox(height: 16),
380
-
381
- GestureDetector(
382
- onTap: () => setState(() => _showAdvanced = !_showAdvanced),
383
- child: Row(
384
- children: [
385
- Icon(
386
- _showAdvanced ? Icons.expand_less : Icons.expand_more,
387
- color: isDark
388
- ? const Color(0xFF888888)
389
- : const Color(0xFF999999),
390
- ),
391
- const SizedBox(width: 4),
392
- Text(
393
- l10n.advanced_settings,
394
- style: TextStyle(
395
- fontSize: 14,
396
- fontWeight: FontWeight.w500,
397
- color: isDark
398
- ? const Color(0xFF888888)
399
- : const Color(0xFF999999),
400
- ),
401
- ),
402
- ],
403
- ),
404
- ),
405
- if (_showAdvanced) ...[
406
- const SizedBox(height: 12),
407
- Row(
408
- children: [
409
- Expanded(
410
- child: TextFormField(
411
- controller: _tempController,
412
- decoration: InputDecoration(
413
- labelText: l10n.temperature_label,
414
- hintText: l10n.temp_hint,
415
- ),
416
- keyboardType: const TextInputType.numberWithOptions(
417
- decimal: true,
418
- ),
419
- validator: (v) {
420
- if (v == null || v.trim().isEmpty) return null;
421
- final val = double.tryParse(v.trim());
422
- if (val == null || val < 0 || val > 2) return l10n.range_0_2;
423
- return null;
424
- },
425
- ),
426
- ),
427
- const SizedBox(width: 12),
428
- Expanded(
429
- child: TextFormField(
430
- controller: _topPController,
431
- decoration: InputDecoration(
432
- labelText: l10n.top_p_label,
433
- hintText: l10n.top_p_hint,
434
- ),
435
- keyboardType: const TextInputType.numberWithOptions(
436
- decimal: true,
437
- ),
438
- validator: (v) {
439
- if (v == null || v.trim().isEmpty) return null;
440
- final val = double.tryParse(v.trim());
441
- if (val == null || val < 0 || val > 1) return l10n.range_0_1;
442
- return null;
443
- },
444
- ),
445
- ),
446
- ],
447
- ),
448
- ],
449
- const SizedBox(height: 32),
450
- ElevatedButton(
451
- onPressed: _isSaving ? null : _save,
452
- child: _isSaving
453
- ? const SizedBox(
454
- width: 20,
455
- height: 20,
456
- child: CircularProgressIndicator(strokeWidth: 2),
457
- )
458
- : Text(_isEditing ? l10n.save_changes : l10n.create_persona_button),
459
- ),
460
- ],
461
- ),
462
- ),
463
- );
12
+ return CreatePersonaScreenContent(editPersona: editPersona);
13
}
14
}
lib/features/personas/views/persona_list_screen.dart
+4
-494
@@ -1,501 +1,11 @@
1
-import "package:localmind/core/theme/colors.dart";
2
-import 'package:flutter/material.dart';
3
-import 'package:flutter_riverpod/flutter_riverpod.dart';
4
-import 'package:go_router/go_router.dart';
5
-import 'package:localmind/l10n/app_localizations.dart';
6
-import '../../../core/routes/app_routes.dart';
7
-import '../data/models/persona.dart';
8
-import '../providers/personas_providers.dart';
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/features/personas/views/components/persona_list_screen_content.dart';
3
10
-class PersonaListScreen extends ConsumerWidget {
4
+class PersonaListScreen extends StatelessWidget {
5
const PersonaListScreen({super.key});
6
13
- @override
14
- Widget build(BuildContext context, WidgetRef ref) {
15
- final l10n = AppLocalizations.of(context)!;
16
- final filteredPersonas = ref.watch(filteredPersonasProvider);
17
- final builtIn = filteredPersonas.where((p) => p.isBuiltIn).toList();
18
- final userCreated = filteredPersonas.where((p) => !p.isBuiltIn).toList();
19
- final selectedCategory = ref.watch(personaCategoryFilterProvider);
20
- final theme = Theme.of(context);
21
- final isDark = theme.brightness == Brightness.dark;
22
- final topPadding = MediaQuery.of(context).padding.top;
23
-
24
- return Stack(
25
- children: [
26
- Column(
27
- children: [
28
- Container(
29
- padding: EdgeInsets.only(
30
- left: 16,
31
- right: 16,
32
- top: topPadding + 8,
33
- bottom: 16,
34
- ),
35
- decoration: BoxDecoration(
36
- color: isDark
37
- ? const Color(0xFF0A0A0A)
38
- : const Color(0xFFFAFAFA),
39
- border: Border(
40
- bottom: BorderSide(
41
- color: isDark
42
- ? const Color(0xFF2A2A2A)
43
- : const Color(0xFFE5E5E5),
44
- ),
45
- ),
46
- ),
47
- child: Row(
48
- children: [
49
- Builder(
50
- builder: (context) => IconButton(
51
- icon: const Icon(Icons.menu),
52
- onPressed: () => Scaffold.of(context).openDrawer(),
53
- ),
54
- ),
55
- const SizedBox(width: 8),
56
- Text(
57
- l10n.personas_title,
58
- style: TextStyle(
59
- fontSize: 20,
60
- fontWeight: FontWeight.bold,
61
- color: isDark ? Colors.white : Colors.black,
62
- ),
63
- ),
64
- ],
65
- ),
66
- ),
67
- SizedBox(
68
- height: 44,
69
- child: ListView(
70
- scrollDirection: Axis.horizontal,
71
- padding: const EdgeInsets.symmetric(horizontal: 12),
72
- children:
73
- [
74
- l10n.all,
75
- l10n.persona_category_general,
76
- l10n.persona_category_coding,
77
- l10n.persona_category_education,
78
- l10n.persona_category_creative,
79
- ].map((cat) {
80
- final isActive =
81
- selectedCategory == (cat == l10n.all ? null : cat);
82
- return Padding(
83
- padding: const EdgeInsets.symmetric(
84
- horizontal: 4,
85
- vertical: 6,
86
- ),
87
- child: FilterChip(
88
- label: Text(
89
- cat,
90
- style: TextStyle(
91
- fontSize: 13,
92
- fontWeight: isActive
93
- ? FontWeight.w600
94
- : FontWeight.normal,
95
- color: isActive
96
- ? (isDark ? Colors.white : Colors.white)
97
- : (isDark
98
- ? const Color(0xFFAAAAAA)
99
- : const Color(0xFF666666)),
100
- ),
101
- ),
102
- selected: isActive,
103
- onSelected: (_) {
104
- ref
105
- .read(personaCategoryFilterProvider.notifier)
106
- .setCategory(cat == l10n.all ? null : cat);
107
- },
108
- selectedColor: isDark
109
- ? const Color(0xFF3B82F6)
110
- : const Color(0xFF2563EB),
111
- backgroundColor: isDark
112
- ? const Color(0xFF1F1F1F)
113
- : const Color(0xFFF5F5F5),
114
- side: BorderSide(
115
- color: isActive
116
- ? (isDark
117
- ? const Color(0xFF3B82F6)
118
- : const Color(0xFF2563EB))
119
- : (isDark
120
- ? const Color(0xFF3A3A3A)
121
- : const Color(0xFFE5E5E5)),
122
- ),
123
- showCheckmark: false,
124
- shape: RoundedRectangleBorder(
125
- borderRadius: BorderRadius.circular(20),
126
- ),
127
- padding: const EdgeInsets.symmetric(horizontal: 8),
128
- materialTapTargetSize:
129
- MaterialTapTargetSize.shrinkWrap,
130
- ),
131
- );
132
- }).toList(),
133
- ),
134
- ),
135
- Expanded(
136
- child: filteredPersonas.isEmpty
137
- ? _EmptyState(isDark: isDark, l10n: l10n)
138
- : ListView(
139
- padding: const EdgeInsets.symmetric(
140
- horizontal: 16,
141
- vertical: 8,
142
- ),
143
- children: [
144
- if (builtIn.isNotEmpty) ...[
145
- _SectionLabel(label: l10n.persona_builtin_section, isDark: isDark),
146
- ...builtIn.map(
147
- (p) => _PersonaCard(
148
- persona: p,
149
- isDark: isDark,
150
- l10n: l10n,
151
- onLongPress: () => _showActions(
152
- context,
153
- ref,
154
- p,
155
- isBuiltIn: true,
156
- ),
157
- ),
158
- ),
159
- const SizedBox(height: 12),
160
- ],
161
- if (userCreated.isNotEmpty) ...[
162
- _SectionLabel(label: l10n.persona_my_section, isDark: isDark),
163
- ...userCreated.map(
164
- (p) => _PersonaCard(
165
- persona: p,
166
- isDark: isDark,
167
- l10n: l10n,
168
- onTap: () => context.push(
169
- AppRoutes.createPersona,
170
- extra: p,
171
- ),
172
- onLongPress: () => _showActions(
173
- context,
174
- ref,
175
- p,
176
- isBuiltIn: false,
177
- ),
178
- ),
179
- ),
180
- ],
181
- const SizedBox(height: 80),
182
- ],
183
- ),
184
- ),
185
- ],
186
- ),
187
- PositionedDirectional(
188
- bottom: 24,
189
- end: 24,
190
- child: FloatingActionButton(
191
- onPressed: () => context.push(AppRoutes.createPersona),
192
- child: const Icon(Icons.add),
193
- ),
194
- ),
195
- ],
196
- );
197
- }
198
-
199
- void _showActions(
200
- BuildContext context,
201
- WidgetRef ref,
202
- Persona persona, {
203
- required bool isBuiltIn,
204
- }) {
205
- showModalBottomSheet(
206
- context: context,
207
- builder: (ctx) {
208
- final sheetL10n = AppLocalizations.of(ctx)!;
209
- return SafeArea(
210
- child: Column(
211
- mainAxisSize: MainAxisSize.min,
212
- children: [
213
- if (!isBuiltIn) ...[
214
- ListTile(
215
- leading: const Icon(Icons.edit),
216
- title: Text(sheetL10n.edit),
217
- onTap: () {
218
- Navigator.pop(ctx);
219
- context.push(AppRoutes.createPersona, extra: persona);
220
- },
221
- ),
222
- ListTile(
223
- leading: const Icon(Icons.delete_outline, color: Colors.red),
224
- title: Text(
225
- sheetL10n.delete,
226
- style: const TextStyle(color: Colors.red),
227
- ),
228
- onTap: () {
229
- Navigator.pop(ctx);
230
- showDialog(
231
- context: context,
232
- builder: (dCtx) {
233
- final dlgL10n = AppLocalizations.of(dCtx)!;
234
- return AlertDialog(
235
- title: Text(dlgL10n.delete_persona_title(persona.name)),
236
- content: Text(dlgL10n.delete_persona_body),
237
- actions: [
238
- TextButton(
239
- onPressed: () => Navigator.pop(dCtx),
240
- child: Text(dlgL10n.cancel),
241
- ),
242
- TextButton(
243
- onPressed: () {
244
- ref
245
- .read(personasNotifierProvider.notifier)
246
- .deletePersona(persona.id);
247
- Navigator.pop(dCtx);
248
- },
249
- style: TextButton.styleFrom(
250
- foregroundColor: Colors.red,
251
- ),
252
- child: Text(dlgL10n.delete),
253
- ),
254
- ],
255
- );
256
- },
257
- );
258
- },
259
- ),
260
- ],
261
- ListTile(
262
- leading: const Icon(Icons.copy),
263
- title: Text(sheetL10n.clone_edit),
264
- onTap: () {
265
- Navigator.pop(ctx);
266
- final clone = ref
267
- .read(personasNotifierProvider.notifier)
268
- .clonePersona(persona.id);
269
- clone.then((c) {
270
- if (!context.mounted) return;
271
- context.push(AppRoutes.createPersona, extra: c);
272
- });
273
- },
274
- ),
275
- const SizedBox(height: 8),
276
- ],
277
- ),
278
- );
279
- },
280
- );
281
- }
282
-}
283
-
284
-class _SectionLabel extends StatelessWidget {
285
- const _SectionLabel({required this.label, required this.isDark});
286
- final String label;
287
- final bool isDark;
288
-
289
- @override
290
- Widget build(BuildContext context) {
291
- return Padding(
292
- padding: const EdgeInsets.only(bottom: 8),
293
- child: Text(
294
- label,
295
- style: TextStyle(
296
- fontSize: 11,
297
- fontWeight: FontWeight.w600,
298
- letterSpacing: 0.8,
299
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
300
- ),
301
- ),
302
- );
303
- }
304
-}
305
-
306
-class _PersonaCard extends StatelessWidget {
307
- const _PersonaCard({
308
- required this.persona,
309
- required this.isDark,
310
- required this.l10n,
311
- this.onTap,
312
- this.onLongPress,
313
- });
314
-
315
- final Persona persona;
316
- final bool isDark;
317
- final AppLocalizations l10n;
318
- final VoidCallback? onTap;
319
- final VoidCallback? onLongPress;
320
-
321
- @override
322
- Widget build(BuildContext context) {
323
- return GestureDetector(
324
- onTap: onTap,
325
- onLongPress: onLongPress,
326
- child: Container(
327
- margin: const EdgeInsets.only(bottom: 8),
328
- padding: const EdgeInsets.all(12),
329
- decoration: BoxDecoration(
330
- color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
331
- borderRadius: BorderRadius.circular(10),
332
- border: Border.all(
333
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
334
- ),
335
- ),
336
- child: Row(
337
- children: [
338
- Container(
339
- width: 44,
340
- height: 44,
341
- decoration: BoxDecoration(
342
- color: isDark
343
- ? const Color(0xFF2A2A2A)
344
- : const Color(0xFFEDEDED),
345
- borderRadius: BorderRadius.circular(10),
346
- ),
347
- child: Center(
348
- child: Text(
349
- persona.emoji,
350
- style: const TextStyle(fontSize: 22),
351
- ),
352
- ),
353
- ),
354
- const SizedBox(width: 12),
355
- Expanded(
356
- child: Column(
357
- crossAxisAlignment: CrossAxisAlignment.start,
358
- children: [
359
- Row(
360
- children: [
361
- Expanded(
362
- child: Text(
363
- persona.name,
364
- style: TextStyle(
365
- fontSize: 15,
366
- fontWeight: FontWeight.w600,
367
- color: isDark ? Colors.white : Colors.black,
368
- ),
369
- maxLines: 1,
370
- overflow: TextOverflow.ellipsis,
371
- ),
372
- ),
373
- if (persona.isBuiltIn)
374
- Container(
375
- padding: const EdgeInsets.symmetric(
376
- horizontal: 6,
377
- vertical: 2,
378
- ),
379
- decoration: BoxDecoration(
380
- color: isDark
381
- ? const Color(
382
- 0xFF3B82F6,
383
- ).withValues(alpha: 0.15)
384
- : const Color(
385
- 0xFF2563EB,
386
- ).withValues(alpha: 0.1),
387
- borderRadius: BorderRadius.circular(4),
388
- ),
389
- child: Text(
390
- l10n.builtin_badge,
391
- style: TextStyle(
392
- fontSize: 10,
393
- fontWeight: FontWeight.w600,
394
- color: isDark
395
- ? const Color(0xFF60A5FA)
396
- : const Color(0xFF2563EB),
397
- ),
398
- ),
399
- ),
400
- ],
401
- ),
402
- const SizedBox(height: 2),
403
- Text(
404
- persona.description ??
405
- persona.systemPrompt.substring(
406
- 0,
407
- persona.systemPrompt.length.clamp(0, 60),
408
- ),
409
- style: TextStyle(
410
- fontSize: 13,
411
- color: isDark
412
- ? const Color(0xFF888888)
413
- : const Color(0xFF999999),
414
- ),
415
- maxLines: 1,
416
- overflow: TextOverflow.ellipsis,
417
- ),
418
- if (persona.category != null) ...[
419
- const SizedBox(height: 4),
420
- Container(
421
- padding: const EdgeInsets.symmetric(
422
- horizontal: 6,
423
- vertical: 2,
424
- ),
425
- decoration: BoxDecoration(
426
- color: isDark
427
- ? const Color(0xFF2A2A2A)
428
- : const Color(0xFFE8E8E8),
429
- borderRadius: BorderRadius.circular(4),
430
- ),
431
- child: Text(
432
- persona.category!,
433
- style: TextStyle(
434
- fontSize: 10,
435
- color: isDark
436
- ? const Color(0xFFAAAAAA)
437
- : const Color(0xFF777777),
438
- ),
439
- ),
440
- ),
441
- ],
442
- ],
443
- ),
444
- ),
445
- const SizedBox(width: 8),
446
- Icon(
447
- Icons.chevron_right,
448
- size: 20,
449
- color: isDark ? const Color(0xFF555555) : const Color(0xFFCCCCCC),
450
- ),
451
- ],
452
- ),
453
- ),
454
- );
455
- }
456
-}
457
-
458
-class _EmptyState extends StatelessWidget {
459
- const _EmptyState({required this.isDark, required this.l10n});
460
- final bool isDark;
461
- final AppLocalizations l10n;
462
-
7
@override
8
Widget build(BuildContext context) {
465
- return Center(
466
- child: Padding(
467
- padding: const EdgeInsets.all(32),
468
- child: Column(
469
- mainAxisAlignment: MainAxisAlignment.center,
470
- children: [
471
- Icon(
472
- Icons.smart_toy_outlined,
473
- size: 64,
474
- color: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
475
- ),
476
- const SizedBox(height: 16),
477
- Text(
478
- l10n.no_personas_found,
479
- style: TextStyle(
480
- fontSize: 18,
481
- fontWeight: FontWeight.w500,
482
- color: isDark ? Colors.white : Colors.black,
483
- ),
484
- ),
485
- const SizedBox(height: 8),
486
- Text(
487
- l10n.no_personas_desc,
488
- style: TextStyle(
489
- fontSize: 14,
490
- color: isDark
491
- ? const Color(0xFF888888)
492
- : const Color(0xFF999999),
493
- ),
494
- textAlign: TextAlign.center,
495
- ),
496
- ],
497
- ),
498
- ),
499
- );
9
+ return const PersonaListScreenContent();
10
}
11
}
lib/features/servers/data/repositories/server_api_service.dart
renamed
+4
-5
@@ -1,9 +1,8 @@
1
import 'package:dio/dio.dart';
2
-
3
-import '../../../core/logger/app_logger.dart';
4
-import '../../../core/models/enums.dart';
5
-import '../../models/data/models/model_info.dart';
6
-import 'models/server.dart';
2
+import 'package:localmind/core/logger/app_logger.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/models/model_info.dart';
5
+import 'package:localmind/features/servers/data/models/server.dart';
6
7
class ServerApiService {
8
final Dio _dio;
lib/features/servers/providers/server_providers.dart
+2
-2
@@ -5,10 +5,10 @@ import '../../../core/models/enums.dart';
5
import '../../../core/providers/storage_providers.dart';
6
import '../../../core/providers/service_providers.dart';
7
import '../../../core/storage/entities.dart';
8
-import '../../models/data/model_cache.dart';
8
+import '../../models/data/repositories/model_cache.dart';
9
10
import '../../../objectbox.g.dart';
11
-import '../../models/data/models/model_info.dart';
11
+import '../../../core/models/model_info.dart';
12
import '../../on_device/providers/on_device_providers.dart';
13
import '../../on_device/data/models/on_device_model.dart';
14
lib/features/servers/views/add_server_screen.dart
+6
-708
@@ -1,716 +1,14 @@
1
-import 'package:flutter/material.dart';
2
-import 'package:hugeicons/hugeicons.dart';
3
-import 'package:flutter_riverpod/flutter_riverpod.dart';
4
-import 'package:go_router/go_router.dart';
5
-import 'package:shadcn_ui/shadcn_ui.dart';
6
-import 'package:localmind/l10n/app_localizations.dart';
7
-import '../../../core/constants/app_constants.dart';
8
-import '../data/models/server.dart';
9
-import '../../../core/models/enums.dart';
10
-import '../../../core/providers/service_providers.dart';
11
-import '../../../core/utils/system_insets.dart';
12
-import '../providers/server_providers.dart';
13
-import 'components/https_scheme_hint.dart';
14
-import 'components/server_icon_picker.dart';
15
-import 'components/server_type_selector.dart';
16
-
17
-class AddServerScreen extends ConsumerStatefulWidget {
18
- final Server? editServer;
1
+import 'package:flutter/widgets.dart';
2
+import 'package:localmind/features/servers/data/models/server.dart';
3
+import 'package:localmind/features/servers/views/components/add_server_screen_content.dart';
4
5
+class AddServerScreen extends StatelessWidget {
6
const AddServerScreen({super.key, this.editServer});
7
22
- @override
23
- ConsumerState<AddServerScreen> createState() => _AddServerScreenState();
24
-}
25
-
26
-class _AddServerScreenState extends ConsumerState<AddServerScreen> {
27
- final _formKey = GlobalKey<FormState>();
28
- late TextEditingController _nameController;
29
- late TextEditingController _hostController;
30
- late TextEditingController _portController;
31
- late TextEditingController _apiKeyController;
32
-
33
- late ServerType _selectedType;
34
- String? _selectedIconName;
35
- bool _isTesting = false;
36
- bool _isSaving = false;
37
- String? _testResult;
38
-
39
- bool get _isEditing => widget.editServer != null;
40
- bool get _requiresEndpoint => _selectedType != ServerType.openRouter;
41
- bool get _requiresMandatoryApiKey => _selectedType == ServerType.openRouter;
42
-
43
- @override
44
- void initState() {
45
- super.initState();
46
- final server = widget.editServer;
47
- _selectedType = server?.type ?? ServerType.lmStudio;
48
- _selectedIconName = server?.iconName;
49
- _nameController = TextEditingController(text: server?.name ?? '');
50
- _hostController = TextEditingController(text: server?.host ?? '');
51
- _portController = TextEditingController(
52
- text:
53
- server?.port.toString() ??
54
- AppConstants.lmStudioDefaultPort.toString(),
55
- );
56
- _apiKeyController = TextEditingController(text: server?.apiKey ?? '');
57
- }
58
-
59
- @override
60
- void dispose() {
61
- _nameController.dispose();
62
- _hostController.dispose();
63
- _portController.dispose();
64
- _apiKeyController.dispose();
65
- super.dispose();
66
- }
67
-
68
- void _onTypeChanged(ServerType type) {
69
- setState(() {
70
- _selectedType = type;
71
- _testResult = null;
72
-
73
- if (type == ServerType.openRouter) {
74
- _portController.text = '443';
75
- } else if (type == ServerType.lmStudio) {
76
- _portController.text = AppConstants.lmStudioDefaultPort.toString();
77
- } else if (type == ServerType.openAICompatible) {
78
- _portController.text = AppConstants.openAICompatibleDefaultPort
79
- .toString();
80
- } else if (type == ServerType.ollama) {
81
- _portController.text = AppConstants.ollamaDefaultPort.toString();
82
- }
83
- });
84
- }
85
-
86
- Future<void> _testConnection() async {
87
- if (!_formKey.currentState!.validate()) return;
88
-
89
- setState(() {
90
- _isTesting = true;
91
- _testResult = null;
92
- });
93
-
94
- final apiService = ref.read(serverApiServiceProvider);
95
- final testServer = _buildServer();
96
-
97
- try {
98
- final isConnected = await apiService.testConnection(testServer);
99
- setState(() {
100
- _testResult = isConnected
101
- ? AppLocalizations.of(context)!.connection_successful
102
- : AppLocalizations.of(context)!.connection_failed;
103
- });
104
- if (isConnected) {
105
- invalidateAvailableModelsCache(testServer.id);
106
- }
107
- } catch (e) {
108
- setState(() {
109
- _testResult = AppLocalizations.of(
110
- context,
111
- )!.error_with_message(e.toString());
112
- });
113
- } finally {
114
- setState(() {
115
- _isTesting = false;
116
- });
117
- }
118
- }
119
-
120
- Server _buildServer() {
121
- return Server(
122
- id:
123
- widget.editServer?.id ??
124
- DateTime.now().millisecondsSinceEpoch.toString(),
125
- name: _nameController.text.trim(),
126
- type: _selectedType,
127
- host: _hostController.text.trim(),
128
- port: int.parse(_portController.text.trim()),
129
- apiKey: _selectedType == ServerType.openRouter
130
- ? _apiKeyController.text.trim()
131
- : (_apiKeyController.text.trim().isNotEmpty
132
- ? _apiKeyController.text.trim()
133
- : null),
134
- isDefault: widget.editServer?.isDefault ?? false,
135
- createdAt: widget.editServer?.createdAt ?? DateTime.now(),
136
- lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
137
- status: ConnectionStatus.disconnected,
138
- iconName: _selectedIconName,
139
- );
140
- }
141
-
142
- Future<void> _saveServer() async {
143
- if (!_formKey.currentState!.validate()) return;
144
-
145
- setState(() {
146
- _isSaving = true;
147
- });
148
-
149
- try {
150
- final server = _buildServer();
151
-
152
- if (_isEditing) {
153
- await ref.read(serversProvider.notifier).updateServer(server);
154
- } else {
155
- await ref.read(serversProvider.notifier).addServer(server);
156
- }
157
- invalidateAvailableModelsCache(server.id);
158
-
159
- if (mounted) {
160
- final l10n = AppLocalizations.of(context)!;
161
- context.pop();
162
- ScaffoldMessenger.of(context).showSnackBar(
163
- SnackBar(
164
- content: Text(_isEditing ? l10n.server_updated : l10n.server_added),
165
- duration: const Duration(seconds: 2),
166
- ),
167
- );
168
- }
169
- } catch (e) {
170
- if (mounted) {
171
- ScaffoldMessenger.of(context).showSnackBar(
172
- SnackBar(
173
- content: Text(
174
- AppLocalizations.of(context)!.error_with_message(e.toString()),
175
- ),
176
- backgroundColor: Colors.red,
177
- ),
178
- );
179
- }
180
- } finally {
181
- if (mounted) {
182
- setState(() {
183
- _isSaving = false;
184
- });
185
- }
186
- }
187
- }
188
-
189
- String? _validateName(String? value) {
190
- final context = this.context;
191
- final l10n = AppLocalizations.of(context)!;
192
- if (value == null || value.trim().isEmpty) {
193
- return l10n.name_required;
194
- }
195
- if (value.trim().length > 50) {
196
- return l10n.name_length_validation;
197
- }
198
- return null;
199
- }
200
-
201
- String? _validateHost(String? value) {
202
- if (!_requiresEndpoint) return null;
203
- final l10n = AppLocalizations.of(context)!;
204
-
205
- if (value == null || value.trim().isEmpty) {
206
- return l10n.host_required;
207
- }
208
- final parsed = parseServerAddressInput(value);
209
- if (parsed == null) {
210
- return l10n.host_valid;
211
- }
212
- return null;
213
- }
214
-
215
- String? _validatePort(String? value) {
216
- if (!_requiresEndpoint) return null;
217
- final l10n = AppLocalizations.of(context)!;
218
-
219
- if (value == null || value.trim().isEmpty) {
220
- return l10n.port_required;
221
- }
222
- final port = int.tryParse(value.trim());
223
- if (port == null || port < 1 || port > 65535) {
224
- return l10n.port_range;
225
- }
226
- return null;
227
- }
228
-
229
- String? _validateApiKey(String? value) {
230
- if (_requiresMandatoryApiKey) {
231
- final l10n = AppLocalizations.of(context)!;
232
- if (value == null || value.trim().isEmpty) {
233
- return l10n.api_key_required_openrouter;
234
- }
235
- if (!value.trim().startsWith('sk-')) {
236
- return l10n.api_key_format;
237
- }
238
- }
239
- return null;
240
- }
241
-
242
- void _showIconPicker() {
243
- showShadSheet(
244
- context: context,
245
- side: ShadSheetSide.bottom,
246
- builder: (context) => ServerIconPicker(
247
- selectedIconName: _selectedIconName,
248
- onIconSelected: (iconName) {
249
- setState(() {
250
- _selectedIconName = iconName;
251
- });
252
- },
253
- ),
254
- );
255
- }
8
+ final Server? editServer;
9
10
@override
11
Widget build(BuildContext context) {
259
- final l10n = AppLocalizations.of(context)!;
260
- final theme = Theme.of(context);
261
- final systemBottomInset = bottomSystemInset(context);
262
- final colorScheme = theme.colorScheme;
263
-
264
- return Scaffold(
265
- appBar: AppBar(
266
- title: Text(_isEditing ? l10n.edit_server : l10n.add_server_title),
267
- leading: IconButton(
268
- icon: const Icon(Icons.close),
269
- onPressed: () => context.pop(),
270
- ),
271
- ),
272
- floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
273
- floatingActionButton: _buildFloatingActionBar(
274
- context,
275
- bottomInset: systemBottomInset,
276
- ),
277
- body: Form(
278
- key: _formKey,
279
- child: ListView(
280
- padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + systemBottomInset),
281
- children: [
282
- _buildHeaderCard(context),
283
- const SizedBox(height: 14),
284
- _buildSectionCard(
285
- context,
286
- title: l10n.server_type_label,
287
- subtitle: l10n.server_type_help,
288
- child: ServerTypeSelector(
289
- selectedType: _selectedType,
290
- onChanged: _onTypeChanged,
291
- ),
292
- ),
293
- const SizedBox(height: 12),
294
- _buildSectionCard(
295
- context,
296
- title: l10n.server_identity_title,
297
- subtitle: l10n.server_identity_desc,
298
- child: Column(
299
- children: [
300
- GestureDetector(
301
- onTap: _showIconPicker,
302
- child: Container(
303
- padding: const EdgeInsets.all(14),
304
- decoration: BoxDecoration(
305
- color: colorScheme.surfaceContainerHighest.withValues(
306
- alpha: 0.35,
307
- ),
308
- border: Border.all(
309
- color: colorScheme.outline.withValues(alpha: 0.4),
310
- ),
311
- borderRadius: BorderRadius.circular(16),
312
- ),
313
- child: Row(
314
- children: [
315
- Container(
316
- width: 48,
317
- height: 48,
318
- decoration: BoxDecoration(
319
- color: colorScheme.primary.withValues(
320
- alpha: 0.12,
321
- ),
322
- borderRadius: BorderRadius.circular(14),
323
- ),
324
- child: HugeIcon(
325
- icon: _selectedIconName != null
326
- ? (getHugeIconByName(
327
- _selectedIconName,
328
- )?.icon ??
329
- getDefaultServerIcon(
330
- _selectedType.name,
331
- )!.icon)
332
- : getDefaultServerIcon(
333
- _selectedType.name,
334
- )!.icon,
335
- size: 24,
336
- color: colorScheme.primary,
337
- ),
338
- ),
339
- const SizedBox(width: 14),
340
- Expanded(
341
- child: Column(
342
- crossAxisAlignment: CrossAxisAlignment.start,
343
- children: [
344
- Text(
345
- l10n.server_icon_label,
346
- style: theme.textTheme.labelLarge?.copyWith(
347
- fontWeight: FontWeight.w600,
348
- ),
349
- ),
350
- const SizedBox(height: 4),
351
- Text(
352
- _selectedIconName ?? l10n.default_icon,
353
- style: theme.textTheme.bodyMedium?.copyWith(
354
- color: colorScheme.onSurfaceVariant,
355
- ),
356
- ),
357
- ],
358
- ),
359
- ),
360
- Icon(
361
- Icons.arrow_forward_ios_rounded,
362
- size: 16,
363
- color: colorScheme.onSurfaceVariant,
364
- ),
365
- ],
366
- ),
367
- ),
368
- ),
369
- const SizedBox(height: 12),
370
- TextFormField(
371
- controller: _nameController,
372
- decoration: InputDecoration(
373
- labelText: l10n.name_label,
374
- hintText: l10n.my_server_hint,
375
- ),
376
- validator: _validateName,
377
- textInputAction: TextInputAction.next,
378
- ),
379
- ],
380
- ),
381
- ),
382
- if (_requiresEndpoint) ...[
383
- const SizedBox(height: 12),
384
- _buildSectionCard(
385
- context,
386
- title: l10n.server_connection_title,
387
- subtitle: l10n.server_connection_desc,
388
- child: Column(
389
- children: [
390
- TextFormField(
391
- controller: _hostController,
392
- decoration: InputDecoration(
393
- labelText: l10n.host_label,
394
- hintText: '192.168.1.100',
395
- ),
396
- validator: _validateHost,
397
- keyboardType: TextInputType.text,
398
- textInputAction: TextInputAction.next,
399
- ),
400
- HttpsSchemeHint(controller: _hostController),
401
- const SizedBox(height: 12),
402
- TextFormField(
403
- controller: _portController,
404
- decoration: InputDecoration(
405
- labelText: l10n.port_label,
406
- hintText: _selectedType == ServerType.lmStudio
407
- ? AppConstants.lmStudioDefaultPort.toString()
408
- : AppConstants.ollamaDefaultPort.toString(),
409
- ),
410
- validator: _validatePort,
411
- keyboardType: TextInputType.number,
412
- textInputAction: TextInputAction.next,
413
- ),
414
- ],
415
- ),
416
- ),
417
- ],
418
- const SizedBox(height: 12),
419
- _buildSectionCard(
420
- context,
421
- title: l10n.server_authentication_title,
422
- subtitle: _requiresMandatoryApiKey
423
- ? l10n.server_authentication_required_desc
424
- : l10n.server_authentication_optional_desc,
425
- child: TextFormField(
426
- controller: _apiKeyController,
427
- decoration: InputDecoration(
428
- labelText: _requiresMandatoryApiKey
429
- ? l10n.api_key_required
430
- : l10n.api_key_optional,
431
- hintText: _requiresMandatoryApiKey
432
- ? l10n.api_key_hint_openrouter
433
- : l10n.api_key_hint_generic,
434
- ),
435
- validator: _validateApiKey,
436
- obscureText: true,
437
- textInputAction: TextInputAction.done,
438
- ),
439
- ),
440
- if (_testResult != null) ...[
441
- const SizedBox(height: 12),
442
- _buildTestResultBanner(context),
443
- ],
444
- ],
445
- ),
446
- ),
447
- );
448
- }
449
-
450
- Widget _buildHeaderCard(BuildContext context) {
451
- final l10n = AppLocalizations.of(context)!;
452
- final theme = Theme.of(context);
453
- final colorScheme = theme.colorScheme;
454
-
455
- return Container(
456
- padding: const EdgeInsets.all(18),
457
- decoration: BoxDecoration(
458
- color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38),
459
- borderRadius: BorderRadius.circular(24),
460
- border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)),
461
- ),
462
- child: Column(
463
- crossAxisAlignment: CrossAxisAlignment.start,
464
- children: [
465
- Container(
466
- width: 52,
467
- height: 52,
468
- decoration: BoxDecoration(
469
- color: colorScheme.primary.withValues(alpha: 0.14),
470
- borderRadius: BorderRadius.circular(16),
471
- ),
472
- child: HugeIcon(
473
- icon: getDefaultServerIcon(_selectedType.name)!.icon,
474
- size: 28,
475
- color: colorScheme.primary,
476
- ),
477
- ),
478
- const SizedBox(height: 12),
479
- Text(
480
- _isEditing ? l10n.edit_server : l10n.add_server_title,
481
- style: theme.textTheme.headlineSmall?.copyWith(
482
- fontWeight: FontWeight.w700,
483
- ),
484
- ),
485
- const SizedBox(height: 6),
486
- Text(
487
- _requiresEndpoint
488
- ? 'Configure a local or self-hosted endpoint, then verify the connection before saving.'
489
- : 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.',
490
- style: theme.textTheme.bodyMedium?.copyWith(
491
- color: colorScheme.onSurfaceVariant,
492
- height: 1.4,
493
- ),
494
- ),
495
- const SizedBox(height: 12),
496
- Wrap(
497
- spacing: 8,
498
- runSpacing: 8,
499
- children: [
500
- _buildInfoPill(
501
- context,
502
- icon: Icons.hub_outlined,
503
- label: _serverTypeLabel(context),
504
- ),
505
- _buildInfoPill(
506
- context,
507
- icon: _requiresMandatoryApiKey ? Icons.key_outlined : Icons.dns,
508
- label: _requiresMandatoryApiKey
509
- ? l10n.api_key_required
510
- : l10n.host_label,
511
- ),
512
- ],
513
- ),
514
- ],
515
- ),
516
- );
517
- }
518
-
519
- Widget _buildSectionCard(
520
- BuildContext context, {
521
- required String title,
522
- required String subtitle,
523
- required Widget child,
524
- }) {
525
- final theme = Theme.of(context);
526
- final colorScheme = theme.colorScheme;
527
-
528
- return Container(
529
- padding: const EdgeInsets.all(16),
530
- decoration: BoxDecoration(
531
- color: colorScheme.surface,
532
- borderRadius: BorderRadius.circular(20),
533
- border: Border.all(color: colorScheme.outline.withValues(alpha: 0.16)),
534
- ),
535
- child: Column(
536
- crossAxisAlignment: CrossAxisAlignment.start,
537
- children: [
538
- Text(
539
- title,
540
- style: theme.textTheme.titleMedium?.copyWith(
541
- fontWeight: FontWeight.w700,
542
- ),
543
- ),
544
- const SizedBox(height: 4),
545
- Text(
546
- subtitle,
547
- style: theme.textTheme.bodySmall?.copyWith(
548
- color: colorScheme.onSurfaceVariant,
549
- height: 1.35,
550
- ),
551
- ),
552
- const SizedBox(height: 12),
553
- child,
554
- ],
555
- ),
556
- );
557
- }
558
-
559
- Widget _buildTestResultBanner(BuildContext context) {
560
- final l10n = AppLocalizations.of(context)!;
561
- final theme = Theme.of(context);
562
- final success = _testResult!.contains(
563
- l10n.connection_successful.split('!')[0],
564
- );
565
- final accent = success ? Colors.green : theme.colorScheme.error;
566
-
567
- return Container(
568
- padding: const EdgeInsets.all(14),
569
- decoration: BoxDecoration(
570
- color: accent.withValues(alpha: 0.08),
571
- borderRadius: BorderRadius.circular(18),
572
- border: Border.all(color: accent.withValues(alpha: 0.3)),
573
- ),
574
- child: Row(
575
- crossAxisAlignment: CrossAxisAlignment.start,
576
- children: [
577
- Icon(
578
- success ? Icons.check_circle_rounded : Icons.error_rounded,
579
- color: accent,
580
- ),
581
- const SizedBox(width: 10),
582
- Expanded(
583
- child: Text(
584
- _testResult!,
585
- style: theme.textTheme.bodyMedium?.copyWith(
586
- color: accent,
587
- fontWeight: FontWeight.w600,
588
- ),
589
- ),
590
- ),
591
- ],
592
- ),
593
- );
594
- }
595
-
596
- Widget _buildFloatingActionBar(
597
- BuildContext context, {
598
- required double bottomInset,
599
- }) {
600
- final l10n = AppLocalizations.of(context)!;
601
- final theme = Theme.of(context);
602
- final colorScheme = theme.colorScheme;
603
-
604
- return Padding(
605
- padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset > 0 ? 4 : 8),
606
- child: DecoratedBox(
607
- decoration: BoxDecoration(
608
- color: colorScheme.surface.withValues(alpha: 0.96),
609
- borderRadius: BorderRadius.circular(24),
610
- border: Border.all(
611
- color: colorScheme.outline.withValues(alpha: 0.14),
612
- ),
613
- boxShadow: [
614
- BoxShadow(
615
- color: Colors.black.withValues(alpha: 0.08),
616
- blurRadius: 24,
617
- offset: const Offset(0, 12),
618
- ),
619
- ],
620
- ),
621
- child: Padding(
622
- padding: const EdgeInsets.all(12),
623
- child: Row(
624
- children: [
625
- Expanded(
626
- child: OutlinedButton.icon(
627
- onPressed: _isTesting ? null : _testConnection,
628
- style: OutlinedButton.styleFrom(
629
- minimumSize: const Size.fromHeight(52),
630
- shape: RoundedRectangleBorder(
631
- borderRadius: BorderRadius.circular(16),
632
- ),
633
- ),
634
- icon: _isTesting
635
- ? const SizedBox(
636
- width: 16,
637
- height: 16,
638
- child: CircularProgressIndicator(strokeWidth: 2),
639
- )
640
- : const Icon(Icons.network_check_rounded),
641
- label: Text(_isTesting ? l10n.testing : l10n.test_connection),
642
- ),
643
- ),
644
- const SizedBox(width: 12),
645
- Expanded(
646
- child: ElevatedButton.icon(
647
- onPressed: _isSaving ? null : _saveServer,
648
- style: ElevatedButton.styleFrom(
649
- minimumSize: const Size.fromHeight(52),
650
- shape: RoundedRectangleBorder(
651
- borderRadius: BorderRadius.circular(16),
652
- ),
653
- ),
654
- icon: _isSaving
655
- ? const SizedBox(
656
- width: 18,
657
- height: 18,
658
- child: CircularProgressIndicator(strokeWidth: 2),
659
- )
660
- : const Icon(Icons.save_rounded),
661
- label: Text(
662
- _isSaving
663
- ? l10n.save
664
- : (_isEditing ? l10n.update_server : l10n.save_server),
665
- ),
666
- ),
667
- ),
668
- ],
669
- ),
670
- ),
671
- ),
672
- );
673
- }
674
-
675
- Widget _buildInfoPill(
676
- BuildContext context, {
677
- required IconData icon,
678
- required String label,
679
- }) {
680
- final theme = Theme.of(context);
681
- final colorScheme = theme.colorScheme;
682
-
683
- return Container(
684
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
685
- decoration: BoxDecoration(
686
- color: colorScheme.surface.withValues(alpha: 0.7),
687
- borderRadius: BorderRadius.circular(999),
688
- border: Border.all(color: colorScheme.outline.withValues(alpha: 0.18)),
689
- ),
690
- child: Row(
691
- mainAxisSize: MainAxisSize.min,
692
- children: [
693
- Icon(icon, size: 16, color: colorScheme.primary),
694
- const SizedBox(width: 8),
695
- Text(
696
- label,
697
- style: theme.textTheme.labelLarge?.copyWith(
698
- fontWeight: FontWeight.w600,
699
- ),
700
- ),
701
- ],
702
- ),
703
- );
704
- }
705
-
706
- String _serverTypeLabel(BuildContext context) {
707
- final l10n = AppLocalizations.of(context)!;
708
- return switch (_selectedType) {
709
- ServerType.lmStudio => l10n.server_type_lm_studio,
710
- ServerType.openAICompatible => l10n.server_type_openai_display,
711
- ServerType.ollama => l10n.server_type_ollama,
712
- ServerType.openRouter => l10n.server_type_openrouter,
713
- ServerType.onDevice => l10n.server_type_on_device_display,
714
- };
12
+ return AddServerScreenContent(editServer: editServer);
13
}
14
}
lib/features/servers/views/components/add_server_components.dart
new
+288
@@ -0,0 +1,288 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class AddServerHeaderCard extends StatelessWidget {
4
+ const AddServerHeaderCard({
5
+ super.key,
6
+ required this.leadingIcon,
7
+ required this.title,
8
+ required this.description,
9
+ required this.badges,
10
+ required this.onTap,
11
+ });
12
+
13
+ final Widget leadingIcon;
14
+ final String title;
15
+ final String description;
16
+ final List<Widget> badges;
17
+ final VoidCallback onTap;
18
+
19
+ @override
20
+ Widget build(BuildContext context) {
21
+ final theme = Theme.of(context);
22
+ final colorScheme = theme.colorScheme;
23
+
24
+ return Container(
25
+ padding: const EdgeInsets.all(18),
26
+ decoration: BoxDecoration(
27
+ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38),
28
+ borderRadius: BorderRadius.circular(24),
29
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)),
30
+ ),
31
+ child: Column(
32
+ crossAxisAlignment: CrossAxisAlignment.start,
33
+ children: [
34
+ GestureDetector(
35
+ onTap: onTap,
36
+ child: leadingIcon,
37
+ ),
38
+ const SizedBox(height: 12),
39
+ Text(
40
+ title,
41
+ style: theme.textTheme.headlineSmall?.copyWith(
42
+ fontWeight: FontWeight.w700,
43
+ ),
44
+ ),
45
+ const SizedBox(height: 6),
46
+ Text(
47
+ description,
48
+ style: theme.textTheme.bodyMedium?.copyWith(
49
+ color: colorScheme.onSurfaceVariant,
50
+ height: 1.4,
51
+ ),
52
+ ),
53
+ const SizedBox(height: 12),
54
+ Wrap(spacing: 8, runSpacing: 8, children: badges),
55
+ ],
56
+ ),
57
+ );
58
+ }
59
+}
60
+
61
+class AddServerSectionCard extends StatelessWidget {
62
+ const AddServerSectionCard({
63
+ super.key,
64
+ required this.title,
65
+ required this.subtitle,
66
+ required this.child,
67
+ });
68
+
69
+ final String title;
70
+ final String subtitle;
71
+ final Widget child;
72
+
73
+ @override
74
+ Widget build(BuildContext context) {
75
+ final theme = Theme.of(context);
76
+ final colorScheme = theme.colorScheme;
77
+
78
+ return Container(
79
+ padding: const EdgeInsets.all(16),
80
+ decoration: BoxDecoration(
81
+ color: colorScheme.surface,
82
+ borderRadius: BorderRadius.circular(20),
83
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.16)),
84
+ ),
85
+ child: Column(
86
+ crossAxisAlignment: CrossAxisAlignment.start,
87
+ children: [
88
+ Text(
89
+ title,
90
+ style: theme.textTheme.titleMedium?.copyWith(
91
+ fontWeight: FontWeight.w700,
92
+ ),
93
+ ),
94
+ const SizedBox(height: 4),
95
+ Text(
96
+ subtitle,
97
+ style: theme.textTheme.bodySmall?.copyWith(
98
+ color: colorScheme.onSurfaceVariant,
99
+ height: 1.35,
100
+ ),
101
+ ),
102
+ const SizedBox(height: 12),
103
+ child,
104
+ ],
105
+ ),
106
+ );
107
+ }
108
+}
109
+
110
+class AddServerTestResultBanner extends StatelessWidget {
111
+ const AddServerTestResultBanner({
112
+ super.key,
113
+ required this.message,
114
+ required this.success,
115
+ });
116
+
117
+ final String message;
118
+ final bool success;
119
+
120
+ @override
121
+ Widget build(BuildContext context) {
122
+ final theme = Theme.of(context);
123
+ final accent = success ? Colors.green : theme.colorScheme.error;
124
+
125
+ return Container(
126
+ padding: const EdgeInsets.all(14),
127
+ decoration: BoxDecoration(
128
+ color: accent.withValues(alpha: 0.08),
129
+ borderRadius: BorderRadius.circular(18),
130
+ border: Border.all(color: accent.withValues(alpha: 0.3)),
131
+ ),
132
+ child: Row(
133
+ crossAxisAlignment: CrossAxisAlignment.start,
134
+ children: [
135
+ Icon(
136
+ success ? Icons.check_circle_rounded : Icons.error_rounded,
137
+ color: accent,
138
+ ),
139
+ const SizedBox(width: 10),
140
+ Expanded(
141
+ child: Text(
142
+ message,
143
+ style: theme.textTheme.bodyMedium?.copyWith(
144
+ color: accent,
145
+ fontWeight: FontWeight.w600,
146
+ ),
147
+ ),
148
+ ),
149
+ ],
150
+ ),
151
+ );
152
+ }
153
+}
154
+
155
+class AddServerFloatingActionBar extends StatelessWidget {
156
+ const AddServerFloatingActionBar({
157
+ super.key,
158
+ required this.bottomInset,
159
+ required this.isTesting,
160
+ required this.isSaving,
161
+ required this.onTestPressed,
162
+ required this.onSavePressed,
163
+ required this.testLabel,
164
+ required this.testingLabel,
165
+ required this.saveLabel,
166
+ required this.savingLabel,
167
+ });
168
+
169
+ final double bottomInset;
170
+ final bool isTesting;
171
+ final bool isSaving;
172
+ final VoidCallback onTestPressed;
173
+ final VoidCallback onSavePressed;
174
+ final String testLabel;
175
+ final String testingLabel;
176
+ final String saveLabel;
177
+ final String savingLabel;
178
+
179
+ @override
180
+ Widget build(BuildContext context) {
181
+ final colorScheme = Theme.of(context).colorScheme;
182
+
183
+ return Padding(
184
+ padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset > 0 ? 4 : 8),
185
+ child: DecoratedBox(
186
+ decoration: BoxDecoration(
187
+ color: colorScheme.surface.withValues(alpha: 0.96),
188
+ borderRadius: BorderRadius.circular(24),
189
+ border: Border.all(
190
+ color: colorScheme.outline.withValues(alpha: 0.14),
191
+ ),
192
+ boxShadow: [
193
+ BoxShadow(
194
+ color: Colors.black.withValues(alpha: 0.08),
195
+ blurRadius: 24,
196
+ offset: const Offset(0, 12),
197
+ ),
198
+ ],
199
+ ),
200
+ child: Padding(
201
+ padding: const EdgeInsets.all(12),
202
+ child: Row(
203
+ children: [
204
+ Expanded(
205
+ child: OutlinedButton.icon(
206
+ onPressed: isTesting ? null : onTestPressed,
207
+ style: OutlinedButton.styleFrom(
208
+ minimumSize: const Size.fromHeight(52),
209
+ shape: RoundedRectangleBorder(
210
+ borderRadius: BorderRadius.circular(16),
211
+ ),
212
+ ),
213
+ icon: isTesting
214
+ ? const SizedBox(
215
+ width: 16,
216
+ height: 16,
217
+ child: CircularProgressIndicator(strokeWidth: 2),
218
+ )
219
+ : const Icon(Icons.network_check_rounded),
220
+ label: Text(isTesting ? testingLabel : testLabel),
221
+ ),
222
+ ),
223
+ const SizedBox(width: 12),
224
+ Expanded(
225
+ child: ElevatedButton.icon(
226
+ onPressed: isSaving ? null : onSavePressed,
227
+ style: ElevatedButton.styleFrom(
228
+ minimumSize: const Size.fromHeight(52),
229
+ shape: RoundedRectangleBorder(
230
+ borderRadius: BorderRadius.circular(16),
231
+ ),
232
+ ),
233
+ icon: isSaving
234
+ ? const SizedBox(
235
+ width: 18,
236
+ height: 18,
237
+ child: CircularProgressIndicator(strokeWidth: 2),
238
+ )
239
+ : const Icon(Icons.save_rounded),
240
+ label: Text(isSaving ? savingLabel : saveLabel),
241
+ ),
242
+ ),
243
+ ],
244
+ ),
245
+ ),
246
+ ),
247
+ );
248
+ }
249
+}
250
+
251
+class AddServerInfoPill extends StatelessWidget {
252
+ const AddServerInfoPill({
253
+ super.key,
254
+ required this.icon,
255
+ required this.label,
256
+ });
257
+
258
+ final IconData icon;
259
+ final String label;
260
+
261
+ @override
262
+ Widget build(BuildContext context) {
263
+ final theme = Theme.of(context);
264
+ final colorScheme = theme.colorScheme;
265
+
266
+ return Container(
267
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
268
+ decoration: BoxDecoration(
269
+ color: colorScheme.surface.withValues(alpha: 0.7),
270
+ borderRadius: BorderRadius.circular(999),
271
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.18)),
272
+ ),
273
+ child: Row(
274
+ mainAxisSize: MainAxisSize.min,
275
+ children: [
276
+ Icon(icon, size: 16, color: colorScheme.primary),
277
+ const SizedBox(width: 8),
278
+ Text(
279
+ label,
280
+ style: theme.textTheme.labelLarge?.copyWith(
281
+ fontWeight: FontWeight.w600,
282
+ ),
283
+ ),
284
+ ],
285
+ ),
286
+ );
287
+ }
288
+}
lib/features/servers/views/components/add_server_screen_content.dart
new
+501
@@ -0,0 +1,501 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:hugeicons/hugeicons.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:go_router/go_router.dart';
5
+import 'package:shadcn_ui/shadcn_ui.dart';
6
+import 'package:localmind/l10n/app_localizations.dart';
7
+import 'package:localmind/core/components/server/server_icon_picker.dart';
8
+import 'package:localmind/core/constants/app_constants.dart';
9
+import 'package:localmind/core/models/enums.dart';
10
+import 'package:localmind/core/providers/service_providers.dart';
11
+import 'package:localmind/core/utils/system_insets.dart';
12
+import 'package:localmind/features/servers/data/models/server.dart';
13
+import 'package:localmind/features/servers/providers/server_providers.dart';
14
+import 'add_server_components.dart';
15
+import 'https_scheme_hint.dart';
16
+import 'server_type_selector.dart';
17
+
18
+class AddServerScreenContent extends ConsumerStatefulWidget {
19
+ final Server? editServer;
20
+
21
+ const AddServerScreenContent({super.key, this.editServer});
22
+
23
+ @override
24
+ ConsumerState<AddServerScreenContent> createState() =>
25
+ _AddServerScreenContentState();
26
+}
27
+
28
+class _AddServerScreenContentState extends ConsumerState<AddServerScreenContent> {
29
+ final _formKey = GlobalKey<FormState>();
30
+ late TextEditingController _nameController;
31
+ late TextEditingController _hostController;
32
+ late TextEditingController _portController;
33
+ late TextEditingController _apiKeyController;
34
+
35
+ late ServerType _selectedType;
36
+ String? _selectedIconName;
37
+ bool _isTesting = false;
38
+ bool _isSaving = false;
39
+ String? _testResult;
40
+
41
+ bool get _isEditing => widget.editServer != null;
42
+ bool get _requiresEndpoint => _selectedType != ServerType.openRouter;
43
+ bool get _requiresMandatoryApiKey => _selectedType == ServerType.openRouter;
44
+
45
+ @override
46
+ void initState() {
47
+ super.initState();
48
+ final server = widget.editServer;
49
+ _selectedType = server?.type ?? ServerType.lmStudio;
50
+ _selectedIconName = server?.iconName;
51
+ _nameController = TextEditingController(text: server?.name ?? '');
52
+ _hostController = TextEditingController(text: server?.host ?? '');
53
+ _portController = TextEditingController(
54
+ text:
55
+ server?.port.toString() ??
56
+ AppConstants.lmStudioDefaultPort.toString(),
57
+ );
58
+ _apiKeyController = TextEditingController(text: server?.apiKey ?? '');
59
+ }
60
+
61
+ @override
62
+ void dispose() {
63
+ _nameController.dispose();
64
+ _hostController.dispose();
65
+ _portController.dispose();
66
+ _apiKeyController.dispose();
67
+ super.dispose();
68
+ }
69
+
70
+ void _onTypeChanged(ServerType type) {
71
+ setState(() {
72
+ _selectedType = type;
73
+ _testResult = null;
74
+
75
+ if (type == ServerType.openRouter) {
76
+ _portController.text = '443';
77
+ } else if (type == ServerType.lmStudio) {
78
+ _portController.text = AppConstants.lmStudioDefaultPort.toString();
79
+ } else if (type == ServerType.openAICompatible) {
80
+ _portController.text = AppConstants.openAICompatibleDefaultPort
81
+ .toString();
82
+ } else if (type == ServerType.ollama) {
83
+ _portController.text = AppConstants.ollamaDefaultPort.toString();
84
+ }
85
+ });
86
+ }
87
+
88
+ Future<void> _testConnection() async {
89
+ if (!_formKey.currentState!.validate()) return;
90
+
91
+ setState(() {
92
+ _isTesting = true;
93
+ _testResult = null;
94
+ });
95
+
96
+ final apiService = ref.read(serverApiServiceProvider);
97
+ final testServer = _buildServer();
98
+
99
+ try {
100
+ final isConnected = await apiService.testConnection(testServer);
101
+ setState(() {
102
+ _testResult = isConnected
103
+ ? AppLocalizations.of(context)!.connection_successful
104
+ : AppLocalizations.of(context)!.connection_failed;
105
+ });
106
+ if (isConnected) {
107
+ invalidateAvailableModelsCache(testServer.id);
108
+ }
109
+ } catch (e) {
110
+ setState(() {
111
+ _testResult = AppLocalizations.of(
112
+ context,
113
+ )!.error_with_message(e.toString());
114
+ });
115
+ } finally {
116
+ setState(() {
117
+ _isTesting = false;
118
+ });
119
+ }
120
+ }
121
+
122
+ Server _buildServer() {
123
+ return Server(
124
+ id:
125
+ widget.editServer?.id ??
126
+ DateTime.now().millisecondsSinceEpoch.toString(),
127
+ name: _nameController.text.trim(),
128
+ type: _selectedType,
129
+ host: _hostController.text.trim(),
130
+ port: int.parse(_portController.text.trim()),
131
+ apiKey: _selectedType == ServerType.openRouter
132
+ ? _apiKeyController.text.trim()
133
+ : (_apiKeyController.text.trim().isNotEmpty
134
+ ? _apiKeyController.text.trim()
135
+ : null),
136
+ isDefault: widget.editServer?.isDefault ?? false,
137
+ createdAt: widget.editServer?.createdAt ?? DateTime.now(),
138
+ lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
139
+ status: ConnectionStatus.disconnected,
140
+ iconName: _selectedIconName,
141
+ );
142
+ }
143
+
144
+ Future<void> _saveServer() async {
145
+ if (!_formKey.currentState!.validate()) return;
146
+
147
+ setState(() {
148
+ _isSaving = true;
149
+ });
150
+
151
+ try {
152
+ final server = _buildServer();
153
+
154
+ if (_isEditing) {
155
+ await ref.read(serversProvider.notifier).updateServer(server);
156
+ } else {
157
+ await ref.read(serversProvider.notifier).addServer(server);
158
+ }
159
+ invalidateAvailableModelsCache(server.id);
160
+
161
+ if (mounted) {
162
+ final l10n = AppLocalizations.of(context)!;
163
+ context.pop();
164
+ ScaffoldMessenger.of(context).showSnackBar(
165
+ SnackBar(
166
+ content: Text(_isEditing ? l10n.server_updated : l10n.server_added),
167
+ duration: const Duration(seconds: 2),
168
+ ),
169
+ );
170
+ }
171
+ } catch (e) {
172
+ if (mounted) {
173
+ ScaffoldMessenger.of(context).showSnackBar(
174
+ SnackBar(
175
+ content: Text(
176
+ AppLocalizations.of(context)!.error_with_message(e.toString()),
177
+ ),
178
+ backgroundColor: Colors.red,
179
+ ),
180
+ );
181
+ }
182
+ } finally {
183
+ if (mounted) {
184
+ setState(() {
185
+ _isSaving = false;
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ String? _validateName(String? value) {
192
+ final context = this.context;
193
+ final l10n = AppLocalizations.of(context)!;
194
+ if (value == null || value.trim().isEmpty) {
195
+ return l10n.name_required;
196
+ }
197
+ if (value.trim().length > 50) {
198
+ return l10n.name_length_validation;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ String? _validateHost(String? value) {
204
+ if (!_requiresEndpoint) return null;
205
+ final l10n = AppLocalizations.of(context)!;
206
+
207
+ if (value == null || value.trim().isEmpty) {
208
+ return l10n.host_required;
209
+ }
210
+ final parsed = parseServerAddressInput(value);
211
+ if (parsed == null) {
212
+ return l10n.host_valid;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ String? _validatePort(String? value) {
218
+ if (!_requiresEndpoint) return null;
219
+ final l10n = AppLocalizations.of(context)!;
220
+
221
+ if (value == null || value.trim().isEmpty) {
222
+ return l10n.port_required;
223
+ }
224
+ final port = int.tryParse(value.trim());
225
+ if (port == null || port < 1 || port > 65535) {
226
+ return l10n.port_range;
227
+ }
228
+ return null;
229
+ }
230
+
231
+ String? _validateApiKey(String? value) {
232
+ if (_requiresMandatoryApiKey) {
233
+ final l10n = AppLocalizations.of(context)!;
234
+ if (value == null || value.trim().isEmpty) {
235
+ return l10n.api_key_required_openrouter;
236
+ }
237
+ if (!value.trim().startsWith('sk-')) {
238
+ return l10n.api_key_format;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ void _showIconPicker() {
245
+ showShadSheet(
246
+ context: context,
247
+ side: ShadSheetSide.bottom,
248
+ builder: (context) => ServerIconPicker(
249
+ selectedIconName: _selectedIconName,
250
+ onIconSelected: (iconName) {
251
+ setState(() {
252
+ _selectedIconName = iconName;
253
+ });
254
+ },
255
+ ),
256
+ );
257
+ }
258
+
259
+ @override
260
+ Widget build(BuildContext context) {
261
+ final l10n = AppLocalizations.of(context)!;
262
+ final theme = Theme.of(context);
263
+ final systemBottomInset = bottomSystemInset(context);
264
+ final colorScheme = theme.colorScheme;
265
+
266
+ return Scaffold(
267
+ appBar: AppBar(
268
+ title: Text(_isEditing ? l10n.edit_server : l10n.add_server_title),
269
+ leading: IconButton(
270
+ icon: const Icon(Icons.close),
271
+ onPressed: () => context.pop(),
272
+ ),
273
+ ),
274
+ floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
275
+ floatingActionButton: AddServerFloatingActionBar(
276
+ bottomInset: systemBottomInset,
277
+ isTesting: _isTesting,
278
+ isSaving: _isSaving,
279
+ onTestPressed: _testConnection,
280
+ onSavePressed: _saveServer,
281
+ testLabel: l10n.test_connection,
282
+ testingLabel: l10n.testing,
283
+ saveLabel: _isEditing ? l10n.update_server : l10n.save_server,
284
+ savingLabel: l10n.save,
285
+ ),
286
+ body: Form(
287
+ key: _formKey,
288
+ child: ListView(
289
+ padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + systemBottomInset),
290
+ children: [
291
+ AddServerHeaderCard(
292
+ onTap: _showIconPicker,
293
+ title: _isEditing ? l10n.edit_server : l10n.add_server_title,
294
+ description: _requiresEndpoint
295
+ ? 'Configure a local or self-hosted endpoint, then verify the connection before saving.'
296
+ : 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.',
297
+ leadingIcon: Container(
298
+ width: 52,
299
+ height: 52,
300
+ decoration: BoxDecoration(
301
+ color: colorScheme.primary.withValues(alpha: 0.14),
302
+ borderRadius: BorderRadius.circular(16),
303
+ ),
304
+ child: HugeIcon(
305
+ icon: getDefaultServerIcon(_selectedType.name)!.icon,
306
+ size: 28,
307
+ color: colorScheme.primary,
308
+ ),
309
+ ),
310
+ badges: [
311
+ AddServerInfoPill(
312
+ icon: Icons.hub_outlined,
313
+ label: _serverTypeLabel(context),
314
+ ),
315
+ AddServerInfoPill(
316
+ icon: _requiresMandatoryApiKey ? Icons.key_outlined : Icons.dns,
317
+ label: _requiresMandatoryApiKey
318
+ ? l10n.api_key_required
319
+ : l10n.host_label,
320
+ ),
321
+ ],
322
+ ),
323
+ const SizedBox(height: 14),
324
+ AddServerSectionCard(
325
+ title: l10n.server_type_label,
326
+ subtitle: l10n.server_type_help,
327
+ child: ServerTypeSelector(
328
+ selectedType: _selectedType,
329
+ onChanged: _onTypeChanged,
330
+ ),
331
+ ),
332
+ const SizedBox(height: 12),
333
+ AddServerSectionCard(
334
+ title: l10n.server_identity_title,
335
+ subtitle: l10n.server_identity_desc,
336
+ child: Column(
337
+ children: [
338
+ GestureDetector(
339
+ onTap: _showIconPicker,
340
+ child: Container(
341
+ padding: const EdgeInsets.all(14),
342
+ decoration: BoxDecoration(
343
+ color: colorScheme.surfaceContainerHighest.withValues(
344
+ alpha: 0.35,
345
+ ),
346
+ border: Border.all(
347
+ color: colorScheme.outline.withValues(alpha: 0.4),
348
+ ),
349
+ borderRadius: BorderRadius.circular(16),
350
+ ),
351
+ child: Row(
352
+ children: [
353
+ Container(
354
+ width: 48,
355
+ height: 48,
356
+ decoration: BoxDecoration(
357
+ color: colorScheme.primary.withValues(
358
+ alpha: 0.12,
359
+ ),
360
+ borderRadius: BorderRadius.circular(14),
361
+ ),
362
+ child: HugeIcon(
363
+ icon: _selectedIconName != null
364
+ ? (getHugeIconByName(
365
+ _selectedIconName,
366
+ )?.icon ??
367
+ getDefaultServerIcon(
368
+ _selectedType.name,
369
+ )!.icon)
370
+ : getDefaultServerIcon(
371
+ _selectedType.name,
372
+ )!.icon,
373
+ size: 24,
374
+ color: colorScheme.primary,
375
+ ),
376
+ ),
377
+ const SizedBox(width: 14),
378
+ Expanded(
379
+ child: Column(
380
+ crossAxisAlignment: CrossAxisAlignment.start,
381
+ children: [
382
+ Text(
383
+ l10n.server_icon_label,
384
+ style: theme.textTheme.labelLarge?.copyWith(
385
+ fontWeight: FontWeight.w600,
386
+ ),
387
+ ),
388
+ const SizedBox(height: 4),
389
+ Text(
390
+ _selectedIconName ?? l10n.default_icon,
391
+ style: theme.textTheme.bodyMedium?.copyWith(
392
+ color: colorScheme.onSurfaceVariant,
393
+ ),
394
+ ),
395
+ ],
396
+ ),
397
+ ),
398
+ Icon(
399
+ Icons.arrow_forward_ios_rounded,
400
+ size: 16,
401
+ color: colorScheme.onSurfaceVariant,
402
+ ),
403
+ ],
404
+ ),
405
+ ),
406
+ ),
407
+ const SizedBox(height: 12),
408
+ TextFormField(
409
+ controller: _nameController,
410
+ decoration: InputDecoration(
411
+ labelText: l10n.name_label,
412
+ hintText: l10n.my_server_hint,
413
+ ),
414
+ validator: _validateName,
415
+ textInputAction: TextInputAction.next,
416
+ ),
417
+ ],
418
+ ),
419
+ ),
420
+ if (_requiresEndpoint) ...[
421
+ const SizedBox(height: 12),
422
+ AddServerSectionCard(
423
+ title: l10n.server_connection_title,
424
+ subtitle: l10n.server_connection_desc,
425
+ child: Column(
426
+ children: [
427
+ TextFormField(
428
+ controller: _hostController,
429
+ decoration: InputDecoration(
430
+ labelText: l10n.host_label,
431
+ hintText: '192.168.1.100',
432
+ ),
433
+ validator: _validateHost,
434
+ keyboardType: TextInputType.text,
435
+ textInputAction: TextInputAction.next,
436
+ ),
437
+ HttpsSchemeHint(controller: _hostController),
438
+ const SizedBox(height: 12),
439
+ TextFormField(
440
+ controller: _portController,
441
+ decoration: InputDecoration(
442
+ labelText: l10n.port_label,
443
+ hintText: _selectedType == ServerType.lmStudio
444
+ ? AppConstants.lmStudioDefaultPort.toString()
445
+ : AppConstants.ollamaDefaultPort.toString(),
446
+ ),
447
+ validator: _validatePort,
448
+ keyboardType: TextInputType.number,
449
+ textInputAction: TextInputAction.next,
450
+ ),
451
+ ],
452
+ ),
453
+ ),
454
+ ],
455
+ const SizedBox(height: 12),
456
+ AddServerSectionCard(
457
+ title: l10n.server_authentication_title,
458
+ subtitle: _requiresMandatoryApiKey
459
+ ? l10n.server_authentication_required_desc
460
+ : l10n.server_authentication_optional_desc,
461
+ child: TextFormField(
462
+ controller: _apiKeyController,
463
+ decoration: InputDecoration(
464
+ labelText: _requiresMandatoryApiKey
465
+ ? l10n.api_key_required
466
+ : l10n.api_key_optional,
467
+ hintText: _requiresMandatoryApiKey
468
+ ? l10n.api_key_hint_openrouter
469
+ : l10n.api_key_hint_generic,
470
+ ),
471
+ validator: _validateApiKey,
472
+ obscureText: true,
473
+ textInputAction: TextInputAction.done,
474
+ ),
475
+ ),
476
+ if (_testResult != null) ...[
477
+ const SizedBox(height: 12),
478
+ AddServerTestResultBanner(
479
+ message: _testResult!,
480
+ success: _testResult!.contains(
481
+ l10n.connection_successful.split('!')[0],
482
+ ),
483
+ ),
484
+ ],
485
+ ],
486
+ ),
487
+ ),
488
+ );
489
+ }
490
+
491
+ String _serverTypeLabel(BuildContext context) {
492
+ final l10n = AppLocalizations.of(context)!;
493
+ return switch (_selectedType) {
494
+ ServerType.lmStudio => l10n.server_type_lm_studio,
495
+ ServerType.openAICompatible => l10n.server_type_openai_display,
496
+ ServerType.ollama => l10n.server_type_ollama,
497
+ ServerType.openRouter => l10n.server_type_openrouter,
498
+ ServerType.onDevice => l10n.server_type_on_device_display,
499
+ };
500
+ }
501
+}
lib/features/servers/views/components/server_card.dart
+1
-1
@@ -1,10 +1,10 @@
1
import 'package:flutter/material.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
+import 'package:localmind/core/components/server/server_icon_picker.dart';
4
import 'package:localmind/l10n/app_localizations.dart';
5
import '../../../../core/models/enums.dart';
6
import '../../data/models/server.dart';
7
import 'connection_status_indicator.dart';
7
-import 'server_icon_picker.dart';
8
9
class ServerCard extends StatelessWidget {
10
final Server server;
lib/features/settings/views/settings_screen.dart
+7
-2042
@@ -16,2051 +16,16 @@ import '../../personas/providers/personas_providers.dart';
16
import '../../servers/providers/server_providers.dart';
17
import '../data/models/app_settings.dart';
18
19
-class SettingsViews extends ConsumerWidget {
20
- const SettingsViews({super.key});
19
+part 'settings_screen_layout_parts.dart';
20
+part 'settings_screen_controls_parts.dart';
21
+part 'settings_screen_extras_parts.dart';
22
+part 'settings_screen_parts.dart';
23
22
- @override
23
- Widget build(BuildContext context, WidgetRef ref) {
24
- final l10n = AppLocalizations.of(context)!;
25
- final settings = ref.watch(settingsProvider);
26
- final currentTheme = ref.watch(themeModeProvider);
27
- final systemBottomInset = bottomSystemInset(context);
28
- final packageInfo = ref.watch(packageInfoProvider);
29
- final servers = (ref.watch(serversProvider).value ?? [])
30
- .map((server) => (server.id, server.name))
31
- .toList();
32
- final personas = (ref.watch(personasNotifierProvider).value ?? [])
33
- .map((persona) => (persona.id, '${persona.emoji} ${persona.name}'))
34
- .toList();
35
-
36
- final isDark = Theme.of(context).brightness == Brightness.dark;
37
- final topPadding = MediaQuery.of(context).padding.top;
38
-
39
- return Column(
40
- children: [
41
- Container(
42
- padding: EdgeInsets.only(
43
- left: 16,
44
- right: 16,
45
- top: topPadding + 8,
46
- bottom: 16,
47
- ),
48
- decoration: BoxDecoration(
49
- color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
50
- border: Border(
51
- bottom: BorderSide(
52
- color: isDark
53
- ? const Color(0xFF2A2A2A)
54
- : const Color(0xFFE5E5E5),
55
- ),
56
- ),
57
- ),
58
- child: Row(
59
- children: [
60
- Builder(
61
- builder: (context) => IconButton(
62
- icon: const Icon(Icons.menu),
63
- onPressed: () => Scaffold.of(context).openDrawer(),
64
- ),
65
- ),
66
- const SizedBox(width: 8),
67
- Text(
68
- l10n.settings_title,
69
- style: TextStyle(
70
- fontSize: 20,
71
- fontWeight: FontWeight.bold,
72
- color: isDark ? Colors.white : Colors.black,
73
- ),
74
- ),
75
- ],
76
- ),
77
- ),
78
- Expanded(
79
- child: LayoutBuilder(
80
- builder: (context, constraints) {
81
- final useTwoColumns = constraints.maxWidth >= 1080;
82
- final contentMaxWidth = useTwoColumns ? 1120.0 : 720.0;
83
- final horizontalPadding = constraints.maxWidth >= 720
84
- ? 20.0
85
- : 12.0;
86
-
87
- final appearanceCard = _SettingsSectionCard(
88
- title: l10n.settings_appearance,
89
- icon: Icons.palette_outlined,
90
- accent: const Color(0xFF8B5CF6),
91
- children: [
92
- _ThemeToggle(ref: ref),
93
- _LanguageSetting(
94
- current: settings.localeCode,
95
- onChanged: (value) => ref
96
- .read(settingsProvider.notifier)
97
- .setLocaleCode(value),
98
- ),
99
- _SliderSetting(
100
- label: l10n.font_size,
101
- value: settings.fontSize,
102
- min: 12.0,
103
- max: 24.0,
104
- divisions: 12,
105
- description: l10n.font_size_desc,
106
- onChanged: (value) =>
107
- ref.read(settingsProvider.notifier).setFontSize(value),
108
- valueFormat: (value) => value.toStringAsFixed(0),
109
- previewText: l10n.font_preview,
110
- ),
111
- _CodeThemeDropdown(
112
- label: l10n.code_theme_dark,
113
- current: settings.codeThemeDark,
114
- onChanged: (value) => ref
115
- .read(settingsProvider.notifier)
116
- .setCodeThemeDark(value),
117
- ),
118
- _CodeThemeDropdown(
119
- label: l10n.code_theme_light,
120
- current: settings.codeThemeLight,
121
- onChanged: (value) => ref
122
- .read(settingsProvider.notifier)
123
- .setCodeThemeLight(value),
124
- ),
125
- ],
126
- );
127
-
128
- final ttsCard = _SettingsSectionCard(
129
- title: l10n.settings_tts,
130
- icon: Icons.graphic_eq_rounded,
131
- accent: const Color(0xFF0EA5E9),
132
- children: [
133
- _EngineDropdown(
134
- current: settings.ttsEngine,
135
- onChanged: (value) =>
136
- ref.read(settingsProvider.notifier).setTtsEngine(value),
137
- ),
138
- _SectionActionButton(
139
- icon: Icons.record_voice_over_outlined,
140
- label: l10n.manage_tts_models,
141
- onPressed: () => context.push(AppRoutes.ttsModels),
142
- ),
143
- if (settings.ttsEngine != EngineId.system) ...[
144
- _VoiceSelector(
145
- engine: settings.ttsEngine,
146
- currentVoiceId: settings.ttsVoiceId,
147
- onChanged: (voice) => ref
148
- .read(settingsProvider.notifier)
149
- .setTtsVoiceId(voice?.id),
150
- ),
151
- _SliderSetting(
152
- label: l10n.tts_speed,
153
- value: settings.ttsSpeed,
154
- min: 0.5,
155
- max: 2.0,
156
- divisions: 15,
157
- description: l10n.tts_speed_desc,
158
- onChanged: (value) => ref
159
- .read(settingsProvider.notifier)
160
- .setTtsSpeed(value),
161
- valueFormat: (value) => '${value.toStringAsFixed(2)}x',
162
- ),
163
- ],
164
- ],
165
- );
166
-
167
- final behaviorCard = _SettingsSectionCard(
168
- title: l10n.settings_behavior,
169
- icon: Icons.tune_rounded,
170
- accent: const Color(0xFF22C55E),
171
- children: [
172
- _ToggleSetting(
173
- label: l10n.streaming_responses,
174
- value: settings.streamingEnabled,
175
- onChanged: (value) => ref
176
- .read(settingsProvider.notifier)
177
- .setStreamingEnabled(value),
178
- ),
179
- _ToggleSetting(
180
- label: l10n.auto_generate_titles,
181
- value: settings.autoGenerateTitle,
182
- onChanged: (value) => ref
183
- .read(settingsProvider.notifier)
184
- .setAutoGenerateTitle(value),
185
- ),
186
- _ToggleSetting(
187
- label: l10n.send_on_enter,
188
- value: settings.sendOnEnter,
189
- onChanged: (value) => ref
190
- .read(settingsProvider.notifier)
191
- .setSendOnEnter(value),
192
- ),
193
- _ToggleSetting(
194
- label: l10n.show_system_messages,
195
- value: settings.showSystemMessages,
196
- onChanged: (value) => ref
197
- .read(settingsProvider.notifier)
198
- .setShowSystemMessages(value),
199
- ),
200
- _ToggleSetting(
201
- label: l10n.haptic_feedback,
202
- value: settings.hapticFeedbackEnabled,
203
- onChanged: (value) => ref
204
- .read(settingsProvider.notifier)
205
- .setHapticFeedback(value),
206
- ),
207
- _ToggleSetting(
208
- label: l10n.enable_mcp,
209
- value: settings.mcpEnabled,
210
- badges: [_FeatureBadge(label: l10n.experimental_label)],
211
- onChanged: (value) => ref
212
- .read(settingsProvider.notifier)
213
- .setMcpEnabled(value),
214
- ),
215
- if (settings.mcpEnabled)
216
- _ToggleSetting(
217
- label: l10n.new_chat_mcp_default,
218
- value: settings.newChatMcpEnabled,
219
- onChanged: (value) => ref
220
- .read(settingsProvider.notifier)
221
- .setNewChatMcpEnabled(value),
222
- ),
223
- ],
224
- );
225
-
226
- final onDeviceCard = _SettingsSectionCard(
227
- title: l10n.settings_on_device,
228
- icon: Icons.memory_rounded,
229
- accent: const Color(0xFFF97316),
230
- children: [
231
- _ToggleSetting(
232
- label: l10n.enable_smart_reply,
233
- value: settings.smartReplyEnabled,
234
- onChanged: (value) => ref
235
- .read(settingsProvider.notifier)
236
- .setSmartReplyEnabled(value),
237
- ),
238
- _HuggingFaceTokenSetting(
239
- currentToken: settings.huggingFaceToken,
240
- onSave: (value) {
241
- ref
242
- .read(settingsProvider.notifier)
243
- .setHuggingFaceToken(value);
244
- ScaffoldMessenger.of(context).showSnackBar(
245
- SnackBar(
246
- content: Text(
247
- value == null || value.isEmpty
248
- ? l10n.settings_huggingface_token_cleared
249
- : l10n.settings_huggingface_token_set,
250
- ),
251
- ),
252
- );
253
- },
254
- ),
255
- _SectionActionButton(
256
- icon: Icons.phone_android_rounded,
257
- label: l10n.manage_on_device_models,
258
- onPressed: () => context.push(AppRoutes.onDeviceModels),
259
- ),
260
- const _OnDeviceEngineStatusCard(),
261
- ],
262
- );
263
-
264
- final defaultsCard = _SettingsSectionCard(
265
- title: l10n.settings_default_server,
266
- icon: Icons.hub_outlined,
267
- accent: const Color(0xFF06B6D4),
268
- children: [
269
- _DropdownSetting(
270
- label: l10n.settings_default_server,
271
- currentValue: settings.defaultServerId,
272
- items: servers,
273
- onChanged: (value) => ref
274
- .read(settingsProvider.notifier)
275
- .setDefaultServer(value),
276
- icon: Icons.computer_rounded,
277
- ),
278
- _DropdownSetting(
279
- label: l10n.settings_default_persona,
280
- currentValue: settings.defaultPersonaId,
281
- items: personas,
282
- onChanged: (value) => ref
283
- .read(settingsProvider.notifier)
284
- .setDefaultPersona(value),
285
- icon: Icons.smart_toy_outlined,
286
- ),
287
- ],
288
- );
289
-
290
- final privacyCard = _SettingsSectionCard(
291
- title: l10n.settings_privacy,
292
- icon: Icons.lock_outline_rounded,
293
- accent: const Color(0xFF14B8A6),
294
- children: [
295
- _ToggleSetting(
296
- label: l10n.show_data_indicator,
297
- value: settings.showDataIndicator,
298
- onChanged: (value) => ref
299
- .read(settingsProvider.notifier)
300
- .setShowDataIndicator(value),
301
- ),
302
- _MutedCallout(message: l10n.privacy_info),
303
- ],
304
- );
305
-
306
- final dataCard = _SettingsSectionCard(
307
- title: l10n.settings_data_management,
308
- icon: Icons.restore_page_outlined,
309
- accent: const Color(0xFFEF4444),
310
- children: [
311
- _DangerousAction(
312
- label: l10n.delete_all_conversations,
313
- icon: Icons.delete_outline_rounded,
314
- onConfirm: () =>
315
- ref.read(conversationsProvider.notifier).deleteAll(),
316
- ),
317
- _DangerousAction(
318
- label: l10n.reset_settings_defaults,
319
- icon: Icons.restore_rounded,
320
- onConfirm: () =>
321
- ref.read(settingsProvider.notifier).resetToDefaults(),
322
- ),
323
- ],
324
- );
325
-
326
- final aboutCard = _SettingsSectionCard(
327
- title: l10n.settings_about,
328
- icon: Icons.info_outline_rounded,
329
- accent: const Color(0xFFF59E0B),
330
- children: [
331
- _AboutPanel(
332
- title:
333
- '${l10n.app_name} v${packageInfo.value?.version ?? l10n.app_version} (${packageInfo.value?.buildNumber ?? ''})',
334
- subtitle: l10n.onboarding_connect_desc.replaceAll(
335
- '\n',
336
- ' ',
337
- ),
338
- providers: [
339
- l10n.server_type_lm_studio,
340
- l10n.server_type_ollama,
341
- l10n.server_type_openrouter,
342
- ],
343
- highlights: [
344
- l10n.settings_on_device,
345
- l10n.streaming_responses,
346
- l10n.nav_personas,
347
- ],
348
- stack: const ['Flutter', 'Riverpod', 'shadcn_ui'],
349
- openSource: l10n.open_source_desc,
350
- ),
351
- ],
352
- );
353
-
354
- return ListView(
355
- padding: EdgeInsets.fromLTRB(
356
- horizontalPadding,
357
- 12,
358
- horizontalPadding,
359
- 24 + systemBottomInset,
360
- ),
361
- children: [
362
- Center(
363
- child: ConstrainedBox(
364
- constraints: BoxConstraints(maxWidth: contentMaxWidth),
365
- child: Column(
366
- crossAxisAlignment: CrossAxisAlignment.stretch,
367
- children: [
368
- _SettingsHero(
369
- settings: settings,
370
- themeType: currentTheme,
371
- ),
372
- const SizedBox(height: 16),
373
- if (useTwoColumns)
374
- Row(
375
- crossAxisAlignment: CrossAxisAlignment.start,
376
- children: [
377
- Expanded(
378
- child: _SettingsSectionColumn(
379
- children: [
380
- appearanceCard,
381
- behaviorCard,
382
- defaultsCard,
383
- privacyCard,
384
- ],
385
- ),
386
- ),
387
- const SizedBox(width: 20),
388
- Expanded(
389
- child: _SettingsSectionColumn(
390
- children: [
391
- ttsCard,
392
- onDeviceCard,
393
- dataCard,
394
- aboutCard,
395
- ],
396
- ),
397
- ),
398
- ],
399
- )
400
- else
401
- _SettingsSectionColumn(
402
- children: [
403
- appearanceCard,
404
- ttsCard,
405
- behaviorCard,
406
- onDeviceCard,
407
- defaultsCard,
408
- privacyCard,
409
- dataCard,
410
- aboutCard,
411
- ],
412
- ),
413
- ],
414
- ),
415
- ),
416
- ),
417
- ],
418
- );
419
- },
420
- ),
421
- ),
422
- ],
423
- );
424
- }
425
-}
426
-
427
-class _SettingsSectionColumn extends StatelessWidget {
428
- const _SettingsSectionColumn({required this.children});
429
-
430
- final List<Widget> children;
431
-
432
- @override
433
- Widget build(BuildContext context) {
434
- return Column(
435
- crossAxisAlignment: CrossAxisAlignment.stretch,
436
- children: _withVerticalSpacing(children, gap: 16),
437
- );
438
- }
439
-}
440
-
441
-class _SettingsHero extends StatelessWidget {
442
- const _SettingsHero({required this.settings, required this.themeType});
443
-
444
- final AppSettings settings;
445
- final AppThemeType themeType;
446
-
447
- @override
448
- Widget build(BuildContext context) {
449
- final l10n = AppLocalizations.of(context)!;
450
- final theme = Theme.of(context);
451
- final primary = _primaryColor(context);
452
-
453
- return Container(
454
- decoration: BoxDecoration(
455
- color: _surfaceColor(context),
456
- borderRadius: BorderRadius.circular(20),
457
- border: Border.all(color: _outlineColor(context, alpha: 0.9)),
458
- ),
459
- child: Padding(
460
- padding: const EdgeInsets.all(16),
461
- child: Column(
462
- crossAxisAlignment: CrossAxisAlignment.start,
463
- children: [
464
- Row(
465
- children: [
466
- Container(
467
- width: 36,
468
- height: 36,
469
- decoration: BoxDecoration(
470
- color: primary.withValues(alpha: 0.10),
471
- borderRadius: BorderRadius.circular(12),
472
- ),
473
- child: Icon(
474
- Icons.settings_outlined,
475
- size: 18,
476
- color: primary,
477
- ),
478
- ),
479
- const SizedBox(width: 10),
480
- Expanded(
481
- child: Text(
482
- l10n.settings_title,
483
- style: theme.textTheme.headlineSmall?.copyWith(
484
- fontWeight: FontWeight.w700,
485
- ),
486
- ),
487
- ),
488
- ],
489
- ),
490
- const SizedBox(height: 6),
491
- Text(
492
- l10n.app_tagline,
493
- style: theme.textTheme.bodyMedium?.copyWith(
494
- color: _mutedColor(context),
495
- height: 1.35,
496
- ),
497
- ),
498
- const SizedBox(height: 14),
499
- Wrap(
500
- spacing: 8,
501
- runSpacing: 8,
502
- children: [
503
- _HeroStat(
504
- label: l10n.theme,
505
- value: _themeLabel(themeType, l10n),
506
- icon: Icons.auto_awesome_rounded,
507
- ),
508
- _HeroStat(
509
- label: l10n.settings_language,
510
- value: _languageLabel(settings.localeCode, l10n),
511
- icon: Icons.language_rounded,
512
- ),
513
- _HeroStat(
514
- label: l10n.tts_engine,
515
- value: _engineLabel(settings.ttsEngine, l10n),
516
- icon: Icons.record_voice_over_rounded,
517
- ),
518
- ],
519
- ),
520
- ],
521
- ),
522
- ),
523
- );
524
- }
525
-}
526
-
527
-class _HeroStat extends StatelessWidget {
528
- const _HeroStat({
529
- required this.label,
530
- required this.value,
531
- required this.icon,
532
- });
533
-
534
- final String label;
535
- final String value;
536
- final IconData icon;
537
-
538
- @override
539
- Widget build(BuildContext context) {
540
- final theme = Theme.of(context);
541
- final primary = _primaryColor(context);
542
-
543
- return Container(
544
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
545
- decoration: BoxDecoration(
546
- color: _panelColor(context),
547
- borderRadius: BorderRadius.circular(12),
548
- border: Border.all(color: _outlineColor(context)),
549
- ),
550
- child: Row(
551
- mainAxisSize: MainAxisSize.min,
552
- children: [
553
- Icon(icon, size: 14, color: primary),
554
- const SizedBox(width: 8),
555
- Text(
556
- '$label: ',
557
- style: theme.textTheme.labelMedium?.copyWith(
558
- color: _mutedColor(context),
559
- fontWeight: FontWeight.w600,
560
- ),
561
- ),
562
- Flexible(
563
- child: Text(
564
- value,
565
- overflow: TextOverflow.ellipsis,
566
- style: theme.textTheme.labelLarge?.copyWith(
567
- fontWeight: FontWeight.w700,
568
- ),
569
- ),
570
- ),
571
- ],
572
- ),
573
- );
574
- }
575
-}
576
-
577
-class _SettingsSectionCard extends StatelessWidget {
578
- const _SettingsSectionCard({
579
- required this.title,
580
- required this.icon,
581
- required this.accent,
582
- required this.children,
583
- });
584
-
585
- final String title;
586
- final IconData icon;
587
- final Color accent;
588
- final List<Widget> children;
589
-
590
- @override
591
- Widget build(BuildContext context) {
592
- return Container(
593
- decoration: BoxDecoration(
594
- color: _surfaceColor(context),
595
- borderRadius: BorderRadius.circular(18),
596
- border: Border.all(color: _outlineColor(context, alpha: 0.9)),
597
- ),
598
- child: Padding(
599
- padding: const EdgeInsets.all(16),
600
- child: Column(
601
- crossAxisAlignment: CrossAxisAlignment.start,
602
- children: [
603
- Row(
604
- children: [
605
- Container(
606
- width: 34,
607
- height: 34,
608
- decoration: BoxDecoration(
609
- color: accent.withValues(alpha: 0.10),
610
- borderRadius: BorderRadius.circular(10),
611
- ),
612
- child: Icon(icon, color: accent, size: 16),
613
- ),
614
- const SizedBox(width: 10),
615
- Expanded(
616
- child: Text(
617
- title,
618
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
619
- fontWeight: FontWeight.w700,
620
- ),
621
- ),
622
- ),
623
- ],
624
- ),
625
- const SizedBox(height: 12),
626
- ..._withVerticalSpacing(children, gap: 10),
627
- ],
628
- ),
629
- ),
630
- );
631
- }
632
-}
633
-
634
-class _SettingPanel extends StatelessWidget {
635
- const _SettingPanel({required this.child});
636
-
637
- final Widget child;
638
-
639
- @override
640
- Widget build(BuildContext context) {
641
- return Container(
642
- padding: const EdgeInsets.all(12),
643
- decoration: BoxDecoration(
644
- color: _panelColor(context),
645
- borderRadius: BorderRadius.circular(14),
646
- border: Border.all(color: _outlineColor(context)),
647
- ),
648
- child: child,
649
- );
650
- }
651
-}
652
-
653
-class _MutedCallout extends StatelessWidget {
654
- const _MutedCallout({required this.message});
655
-
656
- final String message;
657
-
658
- @override
659
- Widget build(BuildContext context) {
660
- return _SettingPanel(
661
- child: Row(
662
- crossAxisAlignment: CrossAxisAlignment.start,
663
- children: [
664
- Icon(
665
- Icons.shield_moon_outlined,
666
- size: 18,
667
- color: _mutedColor(context),
668
- ),
669
- const SizedBox(width: 10),
670
- Expanded(
671
- child: Text(
672
- message,
673
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
674
- color: _mutedColor(context),
675
- height: 1.35,
676
- ),
677
- ),
678
- ),
679
- ],
680
- ),
681
- );
682
- }
683
-}
684
-
685
-class _AboutPanel extends StatelessWidget {
686
- const _AboutPanel({
687
- required this.title,
688
- required this.subtitle,
689
- required this.providers,
690
- required this.highlights,
691
- required this.stack,
692
- required this.openSource,
693
- });
694
-
695
- final String title;
696
- final String subtitle;
697
- final List<String> providers;
698
- final List<String> highlights;
699
- final List<String> stack;
700
- final String openSource;
701
-
702
- @override
703
- Widget build(BuildContext context) {
704
- final theme = Theme.of(context);
705
- final l10n = AppLocalizations.of(context)!;
706
-
707
- return _SettingPanel(
708
- child: Column(
709
- crossAxisAlignment: CrossAxisAlignment.start,
710
- children: [
711
- Text(
712
- title,
713
- style: theme.textTheme.titleMedium?.copyWith(
714
- fontWeight: FontWeight.w700,
715
- ),
716
- ),
717
- const SizedBox(height: 6),
718
- Text(
719
- subtitle,
720
- style: theme.textTheme.bodyMedium?.copyWith(
721
- color: _mutedColor(context),
722
- height: 1.35,
723
- ),
724
- ),
725
- const SizedBox(height: 12),
726
- _AboutLabel(label: l10n.app_tagline),
727
- const SizedBox(height: 8),
728
- Wrap(
729
- spacing: 6,
730
- runSpacing: 6,
731
- children: providers
732
- .map(
733
- (provider) =>
734
- _AboutChip(label: provider, icon: Icons.hub_outlined),
735
- )
736
- .toList(),
737
- ),
738
- const SizedBox(height: 12),
739
- _AboutLabel(label: l10n.highlights_label),
740
- const SizedBox(height: 8),
741
- Wrap(
742
- spacing: 6,
743
- runSpacing: 6,
744
- children: highlights
745
- .map(
746
- (highlight) => _AboutChip(
747
- label: highlight,
748
- icon: Icons.check_circle_outline_rounded,
749
- ),
750
- )
751
- .toList(),
752
- ),
753
- const SizedBox(height: 12),
754
- _AboutLabel(label: l10n.built_with_label),
755
- const SizedBox(height: 8),
756
- Wrap(
757
- spacing: 6,
758
- runSpacing: 6,
759
- children: stack
760
- .map(
761
- (item) => _AboutChip(label: item, icon: Icons.code_rounded),
762
- )
763
- .toList(),
764
- ),
765
- const SizedBox(height: 12),
766
- Container(
767
- width: double.infinity,
768
- padding: const EdgeInsets.all(10),
769
- decoration: BoxDecoration(
770
- color: _surfaceColor(context),
771
- borderRadius: BorderRadius.circular(10),
772
- border: Border.all(color: _outlineColor(context, alpha: 0.8)),
773
- ),
774
- child: Row(
775
- crossAxisAlignment: CrossAxisAlignment.start,
776
- children: [
777
- Icon(
778
- Icons.open_in_new_rounded,
779
- size: 15,
780
- color: _mutedColor(context),
781
- ),
782
- const SizedBox(width: 8),
783
- Expanded(
784
- child: Text(
785
- openSource,
786
- style: theme.textTheme.bodySmall?.copyWith(
787
- color: _mutedColor(context),
788
- height: 1.35,
789
- ),
790
- ),
791
- ),
792
- ],
793
- ),
794
- ),
795
- ],
796
- ),
797
- );
798
- }
799
-}
800
-
801
-class _AboutLabel extends StatelessWidget {
802
- const _AboutLabel({required this.label});
803
-
804
- final String label;
805
-
806
- @override
807
- Widget build(BuildContext context) {
808
- return Text(
809
- label,
810
- style: Theme.of(context).textTheme.labelMedium?.copyWith(
811
- color: _mutedColor(context),
812
- fontWeight: FontWeight.w700,
813
- ),
814
- );
815
- }
816
-}
817
-
818
-class _AboutChip extends StatelessWidget {
819
- const _AboutChip({required this.label, required this.icon});
820
-
821
- final String label;
822
- final IconData icon;
823
-
824
- @override
825
- Widget build(BuildContext context) {
826
- return Container(
827
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
828
- decoration: BoxDecoration(
829
- color: _surfaceColor(context),
830
- borderRadius: BorderRadius.circular(10),
831
- border: Border.all(color: _outlineColor(context, alpha: 0.75)),
832
- ),
833
- child: Row(
834
- mainAxisSize: MainAxisSize.min,
835
- children: [
836
- Icon(icon, size: 13, color: _mutedColor(context)),
837
- const SizedBox(width: 6),
838
- Text(
839
- label,
840
- style: Theme.of(
841
- context,
842
- ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
843
- ),
844
- ],
845
- ),
846
- );
847
- }
848
-}
849
-
850
-class _LanguageSetting extends StatelessWidget {
851
- const _LanguageSetting({required this.current, required this.onChanged});
852
-
853
- static const localeItems = <(String, String, String, String)>[
854
- ('en', 'English', 'assets/images/flag_us.png', '🇺🇸'),
855
- ('ja', '日本語', 'assets/images/flag_jp.png', '🇯🇵'),
856
- ('it', 'Italiano', 'assets/images/flag_it.png', '🇮🇹'),
857
- ('es', 'Español', 'assets/images/flag_es.png', '🇪🇸'),
858
- ('zh', '简体中文', 'assets/images/flag_cn.png', '🇨🇳'),
859
- ('zh_TW', '繁體中文', 'assets/images/flag_tw.png', '🇹🇼'),
860
- ('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
861
- ('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
862
- ('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
863
- ];
864
-
865
- final String? current;
866
- final ValueChanged<String?> onChanged;
867
-
868
- @override
869
- Widget build(BuildContext context) {
870
- final l10n = AppLocalizations.of(context)!;
871
- final theme = Theme.of(context);
872
-
873
- return _SettingPanel(
874
- child: Column(
875
- crossAxisAlignment: CrossAxisAlignment.start,
876
- children: [
877
- _SettingHeader(label: l10n.settings_language),
878
- const SizedBox(height: 10),
879
- _InputShell(
880
- child: DropdownButtonHideUnderline(
881
- child: DropdownButton<String?>(
882
- value: current,
883
- isExpanded: true,
884
- padding: const EdgeInsets.symmetric(horizontal: 14),
885
- borderRadius: BorderRadius.circular(16),
886
- dropdownColor: theme.colorScheme.surface,
887
- items: [
888
- DropdownMenuItem<String?>(
889
- value: null,
890
- child: Row(
891
- children: [
892
- Icon(
893
- Icons.settings_suggest_outlined,
894
- size: 18,
895
- color: _mutedColor(context),
896
- ),
897
- const SizedBox(width: 10),
898
- Flexible(
899
- child: Text(
900
- l10n.language_system_default,
901
- overflow: TextOverflow.ellipsis,
902
- ),
903
- ),
904
- ],
905
- ),
906
- ),
907
- ...localeItems.map(
908
- (item) => DropdownMenuItem<String?>(
909
- value: item.$1,
910
- child: Row(
911
- children: [
912
- Container(
913
- width: 24,
914
- height: 16,
915
- decoration: BoxDecoration(
916
- borderRadius: BorderRadius.circular(4),
917
- border: Border.all(
918
- color: _outlineColor(context, alpha: 0.8),
919
- width: 0.6,
920
- ),
921
- ),
922
- child: ClipRRect(
923
- borderRadius: BorderRadius.circular(4),
924
- child: Image.asset(
925
- item.$3,
926
- fit: BoxFit.cover,
927
- errorBuilder: (context, error, stackTrace) {
928
- return Center(
929
- child: Text(
930
- item.$4,
931
- style: const TextStyle(fontSize: 12),
932
- ),
933
- );
934
- },
935
- ),
936
- ),
937
- ),
938
- const SizedBox(width: 10),
939
- Flexible(
940
- child: Text(
941
- item.$2,
942
- overflow: TextOverflow.ellipsis,
943
- ),
944
- ),
945
- ],
946
- ),
947
- ),
948
- ),
949
- ],
950
- onChanged: onChanged,
951
- ),
952
- ),
953
- ),
954
- ],
955
- ),
956
- );
957
- }
958
-}
959
-
960
-class _SliderSetting extends StatelessWidget {
961
- const _SliderSetting({
962
- required this.label,
963
- required this.value,
964
- required this.min,
965
- required this.max,
966
- required this.divisions,
967
- required this.description,
968
- required this.onChanged,
969
- this.valueFormat,
970
- this.previewText,
971
- });
972
-
973
- final String label;
974
- final double value;
975
- final double min;
976
- final double max;
977
- final int divisions;
978
- final String description;
979
- final ValueChanged<double> onChanged;
980
- final String Function(double)? valueFormat;
981
- final String? previewText;
982
-
983
- @override
984
- Widget build(BuildContext context) {
985
- final theme = Theme.of(context);
986
- final primary = theme.colorScheme.primary;
987
- final displayValue = valueFormat != null
988
- ? valueFormat!(value)
989
- : value.toStringAsFixed(2);
990
-
991
- return _SettingPanel(
992
- child: Column(
993
- crossAxisAlignment: CrossAxisAlignment.start,
994
- children: [
995
- Row(
996
- children: [
997
- Expanded(
998
- child: Text(
999
- label,
1000
- style: theme.textTheme.titleSmall?.copyWith(
1001
- fontWeight: FontWeight.w700,
1002
- ),
1003
- ),
1004
- ),
1005
- Container(
1006
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1007
- decoration: BoxDecoration(
1008
- color: primary.withValues(alpha: 0.10),
1009
- borderRadius: BorderRadius.circular(999),
1010
- ),
1011
- child: Text(
1012
- displayValue,
1013
- style: theme.textTheme.labelLarge?.copyWith(
1014
- color: primary,
1015
- fontWeight: FontWeight.w700,
1016
- ),
1017
- ),
1018
- ),
1019
- ],
1020
- ),
1021
- const SizedBox(height: 8),
1022
- SliderTheme(
1023
- data: SliderTheme.of(context).copyWith(
1024
- trackHeight: 4,
1025
- thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
1026
- overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
1027
- activeTrackColor: primary,
1028
- thumbColor: primary,
1029
- overlayColor: primary.withValues(alpha: 0.12),
1030
- inactiveTrackColor: _outlineColor(context, alpha: 0.6),
1031
- ),
1032
- child: Slider(
1033
- value: value.clamp(min, max),
1034
- min: min,
1035
- max: max,
1036
- divisions: divisions,
1037
- onChanged: onChanged,
1038
- ),
1039
- ),
1040
- Text(
1041
- description,
1042
- style: theme.textTheme.bodySmall?.copyWith(
1043
- color: _mutedColor(context),
1044
- height: 1.4,
1045
- ),
1046
- ),
1047
- if (previewText != null) ...[
1048
- const SizedBox(height: 8),
1049
- Container(
1050
- width: double.infinity,
1051
- padding: const EdgeInsets.all(10),
1052
- decoration: BoxDecoration(
1053
- color: theme.scaffoldBackgroundColor.withValues(alpha: 0.65),
1054
- borderRadius: BorderRadius.circular(12),
1055
- border: Border.all(color: _outlineColor(context, alpha: 0.75)),
1056
- ),
1057
- child: Text(
1058
- previewText!,
1059
- style: theme.textTheme.bodyMedium?.copyWith(
1060
- fontSize: value.clamp(12.0, 24.0),
1061
- color: _mutedColor(context),
1062
- ),
1063
- ),
1064
- ),
1065
- ],
1066
- ],
1067
- ),
1068
- );
1069
- }
1070
-}
1071
-
1072
-class _ToggleSetting extends StatelessWidget {
1073
- const _ToggleSetting({
1074
- required this.label,
1075
- required this.value,
1076
- required this.onChanged,
1077
- this.badges = const [],
1078
- });
1079
-
1080
- final String label;
1081
- final bool value;
1082
- final ValueChanged<bool> onChanged;
1083
- final List<Widget> badges;
1084
-
1085
- @override
1086
- Widget build(BuildContext context) {
1087
- final theme = Theme.of(context);
1088
-
1089
- return _SettingPanel(
1090
- child: Row(
1091
- crossAxisAlignment: CrossAxisAlignment.center,
1092
- children: [
1093
- Expanded(
1094
- child: Wrap(
1095
- spacing: 8,
1096
- runSpacing: 6,
1097
- crossAxisAlignment: WrapCrossAlignment.center,
1098
- children: [
1099
- Text(
1100
- label,
1101
- style: theme.textTheme.titleSmall?.copyWith(
1102
- fontWeight: FontWeight.w700,
1103
- ),
1104
- ),
1105
- ...badges,
1106
- ],
1107
- ),
1108
- ),
1109
- const SizedBox(width: 8),
1110
- Switch.adaptive(value: value, onChanged: onChanged),
1111
- ],
1112
- ),
1113
- );
1114
- }
1115
-}
1116
-
1117
-class _FeatureBadge extends StatelessWidget {
1118
- const _FeatureBadge({required this.label});
1119
-
1120
- final String label;
1121
-
1122
- @override
1123
- Widget build(BuildContext context) {
1124
- return Container(
1125
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1126
- decoration: BoxDecoration(
1127
- color: Colors.amber.withValues(alpha: 0.10),
1128
- borderRadius: BorderRadius.circular(999),
1129
- border: Border.all(color: Colors.amber.withValues(alpha: 0.22)),
1130
- ),
1131
- child: Text(
1132
- label.toUpperCase(),
1133
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
1134
- color: const Color(0xFFB45309),
1135
- fontWeight: FontWeight.w800,
1136
- letterSpacing: 0.45,
1137
- ),
1138
- ),
1139
- );
1140
- }
1141
-}
1142
-
1143
-class _ThemeToggle extends StatelessWidget {
1144
- const _ThemeToggle({required this.ref});
1145
-
1146
- final WidgetRef ref;
1147
-
1148
- @override
1149
- Widget build(BuildContext context) {
1150
- final l10n = AppLocalizations.of(context)!;
1151
- final currentTheme = ref.watch(themeModeProvider);
1152
-
1153
- return _SettingPanel(
1154
- child: Column(
1155
- crossAxisAlignment: CrossAxisAlignment.start,
1156
- children: [
1157
- _SettingHeader(label: l10n.theme),
1158
- const SizedBox(height: 8),
1159
- LayoutBuilder(
1160
- builder: (context, constraints) {
1161
- final spacing = 8.0;
1162
- final columns = constraints.maxWidth >= 700
1163
- ? 4
1164
- : constraints.maxWidth >= 420
1165
- ? 2
1166
- : 1;
1167
- final itemWidth =
1168
- (constraints.maxWidth - (spacing * (columns - 1))) / columns;
1169
-
1170
- return Wrap(
1171
- spacing: spacing,
1172
- runSpacing: spacing,
1173
- children: [
1174
- SizedBox(
1175
- width: itemWidth,
1176
- child: _ThemeOption(
1177
- label: l10n.theme_system,
1178
- icon: Icons.brightness_auto_rounded,
1179
- isSelected: currentTheme == AppThemeType.system,
1180
- onTap: () => ref
1181
- .read(themeModeProvider.notifier)
1182
- .setThemeMode(AppThemeType.system),
1183
- ),
1184
- ),
1185
- SizedBox(
1186
- width: itemWidth,
1187
- child: _ThemeOption(
1188
- label: l10n.theme_light,
1189
- icon: Icons.light_mode_rounded,
1190
- isSelected: currentTheme == AppThemeType.light,
1191
- onTap: () => ref
1192
- .read(themeModeProvider.notifier)
1193
- .setThemeMode(AppThemeType.light),
1194
- ),
1195
- ),
1196
- SizedBox(
1197
- width: itemWidth,
1198
- child: _ThemeOption(
1199
- label: l10n.theme_dark,
1200
- icon: Icons.dark_mode_rounded,
1201
- isSelected: currentTheme == AppThemeType.dark,
1202
- onTap: () => ref
1203
- .read(themeModeProvider.notifier)
1204
- .setThemeMode(AppThemeType.dark),
1205
- ),
1206
- ),
1207
- SizedBox(
1208
- width: itemWidth,
1209
- child: _ThemeOption(
1210
- label: l10n.theme_claude,
1211
- icon: Icons.auto_awesome_rounded,
1212
- isSelected: currentTheme == AppThemeType.claude,
1213
- onTap: () => ref
1214
- .read(themeModeProvider.notifier)
1215
- .setThemeMode(AppThemeType.claude),
1216
- ),
1217
- ),
1218
- ],
1219
- );
1220
- },
1221
- ),
1222
- ],
1223
- ),
1224
- );
1225
- }
1226
-}
1227
-
1228
-class _ThemeOption extends StatelessWidget {
1229
- const _ThemeOption({
1230
- required this.label,
1231
- required this.icon,
1232
- required this.isSelected,
1233
- required this.onTap,
1234
- });
1235
-
1236
- final String label;
1237
- final IconData icon;
1238
- final bool isSelected;
1239
- final VoidCallback onTap;
1240
-
1241
- @override
1242
- Widget build(BuildContext context) {
1243
- final theme = Theme.of(context);
1244
- final primary = theme.colorScheme.primary;
1245
-
1246
- return Material(
1247
- color: Colors.transparent,
1248
- child: InkWell(
1249
- onTap: onTap,
1250
- borderRadius: BorderRadius.circular(14),
1251
- child: Container(
1252
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1253
- decoration: BoxDecoration(
1254
- color: isSelected
1255
- ? primary.withValues(alpha: 0.10)
1256
- : theme.scaffoldBackgroundColor.withValues(alpha: 0.55),
1257
- borderRadius: BorderRadius.circular(14),
1258
- border: Border.all(
1259
- color: isSelected ? primary : _outlineColor(context, alpha: 0.8),
1260
- ),
1261
- ),
1262
- child: Row(
1263
- children: [
1264
- Container(
1265
- width: 30,
1266
- height: 30,
1267
- decoration: BoxDecoration(
1268
- color: isSelected
1269
- ? primary.withValues(alpha: 0.14)
1270
- : _panelColor(context),
1271
- borderRadius: BorderRadius.circular(10),
1272
- ),
1273
- child: Icon(
1274
- icon,
1275
- size: 16,
1276
- color: isSelected ? primary : _mutedColor(context),
1277
- ),
1278
- ),
1279
- const SizedBox(width: 10),
1280
- Expanded(
1281
- child: Text(
1282
- label,
1283
- style: theme.textTheme.titleSmall?.copyWith(
1284
- fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
1285
- color: isSelected ? primary : null,
1286
- ),
1287
- ),
1288
- ),
1289
- Icon(
1290
- isSelected
1291
- ? Icons.check_circle_rounded
1292
- : Icons.radio_button_unchecked_rounded,
1293
- size: 16,
1294
- color: isSelected ? primary : _mutedColor(context),
1295
- ),
1296
- ],
1297
- ),
1298
- ),
1299
- ),
1300
- );
1301
- }
1302
-}
1303
-
1304
-class _DropdownSetting extends StatelessWidget {
1305
- const _DropdownSetting({
1306
- required this.label,
1307
- required this.currentValue,
1308
- required this.items,
1309
- required this.onChanged,
1310
- required this.icon,
1311
- });
1312
-
1313
- final String label;
1314
- final String? currentValue;
1315
- final List<(String id, String name)> items;
1316
- final ValueChanged<String?> onChanged;
1317
- final IconData icon;
1318
-
1319
- @override
1320
- Widget build(BuildContext context) {
1321
- final l10n = AppLocalizations.of(context)!;
1322
- final theme = Theme.of(context);
1323
-
1324
- return _SettingPanel(
1325
- child: Column(
1326
- crossAxisAlignment: CrossAxisAlignment.start,
1327
- children: [
1328
- _SettingHeader(label: label),
1329
- const SizedBox(height: 10),
1330
- _InputShell(
1331
- child: DropdownButtonHideUnderline(
1332
- child: DropdownButton<String?>(
1333
- value: currentValue,
1334
- isExpanded: true,
1335
- padding: const EdgeInsets.symmetric(horizontal: 14),
1336
- borderRadius: BorderRadius.circular(16),
1337
- dropdownColor: theme.colorScheme.surface,
1338
- hint: Text(
1339
- l10n.none_selected,
1340
- style: theme.textTheme.bodyMedium?.copyWith(
1341
- color: _mutedColor(context),
1342
- ),
1343
- ),
1344
- items: [
1345
- DropdownMenuItem<String?>(
1346
- value: null,
1347
- child: Text(l10n.none),
1348
- ),
1349
- ...items.map(
1350
- (item) => DropdownMenuItem<String?>(
1351
- value: item.$1,
1352
- child: Row(
1353
- children: [
1354
- Icon(icon, size: 16, color: _mutedColor(context)),
1355
- const SizedBox(width: 8),
1356
- Flexible(
1357
- child: Text(
1358
- item.$2,
1359
- overflow: TextOverflow.ellipsis,
1360
- ),
1361
- ),
1362
- ],
1363
- ),
1364
- ),
1365
- ),
1366
- ],
1367
- onChanged: onChanged,
1368
- ),
1369
- ),
1370
- ),
1371
- ],
1372
- ),
1373
- );
1374
- }
1375
-}
1376
-
1377
-class _CodeThemeDropdown extends StatelessWidget {
1378
- const _CodeThemeDropdown({
1379
- required this.label,
1380
- required this.current,
1381
- required this.onChanged,
1382
- });
1383
-
1384
- final String label;
1385
- final SyntaxThemeName current;
1386
- final ValueChanged<SyntaxThemeName> onChanged;
1387
-
1388
- @override
1389
- Widget build(BuildContext context) {
1390
- final l10n = AppLocalizations.of(context)!;
1391
- final theme = Theme.of(context);
1392
-
1393
- return _SettingPanel(
1394
- child: Column(
1395
- crossAxisAlignment: CrossAxisAlignment.start,
1396
- children: [
1397
- _SettingHeader(label: label),
1398
- const SizedBox(height: 10),
1399
- _InputShell(
1400
- child: DropdownButtonHideUnderline(
1401
- child: DropdownButton<SyntaxThemeName>(
1402
- value: current,
1403
- isExpanded: true,
1404
- padding: const EdgeInsets.symmetric(horizontal: 14),
1405
- borderRadius: BorderRadius.circular(16),
1406
- dropdownColor: theme.colorScheme.surface,
1407
- items: SyntaxThemeName.values.map((codeTheme) {
1408
- final displayName = switch (codeTheme) {
1409
- SyntaxThemeName.light => l10n.theme_light,
1410
- SyntaxThemeName.dark => l10n.theme_dark,
1411
- };
1412
-
1413
- return DropdownMenuItem<SyntaxThemeName>(
1414
- value: codeTheme,
1415
- child: Text(displayName),
1416
- );
1417
- }).toList(),
1418
- onChanged: (value) {
1419
- if (value != null) {
1420
- onChanged(value);
1421
- }
1422
- },
1423
- ),
1424
- ),
1425
- ),
1426
- const SizedBox(height: 8),
1427
- Text(
1428
- l10n.code_theme_desc,
1429
- style: theme.textTheme.bodySmall?.copyWith(
1430
- color: _mutedColor(context),
1431
- ),
1432
- ),
1433
- ],
1434
- ),
1435
- );
1436
- }
1437
-}
1438
-
1439
-class _DangerousAction extends StatelessWidget {
1440
- const _DangerousAction({
1441
- required this.label,
1442
- required this.icon,
1443
- required this.onConfirm,
1444
- });
1445
-
1446
- final String label;
1447
- final IconData icon;
1448
- final VoidCallback onConfirm;
1449
-
1450
- @override
1451
- Widget build(BuildContext context) {
1452
- final l10n = AppLocalizations.of(context)!;
1453
-
1454
- return OutlinedButton.icon(
1455
- onPressed: () {
1456
- showDialog<void>(
1457
- context: context,
1458
- builder: (dialogContext) {
1459
- final dialogL10n = AppLocalizations.of(dialogContext)!;
1460
- return AlertDialog(
1461
- title: Text(label),
1462
- content: Text(dialogL10n.cannot_undo),
1463
- actions: [
1464
- TextButton(
1465
- onPressed: () => Navigator.pop(dialogContext),
1466
- child: Text(dialogL10n.cancel),
1467
- ),
1468
- TextButton(
1469
- onPressed: () {
1470
- Navigator.pop(dialogContext);
1471
- onConfirm();
1472
- ScaffoldMessenger.of(context).showSnackBar(
1473
- SnackBar(content: Text(l10n.label_completed(label))),
1474
- );
1475
- },
1476
- style: TextButton.styleFrom(foregroundColor: Colors.red),
1477
- child: Text(dialogL10n.confirm),
1478
- ),
1479
- ],
1480
- );
1481
- },
1482
- );
1483
- },
1484
- icon: Icon(icon, color: Colors.red, size: 16),
1485
- label: Align(
1486
- alignment: Alignment.centerLeft,
1487
- child: Text(label, style: const TextStyle(color: Colors.red)),
1488
- ),
1489
- style: OutlinedButton.styleFrom(
1490
- foregroundColor: Colors.red,
1491
- side: BorderSide(color: Colors.red.withValues(alpha: 0.55)),
1492
- minimumSize: const Size(double.infinity, 44),
1493
- alignment: Alignment.centerLeft,
1494
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
1495
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
1496
- ),
1497
- );
1498
- }
1499
-}
1500
-
1501
-class _SectionActionButton extends StatelessWidget {
1502
- const _SectionActionButton({
1503
- required this.icon,
1504
- required this.label,
1505
- required this.onPressed,
1506
- });
1507
-
1508
- final IconData icon;
1509
- final String label;
1510
- final VoidCallback onPressed;
1511
-
1512
- @override
1513
- Widget build(BuildContext context) {
1514
- return ShadButton.outline(
1515
- onPressed: onPressed,
1516
- width: double.infinity,
1517
- leading: Icon(icon, size: 16),
1518
- child: Align(alignment: Alignment.centerLeft, child: Text(label)),
1519
- );
1520
- }
1521
-}
1522
-
1523
-class _EngineDropdown extends StatelessWidget {
1524
- const _EngineDropdown({required this.current, required this.onChanged});
1525
-
1526
- final EngineId current;
1527
- final ValueChanged<EngineId> onChanged;
1528
-
1529
- @override
1530
- Widget build(BuildContext context) {
1531
- final l10n = AppLocalizations.of(context)!;
1532
- final theme = Theme.of(context);
1533
-
1534
- return _SettingPanel(
1535
- child: Column(
1536
- crossAxisAlignment: CrossAxisAlignment.start,
1537
- children: [
1538
- _SettingHeader(label: l10n.tts_engine),
1539
- const SizedBox(height: 10),
1540
- _InputShell(
1541
- child: DropdownButtonHideUnderline(
1542
- child: DropdownButton<EngineId>(
1543
- value: current,
1544
- isExpanded: true,
1545
- padding: const EdgeInsets.symmetric(horizontal: 14),
1546
- borderRadius: BorderRadius.circular(16),
1547
- dropdownColor: theme.colorScheme.surface,
1548
- items: [
1549
- _engineItem(
1550
- context,
1551
- EngineId.system,
1552
- l10n.tts_engine_system,
1553
- Icons.record_voice_over_rounded,
1554
- ),
1555
- _engineItem(
1556
- context,
1557
- EngineId.kitten,
1558
- l10n.tts_engine_kitten,
1559
- Icons.auto_awesome_rounded,
1560
- ),
1561
- ],
1562
- onChanged: (value) {
1563
- if (value != null) {
1564
- onChanged(value);
1565
- }
1566
- },
1567
- ),
1568
- ),
1569
- ),
1570
- ],
1571
- ),
1572
- );
1573
- }
1574
-
1575
- DropdownMenuItem<EngineId> _engineItem(
1576
- BuildContext context,
1577
- EngineId id,
1578
- String label,
1579
- IconData icon,
1580
- ) {
1581
- final meta = EngineMeta.forEngine(id);
1582
- return DropdownMenuItem<EngineId>(
1583
- value: id,
1584
- child: Row(
1585
- children: [
1586
- Icon(icon, size: 18, color: Color(meta.accentColor)),
1587
- const SizedBox(width: 10),
1588
- Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
1589
- ],
1590
- ),
1591
- );
1592
- }
1593
-}
1594
-
1595
-class _VoiceSelector extends StatelessWidget {
1596
- const _VoiceSelector({
1597
- required this.engine,
1598
- required this.currentVoiceId,
1599
- required this.onChanged,
1600
- });
1601
-
1602
- final EngineId engine;
1603
- final String? currentVoiceId;
1604
- final ValueChanged<Voice?> onChanged;
1605
-
1606
- @override
1607
- Widget build(BuildContext context) {
1608
- final l10n = AppLocalizations.of(context)!;
1609
- final voices = voicesForEngine(engine);
1610
- final resolvedCurrentVoiceId = voiceFromSettings(
1611
- currentVoiceId,
1612
- engine,
1613
- )?.id;
1614
-
1615
- if (voices.isEmpty) {
1616
- return const SizedBox.shrink();
1617
- }
1618
-
1619
- final femaleVoices = voices.where((voice) => voice.gender == 'f').toList();
1620
- final maleVoices = voices.where((voice) => voice.gender == 'm').toList();
1621
- final otherVoices = voices
1622
- .where((voice) => voice.gender != 'f' && voice.gender != 'm')
1623
- .toList();
1624
-
1625
- return _SettingPanel(
1626
- child: Column(
1627
- crossAxisAlignment: CrossAxisAlignment.start,
1628
- children: [
1629
- _SettingHeader(label: l10n.voice),
1630
- if (femaleVoices.isNotEmpty) ...[
1631
- const SizedBox(height: 8),
1632
- _VoiceGroup(
1633
- title: l10n.voice_female,
1634
- voices: femaleVoices,
1635
- selectedVoiceId: resolvedCurrentVoiceId,
1636
- onChanged: onChanged,
1637
- ),
1638
- ],
1639
- if (maleVoices.isNotEmpty) ...[
1640
- const SizedBox(height: 12),
1641
- _VoiceGroup(
1642
- title: l10n.voice_male,
1643
- voices: maleVoices,
1644
- selectedVoiceId: resolvedCurrentVoiceId,
1645
- onChanged: onChanged,
1646
- ),
1647
- ],
1648
- if (otherVoices.isNotEmpty) ...[
1649
- const SizedBox(height: 12),
1650
- _VoiceGroup(
1651
- title: l10n.voice_other,
1652
- voices: otherVoices,
1653
- selectedVoiceId: resolvedCurrentVoiceId,
1654
- onChanged: onChanged,
1655
- ),
1656
- ],
1657
- ],
1658
- ),
1659
- );
1660
- }
1661
-}
1662
-
1663
-class _VoiceGroup extends StatelessWidget {
1664
- const _VoiceGroup({
1665
- required this.title,
1666
- required this.voices,
1667
- required this.selectedVoiceId,
1668
- required this.onChanged,
1669
- });
1670
-
1671
- final String title;
1672
- final List<Voice> voices;
1673
- final String? selectedVoiceId;
1674
- final ValueChanged<Voice?> onChanged;
1675
-
1676
- @override
1677
- Widget build(BuildContext context) {
1678
- return Column(
1679
- crossAxisAlignment: CrossAxisAlignment.start,
1680
- children: [
1681
- Text(
1682
- title.toUpperCase(),
1683
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
1684
- color: _mutedColor(context),
1685
- fontWeight: FontWeight.w700,
1686
- letterSpacing: 0.5,
1687
- ),
1688
- ),
1689
- const SizedBox(height: 6),
1690
- Wrap(
1691
- spacing: 8,
1692
- runSpacing: 8,
1693
- children: voices
1694
- .map(
1695
- (voice) => _VoiceChip(
1696
- voice: voice,
1697
- selected: voice.id == selectedVoiceId,
1698
- onTap: () => onChanged(voice),
1699
- ),
1700
- )
1701
- .toList(),
1702
- ),
1703
- ],
1704
- );
1705
- }
1706
-}
1707
-
1708
-class _VoiceChip extends StatelessWidget {
1709
- const _VoiceChip({
1710
- required this.voice,
1711
- required this.selected,
1712
- required this.onTap,
1713
- });
1714
-
1715
- final Voice voice;
1716
- final bool selected;
1717
- final VoidCallback onTap;
1718
-
1719
- @override
1720
- Widget build(BuildContext context) {
1721
- final theme = Theme.of(context);
1722
- final primary = theme.colorScheme.primary;
1723
-
1724
- return Material(
1725
- color: Colors.transparent,
1726
- child: InkWell(
1727
- onTap: onTap,
1728
- borderRadius: BorderRadius.circular(12),
1729
- child: Container(
1730
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
1731
- decoration: BoxDecoration(
1732
- color: selected
1733
- ? primary.withValues(alpha: 0.10)
1734
- : theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
1735
- borderRadius: BorderRadius.circular(12),
1736
- border: Border.all(
1737
- color: selected ? primary : _outlineColor(context, alpha: 0.8),
1738
- ),
1739
- ),
1740
- child: Row(
1741
- mainAxisSize: MainAxisSize.min,
1742
- children: [
1743
- Icon(
1744
- selected
1745
- ? Icons.radio_button_checked_rounded
1746
- : Icons.radio_button_off_rounded,
1747
- size: 16,
1748
- color: selected ? primary : _mutedColor(context),
1749
- ),
1750
- const SizedBox(width: 8),
1751
- Text(
1752
- voice.name,
1753
- style: theme.textTheme.bodyMedium?.copyWith(
1754
- fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
1755
- color: selected ? primary : null,
1756
- ),
1757
- ),
1758
- if (voice.language != null) ...[
1759
- const SizedBox(width: 6),
1760
- Container(
1761
- padding: const EdgeInsets.symmetric(
1762
- horizontal: 5,
1763
- vertical: 2,
1764
- ),
1765
- decoration: BoxDecoration(
1766
- color: _panelColor(context),
1767
- borderRadius: BorderRadius.circular(999),
1768
- ),
1769
- child: Text(
1770
- voice.language!,
1771
- style: theme.textTheme.labelSmall?.copyWith(
1772
- color: _mutedColor(context),
1773
- fontWeight: FontWeight.w700,
1774
- ),
1775
- ),
1776
- ),
1777
- ],
1778
- ],
1779
- ),
1780
- ),
1781
- ),
1782
- );
1783
- }
1784
-}
1785
-
1786
-class _OnDeviceEngineStatusCard extends ConsumerWidget {
1787
- const _OnDeviceEngineStatusCard();
24
+class SettingsScreen extends ConsumerWidget {
25
+ const SettingsScreen({super.key});
26
27
@override
28
Widget build(BuildContext context, WidgetRef ref) {
1791
- final l10n = AppLocalizations.of(context)!;
1792
- final engineState = ref.watch(onDeviceEngineProvider);
1793
-
1794
- final (
1795
- Color accent,
1796
- IconData icon,
1797
- String message,
1798
- ) = switch (engineState.status) {
1799
- OnDeviceEngineStatus.loaded => (
1800
- Colors.green,
1801
- Icons.check_circle_rounded,
1802
- l10n.model_loaded(
1803
- engineState.loadedModelId ?? 'unknown',
1804
- engineState.backend?.name ?? 'CPU',
1805
- ),
1806
- ),
1807
- OnDeviceEngineStatus.loading => (
1808
- Colors.blue,
1809
- Icons.hourglass_top_rounded,
1810
- l10n.loading,
1811
- ),
1812
- OnDeviceEngineStatus.error => (
1813
- Colors.red,
1814
- Icons.error_rounded,
1815
- l10n.error_with_message(engineState.error ?? l10n.unknown_error),
1816
- ),
1817
- OnDeviceEngineStatus.notLoaded => (
1818
- Colors.grey,
1819
- Icons.info_outline_rounded,
1820
- l10n.no_model_loaded,
1821
- ),
1822
- };
1823
-
1824
- return Container(
1825
- padding: const EdgeInsets.all(14),
1826
- decoration: BoxDecoration(
1827
- color: accent.withValues(alpha: 0.08),
1828
- borderRadius: BorderRadius.circular(14),
1829
- border: Border.all(color: accent.withValues(alpha: 0.25)),
1830
- ),
1831
- child: Row(
1832
- crossAxisAlignment: CrossAxisAlignment.start,
1833
- children: [
1834
- Icon(icon, size: 18, color: accent),
1835
- const SizedBox(width: 8),
1836
- Expanded(
1837
- child: Text(
1838
- message,
1839
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
1840
- color: accent,
1841
- fontWeight: FontWeight.w600,
1842
- height: 1.3,
1843
- ),
1844
- ),
1845
- ),
1846
- ],
1847
- ),
1848
- );
1849
- }
1850
-}
1851
-
1852
-class _SettingHeader extends StatelessWidget {
1853
- const _SettingHeader({required this.label});
1854
-
1855
- final String label;
1856
-
1857
- @override
1858
- Widget build(BuildContext context) {
1859
- return Text(
1860
- label,
1861
- style: Theme.of(
1862
- context,
1863
- ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
1864
- );
1865
- }
1866
-}
1867
-
1868
-class _InputShell extends StatelessWidget {
1869
- const _InputShell({required this.child});
1870
-
1871
- final Widget child;
1872
-
1873
- @override
1874
- Widget build(BuildContext context) {
1875
- return Container(
1876
- decoration: BoxDecoration(
1877
- color: _surfaceColor(context),
1878
- borderRadius: BorderRadius.circular(12),
1879
- border: Border.all(color: _outlineColor(context, alpha: 0.8)),
1880
- ),
1881
- child: child,
1882
- );
1883
- }
1884
-}
1885
-
1886
-class _HuggingFaceTokenSetting extends StatelessWidget {
1887
- const _HuggingFaceTokenSetting({
1888
- required this.currentToken,
1889
- required this.onSave,
1890
- });
1891
-
1892
- final String? currentToken;
1893
- final ValueChanged<String?> onSave;
1894
-
1895
- bool get _hasToken => currentToken != null && currentToken!.isNotEmpty;
1896
-
1897
- String _maskedToken(String token) {
1898
- if (token.length <= 6) return '••••••';
1899
- return '${token.substring(0, 4)}••••••${token.substring(token.length - 4)}';
1900
- }
1901
-
1902
- Future<void> _editToken(BuildContext context) async {
1903
- final l10n = AppLocalizations.of(context)!;
1904
- final controller = TextEditingController(text: currentToken ?? '');
1905
- final result = await showDialog<String?>(
1906
- context: context,
1907
- builder: (ctx) {
1908
- return AlertDialog(
1909
- title: Text(l10n.edit_huggingface_token_dialog_title),
1910
- content: TextField(
1911
- controller: controller,
1912
- obscureText: true,
1913
- autofocus: true,
1914
- decoration: InputDecoration(
1915
- hintText: l10n.huggingface_token_dialog_hint,
1916
- border: const OutlineInputBorder(),
1917
- ),
1918
- onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()),
1919
- ),
1920
- actions: [
1921
- TextButton(
1922
- onPressed: () => Navigator.of(ctx).pop(),
1923
- child: Text(l10n.cancel),
1924
- ),
1925
- TextButton(
1926
- onPressed: () => Navigator.of(ctx).pop(''),
1927
- child: Text(l10n.clear_huggingface_token),
1928
- ),
1929
- TextButton(
1930
- onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
1931
- child: Text(l10n.save),
1932
- ),
1933
- ],
1934
- );
1935
- },
1936
- );
1937
-
1938
- controller.dispose();
1939
- if (result == null) return;
1940
- onSave(result.isEmpty ? null : result);
29
+ return const SettingsContent();
30
}
1942
-
1943
- @override
1944
- Widget build(BuildContext context) {
1945
- final l10n = AppLocalizations.of(context)!;
1946
- final theme = Theme.of(context);
1947
-
1948
- return _SettingPanel(
1949
- child: Column(
1950
- crossAxisAlignment: CrossAxisAlignment.start,
1951
- children: [
1952
- _SettingHeader(label: l10n.settings_huggingface_token),
1953
- const SizedBox(height: 6),
1954
- Text(
1955
- l10n.settings_huggingface_token_desc,
1956
- style: theme.textTheme.bodySmall?.copyWith(
1957
- color: _mutedColor(context),
1958
- height: 1.4,
1959
- ),
1960
- ),
1961
- const SizedBox(height: 10),
1962
- Row(
1963
- children: [
1964
- Expanded(
1965
- child: _InputShell(
1966
- child: Padding(
1967
- padding: const EdgeInsets.symmetric(
1968
- horizontal: 14,
1969
- vertical: 12,
1970
- ),
1971
- child: Text(
1972
- _hasToken ? _maskedToken(currentToken!) : '—',
1973
- style: theme.textTheme.bodyMedium?.copyWith(
1974
- fontFamily: 'monospace',
1975
- color: _hasToken
1976
- ? theme.colorScheme.onSurface
1977
- : _mutedColor(context),
1978
- ),
1979
- ),
1980
- ),
1981
- ),
1982
- ),
1983
- const SizedBox(width: 10),
1984
- ShadButton.outline(
1985
- onPressed: () => _editToken(context),
1986
- child: Text(_hasToken ? l10n.edit : l10n.set_huggingface_token),
1987
- ),
1988
- if (_hasToken) ...[
1989
- const SizedBox(width: 6),
1990
- ShadButton.outline(
1991
- onPressed: () => onSave(null),
1992
- child: Text(l10n.clear_huggingface_token),
1993
- ),
1994
- ],
1995
- ],
1996
- ),
1997
- ],
1998
- ),
1999
- );
2000
- }
2001
-}
2002
-
2003
-List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
2004
- if (children.isEmpty) {
2005
- return const [];
2006
- }
2007
-
2008
- return [
2009
- for (var index = 0; index < children.length; index++) ...[
2010
- children[index],
2011
- if (index != children.length - 1) SizedBox(height: gap),
2012
- ],
2013
- ];
2014
-}
2015
-
2016
-Color _panelColor(BuildContext context) {
2017
- final scheme = ShadTheme.of(context).colorScheme;
2018
- return scheme.secondary;
2019
-}
2020
-
2021
-Color _surfaceColor(BuildContext context) {
2022
- return ShadTheme.of(context).colorScheme.card;
2023
-}
2024
-
2025
-Color _primaryColor(BuildContext context) {
2026
- return ShadTheme.of(context).colorScheme.primary;
2027
-}
2028
-
2029
-Color _outlineColor(BuildContext context, {double alpha = 0.6}) {
2030
- return ShadTheme.of(context).colorScheme.border.withValues(alpha: alpha);
2031
-}
2032
-
2033
-Color _mutedColor(BuildContext context) {
2034
- return ShadTheme.of(context).colorScheme.mutedForeground;
2035
-}
2036
-
2037
-String _themeLabel(AppThemeType themeType, AppLocalizations l10n) {
2038
- return switch (themeType) {
2039
- AppThemeType.system => l10n.theme_system,
2040
- AppThemeType.light => l10n.theme_light,
2041
- AppThemeType.dark => l10n.theme_dark,
2042
- AppThemeType.claude => l10n.theme_claude,
2043
- };
2044
-}
2045
-
2046
-String _engineLabel(EngineId engine, AppLocalizations l10n) {
2047
- return switch (engine) {
2048
- EngineId.system => l10n.tts_engine_system,
2049
- EngineId.kitten => l10n.tts_engine_kitten,
2050
- EngineId.piper => EngineMeta.piper.name,
2051
- };
2052
-}
2053
-
2054
-String _languageLabel(String? localeCode, AppLocalizations l10n) {
2055
- if (localeCode == null) {
2056
- return l10n.language_system_default;
2057
- }
2058
-
2059
- for (final item in _LanguageSetting.localeItems) {
2060
- if (item.$1 == localeCode) {
2061
- return item.$2;
2062
- }
2063
- }
2064
-
2065
- return localeCode.toUpperCase();
31
}
lib/features/settings/views/settings_screen_controls_parts.dart
new
+864
@@ -0,0 +1,864 @@
1
+part of 'settings_screen.dart';
2
+
3
+class _SettingsHero extends StatelessWidget {
4
+ const _SettingsHero({required this.settings, required this.themeType});
5
+
6
+ final AppSettings settings;
7
+ final AppThemeType themeType;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ final l10n = AppLocalizations.of(context)!;
12
+ final theme = Theme.of(context);
13
+ final primary = _primaryColor(context);
14
+
15
+ return Container(
16
+ decoration: BoxDecoration(
17
+ color: _surfaceColor(context),
18
+ borderRadius: BorderRadius.circular(20),
19
+ border: Border.all(color: _outlineColor(context, alpha: 0.9)),
20
+ ),
21
+ child: Padding(
22
+ padding: const EdgeInsets.all(16),
23
+ child: Column(
24
+ crossAxisAlignment: CrossAxisAlignment.start,
25
+ children: [
26
+ Row(
27
+ children: [
28
+ Container(
29
+ width: 36,
30
+ height: 36,
31
+ decoration: BoxDecoration(
32
+ color: primary.withValues(alpha: 0.10),
33
+ borderRadius: BorderRadius.circular(12),
34
+ ),
35
+ child: Icon(
36
+ Icons.settings_outlined,
37
+ size: 18,
38
+ color: primary,
39
+ ),
40
+ ),
41
+ const SizedBox(width: 10),
42
+ Expanded(
43
+ child: Text(
44
+ l10n.settings_title,
45
+ style: theme.textTheme.headlineSmall?.copyWith(
46
+ fontWeight: FontWeight.w700,
47
+ ),
48
+ ),
49
+ ),
50
+ ],
51
+ ),
52
+ const SizedBox(height: 6),
53
+ Text(
54
+ l10n.app_tagline,
55
+ style: theme.textTheme.bodyMedium?.copyWith(
56
+ color: _mutedColor(context),
57
+ height: 1.35,
58
+ ),
59
+ ),
60
+ const SizedBox(height: 14),
61
+ Wrap(
62
+ spacing: 8,
63
+ runSpacing: 8,
64
+ children: [
65
+ _HeroStat(
66
+ label: l10n.theme,
67
+ value: _themeLabel(themeType, l10n),
68
+ icon: Icons.auto_awesome_rounded,
69
+ ),
70
+ _HeroStat(
71
+ label: l10n.settings_language,
72
+ value: _languageLabel(settings.localeCode, l10n),
73
+ icon: Icons.language_rounded,
74
+ ),
75
+ _HeroStat(
76
+ label: l10n.tts_engine,
77
+ value: _engineLabel(settings.ttsEngine, l10n),
78
+ icon: Icons.record_voice_over_rounded,
79
+ ),
80
+ ],
81
+ ),
82
+ ],
83
+ ),
84
+ ),
85
+ );
86
+ }
87
+}
88
+
89
+class _HeroStat extends StatelessWidget {
90
+ const _HeroStat({
91
+ required this.label,
92
+ required this.value,
93
+ required this.icon,
94
+ });
95
+
96
+ final String label;
97
+ final String value;
98
+ final IconData icon;
99
+
100
+ @override
101
+ Widget build(BuildContext context) {
102
+ final theme = Theme.of(context);
103
+ final primary = _primaryColor(context);
104
+
105
+ return Container(
106
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
107
+ decoration: BoxDecoration(
108
+ color: _panelColor(context),
109
+ borderRadius: BorderRadius.circular(12),
110
+ border: Border.all(color: _outlineColor(context)),
111
+ ),
112
+ child: Row(
113
+ mainAxisSize: MainAxisSize.min,
114
+ children: [
115
+ Icon(icon, size: 14, color: primary),
116
+ const SizedBox(width: 8),
117
+ Text(
118
+ '$label: ',
119
+ style: theme.textTheme.labelMedium?.copyWith(
120
+ color: _mutedColor(context),
121
+ fontWeight: FontWeight.w600,
122
+ ),
123
+ ),
124
+ Flexible(
125
+ child: Text(
126
+ value,
127
+ overflow: TextOverflow.ellipsis,
128
+ style: theme.textTheme.labelLarge?.copyWith(
129
+ fontWeight: FontWeight.w700,
130
+ ),
131
+ ),
132
+ ),
133
+ ],
134
+ ),
135
+ );
136
+ }
137
+}
138
+
139
+class _SettingsSectionCard extends StatelessWidget {
140
+ const _SettingsSectionCard({
141
+ required this.title,
142
+ required this.icon,
143
+ required this.accent,
144
+ required this.children,
145
+ });
146
+
147
+ final String title;
148
+ final IconData icon;
149
+ final Color accent;
150
+ final List<Widget> children;
151
+
152
+ @override
153
+ Widget build(BuildContext context) {
154
+ return Container(
155
+ decoration: BoxDecoration(
156
+ color: _surfaceColor(context),
157
+ borderRadius: BorderRadius.circular(18),
158
+ border: Border.all(color: _outlineColor(context, alpha: 0.9)),
159
+ ),
160
+ child: Padding(
161
+ padding: const EdgeInsets.all(16),
162
+ child: Column(
163
+ crossAxisAlignment: CrossAxisAlignment.start,
164
+ children: [
165
+ Row(
166
+ children: [
167
+ Container(
168
+ width: 34,
169
+ height: 34,
170
+ decoration: BoxDecoration(
171
+ color: accent.withValues(alpha: 0.10),
172
+ borderRadius: BorderRadius.circular(10),
173
+ ),
174
+ child: Icon(icon, color: accent, size: 16),
175
+ ),
176
+ const SizedBox(width: 10),
177
+ Expanded(
178
+ child: Text(
179
+ title,
180
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
181
+ fontWeight: FontWeight.w700,
182
+ ),
183
+ ),
184
+ ),
185
+ ],
186
+ ),
187
+ const SizedBox(height: 12),
188
+ ..._withVerticalSpacing(children, gap: 10),
189
+ ],
190
+ ),
191
+ ),
192
+ );
193
+ }
194
+}
195
+
196
+class _SettingPanel extends StatelessWidget {
197
+ const _SettingPanel({required this.child});
198
+
199
+ final Widget child;
200
+
201
+ @override
202
+ Widget build(BuildContext context) {
203
+ return Container(
204
+ padding: const EdgeInsets.all(12),
205
+ decoration: BoxDecoration(
206
+ color: _panelColor(context),
207
+ borderRadius: BorderRadius.circular(14),
208
+ border: Border.all(color: _outlineColor(context)),
209
+ ),
210
+ child: child,
211
+ );
212
+ }
213
+}
214
+
215
+class _MutedCallout extends StatelessWidget {
216
+ const _MutedCallout({required this.message});
217
+
218
+ final String message;
219
+
220
+ @override
221
+ Widget build(BuildContext context) {
222
+ return _SettingPanel(
223
+ child: Row(
224
+ crossAxisAlignment: CrossAxisAlignment.start,
225
+ children: [
226
+ Icon(
227
+ Icons.shield_moon_outlined,
228
+ size: 18,
229
+ color: _mutedColor(context),
230
+ ),
231
+ const SizedBox(width: 10),
232
+ Expanded(
233
+ child: Text(
234
+ message,
235
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
236
+ color: _mutedColor(context),
237
+ height: 1.35,
238
+ ),
239
+ ),
240
+ ),
241
+ ],
242
+ ),
243
+ );
244
+ }
245
+}
246
+
247
+class _AboutPanel extends StatelessWidget {
248
+ const _AboutPanel({
249
+ required this.title,
250
+ required this.subtitle,
251
+ required this.providers,
252
+ required this.highlights,
253
+ required this.stack,
254
+ required this.openSource,
255
+ });
256
+
257
+ final String title;
258
+ final String subtitle;
259
+ final List<String> providers;
260
+ final List<String> highlights;
261
+ final List<String> stack;
262
+ final String openSource;
263
+
264
+ @override
265
+ Widget build(BuildContext context) {
266
+ final theme = Theme.of(context);
267
+ final l10n = AppLocalizations.of(context)!;
268
+
269
+ return _SettingPanel(
270
+ child: Column(
271
+ crossAxisAlignment: CrossAxisAlignment.start,
272
+ children: [
273
+ Text(
274
+ title,
275
+ style: theme.textTheme.titleMedium?.copyWith(
276
+ fontWeight: FontWeight.w700,
277
+ ),
278
+ ),
279
+ const SizedBox(height: 6),
280
+ Text(
281
+ subtitle,
282
+ style: theme.textTheme.bodyMedium?.copyWith(
283
+ color: _mutedColor(context),
284
+ height: 1.35,
285
+ ),
286
+ ),
287
+ const SizedBox(height: 12),
288
+ _AboutLabel(label: l10n.app_tagline),
289
+ const SizedBox(height: 8),
290
+ Wrap(
291
+ spacing: 6,
292
+ runSpacing: 6,
293
+ children: providers
294
+ .map(
295
+ (provider) =>
296
+ _AboutChip(label: provider, icon: Icons.hub_outlined),
297
+ )
298
+ .toList(),
299
+ ),
300
+ const SizedBox(height: 12),
301
+ _AboutLabel(label: l10n.highlights_label),
302
+ const SizedBox(height: 8),
303
+ Wrap(
304
+ spacing: 6,
305
+ runSpacing: 6,
306
+ children: highlights
307
+ .map(
308
+ (highlight) => _AboutChip(
309
+ label: highlight,
310
+ icon: Icons.check_circle_outline_rounded,
311
+ ),
312
+ )
313
+ .toList(),
314
+ ),
315
+ const SizedBox(height: 12),
316
+ _AboutLabel(label: l10n.built_with_label),
317
+ const SizedBox(height: 8),
318
+ Wrap(
319
+ spacing: 6,
320
+ runSpacing: 6,
321
+ children: stack
322
+ .map(
323
+ (item) => _AboutChip(label: item, icon: Icons.code_rounded),
324
+ )
325
+ .toList(),
326
+ ),
327
+ const SizedBox(height: 12),
328
+ Container(
329
+ width: double.infinity,
330
+ padding: const EdgeInsets.all(10),
331
+ decoration: BoxDecoration(
332
+ color: _surfaceColor(context),
333
+ borderRadius: BorderRadius.circular(10),
334
+ border: Border.all(color: _outlineColor(context, alpha: 0.8)),
335
+ ),
336
+ child: Row(
337
+ crossAxisAlignment: CrossAxisAlignment.start,
338
+ children: [
339
+ Icon(
340
+ Icons.open_in_new_rounded,
341
+ size: 15,
342
+ color: _mutedColor(context),
343
+ ),
344
+ const SizedBox(width: 8),
345
+ Expanded(
346
+ child: Text(
347
+ openSource,
348
+ style: theme.textTheme.bodySmall?.copyWith(
349
+ color: _mutedColor(context),
350
+ height: 1.35,
351
+ ),
352
+ ),
353
+ ),
354
+ ],
355
+ ),
356
+ ),
357
+ ],
358
+ ),
359
+ );
360
+ }
361
+}
362
+
363
+class _AboutLabel extends StatelessWidget {
364
+ const _AboutLabel({required this.label});
365
+
366
+ final String label;
367
+
368
+ @override
369
+ Widget build(BuildContext context) {
370
+ return Text(
371
+ label,
372
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
373
+ color: _mutedColor(context),
374
+ fontWeight: FontWeight.w700,
375
+ ),
376
+ );
377
+ }
378
+}
379
+
380
+class _AboutChip extends StatelessWidget {
381
+ const _AboutChip({required this.label, required this.icon});
382
+
383
+ final String label;
384
+ final IconData icon;
385
+
386
+ @override
387
+ Widget build(BuildContext context) {
388
+ return Container(
389
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
390
+ decoration: BoxDecoration(
391
+ color: _surfaceColor(context),
392
+ borderRadius: BorderRadius.circular(10),
393
+ border: Border.all(color: _outlineColor(context, alpha: 0.75)),
394
+ ),
395
+ child: Row(
396
+ mainAxisSize: MainAxisSize.min,
397
+ children: [
398
+ Icon(icon, size: 13, color: _mutedColor(context)),
399
+ const SizedBox(width: 6),
400
+ Text(
401
+ label,
402
+ style: Theme.of(
403
+ context,
404
+ ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
405
+ ),
406
+ ],
407
+ ),
408
+ );
409
+ }
410
+}
411
+
412
+class _LanguageSetting extends StatelessWidget {
413
+ const _LanguageSetting({required this.current, required this.onChanged});
414
+
415
+ static const localeItems = <(String, String, String, String)>[
416
+ ('en', 'English', 'assets/images/flag_us.png', '🇺🇸'),
417
+ ('ja', '日本語', 'assets/images/flag_jp.png', '🇯🇵'),
418
+ ('it', 'Italiano', 'assets/images/flag_it.png', '🇮🇹'),
419
+ ('es', 'Español', 'assets/images/flag_es.png', '🇪🇸'),
420
+ ('zh', '简体中文', 'assets/images/flag_cn.png', '🇨🇳'),
421
+ ('zh_TW', '繁體中文', 'assets/images/flag_tw.png', '🇹🇼'),
422
+ ('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
423
+ ('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
424
+ ('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
425
+ ];
426
+
427
+ final String? current;
428
+ final ValueChanged<String?> onChanged;
429
+
430
+ @override
431
+ Widget build(BuildContext context) {
432
+ final l10n = AppLocalizations.of(context)!;
433
+ final theme = Theme.of(context);
434
+
435
+ return _SettingPanel(
436
+ child: Column(
437
+ crossAxisAlignment: CrossAxisAlignment.start,
438
+ children: [
439
+ _SettingHeader(label: l10n.settings_language),
440
+ const SizedBox(height: 10),
441
+ _InputShell(
442
+ child: DropdownButtonHideUnderline(
443
+ child: DropdownButton<String?>(
444
+ value: current,
445
+ isExpanded: true,
446
+ padding: const EdgeInsets.symmetric(horizontal: 14),
447
+ borderRadius: BorderRadius.circular(16),
448
+ dropdownColor: theme.colorScheme.surface,
449
+ items: [
450
+ DropdownMenuItem<String?>(
451
+ value: null,
452
+ child: Row(
453
+ children: [
454
+ Icon(
455
+ Icons.settings_suggest_outlined,
456
+ size: 18,
457
+ color: _mutedColor(context),
458
+ ),
459
+ const SizedBox(width: 10),
460
+ Flexible(
461
+ child: Text(
462
+ l10n.language_system_default,
463
+ overflow: TextOverflow.ellipsis,
464
+ ),
465
+ ),
466
+ ],
467
+ ),
468
+ ),
469
+ ...localeItems.map(
470
+ (item) => DropdownMenuItem<String?>(
471
+ value: item.$1,
472
+ child: Row(
473
+ children: [
474
+ Container(
475
+ width: 24,
476
+ height: 16,
477
+ decoration: BoxDecoration(
478
+ borderRadius: BorderRadius.circular(4),
479
+ border: Border.all(
480
+ color: _outlineColor(context, alpha: 0.8),
481
+ width: 0.6,
482
+ ),
483
+ ),
484
+ child: ClipRRect(
485
+ borderRadius: BorderRadius.circular(4),
486
+ child: Image.asset(
487
+ item.$3,
488
+ fit: BoxFit.cover,
489
+ errorBuilder: (context, error, stackTrace) {
490
+ return Center(
491
+ child: Text(
492
+ item.$4,
493
+ style: const TextStyle(fontSize: 12),
494
+ ),
495
+ );
496
+ },
497
+ ),
498
+ ),
499
+ ),
500
+ const SizedBox(width: 10),
501
+ Flexible(
502
+ child: Text(
503
+ item.$2,
504
+ overflow: TextOverflow.ellipsis,
505
+ ),
506
+ ),
507
+ ],
508
+ ),
509
+ ),
510
+ ),
511
+ ],
512
+ onChanged: onChanged,
513
+ ),
514
+ ),
515
+ ),
516
+ ],
517
+ ),
518
+ );
519
+ }
520
+}
521
+
522
+class _SliderSetting extends StatelessWidget {
523
+ const _SliderSetting({
524
+ required this.label,
525
+ required this.value,
526
+ required this.min,
527
+ required this.max,
528
+ required this.divisions,
529
+ required this.description,
530
+ required this.onChanged,
531
+ this.valueFormat,
532
+ this.previewText,
533
+ });
534
+
535
+ final String label;
536
+ final double value;
537
+ final double min;
538
+ final double max;
539
+ final int divisions;
540
+ final String description;
541
+ final ValueChanged<double> onChanged;
542
+ final String Function(double)? valueFormat;
543
+ final String? previewText;
544
+
545
+ @override
546
+ Widget build(BuildContext context) {
547
+ final theme = Theme.of(context);
548
+ final primary = theme.colorScheme.primary;
549
+ final displayValue = valueFormat != null
550
+ ? valueFormat!(value)
551
+ : value.toStringAsFixed(2);
552
+
553
+ return _SettingPanel(
554
+ child: Column(
555
+ crossAxisAlignment: CrossAxisAlignment.start,
556
+ children: [
557
+ Row(
558
+ children: [
559
+ Expanded(
560
+ child: Text(
561
+ label,
562
+ style: theme.textTheme.titleSmall?.copyWith(
563
+ fontWeight: FontWeight.w700,
564
+ ),
565
+ ),
566
+ ),
567
+ Container(
568
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
569
+ decoration: BoxDecoration(
570
+ color: primary.withValues(alpha: 0.10),
571
+ borderRadius: BorderRadius.circular(999),
572
+ ),
573
+ child: Text(
574
+ displayValue,
575
+ style: theme.textTheme.labelLarge?.copyWith(
576
+ color: primary,
577
+ fontWeight: FontWeight.w700,
578
+ ),
579
+ ),
580
+ ),
581
+ ],
582
+ ),
583
+ const SizedBox(height: 8),
584
+ SliderTheme(
585
+ data: SliderTheme.of(context).copyWith(
586
+ trackHeight: 4,
587
+ thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
588
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
589
+ activeTrackColor: primary,
590
+ thumbColor: primary,
591
+ overlayColor: primary.withValues(alpha: 0.12),
592
+ inactiveTrackColor: _outlineColor(context, alpha: 0.6),
593
+ ),
594
+ child: Slider(
595
+ value: value.clamp(min, max),
596
+ min: min,
597
+ max: max,
598
+ divisions: divisions,
599
+ onChanged: onChanged,
600
+ ),
601
+ ),
602
+ Text(
603
+ description,
604
+ style: theme.textTheme.bodySmall?.copyWith(
605
+ color: _mutedColor(context),
606
+ height: 1.4,
607
+ ),
608
+ ),
609
+ if (previewText != null) ...[
610
+ const SizedBox(height: 8),
611
+ Container(
612
+ width: double.infinity,
613
+ padding: const EdgeInsets.all(10),
614
+ decoration: BoxDecoration(
615
+ color: theme.scaffoldBackgroundColor.withValues(alpha: 0.65),
616
+ borderRadius: BorderRadius.circular(12),
617
+ border: Border.all(color: _outlineColor(context, alpha: 0.75)),
618
+ ),
619
+ child: Text(
620
+ previewText!,
621
+ style: theme.textTheme.bodyMedium?.copyWith(
622
+ fontSize: value.clamp(12.0, 24.0),
623
+ color: _mutedColor(context),
624
+ ),
625
+ ),
626
+ ),
627
+ ],
628
+ ],
629
+ ),
630
+ );
631
+ }
632
+}
633
+
634
+class _ToggleSetting extends StatelessWidget {
635
+ const _ToggleSetting({
636
+ required this.label,
637
+ required this.value,
638
+ required this.onChanged,
639
+ this.badges = const [],
640
+ });
641
+
642
+ final String label;
643
+ final bool value;
644
+ final ValueChanged<bool> onChanged;
645
+ final List<Widget> badges;
646
+
647
+ @override
648
+ Widget build(BuildContext context) {
649
+ final theme = Theme.of(context);
650
+
651
+ return _SettingPanel(
652
+ child: Row(
653
+ crossAxisAlignment: CrossAxisAlignment.center,
654
+ children: [
655
+ Expanded(
656
+ child: Wrap(
657
+ spacing: 8,
658
+ runSpacing: 6,
659
+ crossAxisAlignment: WrapCrossAlignment.center,
660
+ children: [
661
+ Text(
662
+ label,
663
+ style: theme.textTheme.titleSmall?.copyWith(
664
+ fontWeight: FontWeight.w700,
665
+ ),
666
+ ),
667
+ ...badges,
668
+ ],
669
+ ),
670
+ ),
671
+ const SizedBox(width: 8),
672
+ Switch.adaptive(value: value, onChanged: onChanged),
673
+ ],
674
+ ),
675
+ );
676
+ }
677
+}
678
+
679
+class _FeatureBadge extends StatelessWidget {
680
+ const _FeatureBadge({required this.label});
681
+
682
+ final String label;
683
+
684
+ @override
685
+ Widget build(BuildContext context) {
686
+ return Container(
687
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
688
+ decoration: BoxDecoration(
689
+ color: Colors.amber.withValues(alpha: 0.10),
690
+ borderRadius: BorderRadius.circular(999),
691
+ border: Border.all(color: Colors.amber.withValues(alpha: 0.22)),
692
+ ),
693
+ child: Text(
694
+ label.toUpperCase(),
695
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
696
+ color: const Color(0xFFB45309),
697
+ fontWeight: FontWeight.w800,
698
+ letterSpacing: 0.45,
699
+ ),
700
+ ),
701
+ );
702
+ }
703
+}
704
+
705
+class _ThemeToggle extends StatelessWidget {
706
+ const _ThemeToggle({required this.ref});
707
+
708
+ final WidgetRef ref;
709
+
710
+ @override
711
+ Widget build(BuildContext context) {
712
+ final l10n = AppLocalizations.of(context)!;
713
+ final currentTheme = ref.watch(themeModeProvider);
714
+
715
+ return _SettingPanel(
716
+ child: Column(
717
+ crossAxisAlignment: CrossAxisAlignment.start,
718
+ children: [
719
+ _SettingHeader(label: l10n.theme),
720
+ const SizedBox(height: 8),
721
+ LayoutBuilder(
722
+ builder: (context, constraints) {
723
+ final spacing = 8.0;
724
+ final columns = constraints.maxWidth >= 700
725
+ ? 4
726
+ : constraints.maxWidth >= 420
727
+ ? 2
728
+ : 1;
729
+ final itemWidth =
730
+ (constraints.maxWidth - (spacing * (columns - 1))) / columns;
731
+
732
+ return Wrap(
733
+ spacing: spacing,
734
+ runSpacing: spacing,
735
+ children: [
736
+ SizedBox(
737
+ width: itemWidth,
738
+ child: _ThemeOption(
739
+ label: l10n.theme_system,
740
+ icon: Icons.brightness_auto_rounded,
741
+ isSelected: currentTheme == AppThemeType.system,
742
+ onTap: () => ref
743
+ .read(themeModeProvider.notifier)
744
+ .setThemeMode(AppThemeType.system),
745
+ ),
746
+ ),
747
+ SizedBox(
748
+ width: itemWidth,
749
+ child: _ThemeOption(
750
+ label: l10n.theme_light,
751
+ icon: Icons.light_mode_rounded,
752
+ isSelected: currentTheme == AppThemeType.light,
753
+ onTap: () => ref
754
+ .read(themeModeProvider.notifier)
755
+ .setThemeMode(AppThemeType.light),
756
+ ),
757
+ ),
758
+ SizedBox(
759
+ width: itemWidth,
760
+ child: _ThemeOption(
761
+ label: l10n.theme_dark,
762
+ icon: Icons.dark_mode_rounded,
763
+ isSelected: currentTheme == AppThemeType.dark,
764
+ onTap: () => ref
765
+ .read(themeModeProvider.notifier)
766
+ .setThemeMode(AppThemeType.dark),
767
+ ),
768
+ ),
769
+ SizedBox(
770
+ width: itemWidth,
771
+ child: _ThemeOption(
772
+ label: l10n.theme_claude,
773
+ icon: Icons.auto_awesome_rounded,
774
+ isSelected: currentTheme == AppThemeType.claude,
775
+ onTap: () => ref
776
+ .read(themeModeProvider.notifier)
777
+ .setThemeMode(AppThemeType.claude),
778
+ ),
779
+ ),
780
+ ],
781
+ );
782
+ },
783
+ ),
784
+ ],
785
+ ),
786
+ );
787
+ }
788
+}
789
+
790
+class _ThemeOption extends StatelessWidget {
791
+ const _ThemeOption({
792
+ required this.label,
793
+ required this.icon,
794
+ required this.isSelected,
795
+ required this.onTap,
796
+ });
797
+
798
+ final String label;
799
+ final IconData icon;
800
+ final bool isSelected;
801
+ final VoidCallback onTap;
802
+
803
+ @override
804
+ Widget build(BuildContext context) {
805
+ final theme = Theme.of(context);
806
+ final primary = theme.colorScheme.primary;
807
+
808
+ return Material(
809
+ color: Colors.transparent,
810
+ child: InkWell(
811
+ onTap: onTap,
812
+ borderRadius: BorderRadius.circular(14),
813
+ child: Container(
814
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
815
+ decoration: BoxDecoration(
816
+ color: isSelected
817
+ ? primary.withValues(alpha: 0.10)
818
+ : theme.scaffoldBackgroundColor.withValues(alpha: 0.55),
819
+ borderRadius: BorderRadius.circular(14),
820
+ border: Border.all(
821
+ color: isSelected ? primary : _outlineColor(context, alpha: 0.8),
822
+ ),
823
+ ),
824
+ child: Row(
825
+ children: [
826
+ Container(
827
+ width: 30,
828
+ height: 30,
829
+ decoration: BoxDecoration(
830
+ color: isSelected
831
+ ? primary.withValues(alpha: 0.14)
832
+ : _panelColor(context),
833
+ borderRadius: BorderRadius.circular(10),
834
+ ),
835
+ child: Icon(
836
+ icon,
837
+ size: 16,
838
+ color: isSelected ? primary : _mutedColor(context),
839
+ ),
840
+ ),
841
+ const SizedBox(width: 10),
842
+ Expanded(
843
+ child: Text(
844
+ label,
845
+ style: theme.textTheme.titleSmall?.copyWith(
846
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
847
+ color: isSelected ? primary : null,
848
+ ),
849
+ ),
850
+ ),
851
+ Icon(
852
+ isSelected
853
+ ? Icons.check_circle_rounded
854
+ : Icons.radio_button_unchecked_rounded,
855
+ size: 16,
856
+ color: isSelected ? primary : _mutedColor(context),
857
+ ),
858
+ ],
859
+ ),
860
+ ),
861
+ ),
862
+ );
863
+ }
864
+}
lib/features/settings/views/settings_screen_extras_parts.dart
new
+700
@@ -0,0 +1,700 @@
1
+part of 'settings_screen.dart';
2
+
3
+class _DropdownSetting extends StatelessWidget {
4
+ const _DropdownSetting({
5
+ required this.label,
6
+ required this.currentValue,
7
+ required this.items,
8
+ required this.onChanged,
9
+ required this.icon,
10
+ });
11
+
12
+ final String label;
13
+ final String? currentValue;
14
+ final List<(String id, String name)> items;
15
+ final ValueChanged<String?> onChanged;
16
+ final IconData icon;
17
+
18
+ @override
19
+ Widget build(BuildContext context) {
20
+ final l10n = AppLocalizations.of(context)!;
21
+ final theme = Theme.of(context);
22
+
23
+ return _SettingPanel(
24
+ child: Column(
25
+ crossAxisAlignment: CrossAxisAlignment.start,
26
+ children: [
27
+ _SettingHeader(label: label),
28
+ const SizedBox(height: 10),
29
+ _InputShell(
30
+ child: DropdownButtonHideUnderline(
31
+ child: DropdownButton<String?>(
32
+ value: currentValue,
33
+ isExpanded: true,
34
+ padding: const EdgeInsets.symmetric(horizontal: 14),
35
+ borderRadius: BorderRadius.circular(16),
36
+ dropdownColor: theme.colorScheme.surface,
37
+ hint: Text(
38
+ l10n.none_selected,
39
+ style: theme.textTheme.bodyMedium?.copyWith(
40
+ color: _mutedColor(context),
41
+ ),
42
+ ),
43
+ items: [
44
+ DropdownMenuItem<String?>(
45
+ value: null,
46
+ child: Text(l10n.none),
47
+ ),
48
+ ...items.map(
49
+ (item) => DropdownMenuItem<String?>(
50
+ value: item.$1,
51
+ child: Row(
52
+ children: [
53
+ Icon(icon, size: 16, color: _mutedColor(context)),
54
+ const SizedBox(width: 8),
55
+ Flexible(
56
+ child: Text(
57
+ item.$2,
58
+ overflow: TextOverflow.ellipsis,
59
+ ),
60
+ ),
61
+ ],
62
+ ),
63
+ ),
64
+ ),
65
+ ],
66
+ onChanged: onChanged,
67
+ ),
68
+ ),
69
+ ),
70
+ ],
71
+ ),
72
+ );
73
+ }
74
+}
75
+
76
+class _CodeThemeDropdown extends StatelessWidget {
77
+ const _CodeThemeDropdown({
78
+ required this.label,
79
+ required this.current,
80
+ required this.onChanged,
81
+ });
82
+
83
+ final String label;
84
+ final SyntaxThemeName current;
85
+ final ValueChanged<SyntaxThemeName> onChanged;
86
+
87
+ @override
88
+ Widget build(BuildContext context) {
89
+ final l10n = AppLocalizations.of(context)!;
90
+ final theme = Theme.of(context);
91
+
92
+ return _SettingPanel(
93
+ child: Column(
94
+ crossAxisAlignment: CrossAxisAlignment.start,
95
+ children: [
96
+ _SettingHeader(label: label),
97
+ const SizedBox(height: 10),
98
+ _InputShell(
99
+ child: DropdownButtonHideUnderline(
100
+ child: DropdownButton<SyntaxThemeName>(
101
+ value: current,
102
+ isExpanded: true,
103
+ padding: const EdgeInsets.symmetric(horizontal: 14),
104
+ borderRadius: BorderRadius.circular(16),
105
+ dropdownColor: theme.colorScheme.surface,
106
+ items: SyntaxThemeName.values.map((codeTheme) {
107
+ final displayName = switch (codeTheme) {
108
+ SyntaxThemeName.light => l10n.theme_light,
109
+ SyntaxThemeName.dark => l10n.theme_dark,
110
+ };
111
+
112
+ return DropdownMenuItem<SyntaxThemeName>(
113
+ value: codeTheme,
114
+ child: Text(displayName),
115
+ );
116
+ }).toList(),
117
+ onChanged: (value) {
118
+ if (value != null) {
119
+ onChanged(value);
120
+ }
121
+ },
122
+ ),
123
+ ),
124
+ ),
125
+ const SizedBox(height: 8),
126
+ Text(
127
+ l10n.code_theme_desc,
128
+ style: theme.textTheme.bodySmall?.copyWith(
129
+ color: _mutedColor(context),
130
+ ),
131
+ ),
132
+ ],
133
+ ),
134
+ );
135
+ }
136
+}
137
+
138
+class _DangerousAction extends StatelessWidget {
139
+ const _DangerousAction({
140
+ required this.label,
141
+ required this.icon,
142
+ required this.onConfirm,
143
+ });
144
+
145
+ final String label;
146
+ final IconData icon;
147
+ final VoidCallback onConfirm;
148
+
149
+ @override
150
+ Widget build(BuildContext context) {
151
+ final l10n = AppLocalizations.of(context)!;
152
+
153
+ return OutlinedButton.icon(
154
+ onPressed: () {
155
+ showDialog<void>(
156
+ context: context,
157
+ builder: (dialogContext) {
158
+ final dialogL10n = AppLocalizations.of(dialogContext)!;
159
+ return AlertDialog(
160
+ title: Text(label),
161
+ content: Text(dialogL10n.cannot_undo),
162
+ actions: [
163
+ TextButton(
164
+ onPressed: () => Navigator.pop(dialogContext),
165
+ child: Text(dialogL10n.cancel),
166
+ ),
167
+ TextButton(
168
+ onPressed: () {
169
+ Navigator.pop(dialogContext);
170
+ onConfirm();
171
+ ScaffoldMessenger.of(context).showSnackBar(
172
+ SnackBar(content: Text(l10n.label_completed(label))),
173
+ );
174
+ },
175
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
176
+ child: Text(dialogL10n.confirm),
177
+ ),
178
+ ],
179
+ );
180
+ },
181
+ );
182
+ },
183
+ icon: Icon(icon, color: Colors.red, size: 16),
184
+ label: Align(
185
+ alignment: Alignment.centerLeft,
186
+ child: Text(label, style: const TextStyle(color: Colors.red)),
187
+ ),
188
+ style: OutlinedButton.styleFrom(
189
+ foregroundColor: Colors.red,
190
+ side: BorderSide(color: Colors.red.withValues(alpha: 0.55)),
191
+ minimumSize: const Size(double.infinity, 44),
192
+ alignment: Alignment.centerLeft,
193
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
194
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
195
+ ),
196
+ );
197
+ }
198
+}
199
+
200
+class _SectionActionButton extends StatelessWidget {
201
+ const _SectionActionButton({
202
+ required this.icon,
203
+ required this.label,
204
+ required this.onPressed,
205
+ });
206
+
207
+ final IconData icon;
208
+ final String label;
209
+ final VoidCallback onPressed;
210
+
211
+ @override
212
+ Widget build(BuildContext context) {
213
+ return ShadButton.outline(
214
+ onPressed: onPressed,
215
+ width: double.infinity,
216
+ leading: Icon(icon, size: 16),
217
+ child: Align(alignment: Alignment.centerLeft, child: Text(label)),
218
+ );
219
+ }
220
+}
221
+
222
+class _EngineDropdown extends StatelessWidget {
223
+ const _EngineDropdown({required this.current, required this.onChanged});
224
+
225
+ final EngineId current;
226
+ final ValueChanged<EngineId> onChanged;
227
+
228
+ @override
229
+ Widget build(BuildContext context) {
230
+ final l10n = AppLocalizations.of(context)!;
231
+ final theme = Theme.of(context);
232
+
233
+ return _SettingPanel(
234
+ child: Column(
235
+ crossAxisAlignment: CrossAxisAlignment.start,
236
+ children: [
237
+ _SettingHeader(label: l10n.tts_engine),
238
+ const SizedBox(height: 10),
239
+ _InputShell(
240
+ child: DropdownButtonHideUnderline(
241
+ child: DropdownButton<EngineId>(
242
+ value: current,
243
+ isExpanded: true,
244
+ padding: const EdgeInsets.symmetric(horizontal: 14),
245
+ borderRadius: BorderRadius.circular(16),
246
+ dropdownColor: theme.colorScheme.surface,
247
+ items: [
248
+ _engineItem(
249
+ context,
250
+ EngineId.system,
251
+ l10n.tts_engine_system,
252
+ Icons.record_voice_over_rounded,
253
+ ),
254
+ _engineItem(
255
+ context,
256
+ EngineId.kitten,
257
+ l10n.tts_engine_kitten,
258
+ Icons.auto_awesome_rounded,
259
+ ),
260
+ ],
261
+ onChanged: (value) {
262
+ if (value != null) {
263
+ onChanged(value);
264
+ }
265
+ },
266
+ ),
267
+ ),
268
+ ),
269
+ ],
270
+ ),
271
+ );
272
+ }
273
+
274
+ DropdownMenuItem<EngineId> _engineItem(
275
+ BuildContext context,
276
+ EngineId id,
277
+ String label,
278
+ IconData icon,
279
+ ) {
280
+ final meta = EngineMeta.forEngine(id);
281
+ return DropdownMenuItem<EngineId>(
282
+ value: id,
283
+ child: Row(
284
+ children: [
285
+ Icon(icon, size: 18, color: Color(meta.accentColor)),
286
+ const SizedBox(width: 10),
287
+ Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
288
+ ],
289
+ ),
290
+ );
291
+ }
292
+}
293
+
294
+class _VoiceSelector extends StatelessWidget {
295
+ const _VoiceSelector({
296
+ required this.engine,
297
+ required this.currentVoiceId,
298
+ required this.onChanged,
299
+ });
300
+
301
+ final EngineId engine;
302
+ final String? currentVoiceId;
303
+ final ValueChanged<Voice?> onChanged;
304
+
305
+ @override
306
+ Widget build(BuildContext context) {
307
+ final l10n = AppLocalizations.of(context)!;
308
+ final voices = voicesForEngine(engine);
309
+ final resolvedCurrentVoiceId = voiceFromSettings(
310
+ currentVoiceId,
311
+ engine,
312
+ )?.id;
313
+
314
+ if (voices.isEmpty) {
315
+ return const SizedBox.shrink();
316
+ }
317
+
318
+ final femaleVoices = voices.where((voice) => voice.gender == 'f').toList();
319
+ final maleVoices = voices.where((voice) => voice.gender == 'm').toList();
320
+ final otherVoices = voices
321
+ .where((voice) => voice.gender != 'f' && voice.gender != 'm')
322
+ .toList();
323
+
324
+ return _SettingPanel(
325
+ child: Column(
326
+ crossAxisAlignment: CrossAxisAlignment.start,
327
+ children: [
328
+ _SettingHeader(label: l10n.voice),
329
+ if (femaleVoices.isNotEmpty) ...[
330
+ const SizedBox(height: 8),
331
+ _VoiceGroup(
332
+ title: l10n.voice_female,
333
+ voices: femaleVoices,
334
+ selectedVoiceId: resolvedCurrentVoiceId,
335
+ onChanged: onChanged,
336
+ ),
337
+ ],
338
+ if (maleVoices.isNotEmpty) ...[
339
+ const SizedBox(height: 12),
340
+ _VoiceGroup(
341
+ title: l10n.voice_male,
342
+ voices: maleVoices,
343
+ selectedVoiceId: resolvedCurrentVoiceId,
344
+ onChanged: onChanged,
345
+ ),
346
+ ],
347
+ if (otherVoices.isNotEmpty) ...[
348
+ const SizedBox(height: 12),
349
+ _VoiceGroup(
350
+ title: l10n.voice_other,
351
+ voices: otherVoices,
352
+ selectedVoiceId: resolvedCurrentVoiceId,
353
+ onChanged: onChanged,
354
+ ),
355
+ ],
356
+ ],
357
+ ),
358
+ );
359
+ }
360
+}
361
+
362
+class _VoiceGroup extends StatelessWidget {
363
+ const _VoiceGroup({
364
+ required this.title,
365
+ required this.voices,
366
+ required this.selectedVoiceId,
367
+ required this.onChanged,
368
+ });
369
+
370
+ final String title;
371
+ final List<Voice> voices;
372
+ final String? selectedVoiceId;
373
+ final ValueChanged<Voice?> onChanged;
374
+
375
+ @override
376
+ Widget build(BuildContext context) {
377
+ return Column(
378
+ crossAxisAlignment: CrossAxisAlignment.start,
379
+ children: [
380
+ Text(
381
+ title.toUpperCase(),
382
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
383
+ color: _mutedColor(context),
384
+ fontWeight: FontWeight.w700,
385
+ letterSpacing: 0.5,
386
+ ),
387
+ ),
388
+ const SizedBox(height: 6),
389
+ Wrap(
390
+ spacing: 8,
391
+ runSpacing: 8,
392
+ children: voices
393
+ .map(
394
+ (voice) => _VoiceChip(
395
+ voice: voice,
396
+ selected: voice.id == selectedVoiceId,
397
+ onTap: () => onChanged(voice),
398
+ ),
399
+ )
400
+ .toList(),
401
+ ),
402
+ ],
403
+ );
404
+ }
405
+}
406
+
407
+class _VoiceChip extends StatelessWidget {
408
+ const _VoiceChip({
409
+ required this.voice,
410
+ required this.selected,
411
+ required this.onTap,
412
+ });
413
+
414
+ final Voice voice;
415
+ final bool selected;
416
+ final VoidCallback onTap;
417
+
418
+ @override
419
+ Widget build(BuildContext context) {
420
+ final theme = Theme.of(context);
421
+ final primary = theme.colorScheme.primary;
422
+
423
+ return Material(
424
+ color: Colors.transparent,
425
+ child: InkWell(
426
+ onTap: onTap,
427
+ borderRadius: BorderRadius.circular(12),
428
+ child: Container(
429
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
430
+ decoration: BoxDecoration(
431
+ color: selected
432
+ ? primary.withValues(alpha: 0.10)
433
+ : theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
434
+ borderRadius: BorderRadius.circular(12),
435
+ border: Border.all(
436
+ color: selected ? primary : _outlineColor(context, alpha: 0.8),
437
+ ),
438
+ ),
439
+ child: Row(
440
+ mainAxisSize: MainAxisSize.min,
441
+ children: [
442
+ Icon(
443
+ selected
444
+ ? Icons.radio_button_checked_rounded
445
+ : Icons.radio_button_off_rounded,
446
+ size: 16,
447
+ color: selected ? primary : _mutedColor(context),
448
+ ),
449
+ const SizedBox(width: 8),
450
+ Text(
451
+ voice.name,
452
+ style: theme.textTheme.bodyMedium?.copyWith(
453
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
454
+ color: selected ? primary : null,
455
+ ),
456
+ ),
457
+ if (voice.language != null) ...[
458
+ const SizedBox(width: 6),
459
+ Container(
460
+ padding: const EdgeInsets.symmetric(
461
+ horizontal: 5,
462
+ vertical: 2,
463
+ ),
464
+ decoration: BoxDecoration(
465
+ color: _panelColor(context),
466
+ borderRadius: BorderRadius.circular(999),
467
+ ),
468
+ child: Text(
469
+ voice.language!,
470
+ style: theme.textTheme.labelSmall?.copyWith(
471
+ color: _mutedColor(context),
472
+ fontWeight: FontWeight.w700,
473
+ ),
474
+ ),
475
+ ),
476
+ ],
477
+ ],
478
+ ),
479
+ ),
480
+ ),
481
+ );
482
+ }
483
+}
484
+
485
+class _OnDeviceEngineStatusCard extends ConsumerWidget {
486
+ const _OnDeviceEngineStatusCard();
487
+
488
+ @override
489
+ Widget build(BuildContext context, WidgetRef ref) {
490
+ final l10n = AppLocalizations.of(context)!;
491
+ final engineState = ref.watch(onDeviceEngineProvider);
492
+
493
+ final (
494
+ Color accent,
495
+ IconData icon,
496
+ String message,
497
+ ) = switch (engineState.status) {
498
+ OnDeviceEngineStatus.loaded => (
499
+ Colors.green,
500
+ Icons.check_circle_rounded,
501
+ l10n.model_loaded(
502
+ engineState.loadedModelId ?? 'unknown',
503
+ engineState.backend?.name ?? 'CPU',
504
+ ),
505
+ ),
506
+ OnDeviceEngineStatus.loading => (
507
+ Colors.blue,
508
+ Icons.hourglass_top_rounded,
509
+ l10n.loading,
510
+ ),
511
+ OnDeviceEngineStatus.error => (
512
+ Colors.red,
513
+ Icons.error_rounded,
514
+ l10n.error_with_message(engineState.error ?? l10n.unknown_error),
515
+ ),
516
+ OnDeviceEngineStatus.notLoaded => (
517
+ Colors.grey,
518
+ Icons.info_outline_rounded,
519
+ l10n.no_model_loaded,
520
+ ),
521
+ };
522
+
523
+ return Container(
524
+ padding: const EdgeInsets.all(14),
525
+ decoration: BoxDecoration(
526
+ color: accent.withValues(alpha: 0.08),
527
+ borderRadius: BorderRadius.circular(14),
528
+ border: Border.all(color: accent.withValues(alpha: 0.25)),
529
+ ),
530
+ child: Row(
531
+ crossAxisAlignment: CrossAxisAlignment.start,
532
+ children: [
533
+ Icon(icon, size: 18, color: accent),
534
+ const SizedBox(width: 8),
535
+ Expanded(
536
+ child: Text(
537
+ message,
538
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
539
+ color: accent,
540
+ fontWeight: FontWeight.w600,
541
+ height: 1.3,
542
+ ),
543
+ ),
544
+ ),
545
+ ],
546
+ ),
547
+ );
548
+ }
549
+}
550
+
551
+class _SettingHeader extends StatelessWidget {
552
+ const _SettingHeader({required this.label});
553
+
554
+ final String label;
555
+
556
+ @override
557
+ Widget build(BuildContext context) {
558
+ return Text(
559
+ label,
560
+ style: Theme.of(
561
+ context,
562
+ ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
563
+ );
564
+ }
565
+}
566
+
567
+class _InputShell extends StatelessWidget {
568
+ const _InputShell({required this.child});
569
+
570
+ final Widget child;
571
+
572
+ @override
573
+ Widget build(BuildContext context) {
574
+ return Container(
575
+ decoration: BoxDecoration(
576
+ color: _surfaceColor(context),
577
+ borderRadius: BorderRadius.circular(12),
578
+ border: Border.all(color: _outlineColor(context, alpha: 0.8)),
579
+ ),
580
+ child: child,
581
+ );
582
+ }
583
+}
584
+
585
+class _HuggingFaceTokenSetting extends StatelessWidget {
586
+ const _HuggingFaceTokenSetting({
587
+ required this.currentToken,
588
+ required this.onSave,
589
+ });
590
+
591
+ final String? currentToken;
592
+ final ValueChanged<String?> onSave;
593
+
594
+ bool get _hasToken => currentToken != null && currentToken!.isNotEmpty;
595
+
596
+ String _maskedToken(String token) {
597
+ if (token.length <= 6) return '••••••';
598
+ return '${token.substring(0, 4)}••••••${token.substring(token.length - 4)}';
599
+ }
600
+
601
+ Future<void> _editToken(BuildContext context) async {
602
+ final l10n = AppLocalizations.of(context)!;
603
+ final controller = TextEditingController(text: currentToken ?? '');
604
+ final result = await showDialog<String?>(
605
+ context: context,
606
+ builder: (ctx) {
607
+ return AlertDialog(
608
+ title: Text(l10n.edit_huggingface_token_dialog_title),
609
+ content: TextField(
610
+ controller: controller,
611
+ obscureText: true,
612
+ autofocus: true,
613
+ decoration: InputDecoration(
614
+ hintText: l10n.huggingface_token_dialog_hint,
615
+ border: const OutlineInputBorder(),
616
+ ),
617
+ onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()),
618
+ ),
619
+ actions: [
620
+ TextButton(
621
+ onPressed: () => Navigator.of(ctx).pop(),
622
+ child: Text(l10n.cancel),
623
+ ),
624
+ TextButton(
625
+ onPressed: () => Navigator.of(ctx).pop(''),
626
+ child: Text(l10n.clear_huggingface_token),
627
+ ),
628
+ TextButton(
629
+ onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
630
+ child: Text(l10n.save),
631
+ ),
632
+ ],
633
+ );
634
+ },
635
+ );
636
+
637
+ controller.dispose();
638
+ if (result == null) return;
639
+ onSave(result.isEmpty ? null : result);
640
+ }
641
+
642
+ @override
643
+ Widget build(BuildContext context) {
644
+ final l10n = AppLocalizations.of(context)!;
645
+ final theme = Theme.of(context);
646
+
647
+ return _SettingPanel(
648
+ child: Column(
649
+ crossAxisAlignment: CrossAxisAlignment.start,
650
+ children: [
651
+ _SettingHeader(label: l10n.settings_huggingface_token),
652
+ const SizedBox(height: 6),
653
+ Text(
654
+ l10n.settings_huggingface_token_desc,
655
+ style: theme.textTheme.bodySmall?.copyWith(
656
+ color: _mutedColor(context),
657
+ height: 1.4,
658
+ ),
659
+ ),
660
+ const SizedBox(height: 10),
661
+ Row(
662
+ children: [
663
+ Expanded(
664
+ child: _InputShell(
665
+ child: Padding(
666
+ padding: const EdgeInsets.symmetric(
667
+ horizontal: 14,
668
+ vertical: 12,
669
+ ),
670
+ child: Text(
671
+ _hasToken ? _maskedToken(currentToken!) : '—',
672
+ style: theme.textTheme.bodyMedium?.copyWith(
673
+ fontFamily: 'monospace',
674
+ color: _hasToken
675
+ ? theme.colorScheme.onSurface
676
+ : _mutedColor(context),
677
+ ),
678
+ ),
679
+ ),
680
+ ),
681
+ ),
682
+ const SizedBox(width: 10),
683
+ ShadButton.outline(
684
+ onPressed: () => _editToken(context),
685
+ child: Text(_hasToken ? l10n.edit : l10n.set_huggingface_token),
686
+ ),
687
+ if (_hasToken) ...[
688
+ const SizedBox(width: 6),
689
+ ShadButton.outline(
690
+ onPressed: () => onSave(null),
691
+ child: Text(l10n.clear_huggingface_token),
692
+ ),
693
+ ],
694
+ ],
695
+ ),
696
+ ],
697
+ ),
698
+ );
699
+ }
700
+}
lib/features/settings/views/settings_screen_layout_parts.dart
new
+423
@@ -0,0 +1,423 @@
1
+part of 'settings_screen.dart';
2
+
3
+class SettingsContent extends ConsumerWidget {
4
+ const SettingsContent({super.key});
5
+
6
+ @override
7
+ Widget build(BuildContext context, WidgetRef ref) {
8
+ final l10n = AppLocalizations.of(context)!;
9
+ final settings = ref.watch(settingsProvider);
10
+ final currentTheme = ref.watch(themeModeProvider);
11
+ final systemBottomInset = bottomSystemInset(context);
12
+ final packageInfo = ref.watch(packageInfoProvider);
13
+ final servers = (ref.watch(serversProvider).value ?? [])
14
+ .map((server) => (server.id, server.name))
15
+ .toList();
16
+ final personas = (ref.watch(personasNotifierProvider).value ?? [])
17
+ .map((persona) => (persona.id, '${persona.emoji} ${persona.name}'))
18
+ .toList();
19
+
20
+ final isDark = Theme.of(context).brightness == Brightness.dark;
21
+ final topPadding = MediaQuery.of(context).padding.top;
22
+
23
+ return Column(
24
+ children: [
25
+ Container(
26
+ padding: EdgeInsets.only(
27
+ left: 16,
28
+ right: 16,
29
+ top: topPadding + 8,
30
+ bottom: 16,
31
+ ),
32
+ decoration: BoxDecoration(
33
+ color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
34
+ border: Border(
35
+ bottom: BorderSide(
36
+ color: isDark
37
+ ? const Color(0xFF2A2A2A)
38
+ : const Color(0xFFE5E5E5),
39
+ ),
40
+ ),
41
+ ),
42
+ child: Row(
43
+ children: [
44
+ Builder(
45
+ builder: (context) => IconButton(
46
+ icon: const Icon(Icons.menu),
47
+ onPressed: () => Scaffold.of(context).openDrawer(),
48
+ ),
49
+ ),
50
+ const SizedBox(width: 8),
51
+ Text(
52
+ l10n.settings_title,
53
+ style: TextStyle(
54
+ fontSize: 20,
55
+ fontWeight: FontWeight.bold,
56
+ color: isDark ? Colors.white : Colors.black,
57
+ ),
58
+ ),
59
+ ],
60
+ ),
61
+ ),
62
+ Expanded(
63
+ child: LayoutBuilder(
64
+ builder: (context, constraints) {
65
+ final useTwoColumns = constraints.maxWidth >= 1080;
66
+ final contentMaxWidth = useTwoColumns ? 1120.0 : 720.0;
67
+ final horizontalPadding = constraints.maxWidth >= 720
68
+ ? 20.0
69
+ : 12.0;
70
+
71
+ final appearanceCard = _SettingsSectionCard(
72
+ title: l10n.settings_appearance,
73
+ icon: Icons.palette_outlined,
74
+ accent: const Color(0xFF8B5CF6),
75
+ children: [
76
+ _ThemeToggle(ref: ref),
77
+ _LanguageSetting(
78
+ current: settings.localeCode,
79
+ onChanged: (value) => ref
80
+ .read(settingsProvider.notifier)
81
+ .setLocaleCode(value),
82
+ ),
83
+ _SliderSetting(
84
+ label: l10n.font_size,
85
+ value: settings.fontSize,
86
+ min: 12.0,
87
+ max: 24.0,
88
+ divisions: 12,
89
+ description: l10n.font_size_desc,
90
+ onChanged: (value) =>
91
+ ref.read(settingsProvider.notifier).setFontSize(value),
92
+ valueFormat: (value) => value.toStringAsFixed(0),
93
+ previewText: l10n.font_preview,
94
+ ),
95
+ _CodeThemeDropdown(
96
+ label: l10n.code_theme_dark,
97
+ current: settings.codeThemeDark,
98
+ onChanged: (value) => ref
99
+ .read(settingsProvider.notifier)
100
+ .setCodeThemeDark(value),
101
+ ),
102
+ _CodeThemeDropdown(
103
+ label: l10n.code_theme_light,
104
+ current: settings.codeThemeLight,
105
+ onChanged: (value) => ref
106
+ .read(settingsProvider.notifier)
107
+ .setCodeThemeLight(value),
108
+ ),
109
+ ],
110
+ );
111
+
112
+ final ttsCard = _SettingsSectionCard(
113
+ title: l10n.settings_tts,
114
+ icon: Icons.graphic_eq_rounded,
115
+ accent: const Color(0xFF0EA5E9),
116
+ children: [
117
+ _EngineDropdown(
118
+ current: settings.ttsEngine,
119
+ onChanged: (value) =>
120
+ ref.read(settingsProvider.notifier).setTtsEngine(value),
121
+ ),
122
+ _SectionActionButton(
123
+ icon: Icons.record_voice_over_outlined,
124
+ label: l10n.manage_tts_models,
125
+ onPressed: () => context.push(AppRoutes.ttsModels),
126
+ ),
127
+ if (settings.ttsEngine != EngineId.system) ...[
128
+ _VoiceSelector(
129
+ engine: settings.ttsEngine,
130
+ currentVoiceId: settings.ttsVoiceId,
131
+ onChanged: (voice) => ref
132
+ .read(settingsProvider.notifier)
133
+ .setTtsVoiceId(voice?.id),
134
+ ),
135
+ _SliderSetting(
136
+ label: l10n.tts_speed,
137
+ value: settings.ttsSpeed,
138
+ min: 0.5,
139
+ max: 2.0,
140
+ divisions: 15,
141
+ description: l10n.tts_speed_desc,
142
+ onChanged: (value) => ref
143
+ .read(settingsProvider.notifier)
144
+ .setTtsSpeed(value),
145
+ valueFormat: (value) => '${value.toStringAsFixed(2)}x',
146
+ ),
147
+ ],
148
+ ],
149
+ );
150
+
151
+ final behaviorCard = _SettingsSectionCard(
152
+ title: l10n.settings_behavior,
153
+ icon: Icons.tune_rounded,
154
+ accent: const Color(0xFF22C55E),
155
+ children: [
156
+ _ToggleSetting(
157
+ label: l10n.streaming_responses,
158
+ value: settings.streamingEnabled,
159
+ onChanged: (value) => ref
160
+ .read(settingsProvider.notifier)
161
+ .setStreamingEnabled(value),
162
+ ),
163
+ _ToggleSetting(
164
+ label: l10n.auto_generate_titles,
165
+ value: settings.autoGenerateTitle,
166
+ onChanged: (value) => ref
167
+ .read(settingsProvider.notifier)
168
+ .setAutoGenerateTitle(value),
169
+ ),
170
+ _ToggleSetting(
171
+ label: l10n.send_on_enter,
172
+ value: settings.sendOnEnter,
173
+ onChanged: (value) => ref
174
+ .read(settingsProvider.notifier)
175
+ .setSendOnEnter(value),
176
+ ),
177
+ _ToggleSetting(
178
+ label: l10n.show_system_messages,
179
+ value: settings.showSystemMessages,
180
+ onChanged: (value) => ref
181
+ .read(settingsProvider.notifier)
182
+ .setShowSystemMessages(value),
183
+ ),
184
+ _ToggleSetting(
185
+ label: l10n.haptic_feedback,
186
+ value: settings.hapticFeedbackEnabled,
187
+ onChanged: (value) => ref
188
+ .read(settingsProvider.notifier)
189
+ .setHapticFeedback(value),
190
+ ),
191
+ _ToggleSetting(
192
+ label: l10n.enable_mcp,
193
+ value: settings.mcpEnabled,
194
+ badges: [_FeatureBadge(label: l10n.experimental_label)],
195
+ onChanged: (value) => ref
196
+ .read(settingsProvider.notifier)
197
+ .setMcpEnabled(value),
198
+ ),
199
+ if (settings.mcpEnabled)
200
+ _ToggleSetting(
201
+ label: l10n.new_chat_mcp_default,
202
+ value: settings.newChatMcpEnabled,
203
+ onChanged: (value) => ref
204
+ .read(settingsProvider.notifier)
205
+ .setNewChatMcpEnabled(value),
206
+ ),
207
+ ],
208
+ );
209
+
210
+ final onDeviceCard = _SettingsSectionCard(
211
+ title: l10n.settings_on_device,
212
+ icon: Icons.memory_rounded,
213
+ accent: const Color(0xFFF97316),
214
+ children: [
215
+ _ToggleSetting(
216
+ label: l10n.enable_smart_reply,
217
+ value: settings.smartReplyEnabled,
218
+ onChanged: (value) => ref
219
+ .read(settingsProvider.notifier)
220
+ .setSmartReplyEnabled(value),
221
+ ),
222
+ _HuggingFaceTokenSetting(
223
+ currentToken: settings.huggingFaceToken,
224
+ onSave: (value) {
225
+ ref
226
+ .read(settingsProvider.notifier)
227
+ .setHuggingFaceToken(value);
228
+ ScaffoldMessenger.of(context).showSnackBar(
229
+ SnackBar(
230
+ content: Text(
231
+ value == null || value.isEmpty
232
+ ? l10n.settings_huggingface_token_cleared
233
+ : l10n.settings_huggingface_token_set,
234
+ ),
235
+ ),
236
+ );
237
+ },
238
+ ),
239
+ _SectionActionButton(
240
+ icon: Icons.phone_android_rounded,
241
+ label: l10n.manage_on_device_models,
242
+ onPressed: () => context.push(AppRoutes.onDeviceModels),
243
+ ),
244
+ const _OnDeviceEngineStatusCard(),
245
+ ],
246
+ );
247
+
248
+ final defaultsCard = _SettingsSectionCard(
249
+ title: l10n.settings_default_server,
250
+ icon: Icons.hub_outlined,
251
+ accent: const Color(0xFF06B6D4),
252
+ children: [
253
+ _DropdownSetting(
254
+ label: l10n.settings_default_server,
255
+ currentValue: settings.defaultServerId,
256
+ items: servers,
257
+ onChanged: (value) => ref
258
+ .read(settingsProvider.notifier)
259
+ .setDefaultServer(value),
260
+ icon: Icons.computer_rounded,
261
+ ),
262
+ _DropdownSetting(
263
+ label: l10n.settings_default_persona,
264
+ currentValue: settings.defaultPersonaId,
265
+ items: personas,
266
+ onChanged: (value) => ref
267
+ .read(settingsProvider.notifier)
268
+ .setDefaultPersona(value),
269
+ icon: Icons.smart_toy_outlined,
270
+ ),
271
+ ],
272
+ );
273
+
274
+ final privacyCard = _SettingsSectionCard(
275
+ title: l10n.settings_privacy,
276
+ icon: Icons.lock_outline_rounded,
277
+ accent: const Color(0xFF14B8A6),
278
+ children: [
279
+ _ToggleSetting(
280
+ label: l10n.show_data_indicator,
281
+ value: settings.showDataIndicator,
282
+ onChanged: (value) => ref
283
+ .read(settingsProvider.notifier)
284
+ .setShowDataIndicator(value),
285
+ ),
286
+ _MutedCallout(message: l10n.privacy_info),
287
+ ],
288
+ );
289
+
290
+ final dataCard = _SettingsSectionCard(
291
+ title: l10n.settings_data_management,
292
+ icon: Icons.restore_page_outlined,
293
+ accent: const Color(0xFFEF4444),
294
+ children: [
295
+ _DangerousAction(
296
+ label: l10n.delete_all_conversations,
297
+ icon: Icons.delete_outline_rounded,
298
+ onConfirm: () =>
299
+ ref.read(conversationsProvider.notifier).deleteAll(),
300
+ ),
301
+ _DangerousAction(
302
+ label: l10n.reset_settings_defaults,
303
+ icon: Icons.restore_rounded,
304
+ onConfirm: () =>
305
+ ref.read(settingsProvider.notifier).resetToDefaults(),
306
+ ),
307
+ ],
308
+ );
309
+
310
+ final aboutCard = _SettingsSectionCard(
311
+ title: l10n.settings_about,
312
+ icon: Icons.info_outline_rounded,
313
+ accent: const Color(0xFFF59E0B),
314
+ children: [
315
+ _AboutPanel(
316
+ title:
317
+ '${l10n.app_name} v${packageInfo.value?.version ?? l10n.app_version} (${packageInfo.value?.buildNumber ?? ''})',
318
+ subtitle: l10n.onboarding_connect_desc.replaceAll(
319
+ '\n',
320
+ ' ',
321
+ ),
322
+ providers: [
323
+ l10n.server_type_lm_studio,
324
+ l10n.server_type_ollama,
325
+ l10n.server_type_openrouter,
326
+ ],
327
+ highlights: [
328
+ l10n.settings_on_device,
329
+ l10n.streaming_responses,
330
+ l10n.nav_personas,
331
+ ],
332
+ stack: const ['Flutter', 'Riverpod', 'shadcn_ui'],
333
+ openSource: l10n.open_source_desc,
334
+ ),
335
+ ],
336
+ );
337
+
338
+ return ListView(
339
+ padding: EdgeInsets.fromLTRB(
340
+ horizontalPadding,
341
+ 12,
342
+ horizontalPadding,
343
+ 24 + systemBottomInset,
344
+ ),
345
+ children: [
346
+ Center(
347
+ child: ConstrainedBox(
348
+ constraints: BoxConstraints(maxWidth: contentMaxWidth),
349
+ child: Column(
350
+ crossAxisAlignment: CrossAxisAlignment.stretch,
351
+ children: [
352
+ _SettingsHero(
353
+ settings: settings,
354
+ themeType: currentTheme,
355
+ ),
356
+ const SizedBox(height: 16),
357
+ if (useTwoColumns)
358
+ Row(
359
+ crossAxisAlignment: CrossAxisAlignment.start,
360
+ children: [
361
+ Expanded(
362
+ child: _SettingsSectionColumn(
363
+ children: [
364
+ appearanceCard,
365
+ behaviorCard,
366
+ defaultsCard,
367
+ privacyCard,
368
+ ],
369
+ ),
370
+ ),
371
+ const SizedBox(width: 20),
372
+ Expanded(
373
+ child: _SettingsSectionColumn(
374
+ children: [
375
+ ttsCard,
376
+ onDeviceCard,
377
+ dataCard,
378
+ aboutCard,
379
+ ],
380
+ ),
381
+ ),
382
+ ],
383
+ )
384
+ else
385
+ _SettingsSectionColumn(
386
+ children: [
387
+ appearanceCard,
388
+ ttsCard,
389
+ behaviorCard,
390
+ onDeviceCard,
391
+ defaultsCard,
392
+ privacyCard,
393
+ dataCard,
394
+ aboutCard,
395
+ ],
396
+ ),
397
+ ],
398
+ ),
399
+ ),
400
+ ),
401
+ ],
402
+ );
403
+ },
404
+ ),
405
+ ),
406
+ ],
407
+ );
408
+ }
409
+}
410
+
411
+class _SettingsSectionColumn extends StatelessWidget {
412
+ const _SettingsSectionColumn({required this.children});
413
+
414
+ final List<Widget> children;
415
+
416
+ @override
417
+ Widget build(BuildContext context) {
418
+ return Column(
419
+ crossAxisAlignment: CrossAxisAlignment.stretch,
420
+ children: _withVerticalSpacing(children, gap: 16),
421
+ );
422
+ }
423
+}
lib/features/settings/views/settings_screen_parts.dart
new
+66
@@ -0,0 +1,66 @@
1
+part of 'settings_screen.dart';
2
+
3
+List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
4
+ if (children.isEmpty) {
5
+ return const [];
6
+ }
7
+
8
+ return [
9
+ for (var index = 0; index < children.length; index++) ...[
10
+ children[index],
11
+ if (index != children.length - 1) SizedBox(height: gap),
12
+ ],
13
+ ];
14
+}
15
+
16
+Color _panelColor(BuildContext context) {
17
+ final scheme = ShadTheme.of(context).colorScheme;
18
+ return scheme.secondary;
19
+}
20
+
21
+Color _surfaceColor(BuildContext context) {
22
+ return ShadTheme.of(context).colorScheme.card;
23
+}
24
+
25
+Color _primaryColor(BuildContext context) {
26
+ return ShadTheme.of(context).colorScheme.primary;
27
+}
28
+
29
+Color _outlineColor(BuildContext context, {double alpha = 0.6}) {
30
+ return ShadTheme.of(context).colorScheme.border.withValues(alpha: alpha);
31
+}
32
+
33
+Color _mutedColor(BuildContext context) {
34
+ return ShadTheme.of(context).colorScheme.mutedForeground;
35
+}
36
+
37
+String _themeLabel(AppThemeType themeType, AppLocalizations l10n) {
38
+ return switch (themeType) {
39
+ AppThemeType.system => l10n.theme_system,
40
+ AppThemeType.light => l10n.theme_light,
41
+ AppThemeType.dark => l10n.theme_dark,
42
+ AppThemeType.claude => l10n.theme_claude,
43
+ };
44
+}
45
+
46
+String _engineLabel(EngineId engine, AppLocalizations l10n) {
47
+ return switch (engine) {
48
+ EngineId.system => l10n.tts_engine_system,
49
+ EngineId.kitten => l10n.tts_engine_kitten,
50
+ EngineId.piper => EngineMeta.piper.name,
51
+ };
52
+}
53
+
54
+String _languageLabel(String? localeCode, AppLocalizations l10n) {
55
+ if (localeCode == null) {
56
+ return l10n.language_system_default;
57
+ }
58
+
59
+ for (final item in _LanguageSetting.localeItems) {
60
+ if (item.$1 == localeCode) {
61
+ return item.$2;
62
+ }
63
+ }
64
+
65
+ return localeCode.toUpperCase();
66
+}
lib/features/sidebar/views/components/active_server_indicator.dart
renamed
+6
-7
@@ -1,15 +1,14 @@
1
import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:hugeicons/hugeicons.dart';
4
-import 'package:go_router/go_router.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
+import 'package:localmind/core/components/server/server_icon_picker.dart';
7
+import 'package:localmind/core/models/enums.dart';
8
+import 'package:localmind/core/providers/server_providers.dart';
9
+import 'package:localmind/core/routes/app_routes.dart';
10
import 'package:localmind/core/theme/colors.dart';
7
-
8
-import '../../../core/models/enums.dart';
9
-import '../../../core/routes/app_routes.dart';
10
-import '../../../l10n/app_localizations.dart';
11
-import '../../servers/providers/server_providers.dart';
12
-import '../../servers/views/components/server_icon_picker.dart';
11
+import 'package:localmind/l10n/app_localizations.dart';
12
13
class ActiveServerIndicator extends ConsumerWidget {
14
const ActiveServerIndicator({super.key});
lib/features/sidebar/views/components/conversation_drawer_header.dart
renamed
+1
-2
@@ -1,8 +1,7 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:localmind/core/theme/colors.dart';
4
-
5
-import '../../../l10n/app_localizations.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
6
class ConversationDrawerHeader extends ConsumerWidget {
7
const ConversationDrawerHeader({super.key});
lib/features/sidebar/views/components/drawer_nav_item.dart
renamed
lib/features/sidebar/views/components/github_repo_card.dart
renamed
+1
-2
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
import 'package:url_launcher/url_launcher.dart';
4
import 'package:localmind/core/theme/colors.dart';
5
-
6
-import '../../../l10n/app_localizations.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
7
class GitHubRepoCard extends StatelessWidget {
8
const GitHubRepoCard({super.key});
lib/features/sidebar/views/components/sidebar_search_button.dart
renamed
+2
-2
@@ -1,8 +1,8 @@
1
import 'package:flutter/material.dart';
2
-import 'package:hugeicons/hugeicons.dart';
2
import 'package:go_router/go_router.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+import 'package:localmind/core/routes/app_routes.dart';
5
import 'package:localmind/core/theme/colors.dart';
5
-import '../../../core/routes/app_routes.dart';
6
7
class SidebarSearchButton extends StatelessWidget {
8
const SidebarSearchButton({super.key});
lib/features/sidebar/views/components/sidebar_widget.dart
renamed
+10
-11
@@ -3,17 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
-
7
-import '../../core/routes/app_routes.dart';
8
-import '../../core/components/app_sizes.dart';
9
-import '../../l10n/app_localizations.dart';
10
-import '../chat/providers/chat_providers.dart';
11
-import 'components/active_server_indicator.dart';
12
-import 'components/conversation_drawer_header.dart';
13
-import 'components/drawer_nav_item.dart';
14
-import 'components/github_repo_card.dart';
15
-import 'components/sidebar_search_button.dart';
16
-import '../tts/views/components/tts_player_bar.dart';
6
+import 'package:localmind/core/components/app_sizes.dart';
7
+import 'package:localmind/core/components/tts/tts_player_bar.dart';
8
+import 'package:localmind/core/providers/chat_providers.dart';
9
+import 'package:localmind/core/routes/app_routes.dart';
10
+import 'package:localmind/l10n/app_localizations.dart';
11
+import 'active_server_indicator.dart';
12
+import 'conversation_drawer_header.dart';
13
+import 'drawer_nav_item.dart';
14
+import 'github_repo_card.dart';
15
+import 'sidebar_search_button.dart';
16
17
class SidebarWidget extends ConsumerWidget {
18
const SidebarWidget({super.key});
lib/features/sidebar/views/components/theme_toggle_button.dart
renamed
+2
-2
@@ -1,8 +1,8 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:shadcn_ui/shadcn_ui.dart';
4
-import '../../../core/providers/app_providers.dart';
5
-import '../../../core/theme/app_theme.dart';
4
+import 'package:localmind/core/providers/app_providers.dart';
5
+import 'package:localmind/core/theme/app_theme.dart';
6
7
class ThemeToggleButton extends ConsumerWidget {
8
const ThemeToggleButton({super.key});
lib/features/sidebar/views/sidebar_drawer.dart
renamed
+2
-2
@@ -1,6 +1,6 @@
1
import 'package:flutter/material.dart';
2
-import '../../core/components/app_sizes.dart';
3
-import 'sidebar_widget.dart';
2
+import '../../../core/components/app_sizes.dart';
3
+import 'components/sidebar_widget.dart';
4
5
class ConversationDrawer extends StatelessWidget {
6
const ConversationDrawer({super.key});
lib/features/tts/data/repositories/kitten_tts_downloader.dart
renamed
+3
-4
@@ -4,10 +4,9 @@ import 'dart:io';
4
import 'package:dio/dio.dart';
5
import 'package:path_provider/path_provider.dart';
6
import 'package:wakelock_plus/wakelock_plus.dart';
7
-
8
-import '../../../core/logger/app_logger.dart';
9
-import 'kitten_tts_model.dart';
10
-import 'sherpa_bundle_downloader.dart';
7
+import 'package:localmind/core/logger/app_logger.dart';
8
+import 'package:localmind/features/tts/data/kitten_tts_model.dart';
9
+import 'package:localmind/features/tts/data/repositories/sherpa_bundle_downloader.dart';
10
11
/// Downloads sherpa-onnx Kitten TTS bundles.
12
class KittenTtsDownloader {
lib/features/tts/data/repositories/model_downloader.dart
renamed
+1
-2
@@ -1,8 +1,7 @@
1
import 'dart:io';
2
3
import 'package:path_provider/path_provider.dart';
4
-
5
-import '../../../core/models/enums.dart';
4
+import 'package:localmind/core/models/enums.dart';
5
6
class ModelDownloader {
7
Future<Directory> getTtsDir() async {
lib/features/tts/data/repositories/piper_tts_downloader.dart
renamed
+3
-4
@@ -4,10 +4,9 @@ import 'dart:io';
4
import 'package:dio/dio.dart';
5
import 'package:path_provider/path_provider.dart';
6
import 'package:wakelock_plus/wakelock_plus.dart';
7
-
8
-import 'model_downloader.dart';
9
-import 'piper_tts_model.dart';
10
-import 'sherpa_bundle_downloader.dart';
7
+import 'package:localmind/features/tts/data/piper_tts_model.dart';
8
+import 'package:localmind/features/tts/data/repositories/model_downloader.dart';
9
+import 'package:localmind/features/tts/data/repositories/sherpa_bundle_downloader.dart';
10
11
class PiperTtsDownloader {
12
final Dio _dio;
lib/features/tts/data/repositories/sherpa_bundle_downloader.dart
renamed
lib/features/tts/providers/tts_model_providers.dart
+3
-3
@@ -3,11 +3,11 @@ import 'dart:async';
3
import 'package:flutter/foundation.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
-import '../data/model_downloader.dart';
6
+import '../data/repositories/model_downloader.dart';
7
import '../data/kitten_tts_model.dart';
8
-import '../data/kitten_tts_downloader.dart';
8
+import '../data/repositories/kitten_tts_downloader.dart';
9
import '../data/piper_tts_model.dart';
10
-import '../data/piper_tts_downloader.dart';
10
+import '../data/repositories/piper_tts_downloader.dart';
11
12
/// Provider for the KittenTTS downloader instance.
13
final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
lib/features/tts/views/tts_model_manager_screen.dart
+2
-968
@@ -13,6 +13,8 @@ import '../data/piper_tts_model.dart';
13
import '../providers/tts_model_providers.dart';
14
import '../providers/tts_providers.dart' as tts;
15
16
+part 'tts_model_manager_screen_parts.dart';
17
+
18
class TtsModelManagerScreen extends ConsumerWidget {
19
const TtsModelManagerScreen({super.key});
20
@@ -83,971 +85,3 @@ class TtsModelManagerScreen extends ConsumerWidget {
85
}
86
87
// ── System Engine ──
86
-
87
-class _SystemEngineCard extends ConsumerWidget {
88
- final bool isSelected;
89
-
90
- const _SystemEngineCard({required this.isSelected});
91
-
92
- @override
93
- Widget build(BuildContext context, WidgetRef ref) {
94
- final l10n = AppLocalizations.of(context)!;
95
- final meta = EngineMeta.system;
96
-
97
- return _EngineCard(
98
- isSelected: isSelected,
99
- meta: meta,
100
- engine: EngineId.system,
101
- installed: true,
102
- statusText: l10n.always_available,
103
- trailing: Row(
104
- mainAxisSize: MainAxisSize.min,
105
- children: [
106
- if (!isSelected)
107
- ShadButton.outline(
108
- size: ShadButtonSize.sm,
109
- onPressed: () => ref
110
- .read(settingsProvider.notifier)
111
- .setTtsEngine(EngineId.system),
112
- child: Text(l10n.select),
113
- ),
114
- ],
115
- ),
116
- child: _SystemVoicesList(),
117
- );
118
- }
119
-}
120
-
121
-class _SystemVoicesList extends ConsumerWidget {
122
- @override
123
- Widget build(BuildContext context, WidgetRef ref) {
124
- final l10n = AppLocalizations.of(context)!;
125
- final theme = Theme.of(context);
126
- return Padding(
127
- padding: const EdgeInsets.only(top: 12),
128
- child: Column(
129
- crossAxisAlignment: CrossAxisAlignment.start,
130
- children: [
131
- Text(
132
- l10n.tts_system_desc,
133
- style: theme.textTheme.bodySmall?.copyWith(
134
- color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
135
- ),
136
- ),
137
- const SizedBox(height: 12),
138
- ShadAlert(
139
- icon: Icon(
140
- Icons.info_outline,
141
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
142
- ),
143
- description: Text(
144
- l10n.tts_other_services_background_note,
145
- style: theme.textTheme.labelSmall?.copyWith(
146
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
147
- ),
148
- ),
149
- ),
150
- ],
151
- ),
152
- );
153
- }
154
-}
155
-
156
-// ── Kitten Engine ──
157
-
158
-class _KittenEngineCard extends ConsumerWidget {
159
- final bool isSelected;
160
-
161
- const _KittenEngineCard({required this.isSelected});
162
-
163
- KittenTtsModel _selectedModel(AppSettings settings) {
164
- final variant = settings.kittenTtsModelVariant;
165
- return KittenTtsModel.allModels.firstWhere((m) => m.variant == variant);
166
- }
167
-
168
- @override
169
- Widget build(BuildContext context, WidgetRef ref) {
170
- final l10n = AppLocalizations.of(context)!;
171
- final settings = ref.watch(settingsProvider);
172
- final meta = EngineMeta.kitten;
173
- final downloadedAsync = ref.watch(downloadedKittenTtsVariantsProvider);
174
- final downloadProgress = ref.watch(ttsDownloadProgressProvider);
175
- final model = _selectedModel(settings);
176
- final variantProgress = downloadProgress[model.variant];
177
- final isInstalled = downloadedAsync.when(
178
- data: (set) => set.contains(model.variant),
179
- loading: () => false,
180
- error: (_, _) => false,
181
- );
182
- final notifier = ref.read(ttsDownloadProgressProvider.notifier);
183
- final isDownloading = notifier.isDownloading(model.variant);
184
-
185
- return _EngineCard(
186
- isSelected: isSelected,
187
- meta: meta,
188
- engine: EngineId.kitten,
189
- installed: isInstalled,
190
- statusText: isInstalled
191
- ? l10n.installed
192
- : isDownloading
193
- ? l10n.downloading_status
194
- : l10n.not_installed,
195
- trailing: _buildAction(context, ref, isInstalled, isDownloading, model),
196
- child: Column(
197
- crossAxisAlignment: CrossAxisAlignment.start,
198
- children: [
199
- Padding(
200
- padding: const EdgeInsets.only(top: 12),
201
- child: ShadBadge.secondary(
202
- child: Row(
203
- mainAxisSize: MainAxisSize.min,
204
- children: [
205
- const Icon(Icons.music_note, size: 12),
206
- const SizedBox(width: 4),
207
- Text(
208
- l10n.tts_supports_background,
209
- style: const TextStyle(fontSize: 10),
210
- ),
211
- ],
212
- ),
213
- ),
214
- ),
215
- Padding(
216
- padding: const EdgeInsets.only(top: 12),
217
- child: Text(
218
- l10n.tts_kitten_desc(_formatSize(model.totalSizeBytes)),
219
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
220
- color: Theme.of(
221
- context,
222
- ).colorScheme.onSurface.withValues(alpha: 0.6),
223
- ),
224
- ),
225
- ),
226
- if (isDownloading) ...[
227
- const SizedBox(height: 12),
228
- _buildVariantDownloadProgress(context, ref, model, variantProgress),
229
- ],
230
- if (isInstalled) ...[
231
- _VoiceChips(voices: kittenVoices, engine: EngineId.kitten),
232
- ],
233
- ],
234
- ),
235
- );
236
- }
237
-
238
- Widget _buildAction(
239
- BuildContext context,
240
- WidgetRef ref,
241
- bool isInstalled,
242
- bool isDownloading,
243
- KittenTtsModel model,
244
- ) {
245
- final l10n = AppLocalizations.of(context)!;
246
- final notifier = ref.read(ttsDownloadProgressProvider.notifier);
247
- if (isDownloading) {
248
- return ShadButton.outline(
249
- size: ShadButtonSize.sm,
250
- onPressed: () => notifier.cancelDownload(model.variant),
251
- child: Text(l10n.cancel),
252
- );
253
- }
254
- if (isInstalled) {
255
- return Row(
256
- mainAxisSize: MainAxisSize.min,
257
- children: [
258
- if (!isSelected)
259
- ShadButton.outline(
260
- size: ShadButtonSize.sm,
261
- onPressed: () => ref
262
- .read(settingsProvider.notifier)
263
- .setTtsEngine(EngineId.kitten),
264
- child: Text(l10n.select),
265
- ),
266
- const SizedBox(width: 8),
267
- ShadIconButton.ghost(
268
- padding: EdgeInsets.zero,
269
- icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
270
- onPressed: () => _confirmDelete(context, ref, model),
271
- ),
272
- ],
273
- );
274
- }
275
- return ShadButton.outline(
276
- size: ShadButtonSize.sm,
277
- onPressed: () => notifier.startDownload(model),
278
- child: Text(l10n.install),
279
- );
280
- }
281
-
282
- void _confirmDelete(
283
- BuildContext context,
284
- WidgetRef ref,
285
- KittenTtsModel model,
286
- ) {
287
- final l10n = AppLocalizations.of(context)!;
288
- showDialog(
289
- context: context,
290
- builder: (context) => AlertDialog(
291
- title: Text(l10n.delete_model_title),
292
- content: Text(
293
- l10n.delete_model_body_with_size(model.displayName, _formatSize(model.totalSizeBytes)),
294
- ),
295
- actions: [
296
- TextButton(
297
- onPressed: () => Navigator.of(context).pop(),
298
- child: Text(l10n.cancel),
299
- ),
300
- TextButton(
301
- onPressed: () {
302
- ref
303
- .read(ttsDownloadProgressProvider.notifier)
304
- .deleteVariant(model.variant);
305
- Navigator.of(context).pop();
306
- },
307
- style: TextButton.styleFrom(foregroundColor: Colors.red),
308
- child: Text(l10n.delete),
309
- ),
310
- ],
311
- ),
312
- );
313
- }
314
-
315
- String _formatSize(int bytes) {
316
- final mb = bytes / (1024 * 1024);
317
- return '${mb.toStringAsFixed(0)} MB';
318
- }
319
-
320
- Widget _buildVariantDownloadProgress(
321
- BuildContext context,
322
- WidgetRef ref,
323
- KittenTtsModel model,
324
- Map<String, KittenTtsFileProgress>? progress,
325
- ) {
326
- final fraction = ref
327
- .read(ttsDownloadProgressProvider.notifier)
328
- .getOverallFraction(model.variant);
329
- return Column(
330
- children: [
331
- LinearProgressIndicator(
332
- value: fraction,
333
- valueColor: AlwaysStoppedAnimation<Color>(
334
- Color(EngineMeta.kitten.accentColor),
335
- ),
336
- backgroundColor: Color(
337
- EngineMeta.kitten.accentColor,
338
- ).withValues(alpha: 0.15),
339
- ),
340
- const SizedBox(height: 4),
341
- if (progress != null)
342
- ...progress.entries.map(
343
- (e) => Padding(
344
- padding: const EdgeInsets.only(bottom: 2),
345
- child: Row(
346
- children: [
347
- Expanded(
348
- child: Text(
349
- e.key,
350
- style: Theme.of(context).textTheme.labelSmall,
351
- ),
352
- ),
353
- Text(
354
- '${(e.value.fraction * 100).toStringAsFixed(0)}%',
355
- style: Theme.of(context).textTheme.labelSmall,
356
- ),
357
- ],
358
- ),
359
- ),
360
- ),
361
- ],
362
- );
363
- }
364
-}
365
-
366
-// ── Piper Engine ──
367
-
368
-class _PiperEngineCard extends ConsumerWidget {
369
- final bool isSelected;
370
-
371
- const _PiperEngineCard({required this.isSelected});
372
-
373
- PiperTtsModelVariant _selectedVariant(AppSettings settings) {
374
- final voiceId =
375
- voiceFromSettings(settings.ttsVoiceId, EngineId.piper)?.id ??
376
- piperVoices.first.id;
377
- return PiperTtsModelVariant.values.firstWhere(
378
- (variant) => variant.id == voiceId,
379
- orElse: () => PiperTtsModelVariant.enUsLessacMedium,
380
- );
381
- }
382
-
383
- @override
384
- Widget build(BuildContext context, WidgetRef ref) {
385
- final l10n = AppLocalizations.of(context)!;
386
- final settings = ref.watch(settingsProvider);
387
- final meta = EngineMeta.piper;
388
- final downloadedAsync = ref.watch(downloadedPiperTtsVariantsProvider);
389
- final downloadProgress = ref.watch(piperTtsDownloadProgressProvider);
390
- final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
391
- final selectedVariant = _selectedVariant(settings);
392
- final selectedVoice = piperVoices.firstWhere(
393
- (voice) => voice.id == selectedVariant.id,
394
- orElse: () => piperVoices.first,
395
- );
396
- final downloadedSet = downloadedAsync.when(
397
- data: (set) => set,
398
- loading: () => <PiperTtsModelVariant>{},
399
- error: (_, _) => <PiperTtsModelVariant>{},
400
- );
401
- final installedVoices = piperVoices
402
- .where(
403
- (voice) => downloadedSet.any((variant) => variant.id == voice.id),
404
- )
405
- .toList();
406
- final variantProgress = downloadProgress[selectedVariant];
407
- final isInstalled = downloadedSet.contains(selectedVariant);
408
- final isDownloading = notifier.isDownloading(selectedVariant);
409
-
410
- return _EngineCard(
411
- isSelected: isSelected,
412
- meta: meta,
413
- engine: EngineId.piper,
414
- installed: installedVoices.isNotEmpty,
415
- statusText: isInstalled
416
- ? l10n.installed
417
- : isDownloading
418
- ? l10n.downloading_status
419
- : l10n.not_installed,
420
- trailing: _buildAction(
421
- context,
422
- ref,
423
- selectedVariant,
424
- selectedVoice,
425
- isInstalled,
426
- isDownloading,
427
- ),
428
- child: Column(
429
- crossAxisAlignment: CrossAxisAlignment.start,
430
- children: [
431
- Padding(
432
- padding: const EdgeInsets.only(top: 12),
433
- child: ShadBadge.secondary(
434
- child: Row(
435
- mainAxisSize: MainAxisSize.min,
436
- children: [
437
- const Icon(Icons.music_note, size: 12),
438
- const SizedBox(width: 4),
439
- Text(
440
- l10n.tts_supports_background,
441
- style: const TextStyle(fontSize: 10),
442
- ),
443
- ],
444
- ),
445
- ),
446
- ),
447
- Padding(
448
- padding: const EdgeInsets.only(top: 12),
449
- child: Text(
450
- l10n.tts_piper_desc(_formatSize(selectedVariant.totalSizeBytes)),
451
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
452
- color: Theme.of(
453
- context,
454
- ).colorScheme.onSurface.withValues(alpha: 0.6),
455
- ),
456
- ),
457
- ),
458
- const SizedBox(height: 12),
459
- Wrap(
460
- spacing: 8,
461
- runSpacing: 8,
462
- children: PiperTtsModelVariant.values
463
- .map(
464
- (variant) => _PiperVariantChip(
465
- variant: variant,
466
- voice: piperVoices.firstWhere(
467
- (voice) => voice.id == variant.id,
468
- orElse: () => selectedVoice,
469
- ),
470
- selected: selectedVariant == variant,
471
- installed: downloadedSet.contains(variant),
472
- onTap: () => ref
473
- .read(settingsProvider.notifier)
474
- .setTtsVoiceId(variant.id),
475
- ),
476
- )
477
- .toList(),
478
- ),
479
- if (isDownloading) ...[
480
- const SizedBox(height: 12),
481
- _buildVariantDownloadProgress(
482
- context,
483
- ref,
484
- selectedVariant,
485
- variantProgress,
486
- ),
487
- ],
488
- if (installedVoices.isNotEmpty) ...[
489
- _VoiceChips(voices: installedVoices, engine: EngineId.piper),
490
- ],
491
- ],
492
- ),
493
- );
494
- }
495
-
496
- Widget _buildAction(
497
- BuildContext context,
498
- WidgetRef ref,
499
- PiperTtsModelVariant variant,
500
- Voice voice,
501
- bool isInstalled,
502
- bool isDownloading,
503
- ) {
504
- final l10n = AppLocalizations.of(context)!;
505
- final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
506
- if (isDownloading) {
507
- return ShadButton.outline(
508
- size: ShadButtonSize.sm,
509
- onPressed: () => notifier.cancelDownload(variant),
510
- child: Text(l10n.cancel),
511
- );
512
- }
513
- if (isInstalled) {
514
- return Row(
515
- mainAxisSize: MainAxisSize.min,
516
- children: [
517
- if (!isSelected)
518
- ShadButton.outline(
519
- size: ShadButtonSize.sm,
520
- onPressed: () {
521
- ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
522
- ref
523
- .read(settingsProvider.notifier)
524
- .setTtsEngine(EngineId.piper);
525
- },
526
- child: Text(l10n.select),
527
- ),
528
- const SizedBox(width: 8),
529
- ShadIconButton.ghost(
530
- padding: EdgeInsets.zero,
531
- icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
532
- onPressed: () => _confirmDelete(context, ref, variant),
533
- ),
534
- ],
535
- );
536
- }
537
- return ShadButton.outline(
538
- size: ShadButtonSize.sm,
539
- onPressed: () => notifier.startDownload(variant),
540
- child: Text(l10n.install),
541
- );
542
- }
543
-
544
- void _confirmDelete(
545
- BuildContext context,
546
- WidgetRef ref,
547
- PiperTtsModelVariant variant,
548
- ) {
549
- final l10n = AppLocalizations.of(context)!;
550
- showDialog(
551
- context: context,
552
- builder: (context) => AlertDialog(
553
- title: Text(l10n.delete_voice_title),
554
- content: Text(
555
- l10n.delete_voice_body(variant.displayName, _formatSize(variant.totalSizeBytes)),
556
- ),
557
- actions: [
558
- TextButton(
559
- onPressed: () => Navigator.of(context).pop(),
560
- child: Text(l10n.cancel),
561
- ),
562
- TextButton(
563
- onPressed: () {
564
- ref
565
- .read(piperTtsDownloadProgressProvider.notifier)
566
- .deleteVariant(variant);
567
- Navigator.of(context).pop();
568
- },
569
- style: TextButton.styleFrom(foregroundColor: Colors.red),
570
- child: Text(l10n.delete),
571
- ),
572
- ],
573
- ),
574
- );
575
- }
576
-
577
- String _formatSize(int bytes) {
578
- final mb = bytes / (1024 * 1024);
579
- return '${mb.toStringAsFixed(0)} MB';
580
- }
581
-
582
- Widget _buildVariantDownloadProgress(
583
- BuildContext context,
584
- WidgetRef ref,
585
- PiperTtsModelVariant variant,
586
- Map<String, PiperTtsFileProgress>? progress,
587
- ) {
588
- final fraction = ref
589
- .read(piperTtsDownloadProgressProvider.notifier)
590
- .getOverallFraction(variant);
591
- return Column(
592
- children: [
593
- LinearProgressIndicator(
594
- value: fraction,
595
- valueColor: AlwaysStoppedAnimation<Color>(
596
- Color(EngineMeta.piper.accentColor),
597
- ),
598
- backgroundColor: Color(
599
- EngineMeta.piper.accentColor,
600
- ).withValues(alpha: 0.15),
601
- ),
602
- const SizedBox(height: 4),
603
- if (progress != null)
604
- ...progress.entries.map(
605
- (e) => Padding(
606
- padding: const EdgeInsets.only(bottom: 2),
607
- child: Row(
608
- children: [
609
- Expanded(
610
- child: Text(
611
- e.key,
612
- style: Theme.of(context).textTheme.labelSmall,
613
- ),
614
- ),
615
- Text(
616
- '${(e.value.fraction * 100).toStringAsFixed(0)}%',
617
- style: Theme.of(context).textTheme.labelSmall,
618
- ),
619
- ],
620
- ),
621
- ),
622
- ),
623
- ],
624
- );
625
- }
626
-}
627
-
628
-class _PiperVariantChip extends StatelessWidget {
629
- final PiperTtsModelVariant variant;
630
- final Voice voice;
631
- final bool selected;
632
- final bool installed;
633
- final VoidCallback onTap;
634
-
635
- const _PiperVariantChip({
636
- required this.variant,
637
- required this.voice,
638
- required this.selected,
639
- required this.installed,
640
- required this.onTap,
641
- });
642
-
643
- @override
644
- Widget build(BuildContext context) {
645
- final l10n = AppLocalizations.of(context)!;
646
- final theme = Theme.of(context);
647
- final accent = Color(EngineMeta.piper.accentColor);
648
-
649
- return InkWell(
650
- onTap: onTap,
651
- borderRadius: BorderRadius.circular(999),
652
- child: Container(
653
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
654
- decoration: BoxDecoration(
655
- color: selected
656
- ? accent.withValues(alpha: 0.12)
657
- : theme.colorScheme.surfaceContainerHighest.withValues(
658
- alpha: 0.2,
659
- ),
660
- borderRadius: BorderRadius.circular(999),
661
- border: Border.all(
662
- color: selected
663
- ? accent.withValues(alpha: 0.45)
664
- : installed
665
- ? Colors.green.withValues(alpha: 0.35)
666
- : theme.colorScheme.outline.withValues(alpha: 0.2),
667
- ),
668
- ),
669
- child: Row(
670
- mainAxisSize: MainAxisSize.min,
671
- children: [
672
- Text(
673
- voice.name,
674
- style: theme.textTheme.labelSmall?.copyWith(
675
- color: selected ? accent : theme.colorScheme.onSurface,
676
- fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
677
- ),
678
- ),
679
- const SizedBox(width: 6),
680
- Text(
681
- variant.id.contains('ryan') ? l10n.voice_male : l10n.voice_female,
682
- style: theme.textTheme.labelSmall?.copyWith(
683
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
684
- fontWeight: FontWeight.w500,
685
- ),
686
- ),
687
- if (installed) ...[
688
- const SizedBox(width: 6),
689
- const Icon(Icons.check_circle, size: 14, color: Colors.green),
690
- ],
691
- ],
692
- ),
693
- ),
694
- );
695
- }
696
-}
697
-
698
-// ── Shared Widgets ──
699
-
700
-class _EngineCard extends StatelessWidget {
701
- final bool isSelected;
702
- final EngineMeta meta;
703
- final EngineId engine;
704
- final bool installed;
705
- final String statusText;
706
- final Widget? trailing;
707
- final Widget? child;
708
-
709
- const _EngineCard({
710
- required this.isSelected,
711
- required this.meta,
712
- required this.engine,
713
- required this.installed,
714
- required this.statusText,
715
- this.trailing,
716
- this.child,
717
- });
718
-
719
- @override
720
- Widget build(BuildContext context) {
721
- final l10n = AppLocalizations.of(context)!;
722
- final theme = Theme.of(context);
723
- final accentColor = Color(meta.accentColor);
724
-
725
- return Container(
726
- padding: const EdgeInsets.all(16),
727
- decoration: BoxDecoration(
728
- color: theme.colorScheme.surface,
729
- borderRadius: BorderRadius.circular(12),
730
- border: Border.all(
731
- color: isSelected
732
- ? accentColor.withValues(alpha: 0.6)
733
- : installed
734
- ? Colors.green.withValues(alpha: 0.3)
735
- : theme.colorScheme.outline.withValues(alpha: 0.3),
736
- width: isSelected ? 1.5 : 1,
737
- ),
738
- ),
739
- child: Column(
740
- crossAxisAlignment: CrossAxisAlignment.start,
741
- children: [
742
- Row(
743
- children: [
744
- Container(
745
- width: 36,
746
- height: 36,
747
- decoration: BoxDecoration(
748
- color: accentColor.withValues(alpha: 0.15),
749
- borderRadius: BorderRadius.circular(8),
750
- ),
751
- child: Icon(
752
- Icons.record_voice_over,
753
- size: 18,
754
- color: accentColor,
755
- ),
756
- ),
757
- const SizedBox(width: 12),
758
- Expanded(
759
- child: Column(
760
- crossAxisAlignment: CrossAxisAlignment.start,
761
- children: [
762
- Text(
763
- meta.name,
764
- style: theme.textTheme.titleSmall?.copyWith(
765
- fontWeight: FontWeight.bold,
766
- ),
767
- ),
768
- Text(
769
- meta.tagline,
770
- style: theme.textTheme.bodySmall?.copyWith(
771
- color: theme.colorScheme.onSurface.withValues(
772
- alpha: 0.6,
773
- ),
774
- ),
775
- ),
776
- ],
777
- ),
778
- ),
779
- Column(
780
- crossAxisAlignment: CrossAxisAlignment.end,
781
- children: [
782
- Text(
783
- statusText,
784
- style: theme.textTheme.labelSmall?.copyWith(
785
- color: installed
786
- ? Colors.green
787
- : theme.colorScheme.onSurface.withValues(alpha: 0.5),
788
- fontWeight: FontWeight.w600,
789
- ),
790
- ),
791
- const SizedBox(height: 2),
792
- Text(
793
- l10n.engine_spec(meta.sizeMb.toString(), meta.ramMb.toString(), meta.voiceCount),
794
- style: theme.textTheme.labelSmall?.copyWith(
795
- color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
796
- fontSize: 10,
797
- ),
798
- ),
799
- ],
800
- ),
801
- ],
802
- ),
803
- const SizedBox(height: 12),
804
- Row(
805
- children: [
806
- if (isSelected)
807
- Container(
808
- padding: const EdgeInsets.symmetric(
809
- horizontal: 10,
810
- vertical: 3,
811
- ),
812
- decoration: BoxDecoration(
813
- color: accentColor.withValues(alpha: 0.1),
814
- borderRadius: BorderRadius.circular(4),
815
- border: Border.all(
816
- color: accentColor.withValues(alpha: 0.3),
817
- ),
818
- ),
819
- child: Text(
820
- l10n.active,
821
- style: theme.textTheme.labelSmall?.copyWith(
822
- color: accentColor,
823
- fontWeight: FontWeight.bold,
824
- ),
825
- ),
826
- ),
827
- ?trailing,
828
- ],
829
- ),
830
- ?child,
831
- ],
832
- ),
833
- );
834
- }
835
-}
836
-
837
-class _VoiceChips extends ConsumerStatefulWidget {
838
- final List<Voice> voices;
839
- final EngineId engine;
840
-
841
- const _VoiceChips({required this.voices, required this.engine});
842
-
843
- @override
844
- ConsumerState<_VoiceChips> createState() => _VoiceChipsState();
845
-}
846
-
847
-class _VoiceChipsState extends ConsumerState<_VoiceChips> {
848
- Voice? _playingVoice;
849
-
850
- @override
851
- Widget build(BuildContext context) {
852
- ref.listen<tts.TtsState>(tts.ttsProvider, (previous, next) {
853
- if (!next.isSpeaking && (previous?.isSpeaking ?? false)) {
854
- if (mounted && _playingVoice != null) {
855
- setState(() => _playingVoice = null);
856
- }
857
- }
858
- });
859
-
860
- final l10n = AppLocalizations.of(context)!;
861
- final theme = Theme.of(context);
862
- final settings = ref.watch(settingsProvider);
863
- final selectedVoiceId = widget.engine == settings.ttsEngine
864
- ? settings.ttsVoiceId
865
- : null;
866
- final females = widget.voices.where((v) => v.gender == 'f').toList();
867
- final males = widget.voices.where((v) => v.gender == 'm').toList();
868
- final accent = Color(EngineMeta.forEngine(widget.engine).accentColor);
869
-
870
- return Padding(
871
- padding: const EdgeInsets.only(top: 12),
872
- child: Column(
873
- crossAxisAlignment: CrossAxisAlignment.start,
874
- children: [
875
- if (males.isNotEmpty) ...[
876
- Text(
877
- l10n.voice_male,
878
- style: theme.textTheme.labelSmall?.copyWith(
879
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
880
- ),
881
- ),
882
- const SizedBox(height: 6),
883
- SingleChildScrollView(
884
- scrollDirection: Axis.horizontal,
885
- child: Row(
886
- children: males
887
- .map(
888
- (v) => Padding(
889
- padding: const EdgeInsetsDirectional.only(end: 8),
890
- child: _voiceChip(context, v, theme, accent, selectedVoiceId),
891
- ),
892
- )
893
- .toList(),
894
- ),
895
- ),
896
- const SizedBox(height: 12),
897
- ],
898
- if (females.isNotEmpty) ...[
899
- Text(
900
- l10n.voice_female,
901
- style: theme.textTheme.labelSmall?.copyWith(
902
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
903
- ),
904
- ),
905
- const SizedBox(height: 6),
906
- SingleChildScrollView(
907
- scrollDirection: Axis.horizontal,
908
- child: Row(
909
- children: females
910
- .map(
911
- (v) => Padding(
912
- padding: const EdgeInsetsDirectional.only(end: 8),
913
- child: _voiceChip(context, v, theme, accent, selectedVoiceId),
914
- ),
915
- )
916
- .toList(),
917
- ),
918
- ),
919
- ],
920
- ],
921
- ),
922
- );
923
- }
924
-
925
- Widget _voiceChip(
926
- BuildContext context,
927
- Voice voice,
928
- ThemeData theme,
929
- Color accent,
930
- String? selectedVoiceId,
931
- ) {
932
- final isPlaying = _playingVoice == voice;
933
- final isSelected = voice.id == selectedVoiceId;
934
-
935
- return Material(
936
- color: Colors.transparent,
937
- child: Container(
938
- height: 36,
939
- decoration: BoxDecoration(
940
- color: isSelected
941
- ? Colors.green.withValues(alpha: 0.1)
942
- : isPlaying
943
- ? accent.withValues(alpha: 0.1)
944
- : Colors.transparent,
945
- borderRadius: BorderRadius.circular(20),
946
- border: Border.all(
947
- color: isSelected
948
- ? Colors.green.withValues(alpha: 0.5)
949
- : isPlaying
950
- ? accent.withValues(alpha: 0.3)
951
- : theme.colorScheme.outline.withValues(alpha: 0.2),
952
- ),
953
- ),
954
- child: Row(
955
- mainAxisSize: MainAxisSize.min,
956
- children: [
957
- InkWell(
958
- onTap: () => _selectVoice(voice),
959
- borderRadius: BorderRadiusDirectional.horizontal(
960
- start: const Radius.circular(20),
961
- ).resolve(Directionality.of(context)),
962
- child: Padding(
963
- padding: const EdgeInsetsDirectional.only(
964
- start: 12,
965
- end: 8,
966
- top: 8,
967
- bottom: 8,
968
- ),
969
- child: Row(
970
- children: [
971
- if (isSelected) ...[
972
- const Icon(
973
- Icons.check_circle,
974
- size: 16,
975
- color: Colors.green,
976
- ),
977
- const SizedBox(width: 6),
978
- ],
979
- Text(
980
- voice.name,
981
- style: theme.textTheme.labelSmall?.copyWith(
982
- color: isSelected
983
- ? Colors.green
984
- : isPlaying
985
- ? accent
986
- : theme.colorScheme.onSurface,
987
- fontWeight: isSelected || isPlaying
988
- ? FontWeight.bold
989
- : FontWeight.normal,
990
- ),
991
- ),
992
- ],
993
- ),
994
- ),
995
- ),
996
- Container(
997
- width: 1,
998
- height: 20,
999
- color: isSelected
1000
- ? Colors.green.withValues(alpha: 0.3)
1001
- : isPlaying
1002
- ? accent.withValues(alpha: 0.2)
1003
- : theme.colorScheme.outline.withValues(alpha: 0.2),
1004
- ),
1005
- InkWell(
1006
- onTap: () => _previewVoice(voice),
1007
- borderRadius: const BorderRadius.horizontal(
1008
- right: Radius.circular(20),
1009
- ),
1010
- child: Padding(
1011
- padding: const EdgeInsets.symmetric(
1012
- horizontal: 10,
1013
- vertical: 8,
1014
- ),
1015
- child: Icon(
1016
- isPlaying ? Icons.stop : Icons.play_arrow,
1017
- size: 16,
1018
- color: isPlaying
1019
- ? accent
1020
- : theme.colorScheme.onSurface.withValues(alpha: 0.5),
1021
- ),
1022
- ),
1023
- ),
1024
- ],
1025
- ),
1026
- ),
1027
- );
1028
- }
1029
-
1030
- void _selectVoice(Voice voice) {
1031
- ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
1032
- }
1033
-
1034
- Future<void> _previewVoice(Voice voice) async {
1035
- final l10n = AppLocalizations.of(context)!;
1036
- if (_playingVoice == voice) {
1037
- await ref.read(tts.ttsProvider.notifier).stop();
1038
- if (mounted) setState(() => _playingVoice = null);
1039
- return;
1040
- }
1041
- try {
1042
- if (mounted) setState(() => _playingVoice = voice);
1043
- await ref.read(tts.ttsProvider.notifier).previewVoice(voice);
1044
- } catch (e) {
1045
- if (mounted) {
1046
- setState(() => _playingVoice = null);
1047
- ScaffoldMessenger.of(
1048
- context,
1049
- ).showSnackBar(SnackBar(content: Text(l10n.preview_failed(e.toString()))));
1050
- }
1051
- }
1052
- }
1053
-}
lib/features/tts/views/tts_model_manager_screen_parts.dart
new
+969
@@ -0,0 +1,969 @@
1
+part of 'tts_model_manager_screen.dart';
2
+
3
+class _SystemEngineCard extends ConsumerWidget {
4
+ final bool isSelected;
5
+
6
+ const _SystemEngineCard({required this.isSelected});
7
+
8
+ @override
9
+ Widget build(BuildContext context, WidgetRef ref) {
10
+ final l10n = AppLocalizations.of(context)!;
11
+ final meta = EngineMeta.system;
12
+
13
+ return _EngineCard(
14
+ isSelected: isSelected,
15
+ meta: meta,
16
+ engine: EngineId.system,
17
+ installed: true,
18
+ statusText: l10n.always_available,
19
+ trailing: Row(
20
+ mainAxisSize: MainAxisSize.min,
21
+ children: [
22
+ if (!isSelected)
23
+ ShadButton.outline(
24
+ size: ShadButtonSize.sm,
25
+ onPressed: () => ref
26
+ .read(settingsProvider.notifier)
27
+ .setTtsEngine(EngineId.system),
28
+ child: Text(l10n.select),
29
+ ),
30
+ ],
31
+ ),
32
+ child: _SystemVoicesList(),
33
+ );
34
+ }
35
+}
36
+
37
+class _SystemVoicesList extends ConsumerWidget {
38
+ @override
39
+ Widget build(BuildContext context, WidgetRef ref) {
40
+ final l10n = AppLocalizations.of(context)!;
41
+ final theme = Theme.of(context);
42
+ return Padding(
43
+ padding: const EdgeInsets.only(top: 12),
44
+ child: Column(
45
+ crossAxisAlignment: CrossAxisAlignment.start,
46
+ children: [
47
+ Text(
48
+ l10n.tts_system_desc,
49
+ style: theme.textTheme.bodySmall?.copyWith(
50
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
51
+ ),
52
+ ),
53
+ const SizedBox(height: 12),
54
+ ShadAlert(
55
+ icon: Icon(
56
+ Icons.info_outline,
57
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
58
+ ),
59
+ description: Text(
60
+ l10n.tts_other_services_background_note,
61
+ style: theme.textTheme.labelSmall?.copyWith(
62
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
63
+ ),
64
+ ),
65
+ ),
66
+ ],
67
+ ),
68
+ );
69
+ }
70
+}
71
+
72
+// ── Kitten Engine ──
73
+
74
+class _KittenEngineCard extends ConsumerWidget {
75
+ final bool isSelected;
76
+
77
+ const _KittenEngineCard({required this.isSelected});
78
+
79
+ KittenTtsModel _selectedModel(AppSettings settings) {
80
+ final variant = settings.kittenTtsModelVariant;
81
+ return KittenTtsModel.allModels.firstWhere((m) => m.variant == variant);
82
+ }
83
+
84
+ @override
85
+ Widget build(BuildContext context, WidgetRef ref) {
86
+ final l10n = AppLocalizations.of(context)!;
87
+ final settings = ref.watch(settingsProvider);
88
+ final meta = EngineMeta.kitten;
89
+ final downloadedAsync = ref.watch(downloadedKittenTtsVariantsProvider);
90
+ final downloadProgress = ref.watch(ttsDownloadProgressProvider);
91
+ final model = _selectedModel(settings);
92
+ final variantProgress = downloadProgress[model.variant];
93
+ final isInstalled = downloadedAsync.when(
94
+ data: (set) => set.contains(model.variant),
95
+ loading: () => false,
96
+ error: (_, _) => false,
97
+ );
98
+ final notifier = ref.read(ttsDownloadProgressProvider.notifier);
99
+ final isDownloading = notifier.isDownloading(model.variant);
100
+
101
+ return _EngineCard(
102
+ isSelected: isSelected,
103
+ meta: meta,
104
+ engine: EngineId.kitten,
105
+ installed: isInstalled,
106
+ statusText: isInstalled
107
+ ? l10n.installed
108
+ : isDownloading
109
+ ? l10n.downloading_status
110
+ : l10n.not_installed,
111
+ trailing: _buildAction(context, ref, isInstalled, isDownloading, model),
112
+ child: Column(
113
+ crossAxisAlignment: CrossAxisAlignment.start,
114
+ children: [
115
+ Padding(
116
+ padding: const EdgeInsets.only(top: 12),
117
+ child: ShadBadge.secondary(
118
+ child: Row(
119
+ mainAxisSize: MainAxisSize.min,
120
+ children: [
121
+ const Icon(Icons.music_note, size: 12),
122
+ const SizedBox(width: 4),
123
+ Text(
124
+ l10n.tts_supports_background,
125
+ style: const TextStyle(fontSize: 10),
126
+ ),
127
+ ],
128
+ ),
129
+ ),
130
+ ),
131
+ Padding(
132
+ padding: const EdgeInsets.only(top: 12),
133
+ child: Text(
134
+ l10n.tts_kitten_desc(_formatSize(model.totalSizeBytes)),
135
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
136
+ color: Theme.of(
137
+ context,
138
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
139
+ ),
140
+ ),
141
+ ),
142
+ if (isDownloading) ...[
143
+ const SizedBox(height: 12),
144
+ _buildVariantDownloadProgress(context, ref, model, variantProgress),
145
+ ],
146
+ if (isInstalled) ...[
147
+ _VoiceChips(voices: kittenVoices, engine: EngineId.kitten),
148
+ ],
149
+ ],
150
+ ),
151
+ );
152
+ }
153
+
154
+ Widget _buildAction(
155
+ BuildContext context,
156
+ WidgetRef ref,
157
+ bool isInstalled,
158
+ bool isDownloading,
159
+ KittenTtsModel model,
160
+ ) {
161
+ final l10n = AppLocalizations.of(context)!;
162
+ final notifier = ref.read(ttsDownloadProgressProvider.notifier);
163
+ if (isDownloading) {
164
+ return ShadButton.outline(
165
+ size: ShadButtonSize.sm,
166
+ onPressed: () => notifier.cancelDownload(model.variant),
167
+ child: Text(l10n.cancel),
168
+ );
169
+ }
170
+ if (isInstalled) {
171
+ return Row(
172
+ mainAxisSize: MainAxisSize.min,
173
+ children: [
174
+ if (!isSelected)
175
+ ShadButton.outline(
176
+ size: ShadButtonSize.sm,
177
+ onPressed: () => ref
178
+ .read(settingsProvider.notifier)
179
+ .setTtsEngine(EngineId.kitten),
180
+ child: Text(l10n.select),
181
+ ),
182
+ const SizedBox(width: 8),
183
+ ShadIconButton.ghost(
184
+ padding: EdgeInsets.zero,
185
+ icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
186
+ onPressed: () => _confirmDelete(context, ref, model),
187
+ ),
188
+ ],
189
+ );
190
+ }
191
+ return ShadButton.outline(
192
+ size: ShadButtonSize.sm,
193
+ onPressed: () => notifier.startDownload(model),
194
+ child: Text(l10n.install),
195
+ );
196
+ }
197
+
198
+ void _confirmDelete(
199
+ BuildContext context,
200
+ WidgetRef ref,
201
+ KittenTtsModel model,
202
+ ) {
203
+ final l10n = AppLocalizations.of(context)!;
204
+ showDialog(
205
+ context: context,
206
+ builder: (context) => AlertDialog(
207
+ title: Text(l10n.delete_model_title),
208
+ content: Text(
209
+ l10n.delete_model_body_with_size(model.displayName, _formatSize(model.totalSizeBytes)),
210
+ ),
211
+ actions: [
212
+ TextButton(
213
+ onPressed: () => Navigator.of(context).pop(),
214
+ child: Text(l10n.cancel),
215
+ ),
216
+ TextButton(
217
+ onPressed: () {
218
+ ref
219
+ .read(ttsDownloadProgressProvider.notifier)
220
+ .deleteVariant(model.variant);
221
+ Navigator.of(context).pop();
222
+ },
223
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
224
+ child: Text(l10n.delete),
225
+ ),
226
+ ],
227
+ ),
228
+ );
229
+ }
230
+
231
+ String _formatSize(int bytes) {
232
+ final mb = bytes / (1024 * 1024);
233
+ return '${mb.toStringAsFixed(0)} MB';
234
+ }
235
+
236
+ Widget _buildVariantDownloadProgress(
237
+ BuildContext context,
238
+ WidgetRef ref,
239
+ KittenTtsModel model,
240
+ Map<String, KittenTtsFileProgress>? progress,
241
+ ) {
242
+ final fraction = ref
243
+ .read(ttsDownloadProgressProvider.notifier)
244
+ .getOverallFraction(model.variant);
245
+ return Column(
246
+ children: [
247
+ LinearProgressIndicator(
248
+ value: fraction,
249
+ valueColor: AlwaysStoppedAnimation<Color>(
250
+ Color(EngineMeta.kitten.accentColor),
251
+ ),
252
+ backgroundColor: Color(
253
+ EngineMeta.kitten.accentColor,
254
+ ).withValues(alpha: 0.15),
255
+ ),
256
+ const SizedBox(height: 4),
257
+ if (progress != null)
258
+ ...progress.entries.map(
259
+ (e) => Padding(
260
+ padding: const EdgeInsets.only(bottom: 2),
261
+ child: Row(
262
+ children: [
263
+ Expanded(
264
+ child: Text(
265
+ e.key,
266
+ style: Theme.of(context).textTheme.labelSmall,
267
+ ),
268
+ ),
269
+ Text(
270
+ '${(e.value.fraction * 100).toStringAsFixed(0)}%',
271
+ style: Theme.of(context).textTheme.labelSmall,
272
+ ),
273
+ ],
274
+ ),
275
+ ),
276
+ ),
277
+ ],
278
+ );
279
+ }
280
+}
281
+
282
+// ── Piper Engine ──
283
+
284
+class _PiperEngineCard extends ConsumerWidget {
285
+ final bool isSelected;
286
+
287
+ const _PiperEngineCard({required this.isSelected});
288
+
289
+ PiperTtsModelVariant _selectedVariant(AppSettings settings) {
290
+ final voiceId =
291
+ voiceFromSettings(settings.ttsVoiceId, EngineId.piper)?.id ??
292
+ piperVoices.first.id;
293
+ return PiperTtsModelVariant.values.firstWhere(
294
+ (variant) => variant.id == voiceId,
295
+ orElse: () => PiperTtsModelVariant.enUsLessacMedium,
296
+ );
297
+ }
298
+
299
+ @override
300
+ Widget build(BuildContext context, WidgetRef ref) {
301
+ final l10n = AppLocalizations.of(context)!;
302
+ final settings = ref.watch(settingsProvider);
303
+ final meta = EngineMeta.piper;
304
+ final downloadedAsync = ref.watch(downloadedPiperTtsVariantsProvider);
305
+ final downloadProgress = ref.watch(piperTtsDownloadProgressProvider);
306
+ final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
307
+ final selectedVariant = _selectedVariant(settings);
308
+ final selectedVoice = piperVoices.firstWhere(
309
+ (voice) => voice.id == selectedVariant.id,
310
+ orElse: () => piperVoices.first,
311
+ );
312
+ final downloadedSet = downloadedAsync.when(
313
+ data: (set) => set,
314
+ loading: () => <PiperTtsModelVariant>{},
315
+ error: (_, _) => <PiperTtsModelVariant>{},
316
+ );
317
+ final installedVoices = piperVoices
318
+ .where(
319
+ (voice) => downloadedSet.any((variant) => variant.id == voice.id),
320
+ )
321
+ .toList();
322
+ final variantProgress = downloadProgress[selectedVariant];
323
+ final isInstalled = downloadedSet.contains(selectedVariant);
324
+ final isDownloading = notifier.isDownloading(selectedVariant);
325
+
326
+ return _EngineCard(
327
+ isSelected: isSelected,
328
+ meta: meta,
329
+ engine: EngineId.piper,
330
+ installed: installedVoices.isNotEmpty,
331
+ statusText: isInstalled
332
+ ? l10n.installed
333
+ : isDownloading
334
+ ? l10n.downloading_status
335
+ : l10n.not_installed,
336
+ trailing: _buildAction(
337
+ context,
338
+ ref,
339
+ selectedVariant,
340
+ selectedVoice,
341
+ isInstalled,
342
+ isDownloading,
343
+ ),
344
+ child: Column(
345
+ crossAxisAlignment: CrossAxisAlignment.start,
346
+ children: [
347
+ Padding(
348
+ padding: const EdgeInsets.only(top: 12),
349
+ child: ShadBadge.secondary(
350
+ child: Row(
351
+ mainAxisSize: MainAxisSize.min,
352
+ children: [
353
+ const Icon(Icons.music_note, size: 12),
354
+ const SizedBox(width: 4),
355
+ Text(
356
+ l10n.tts_supports_background,
357
+ style: const TextStyle(fontSize: 10),
358
+ ),
359
+ ],
360
+ ),
361
+ ),
362
+ ),
363
+ Padding(
364
+ padding: const EdgeInsets.only(top: 12),
365
+ child: Text(
366
+ l10n.tts_piper_desc(_formatSize(selectedVariant.totalSizeBytes)),
367
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
368
+ color: Theme.of(
369
+ context,
370
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
371
+ ),
372
+ ),
373
+ ),
374
+ const SizedBox(height: 12),
375
+ Wrap(
376
+ spacing: 8,
377
+ runSpacing: 8,
378
+ children: PiperTtsModelVariant.values
379
+ .map(
380
+ (variant) => _PiperVariantChip(
381
+ variant: variant,
382
+ voice: piperVoices.firstWhere(
383
+ (voice) => voice.id == variant.id,
384
+ orElse: () => selectedVoice,
385
+ ),
386
+ selected: selectedVariant == variant,
387
+ installed: downloadedSet.contains(variant),
388
+ onTap: () => ref
389
+ .read(settingsProvider.notifier)
390
+ .setTtsVoiceId(variant.id),
391
+ ),
392
+ )
393
+ .toList(),
394
+ ),
395
+ if (isDownloading) ...[
396
+ const SizedBox(height: 12),
397
+ _buildVariantDownloadProgress(
398
+ context,
399
+ ref,
400
+ selectedVariant,
401
+ variantProgress,
402
+ ),
403
+ ],
404
+ if (installedVoices.isNotEmpty) ...[
405
+ _VoiceChips(voices: installedVoices, engine: EngineId.piper),
406
+ ],
407
+ ],
408
+ ),
409
+ );
410
+ }
411
+
412
+ Widget _buildAction(
413
+ BuildContext context,
414
+ WidgetRef ref,
415
+ PiperTtsModelVariant variant,
416
+ Voice voice,
417
+ bool isInstalled,
418
+ bool isDownloading,
419
+ ) {
420
+ final l10n = AppLocalizations.of(context)!;
421
+ final notifier = ref.read(piperTtsDownloadProgressProvider.notifier);
422
+ if (isDownloading) {
423
+ return ShadButton.outline(
424
+ size: ShadButtonSize.sm,
425
+ onPressed: () => notifier.cancelDownload(variant),
426
+ child: Text(l10n.cancel),
427
+ );
428
+ }
429
+ if (isInstalled) {
430
+ return Row(
431
+ mainAxisSize: MainAxisSize.min,
432
+ children: [
433
+ if (!isSelected)
434
+ ShadButton.outline(
435
+ size: ShadButtonSize.sm,
436
+ onPressed: () {
437
+ ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
438
+ ref
439
+ .read(settingsProvider.notifier)
440
+ .setTtsEngine(EngineId.piper);
441
+ },
442
+ child: Text(l10n.select),
443
+ ),
444
+ const SizedBox(width: 8),
445
+ ShadIconButton.ghost(
446
+ padding: EdgeInsets.zero,
447
+ icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
448
+ onPressed: () => _confirmDelete(context, ref, variant),
449
+ ),
450
+ ],
451
+ );
452
+ }
453
+ return ShadButton.outline(
454
+ size: ShadButtonSize.sm,
455
+ onPressed: () => notifier.startDownload(variant),
456
+ child: Text(l10n.install),
457
+ );
458
+ }
459
+
460
+ void _confirmDelete(
461
+ BuildContext context,
462
+ WidgetRef ref,
463
+ PiperTtsModelVariant variant,
464
+ ) {
465
+ final l10n = AppLocalizations.of(context)!;
466
+ showDialog(
467
+ context: context,
468
+ builder: (context) => AlertDialog(
469
+ title: Text(l10n.delete_voice_title),
470
+ content: Text(
471
+ l10n.delete_voice_body(variant.displayName, _formatSize(variant.totalSizeBytes)),
472
+ ),
473
+ actions: [
474
+ TextButton(
475
+ onPressed: () => Navigator.of(context).pop(),
476
+ child: Text(l10n.cancel),
477
+ ),
478
+ TextButton(
479
+ onPressed: () {
480
+ ref
481
+ .read(piperTtsDownloadProgressProvider.notifier)
482
+ .deleteVariant(variant);
483
+ Navigator.of(context).pop();
484
+ },
485
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
486
+ child: Text(l10n.delete),
487
+ ),
488
+ ],
489
+ ),
490
+ );
491
+ }
492
+
493
+ String _formatSize(int bytes) {
494
+ final mb = bytes / (1024 * 1024);
495
+ return '${mb.toStringAsFixed(0)} MB';
496
+ }
497
+
498
+ Widget _buildVariantDownloadProgress(
499
+ BuildContext context,
500
+ WidgetRef ref,
501
+ PiperTtsModelVariant variant,
502
+ Map<String, PiperTtsFileProgress>? progress,
503
+ ) {
504
+ final fraction = ref
505
+ .read(piperTtsDownloadProgressProvider.notifier)
506
+ .getOverallFraction(variant);
507
+ return Column(
508
+ children: [
509
+ LinearProgressIndicator(
510
+ value: fraction,
511
+ valueColor: AlwaysStoppedAnimation<Color>(
512
+ Color(EngineMeta.piper.accentColor),
513
+ ),
514
+ backgroundColor: Color(
515
+ EngineMeta.piper.accentColor,
516
+ ).withValues(alpha: 0.15),
517
+ ),
518
+ const SizedBox(height: 4),
519
+ if (progress != null)
520
+ ...progress.entries.map(
521
+ (e) => Padding(
522
+ padding: const EdgeInsets.only(bottom: 2),
523
+ child: Row(
524
+ children: [
525
+ Expanded(
526
+ child: Text(
527
+ e.key,
528
+ style: Theme.of(context).textTheme.labelSmall,
529
+ ),
530
+ ),
531
+ Text(
532
+ '${(e.value.fraction * 100).toStringAsFixed(0)}%',
533
+ style: Theme.of(context).textTheme.labelSmall,
534
+ ),
535
+ ],
536
+ ),
537
+ ),
538
+ ),
539
+ ],
540
+ );
541
+ }
542
+}
543
+
544
+class _PiperVariantChip extends StatelessWidget {
545
+ final PiperTtsModelVariant variant;
546
+ final Voice voice;
547
+ final bool selected;
548
+ final bool installed;
549
+ final VoidCallback onTap;
550
+
551
+ const _PiperVariantChip({
552
+ required this.variant,
553
+ required this.voice,
554
+ required this.selected,
555
+ required this.installed,
556
+ required this.onTap,
557
+ });
558
+
559
+ @override
560
+ Widget build(BuildContext context) {
561
+ final l10n = AppLocalizations.of(context)!;
562
+ final theme = Theme.of(context);
563
+ final accent = Color(EngineMeta.piper.accentColor);
564
+
565
+ return InkWell(
566
+ onTap: onTap,
567
+ borderRadius: BorderRadius.circular(999),
568
+ child: Container(
569
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
570
+ decoration: BoxDecoration(
571
+ color: selected
572
+ ? accent.withValues(alpha: 0.12)
573
+ : theme.colorScheme.surfaceContainerHighest.withValues(
574
+ alpha: 0.2,
575
+ ),
576
+ borderRadius: BorderRadius.circular(999),
577
+ border: Border.all(
578
+ color: selected
579
+ ? accent.withValues(alpha: 0.45)
580
+ : installed
581
+ ? Colors.green.withValues(alpha: 0.35)
582
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
583
+ ),
584
+ ),
585
+ child: Row(
586
+ mainAxisSize: MainAxisSize.min,
587
+ children: [
588
+ Text(
589
+ voice.name,
590
+ style: theme.textTheme.labelSmall?.copyWith(
591
+ color: selected ? accent : theme.colorScheme.onSurface,
592
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
593
+ ),
594
+ ),
595
+ const SizedBox(width: 6),
596
+ Text(
597
+ variant.id.contains('ryan') ? l10n.voice_male : l10n.voice_female,
598
+ style: theme.textTheme.labelSmall?.copyWith(
599
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
600
+ fontWeight: FontWeight.w500,
601
+ ),
602
+ ),
603
+ if (installed) ...[
604
+ const SizedBox(width: 6),
605
+ const Icon(Icons.check_circle, size: 14, color: Colors.green),
606
+ ],
607
+ ],
608
+ ),
609
+ ),
610
+ );
611
+ }
612
+}
613
+
614
+// ── Shared Widgets ──
615
+
616
+class _EngineCard extends StatelessWidget {
617
+ final bool isSelected;
618
+ final EngineMeta meta;
619
+ final EngineId engine;
620
+ final bool installed;
621
+ final String statusText;
622
+ final Widget? trailing;
623
+ final Widget? child;
624
+
625
+ const _EngineCard({
626
+ required this.isSelected,
627
+ required this.meta,
628
+ required this.engine,
629
+ required this.installed,
630
+ required this.statusText,
631
+ this.trailing,
632
+ this.child,
633
+ });
634
+
635
+ @override
636
+ Widget build(BuildContext context) {
637
+ final l10n = AppLocalizations.of(context)!;
638
+ final theme = Theme.of(context);
639
+ final accentColor = Color(meta.accentColor);
640
+
641
+ return Container(
642
+ padding: const EdgeInsets.all(16),
643
+ decoration: BoxDecoration(
644
+ color: theme.colorScheme.surface,
645
+ borderRadius: BorderRadius.circular(12),
646
+ border: Border.all(
647
+ color: isSelected
648
+ ? accentColor.withValues(alpha: 0.6)
649
+ : installed
650
+ ? Colors.green.withValues(alpha: 0.3)
651
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
652
+ width: isSelected ? 1.5 : 1,
653
+ ),
654
+ ),
655
+ child: Column(
656
+ crossAxisAlignment: CrossAxisAlignment.start,
657
+ children: [
658
+ Row(
659
+ children: [
660
+ Container(
661
+ width: 36,
662
+ height: 36,
663
+ decoration: BoxDecoration(
664
+ color: accentColor.withValues(alpha: 0.15),
665
+ borderRadius: BorderRadius.circular(8),
666
+ ),
667
+ child: Icon(
668
+ Icons.record_voice_over,
669
+ size: 18,
670
+ color: accentColor,
671
+ ),
672
+ ),
673
+ const SizedBox(width: 12),
674
+ Expanded(
675
+ child: Column(
676
+ crossAxisAlignment: CrossAxisAlignment.start,
677
+ children: [
678
+ Text(
679
+ meta.name,
680
+ style: theme.textTheme.titleSmall?.copyWith(
681
+ fontWeight: FontWeight.bold,
682
+ ),
683
+ ),
684
+ Text(
685
+ meta.tagline,
686
+ style: theme.textTheme.bodySmall?.copyWith(
687
+ color: theme.colorScheme.onSurface.withValues(
688
+ alpha: 0.6,
689
+ ),
690
+ ),
691
+ ),
692
+ ],
693
+ ),
694
+ ),
695
+ Column(
696
+ crossAxisAlignment: CrossAxisAlignment.end,
697
+ children: [
698
+ Text(
699
+ statusText,
700
+ style: theme.textTheme.labelSmall?.copyWith(
701
+ color: installed
702
+ ? Colors.green
703
+ : theme.colorScheme.onSurface.withValues(alpha: 0.5),
704
+ fontWeight: FontWeight.w600,
705
+ ),
706
+ ),
707
+ const SizedBox(height: 2),
708
+ Text(
709
+ l10n.engine_spec(meta.sizeMb.toString(), meta.ramMb.toString(), meta.voiceCount),
710
+ style: theme.textTheme.labelSmall?.copyWith(
711
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
712
+ fontSize: 10,
713
+ ),
714
+ ),
715
+ ],
716
+ ),
717
+ ],
718
+ ),
719
+ const SizedBox(height: 12),
720
+ Row(
721
+ children: [
722
+ if (isSelected)
723
+ Container(
724
+ padding: const EdgeInsets.symmetric(
725
+ horizontal: 10,
726
+ vertical: 3,
727
+ ),
728
+ decoration: BoxDecoration(
729
+ color: accentColor.withValues(alpha: 0.1),
730
+ borderRadius: BorderRadius.circular(4),
731
+ border: Border.all(
732
+ color: accentColor.withValues(alpha: 0.3),
733
+ ),
734
+ ),
735
+ child: Text(
736
+ l10n.active,
737
+ style: theme.textTheme.labelSmall?.copyWith(
738
+ color: accentColor,
739
+ fontWeight: FontWeight.bold,
740
+ ),
741
+ ),
742
+ ),
743
+ ?trailing,
744
+ ],
745
+ ),
746
+ ?child,
747
+ ],
748
+ ),
749
+ );
750
+ }
751
+}
752
+
753
+class _VoiceChips extends ConsumerStatefulWidget {
754
+ final List<Voice> voices;
755
+ final EngineId engine;
756
+
757
+ const _VoiceChips({required this.voices, required this.engine});
758
+
759
+ @override
760
+ ConsumerState<_VoiceChips> createState() => _VoiceChipsState();
761
+}
762
+
763
+class _VoiceChipsState extends ConsumerState<_VoiceChips> {
764
+ Voice? _playingVoice;
765
+
766
+ @override
767
+ Widget build(BuildContext context) {
768
+ ref.listen<tts.TtsState>(tts.ttsProvider, (previous, next) {
769
+ if (!next.isSpeaking && (previous?.isSpeaking ?? false)) {
770
+ if (mounted && _playingVoice != null) {
771
+ setState(() => _playingVoice = null);
772
+ }
773
+ }
774
+ });
775
+
776
+ final l10n = AppLocalizations.of(context)!;
777
+ final theme = Theme.of(context);
778
+ final settings = ref.watch(settingsProvider);
779
+ final selectedVoiceId = widget.engine == settings.ttsEngine
780
+ ? settings.ttsVoiceId
781
+ : null;
782
+ final females = widget.voices.where((v) => v.gender == 'f').toList();
783
+ final males = widget.voices.where((v) => v.gender == 'm').toList();
784
+ final accent = Color(EngineMeta.forEngine(widget.engine).accentColor);
785
+
786
+ return Padding(
787
+ padding: const EdgeInsets.only(top: 12),
788
+ child: Column(
789
+ crossAxisAlignment: CrossAxisAlignment.start,
790
+ children: [
791
+ if (males.isNotEmpty) ...[
792
+ Text(
793
+ l10n.voice_male,
794
+ style: theme.textTheme.labelSmall?.copyWith(
795
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
796
+ ),
797
+ ),
798
+ const SizedBox(height: 6),
799
+ SingleChildScrollView(
800
+ scrollDirection: Axis.horizontal,
801
+ child: Row(
802
+ children: males
803
+ .map(
804
+ (v) => Padding(
805
+ padding: const EdgeInsetsDirectional.only(end: 8),
806
+ child: _voiceChip(context, v, theme, accent, selectedVoiceId),
807
+ ),
808
+ )
809
+ .toList(),
810
+ ),
811
+ ),
812
+ const SizedBox(height: 12),
813
+ ],
814
+ if (females.isNotEmpty) ...[
815
+ Text(
816
+ l10n.voice_female,
817
+ style: theme.textTheme.labelSmall?.copyWith(
818
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
819
+ ),
820
+ ),
821
+ const SizedBox(height: 6),
822
+ SingleChildScrollView(
823
+ scrollDirection: Axis.horizontal,
824
+ child: Row(
825
+ children: females
826
+ .map(
827
+ (v) => Padding(
828
+ padding: const EdgeInsetsDirectional.only(end: 8),
829
+ child: _voiceChip(context, v, theme, accent, selectedVoiceId),
830
+ ),
831
+ )
832
+ .toList(),
833
+ ),
834
+ ),
835
+ ],
836
+ ],
837
+ ),
838
+ );
839
+ }
840
+
841
+ Widget _voiceChip(
842
+ BuildContext context,
843
+ Voice voice,
844
+ ThemeData theme,
845
+ Color accent,
846
+ String? selectedVoiceId,
847
+ ) {
848
+ final isPlaying = _playingVoice == voice;
849
+ final isSelected = voice.id == selectedVoiceId;
850
+
851
+ return Material(
852
+ color: Colors.transparent,
853
+ child: Container(
854
+ height: 36,
855
+ decoration: BoxDecoration(
856
+ color: isSelected
857
+ ? Colors.green.withValues(alpha: 0.1)
858
+ : isPlaying
859
+ ? accent.withValues(alpha: 0.1)
860
+ : Colors.transparent,
861
+ borderRadius: BorderRadius.circular(20),
862
+ border: Border.all(
863
+ color: isSelected
864
+ ? Colors.green.withValues(alpha: 0.5)
865
+ : isPlaying
866
+ ? accent.withValues(alpha: 0.3)
867
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
868
+ ),
869
+ ),
870
+ child: Row(
871
+ mainAxisSize: MainAxisSize.min,
872
+ children: [
873
+ InkWell(
874
+ onTap: () => _selectVoice(voice),
875
+ borderRadius: BorderRadiusDirectional.horizontal(
876
+ start: const Radius.circular(20),
877
+ ).resolve(Directionality.of(context)),
878
+ child: Padding(
879
+ padding: const EdgeInsetsDirectional.only(
880
+ start: 12,
881
+ end: 8,
882
+ top: 8,
883
+ bottom: 8,
884
+ ),
885
+ child: Row(
886
+ children: [
887
+ if (isSelected) ...[
888
+ const Icon(
889
+ Icons.check_circle,
890
+ size: 16,
891
+ color: Colors.green,
892
+ ),
893
+ const SizedBox(width: 6),
894
+ ],
895
+ Text(
896
+ voice.name,
897
+ style: theme.textTheme.labelSmall?.copyWith(
898
+ color: isSelected
899
+ ? Colors.green
900
+ : isPlaying
901
+ ? accent
902
+ : theme.colorScheme.onSurface,
903
+ fontWeight: isSelected || isPlaying
904
+ ? FontWeight.bold
905
+ : FontWeight.normal,
906
+ ),
907
+ ),
908
+ ],
909
+ ),
910
+ ),
911
+ ),
912
+ Container(
913
+ width: 1,
914
+ height: 20,
915
+ color: isSelected
916
+ ? Colors.green.withValues(alpha: 0.3)
917
+ : isPlaying
918
+ ? accent.withValues(alpha: 0.2)
919
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
920
+ ),
921
+ InkWell(
922
+ onTap: () => _previewVoice(voice),
923
+ borderRadius: const BorderRadius.horizontal(
924
+ right: Radius.circular(20),
925
+ ),
926
+ child: Padding(
927
+ padding: const EdgeInsets.symmetric(
928
+ horizontal: 10,
929
+ vertical: 8,
930
+ ),
931
+ child: Icon(
932
+ isPlaying ? Icons.stop : Icons.play_arrow,
933
+ size: 16,
934
+ color: isPlaying
935
+ ? accent
936
+ : theme.colorScheme.onSurface.withValues(alpha: 0.5),
937
+ ),
938
+ ),
939
+ ),
940
+ ],
941
+ ),
942
+ ),
943
+ );
944
+ }
945
+
946
+ void _selectVoice(Voice voice) {
947
+ ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
948
+ }
949
+
950
+ Future<void> _previewVoice(Voice voice) async {
951
+ final l10n = AppLocalizations.of(context)!;
952
+ if (_playingVoice == voice) {
953
+ await ref.read(tts.ttsProvider.notifier).stop();
954
+ if (mounted) setState(() => _playingVoice = null);
955
+ return;
956
+ }
957
+ try {
958
+ if (mounted) setState(() => _playingVoice = voice);
959
+ await ref.read(tts.ttsProvider.notifier).previewVoice(voice);
960
+ } catch (e) {
961
+ if (mounted) {
962
+ setState(() => _playingVoice = null);
963
+ ScaffoldMessenger.of(
964
+ context,
965
+ ).showSnackBar(SnackBar(content: Text(l10n.preview_failed(e.toString()))));
966
+ }
967
+ }
968
+ }
969
+}
skills/flutter-feature-based-architecture/SKILL.md
new
+196
@@ -0,0 +1,196 @@
1
+---
2
+name: flutter-feature-based-architecture
3
+description: Design, implement, refactor, and review Flutter apps that use feature-based vertical slices, handwritten Dart models/providers, componentized StatelessWidget UI, Riverpod state management, and a shared core layer. Use when adding Flutter features, organizing lib/, moving shared code, wiring repositories/providers/routes, avoiding code generation, avoiding private UI build helper methods, or checking architecture consistency in a Riverpod Flutter project.
4
+---
5
+
6
+# Flutter Feature-Based Architecture
7
+
8
+Use this skill to keep Flutter code organized by product capability instead of broad technical layers. Prefer vertical feature slices under `lib/features/`, keep stable cross-feature code in `lib/core/`, use Riverpod as the state boundary between UI and data access, write models/providers by hand, and split UI into small `StatelessWidget` components.
9
+
10
+## First Pass
11
+
12
+Before editing a project:
13
+
14
+1. Inspect `pubspec.yaml`, `lib/`, route setup, serialization conventions, and existing Riverpod style.
15
+2. Preserve coherent local conventions, especially folder names such as `repository/` vs `repositories/`, `components/` vs `widgets/`, and handwritten provider organization.
16
+3. Prefer the smallest architecture change that solves the request.
17
+4. State any dependency assumptions if Riverpod, routing, or serialization helpers are missing.
18
+5. Run the project-standard checks after changes, usually `flutter analyze` and relevant tests.
19
+
20
+## Target Shape
21
+
22
+Use this default shape for new projects or features unless the existing project already has a consistent equivalent:
23
+
24
+```text
25
+lib/
26
+├── app/ # Optional app composition: shell, router, bootstrap
27
+├── core/ # Shared, cross-cutting concerns
28
+│ ├── components/ # Reusable presentational widgets
29
+│ ├── constants/ # App-wide constants and non-secret config
30
+│ ├── extensions/ # Shared Dart extensions
31
+│ ├── locales/ # Localization setup and handwritten keys
32
+│ ├── logger/ # App-wide logging wrapper
33
+│ ├── models/ # Cross-feature models only
34
+│ ├── providers/ # App-wide Riverpod providers
35
+│ ├── repositories/ # Shared repositories and infrastructure services
36
+│ ├── routes/ # Route config if the app has no app/ layer
37
+│ ├── theme/ # ThemeData, text styles, component themes
38
+│ └── utils/ # General-purpose helpers
39
+├── features/
40
+│ └── orders/
41
+│ ├── data/
42
+│ │ ├── models/
43
+│ │ └── repositories/
44
+│ ├── providers/
45
+│ └── views/
46
+│ └── components/
47
+└── main.dart
48
+```
49
+
50
+### Core Layer
51
+
52
+Move code to `core/` only when it is genuinely app-wide or shared by multiple features. Keep feature-specific models, providers, repository methods, and UI components inside the owning feature until there is a stable reason to extract them.
53
+
54
+Place composition code carefully:
55
+
56
+- Let `main.dart`, `app/`, or `core/routes/` import feature entry pages to wire navigation.
57
+- Do not let one feature import another feature directly.
58
+- Move shared contracts, route constants, data types, or UI primitives to `core/` when two features need them.
59
+
60
+## Feature Layer
61
+
62
+Use this internal structure for each feature:
63
+
64
+```text
65
+features/<feature_name>/
66
+├── data/
67
+│ ├── models/ # Feature-specific data/domain models
68
+│ └── repositories/ # API, database, cache, and persistence access
69
+├── providers/ # Riverpod state, controllers, and query providers
70
+└── views/
71
+ ├── <feature>_page.dart # Page/screen widgets
72
+ └── components/ # Feature-only StatelessWidget components
73
+```
74
+
75
+Add optional folders only when they carry real weight:
76
+
77
+| Folder | Use for |
78
+| --- | --- |
79
+| `data/datasources/` | Remote/local data source adapters when repositories would become too large |
80
+| `data/dto/` | Transport-specific objects that should not leak into UI state |
81
+| `services/` | Stateless feature services such as validators, schedulers, or mappers |
82
+| `utils/` | Helpers that are private to one feature |
83
+
84
+## Dependency Rules
85
+
86
+- Import from `core/` and the current feature only.
87
+- Do not import from sibling features. Extract shared code to `core/` instead.
88
+- Keep views lean. Views handle layout, gestures, animation, navigation calls, and local-only UI concerns.
89
+- Keep business rules, async work, persistence, and mutation orchestration in Riverpod notifiers/controllers or repositories.
90
+- Keep repositories free of widget concerns. They should expose clear async APIs and translate data-source errors into app-level failures where the project has a failure type.
91
+- Use `setState` only for strictly local ephemeral UI state. Do not use it for business data or cross-widget state.
92
+- Do not split UI with private helper methods such as `_buildHeader`, `_buildBody`, `_buildList`, or `_buildItem`.
93
+- Extract UI sections into named `StatelessWidget` classes under `views/components/` instead.
94
+
95
+## Handwritten Dart Only
96
+
97
+Do not introduce code generation for models, providers, routes, or serialization. Avoid `freezed`, `json_serializable`, `riverpod_annotation`, `build_runner`, generated `part` files, `.g.dart`, and `.freezed.dart` files unless the user explicitly asks for generated code.
98
+
99
+When a project already contains generated files:
100
+
101
+- Do not delete or rewrite existing generated architecture as part of an unrelated feature.
102
+- Do not expand the generated surface for new work.
103
+- Interact with existing generated APIs only when needed for compatibility.
104
+- Ask before changing generator inputs if the task cannot be completed cleanly with handwritten Dart.
105
+
106
+For handwritten models and state classes:
107
+
108
+- Use immutable classes with `final` fields and `const` constructors where possible.
109
+- Add `factory Model.fromJson(Map<String, dynamic> json)`, `Map<String, dynamic> toJson()`, and `copyWith()`.
110
+- Add `==` and `hashCode` manually when value equality affects provider rebuilds, tests, collections, or state comparison.
111
+- Keep parsing and serialization defensive enough for the app's data source.
112
+
113
+## Riverpod Defaults
114
+
115
+Prefer the project's existing Riverpod style when it is internally consistent. For new handwritten Riverpod code, prefer current Riverpod primitives:
116
+
117
+| Need | Default |
118
+| --- | --- |
119
+| Repository/service dependency | `Provider<T>` |
120
+| Simple local filter/toggle/query text | `StateProvider<T>` |
121
+| Read-only one-shot async query | `FutureProvider<T>` |
122
+| Read-only realtime data | `StreamProvider<T>` |
123
+| Synchronous controller with mutable state | `NotifierProvider<Notifier, State>` |
124
+| Async controller with loading/error/data state | `AsyncNotifierProvider<Notifier, State>` |
125
+| Parameterized provider | `.family` |
126
+| Screen-scoped state | `.autoDispose` |
127
+
128
+Use `StateNotifierProvider` only when the project already standardizes on `StateNotifier` or dependency constraints require it.
129
+
130
+### Provider Organization
131
+
132
+In a feature's provider file, use this order:
133
+
134
+1. Repository/service providers.
135
+2. Simple filter or UI-state providers.
136
+3. Query providers or notifier classes.
137
+4. Provider declarations for controllers/notifiers.
138
+
139
+Split a provider file when it becomes hard to scan, not just because a second provider exists. Prefer names that read naturally at call sites: `orderRepositoryProvider`, `orderSearchQueryProvider`, `orderListProvider`, `orderControllerProvider`.
140
+
141
+### Async UI Rules
142
+
143
+- Use `AsyncValue.when`, pattern matching, or project-standard helpers to render loading/error/data states.
144
+- Use `ref.watch()` for reactive state in `build`.
145
+- Use `ref.read()` for event handlers and one-shot actions.
146
+- Use `ref.listen()` for side effects such as snackbars, dialogs, and redirects.
147
+- Do not start network fetches, mutations, navigation, or provider invalidation from `build`, `itemBuilder`, or other repeatedly-called render callbacks.
148
+
149
+## UI Composition
150
+
151
+Use page widgets as thin state connectors. Prefer `ConsumerWidget` pages when Riverpod state is needed, and use `ConsumerStatefulWidget` only for unavoidable local lifecycle concerns such as controllers, focus nodes, or animations. Extracted UI sections should be `StatelessWidget` components by default.
152
+
153
+Follow these rules:
154
+
155
+- Keep each required widget `build` override focused on composing named widgets.
156
+- Do not create private methods that return widgets for page sections.
157
+- Move page sections, cards, list rows, empty states, form sections, buttons groups, and reusable layout pieces into `views/components/`.
158
+- Pass plain data and callbacks into components instead of reading providers inside every component.
159
+- Use `ConsumerWidget` in a component only when passing data/callbacks would create worse coupling or excessive plumbing.
160
+- Promote a feature component to `core/components/` only after it is reused by multiple features.
161
+
162
+## Feature Implementation Workflow
163
+
164
+When adding a feature:
165
+
166
+1. Create `lib/features/<feature_name>/` with `data/models/`, `data/repositories/`, `providers/`, and `views/`.
167
+2. Define handwritten models with `fromJson`, `toJson`, `copyWith`, and manual equality when needed.
168
+3. Implement the repository. Inject shared clients from `core/providers/` or `core/repositories/`; do not instantiate global dependencies deep in views.
169
+4. Add Riverpod providers. Start with repository providers, then query/controller providers.
170
+5. Implement views as thin `ConsumerWidget` shells, using `ConsumerStatefulWidget` only for unavoidable local lifecycle state, then extract UI sections into `StatelessWidget` files under `views/components/`.
171
+6. Wire navigation in the existing composition layer.
172
+7. Extract shared code to `core/` only after confirming it is cross-feature or app-wide.
173
+8. Run formatting, static analysis, and focused tests.
174
+
175
+## Naming
176
+
177
+| Item | Convention | Example |
178
+| --- | --- | --- |
179
+| Feature directory | `snake_case` | `order_history/` |
180
+| Model file | `snake_case` | `order_model.dart` |
181
+| Repository file | `<feature>_repository.dart` | `orders_repository.dart` |
182
+| Provider file | `<feature>_providers.dart` | `orders_providers.dart` |
183
+| Page file | `<feature>_page.dart` or specific screen name | `orders_page.dart`, `order_details_page.dart` |
184
+| Component file | `<component_name>.dart` | `orders_list.dart`, `order_summary_card.dart` |
185
+| Component class | `PascalCase` widget name | `OrdersList`, `OrderSummaryCard` |
186
+| Notifier/controller class | `PascalCase` + `Notifier` or `Controller` | `OrdersNotifier`, `OrderFormController` |
187
+| State class | `PascalCase` + `State` | `OrdersState`, `OrderFormState` |
188
+| Provider variable | `camelCase` + `Provider` | `ordersProvider`, `orderRepositoryProvider` |
189
+| Barrel file | Directory summary name | `models.dart`, `components.dart` |
190
+
191
+## Reference Files
192
+
193
+Load reference files only when useful for the current task:
194
+
195
+- Read [references/handwritten-feature-example.md](references/handwritten-feature-example.md) when implementing a concrete feature or when a full handwritten model, repository, provider, page, and component example would reduce ambiguity.
196
+- Read [references/pagination-state-notifier-example.md](references/pagination-state-notifier-example.md) when implementing page-number pagination in a project that already uses `StateNotifierProvider`, or when the user asks for an indexed pagination controller pattern.
skills/flutter-feature-based-architecture/agents/openai.yaml
new
+4
@@ -0,0 +1,4 @@
1
+interface:
2
+ display_name: "Flutter Feature Architecture"
3
+ short_description: "Handwritten componentized Flutter features"
4
+ default_prompt: "Use $flutter-feature-based-architecture to add a handwritten, componentized Flutter feature with Riverpod."
skills/flutter-feature-based-architecture/references/handwritten-feature-example.md
new
+210
@@ -0,0 +1,210 @@
1
+# Handwritten Feature Example
2
+
3
+Use this as a compact reference for new handwritten Riverpod code. Adapt it to the project's repository, error, pagination, and serialization conventions.
4
+
5
+## Contents
6
+
7
+- [Model](#model)
8
+- [Repository](#repository)
9
+- [Providers](#providers)
10
+- [Page](#page)
11
+- [Components](#components)
12
+
13
+## Model
14
+
15
+```dart
16
+// features/orders/data/models/order_model.dart
17
+class OrderModel {
18
+ const OrderModel({
19
+ required this.id,
20
+ required this.customerName,
21
+ required this.total,
22
+ });
23
+
24
+ final String id;
25
+ final String customerName;
26
+ final double total;
27
+
28
+ factory OrderModel.fromJson(Map<String, dynamic> json) {
29
+ return OrderModel(
30
+ id: json['id'] as String? ?? '',
31
+ customerName: json['customerName'] as String? ?? '',
32
+ total: (json['total'] as num?)?.toDouble() ?? 0,
33
+ );
34
+ }
35
+
36
+ Map<String, dynamic> toJson() {
37
+ return {
38
+ 'id': id,
39
+ 'customerName': customerName,
40
+ 'total': total,
41
+ };
42
+ }
43
+
44
+ OrderModel copyWith({
45
+ String? id,
46
+ String? customerName,
47
+ double? total,
48
+ }) {
49
+ return OrderModel(
50
+ id: id ?? this.id,
51
+ customerName: customerName ?? this.customerName,
52
+ total: total ?? this.total,
53
+ );
54
+ }
55
+
56
+ @override
57
+ bool operator ==(Object other) {
58
+ return identical(this, other) ||
59
+ other is OrderModel &&
60
+ other.id == id &&
61
+ other.customerName == customerName &&
62
+ other.total == total;
63
+ }
64
+
65
+ @override
66
+ int get hashCode => Object.hash(id, customerName, total);
67
+}
68
+```
69
+
70
+## Repository
71
+
72
+```dart
73
+// features/orders/data/repositories/orders_repository.dart
74
+class OrdersRepository {
75
+ const OrdersRepository(this._client);
76
+
77
+ final ApiClient _client;
78
+
79
+ Future<List<OrderModel>> fetchOrders({String query = ''}) async {
80
+ final response = await _client.get('/orders', query: {'q': query});
81
+ return (response as List)
82
+ .map((json) => OrderModel.fromJson(json as Map<String, dynamic>))
83
+ .toList();
84
+ }
85
+
86
+ Future<void> deleteOrder(String id) {
87
+ return _client.delete('/orders/$id');
88
+ }
89
+}
90
+```
91
+
92
+## Providers
93
+
94
+```dart
95
+// features/orders/providers/orders_providers.dart
96
+final ordersRepositoryProvider = Provider<OrdersRepository>((ref) {
97
+ final client = ref.watch(apiClientProvider);
98
+ return OrdersRepository(client);
99
+});
100
+
101
+final orderSearchQueryProvider = StateProvider.autoDispose<String>((ref) => '');
102
+
103
+final ordersProvider =
104
+ AsyncNotifierProvider.autoDispose<OrdersNotifier, List<OrderModel>>(
105
+ OrdersNotifier.new,
106
+);
107
+
108
+class OrdersNotifier extends AutoDisposeAsyncNotifier<List<OrderModel>> {
109
+ @override
110
+ Future<List<OrderModel>> build() {
111
+ final repository = ref.watch(ordersRepositoryProvider);
112
+ final query = ref.watch(orderSearchQueryProvider);
113
+ return repository.fetchOrders(query: query);
114
+ }
115
+
116
+ Future<void> deleteOrder(String id) async {
117
+ state = const AsyncLoading<List<OrderModel>>();
118
+ state = await AsyncValue.guard(() async {
119
+ final repository = ref.read(ordersRepositoryProvider);
120
+ await repository.deleteOrder(id);
121
+ final query = ref.read(orderSearchQueryProvider);
122
+ return repository.fetchOrders(query: query);
123
+ });
124
+ }
125
+}
126
+```
127
+
128
+## Page
129
+
130
+```dart
131
+// features/orders/views/orders_page.dart
132
+class OrdersPage extends ConsumerWidget {
133
+ const OrdersPage({super.key});
134
+
135
+ @override
136
+ Widget build(BuildContext context, WidgetRef ref) {
137
+ final orders = ref.watch(ordersProvider);
138
+
139
+ return orders.when(
140
+ loading: () => const Center(child: CircularProgressIndicator()),
141
+ error: (error, stackTrace) => ErrorView(error: error),
142
+ data: (items) => OrdersList(
143
+ orders: items,
144
+ onDelete: (id) => ref.read(ordersProvider.notifier).deleteOrder(id),
145
+ ),
146
+ );
147
+ }
148
+}
149
+```
150
+
151
+## Components
152
+
153
+```dart
154
+// features/orders/views/components/orders_list.dart
155
+class OrdersList extends StatelessWidget {
156
+ const OrdersList({
157
+ super.key,
158
+ required this.orders,
159
+ required this.onDelete,
160
+ });
161
+
162
+ final List<OrderModel> orders;
163
+ final ValueChanged<String> onDelete;
164
+
165
+ @override
166
+ Widget build(BuildContext context) {
167
+ if (orders.isEmpty) {
168
+ return const Center(child: Text('No orders found'));
169
+ }
170
+
171
+ return ListView.separated(
172
+ itemCount: orders.length,
173
+ separatorBuilder: (context, index) => const Divider(height: 1),
174
+ itemBuilder: (context, index) {
175
+ final order = orders[index];
176
+ return OrderListTile(
177
+ order: order,
178
+ onDelete: () => onDelete(order.id),
179
+ );
180
+ },
181
+ );
182
+ }
183
+}
184
+```
185
+
186
+```dart
187
+// features/orders/views/components/order_list_tile.dart
188
+class OrderListTile extends StatelessWidget {
189
+ const OrderListTile({
190
+ super.key,
191
+ required this.order,
192
+ required this.onDelete,
193
+ });
194
+
195
+ final OrderModel order;
196
+ final VoidCallback onDelete;
197
+
198
+ @override
199
+ Widget build(BuildContext context) {
200
+ return ListTile(
201
+ title: Text(order.customerName),
202
+ subtitle: Text('\$${order.total.toStringAsFixed(2)}'),
203
+ trailing: IconButton(
204
+ icon: const Icon(Icons.delete_outline),
205
+ onPressed: onDelete,
206
+ ),
207
+ );
208
+ }
209
+}
210
+```
skills/flutter-feature-based-architecture/references/pagination-state-notifier-example.md
new
+194
@@ -0,0 +1,194 @@
1
+# Pagination StateNotifier Example
2
+
3
+Use this reference when a project already standardizes on `StateNotifierProvider` or when the user specifically asks for this indexed page-pagination style. For new projects, prefer the main skill's current Riverpod defaults first.
4
+
5
+## Contents
6
+
7
+- [State](#state)
8
+- [Provider and Controller](#provider-and-controller)
9
+- [UI Trigger Guidance](#ui-trigger-guidance)
10
+
11
+## State
12
+
13
+Keep pagination state immutable and handwritten. Track the loaded items, next page, initial load, pagination spinner, error text, and whether more data is available.
14
+
15
+```dart
16
+import 'package:flutter/foundation.dart';
17
+
18
+import '../data/models/article_model.dart';
19
+
20
+class PostPagination {
21
+ static const pageSize = 10;
22
+
23
+ const PostPagination({
24
+ required this.posts,
25
+ required this.page,
26
+ required this.errorMessage,
27
+ required this.initialLoaded,
28
+ required this.isPaginationLoading,
29
+ required this.hasMore,
30
+ });
31
+
32
+ const PostPagination.initial()
33
+ : posts = const [],
34
+ page = 1,
35
+ errorMessage = '',
36
+ initialLoaded = false,
37
+ isPaginationLoading = false,
38
+ hasMore = true;
39
+
40
+ final List<ArticleModel> posts;
41
+ final int page;
42
+ final String errorMessage;
43
+ final bool initialLoaded;
44
+ final bool isPaginationLoading;
45
+ final bool hasMore;
46
+
47
+ bool get hasRefreshError {
48
+ return errorMessage.isNotEmpty && posts.length <= pageSize;
49
+ }
50
+
51
+ PostPagination copyWith({
52
+ List<ArticleModel>? posts,
53
+ int? page,
54
+ String? errorMessage,
55
+ bool? initialLoaded,
56
+ bool? isPaginationLoading,
57
+ bool? hasMore,
58
+ }) {
59
+ return PostPagination(
60
+ posts: posts ?? this.posts,
61
+ page: page ?? this.page,
62
+ errorMessage: errorMessage ?? this.errorMessage,
63
+ initialLoaded: initialLoaded ?? this.initialLoaded,
64
+ isPaginationLoading: isPaginationLoading ?? this.isPaginationLoading,
65
+ hasMore: hasMore ?? this.hasMore,
66
+ );
67
+ }
68
+
69
+ @override
70
+ bool operator ==(Object other) {
71
+ return identical(this, other) ||
72
+ other is PostPagination &&
73
+ listEquals(other.posts, posts) &&
74
+ other.page == page &&
75
+ other.errorMessage == errorMessage &&
76
+ other.initialLoaded == initialLoaded &&
77
+ other.isPaginationLoading == isPaginationLoading &&
78
+ other.hasMore == hasMore;
79
+ }
80
+
81
+ @override
82
+ int get hashCode {
83
+ return Object.hash(
84
+ Object.hashAll(posts),
85
+ page,
86
+ errorMessage,
87
+ initialLoaded,
88
+ isPaginationLoading,
89
+ hasMore,
90
+ );
91
+ }
92
+}
93
+```
94
+
95
+## Provider and Controller
96
+
97
+Keep widget APIs out of the controller. Do not import `material.dart` or use `WidgetsBinding` here. Let the provider create the controller and start the first page load.
98
+
99
+```dart
100
+import 'dart:async';
101
+
102
+import 'package:flutter_riverpod/flutter_riverpod.dart';
103
+
104
+import '../data/models/article_model.dart';
105
+import '../data/repositories/post_repository.dart';
106
+import 'post_pagination.dart';
107
+
108
+final recentPostsControllerProvider =
109
+ StateNotifierProvider.autoDispose<RecentPostsController, PostPagination>(
110
+ (ref) {
111
+ final repository = ref.watch(postRepositoryProvider);
112
+ final controller = RecentPostsController(repository);
113
+ unawaited(controller.loadNextPage());
114
+ return controller;
115
+ },
116
+);
117
+
118
+class RecentPostsController extends StateNotifier<PostPagination> {
119
+ RecentPostsController(this._repository) : super(const PostPagination.initial());
120
+
121
+ final PostRepository _repository;
122
+
123
+ bool _isAlreadyLoading = false;
124
+ int _requestGeneration = 0;
125
+
126
+ Future<void> loadNextPage() async {
127
+ if (_isAlreadyLoading || !state.hasMore) return;
128
+
129
+ _isAlreadyLoading = true;
130
+ final requestGeneration = _requestGeneration;
131
+ final pageToLoad = state.page;
132
+ final isFirstPage = pageToLoad == 1;
133
+
134
+ state = state.copyWith(
135
+ errorMessage: '',
136
+ isPaginationLoading: !isFirstPage,
137
+ );
138
+
139
+ try {
140
+ final fetched = await _repository.getAllPosts(pageNumber: pageToLoad);
141
+
142
+ if (!mounted || requestGeneration != _requestGeneration) return;
143
+
144
+ final posts = fetched
145
+ .map((post) => post.copyWith(heroTag: '${post.link}recents'))
146
+ .toList();
147
+
148
+ state = state.copyWith(
149
+ posts: [...state.posts, ...posts],
150
+ page: pageToLoad + 1,
151
+ initialLoaded: true,
152
+ isPaginationLoading: false,
153
+ hasMore: posts.length >= PostPagination.pageSize,
154
+ );
155
+ } on Exception {
156
+ if (!mounted || requestGeneration != _requestGeneration) return;
157
+
158
+ state = state.copyWith(
159
+ errorMessage: 'Fetch Error',
160
+ initialLoaded: true,
161
+ isPaginationLoading: false,
162
+ );
163
+ } finally {
164
+ if (requestGeneration == _requestGeneration) {
165
+ _isAlreadyLoading = false;
166
+ }
167
+ }
168
+ }
169
+
170
+ void handleVisibleIndex(int index) {
171
+ final itemPosition = index + 1;
172
+ final reachedPageBoundary =
173
+ itemPosition % PostPagination.pageSize == 0 && itemPosition != 0;
174
+ final viewedPage = itemPosition ~/ PostPagination.pageSize;
175
+
176
+ if (reachedPageBoundary && viewedPage + 1 >= state.page) {
177
+ loadNextPage();
178
+ }
179
+ }
180
+
181
+ Future<void> refresh() async {
182
+ _requestGeneration++;
183
+ _isAlreadyLoading = false;
184
+ state = const PostPagination.initial();
185
+ await loadNextPage();
186
+ }
187
+}
188
+```
189
+
190
+## UI Trigger Guidance
191
+
192
+Use `handleVisibleIndex` when a list component has a stable visible-index callback. Avoid calling pagination triggers directly from `itemBuilder`; render callbacks can run repeatedly for reasons unrelated to user scroll intent.
193
+
194
+If the UI has only a standard `ListView`, prefer triggering `loadNextPage()` from a scroll notification, scroll controller, or explicit "load more" affordance. Keep the list UI in `views/components/` as `StatelessWidget` components and pass callbacks from the page.
test/features/on_device/imported_gguf_model_repository_test.dart
+1
-1
@@ -3,7 +3,7 @@ import 'dart:io';
3
4
import 'package:dio/dio.dart';
5
import 'package:flutter_test/flutter_test.dart';
6
-import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
6
+import 'package:localmind/features/on_device/data/repositories/imported_gguf_model_repository.dart';
7
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
8
import 'package:shared_preferences/shared_preferences.dart';
9
test/features/on_device/on_device_imported_models_provider_test.dart
+1
-1
@@ -3,7 +3,7 @@ import 'dart:convert';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:flutter_test/flutter_test.dart';
5
import 'package:localmind/core/providers/storage_providers.dart';
6
-import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
6
+import 'package:localmind/features/on_device/data/repositories/imported_gguf_model_repository.dart';
7
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
8
import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
9
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
test/features/servers/server_api_service_test.dart
+1
-1
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
2
import 'package:flutter_test/flutter_test.dart';
3
import 'package:localmind/core/models/enums.dart';
4
import 'package:localmind/features/servers/data/models/server.dart';
5
-import 'package:localmind/features/servers/data/server_api_service.dart';
5
+import 'package:localmind/features/servers/data/repositories/server_api_service.dart';
6
7
class TestInterceptor extends Interceptor {
8
final dynamic responseData;
test/features/servers/server_setup_ui_test.dart
+1
-1
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:flutter_test/flutter_test.dart';
4
import 'package:localmind/core/theme/app_theme.dart';
5
-import 'package:localmind/features/onboarding/screens/onboarding_server_type_screen.dart';
5
+import 'package:localmind/features/onboarding/views/onboarding_server_type_screen.dart';
6
import 'package:localmind/features/servers/views/components/https_scheme_hint.dart';
7
import 'package:localmind/l10n/app_localizations.dart';
8
import 'package:shadcn_ui/shadcn_ui.dart';