@tduncan / Cortana / commits / b73cbb1

refactor: moved model picker and realigned components

abdulmominsakib committed Jun 30, 2026 at 20:06 UTC b73cbb160ae29ff83802114aa953dbb3591d07d2
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';