马良AI写作初始化仓库
This commit is contained in:
217
AINoval/lib/screens/ai_config/ai_config_management_screen.dart
Normal file
217
AINoval/lib/screens/ai_config/ai_config_management_screen.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/config/app_config.dart'; // <<< Import AppConfig
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/services/api_service/base/api_client.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
import 'package:ainoval/widgets/common/theme_toggle_button.dart';
|
||||
|
||||
import 'widgets/add_edit_ai_config_dialog.dart';
|
||||
import 'widgets/ai_config_list_item.dart';
|
||||
|
||||
class AiConfigManagementScreen extends StatelessWidget {
|
||||
const AiConfigManagementScreen({super.key});
|
||||
|
||||
// TODO: Replace with proper dependency injection for repository
|
||||
static final _tempApiClient =
|
||||
ApiClient(); // Temporary - use injected instance
|
||||
static final UserAIModelConfigRepository _repository =
|
||||
UserAIModelConfigRepositoryImpl(apiClient: _tempApiClient); // Temporary
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// <<< Get userId from AppConfig >>>
|
||||
// Ensure userId is available before navigating here, or handle null case
|
||||
final String? currentUserId = AppConfig.userId; // Allow null initially
|
||||
|
||||
// Show an error/loading state if userId is null and required
|
||||
if (currentUserId == null) {
|
||||
// <<< Check for null
|
||||
return Scaffold(
|
||||
// appBar: AppBar(title: Text(l10n.errorTitle)), // TODO: Add l10n.errorTitle='错误'
|
||||
appBar: AppBar(title: const Text('错误')), // Placeholder
|
||||
// body: Center(child: Text(l10n.errorUserNotLoggedIn)) // TODO: Add l10n.errorUserNotLoggedIn = '无法加载配置:用户未登录。'
|
||||
body: const Center(child: Text('无法加载配置:用户未登录。')) // Placeholder
|
||||
); // <<< 修正: 移除了多余的括号并添加了分号
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
// Use ! because we checked for null above
|
||||
create: (context) => AiConfigBloc(repository: _repository)
|
||||
..add(LoadAiConfigs(userId: currentUserId)),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
// TODO: Add l10n.aiModelConfigTitle string
|
||||
// title: Text(l10n.aiModelConfigTitle), // Placeholder 'AI 模型配置'
|
||||
title: const Text('AI 模型配置'), // Placeholder
|
||||
actions: [
|
||||
const ThemeToggleButton(),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<AiConfigBloc, AiConfigState>(
|
||||
listener: (context, state) {
|
||||
if (state.actionStatus == AiConfigActionStatus.error &&
|
||||
state.actionErrorMessage != null) {
|
||||
TopToast.error(context, '操作失败: ${state.actionErrorMessage!}');
|
||||
}
|
||||
// Optional: Show success message
|
||||
else if (state.actionStatus == AiConfigActionStatus.success) {
|
||||
// Consider showing temporary success confirmations if needed
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(content: Text(l10n.operationSuccessful), backgroundColor: Colors.green), // TODO: Add l10n.operationSuccessful = '操作成功'
|
||||
// );
|
||||
// Reset action status after showing message? Maybe handle in BLoC directly.
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.status == AiConfigStatus.loading &&
|
||||
state.configs.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.status == AiConfigStatus.error && state.configs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Text(l10n.errorLoadingConfig, style: TextStyle(color: Colors.red)), // TODO: Add l10n.errorLoadingConfig = '加载配置时出错'
|
||||
const Text('加载配置时出错',
|
||||
style: TextStyle(color: Colors.red)), // Placeholder
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(state.errorMessage!),
|
||||
),
|
||||
ElevatedButton(
|
||||
// Use ! because userId is checked non-null here
|
||||
onPressed: () => context
|
||||
.read<AiConfigBloc>()
|
||||
.add(LoadAiConfigs(userId: currentUserId)),
|
||||
// child: Text(l10n.retry), // TODO: Add l10n.retry = '重试'
|
||||
child: const Text('重试'), // Placeholder
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
final configs = state.configs;
|
||||
final bool isActionLoading =
|
||||
state.actionStatus == AiConfigActionStatus.loading;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (configs.isEmpty && state.status != AiConfigStatus.loading)
|
||||
// Center(child: Text(l10n.noConfigsFound)), // TODO: Add l10n.noConfigsFound = '未找到任何配置'
|
||||
const Center(child: Text('未找到任何配置')), // Placeholder
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80), // Add padding to avoid FAB overlap
|
||||
itemCount: configs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final config = configs[index];
|
||||
// Pass specific loading state for the item if we track it by ID, otherwise use global action loading state
|
||||
// bool itemIsLoading = isActionLoading && state.loadingConfigId == config.id; // Need state.loadingConfigId
|
||||
|
||||
return AiConfigListItem(
|
||||
config: config,
|
||||
// If not tracking individual item loading, disable buttons globally during action
|
||||
isLoading: isActionLoading,
|
||||
// Use ! for userId
|
||||
onEdit: () => _showAddEditDialog(context, currentUserId,
|
||||
config: config), // Pass userId
|
||||
onDelete: () => _showDeleteConfirmation(
|
||||
context, currentUserId, config), // Pass userId
|
||||
onValidate: () => context.read<AiConfigBloc>().add(
|
||||
ValidateAiConfig(
|
||||
userId: currentUserId,
|
||||
configId: config.id)), // Use userId
|
||||
onSetDefault: () => context.read<AiConfigBloc>().add(
|
||||
SetDefaultAiConfig(
|
||||
userId: currentUserId,
|
||||
configId: config.id)), // Use userId
|
||||
);
|
||||
},
|
||||
),
|
||||
// Optional: Global loading indicator overlay
|
||||
// if (isActionLoading)
|
||||
// Positioned.fill(
|
||||
// child: Container(
|
||||
// color: Colors.black.withOpacity(0.1),
|
||||
// child: const Center(child: CircularProgressIndicator()),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
// Use ! for userId
|
||||
onPressed: () =>
|
||||
_showAddEditDialog(context, currentUserId), // Pass userId
|
||||
// tooltip: l10n.addConfigTooltip, // TODO: Add l10n.addConfigTooltip = '添加配置'
|
||||
tooltip: '添加配置', // Placeholder
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// <<< Add userId parameter >>>
|
||||
void _showAddEditDialog(BuildContext context, String userId,
|
||||
{UserAIModelConfigModel? config}) {
|
||||
final aiConfigBloc =
|
||||
context.read<AiConfigBloc>(); // Get BLoC from current context
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible:
|
||||
false, // Prevent closing while dialog action is in progress
|
||||
builder: (_) => BlocProvider.value(
|
||||
// Provide the *existing* BLoC instance to the dialog
|
||||
value: aiConfigBloc,
|
||||
child: AddEditAiConfigDialog(
|
||||
userId: userId, // Pass userId from parameter
|
||||
configToEdit: config,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// <<< Add userId parameter >>>
|
||||
void _showDeleteConfirmation(
|
||||
BuildContext context, String userId, UserAIModelConfigModel config) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
// title: Text(l10n.deleteConfigTitle), // TODO: Add l10n.deleteConfigTitle = '删除配置'
|
||||
title: const Text('删除配置'), // Placeholder
|
||||
// content: Text(l10n.deleteConfigConfirmation(config.alias)), // TODO: Add l10n.deleteConfigConfirmation
|
||||
content: Text('确定要删除配置 ${config.alias} 吗?此操作无法撤销。'), // Placeholder
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
// child: Text(l10n.cancel), // TODO: Add l10n.cancel = '取消'
|
||||
child: const Text('取消'), // Placeholder
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
onPressed: () {
|
||||
// <<< Use userId from parameter >>>
|
||||
context
|
||||
.read<AiConfigBloc>()
|
||||
.add(DeleteAiConfig(userId: userId, configId: config.id));
|
||||
Navigator.pop(ctx); // Close confirmation dialog
|
||||
},
|
||||
// child: Text(l10n.delete), // TODO: Add l10n.delete = '删除'
|
||||
child: const Text('删除'), // Placeholder
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
|
||||
class AddEditAiConfigDialog extends StatefulWidget {
|
||||
// Needed for add/update events
|
||||
|
||||
const AddEditAiConfigDialog({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.configToEdit,
|
||||
});
|
||||
final UserAIModelConfigModel? configToEdit;
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
State<AddEditAiConfigDialog> createState() => _AddEditAiConfigDialogState();
|
||||
}
|
||||
|
||||
class _AddEditAiConfigDialogState extends State<AddEditAiConfigDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _aliasController;
|
||||
late TextEditingController _apiKeyController;
|
||||
late TextEditingController _apiEndpointController;
|
||||
|
||||
String? _selectedProvider;
|
||||
String? _selectedModel;
|
||||
bool _isLoadingProviders = false;
|
||||
bool _isLoadingModels = false;
|
||||
bool _isSaving = false; // Track internal saving state
|
||||
|
||||
List<String> _providers = [];
|
||||
List<String> _models = [];
|
||||
|
||||
bool get _isEditMode => widget.configToEdit != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize controllers
|
||||
_aliasController =
|
||||
TextEditingController(text: widget.configToEdit?.alias ?? '');
|
||||
_apiKeyController =
|
||||
TextEditingController(); // API key is never pre-filled for editing
|
||||
_apiEndpointController =
|
||||
TextEditingController(text: widget.configToEdit?.apiEndpoint ?? '');
|
||||
_selectedProvider = widget.configToEdit?.provider;
|
||||
_selectedModel = widget.configToEdit?.modelName;
|
||||
|
||||
// Request providers immediately if needed
|
||||
if (!_isEditMode) {
|
||||
_loadProviders();
|
||||
} else if (_selectedProvider != null) {
|
||||
// If editing, load providers to populate dropdown, and models for the selected provider
|
||||
_loadProviders();
|
||||
_loadModels(_selectedProvider!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_aliasController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
_apiEndpointController.dispose();
|
||||
// Clear models when dialog is closed
|
||||
context.read<AiConfigBloc>().add(ClearProviderModels());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
setState(() {
|
||||
_isLoadingProviders = true;
|
||||
});
|
||||
// Use the Bloc provided via context
|
||||
context.read<AiConfigBloc>().add(LoadAvailableProviders());
|
||||
}
|
||||
|
||||
void _loadModels(String provider) {
|
||||
setState(() {
|
||||
_isLoadingModels = true;
|
||||
_selectedModel = null; // Reset model selection when provider changes
|
||||
_models = []; // Clear previous models
|
||||
});
|
||||
context.read<AiConfigBloc>().add(LoadModelsForProvider(provider: provider));
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
final bloc = context.read<AiConfigBloc>();
|
||||
|
||||
if (_isEditMode) {
|
||||
bloc.add(UpdateAiConfig(
|
||||
userId: widget.userId,
|
||||
configId: widget.configToEdit!.id,
|
||||
alias: _aliasController.text.trim().isEmpty
|
||||
? null
|
||||
: _aliasController.text
|
||||
.trim(), // Only send if not empty, or let backend decide
|
||||
apiKey: _apiKeyController.text.trim().isEmpty
|
||||
? null
|
||||
: _apiKeyController.text.trim(), // Only send if changed
|
||||
apiEndpoint: _apiEndpointController.text
|
||||
.trim(), // Send empty string to clear endpoint
|
||||
));
|
||||
} else {
|
||||
bloc.add(AddAiConfig(
|
||||
userId: widget.userId,
|
||||
provider: _selectedProvider!,
|
||||
modelName: _selectedModel!,
|
||||
apiKey: _apiKeyController.text.trim(),
|
||||
alias: _aliasController.text.trim().isEmpty
|
||||
? _selectedModel
|
||||
: _aliasController.text
|
||||
.trim(), // Default alias to model name if empty
|
||||
apiEndpoint: _apiEndpointController.text.trim(),
|
||||
));
|
||||
}
|
||||
// Listen for completion state change to close dialog
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return BlocListener<AiConfigBloc, AiConfigState>(
|
||||
listener: (context, state) {
|
||||
// Update local lists and loading states based on Bloc state
|
||||
setState(() {
|
||||
_providers = state.availableProviders;
|
||||
_isLoadingProviders =
|
||||
false; // Assuming load finishes once providers appear
|
||||
|
||||
if (state.selectedProviderForModels == _selectedProvider) {
|
||||
_models = state.modelsForProvider;
|
||||
_isLoadingModels = false;
|
||||
} else if (_selectedProvider != null &&
|
||||
state.selectedProviderForModels != _selectedProvider) {
|
||||
// Handle case where Bloc state is for a different provider than selected
|
||||
_isLoadingModels = false; // Stop loading indicator
|
||||
}
|
||||
|
||||
// Handle save completion or error
|
||||
if (_isSaving) {
|
||||
if (state.actionStatus == AiConfigActionStatus.success ||
|
||||
state.actionStatus == AiConfigActionStatus.error) {
|
||||
_isSaving = false;
|
||||
if (state.actionStatus == AiConfigActionStatus.success &&
|
||||
mounted) {
|
||||
Navigator.of(context).pop(); // Close dialog on success
|
||||
}
|
||||
// Error message is handled by the main screen's listener
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
child: AlertDialog(
|
||||
// title: Text(_isEditMode ? l10n.editConfigTitle : l10n.addConfigTitle), // TODO: Add l10n
|
||||
title: Text(_isEditMode ? '编辑配置' : '添加配置'), // Placeholder
|
||||
content: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
// --- Provider Dropdown ---
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedProvider,
|
||||
// hint: Text(l10n.selectProviderHint), // TODO: Add l10n
|
||||
hint: const Text('选择提供商'), // Placeholder
|
||||
isExpanded: true,
|
||||
onChanged: _isEditMode
|
||||
? null // Cannot change provider when editing
|
||||
: (String? newValue) {
|
||||
if (newValue != null &&
|
||||
newValue != _selectedProvider) {
|
||||
setState(() {
|
||||
_selectedProvider = newValue;
|
||||
_selectedModel = null; // Reset model
|
||||
_models = []; // Clear models
|
||||
});
|
||||
_loadModels(newValue);
|
||||
}
|
||||
},
|
||||
items:
|
||||
_providers.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(value),
|
||||
);
|
||||
}).toList(),
|
||||
// validator: (value) => value == null ? l10n.providerRequired : null, // TODO: Add l10n
|
||||
validator: (value) =>
|
||||
value == null ? '请选择提供商' : null, // Placeholder
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.providerLabel, // TODO: Add l10n
|
||||
labelText: '提供商', // Placeholder
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _isLoadingProviders
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
),
|
||||
disabledHint: _isEditMode
|
||||
? Text(_selectedProvider ?? '')
|
||||
: null, // Show selected value when disabled
|
||||
style: _isEditMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Model Dropdown ---
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedModel,
|
||||
// hint: Text(l10n.selectModelHint), // TODO: Add l10n
|
||||
hint: const Text('选择模型'), // Placeholder
|
||||
isExpanded: true,
|
||||
onChanged: _isEditMode ||
|
||||
_selectedProvider == null ||
|
||||
_isLoadingModels
|
||||
? null // Cannot change model when editing or provider not selected or loading
|
||||
: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedModel = newValue;
|
||||
});
|
||||
},
|
||||
items: _models.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(value, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}).toList(),
|
||||
// validator: (value) => value == null ? l10n.modelRequired : null, // TODO: Add l10n
|
||||
validator: (value) =>
|
||||
value == null ? '请选择模型' : null, // Placeholder
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.modelLabel, // TODO: Add l10n
|
||||
labelText: '模型', // Placeholder
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _isLoadingModels
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
),
|
||||
disabledHint: _isEditMode
|
||||
? Text(_selectedModel ?? '')
|
||||
: null, // Show selected value when disabled
|
||||
style: _isEditMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Alias ---
|
||||
TextFormField(
|
||||
controller: _aliasController,
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.aliasLabel, // TODO: Add l10n
|
||||
labelText: '别名 (可选)', // Placeholder
|
||||
// hintText: l10n.aliasHint( _selectedModel ?? 'model'), // TODO: Add l10n
|
||||
hintText: '例如:我的${_selectedModel ?? '模型'}', // Placeholder
|
||||
border: const OutlineInputBorder()),
|
||||
// No validator, alias is optional or defaults
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- API Key ---
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.apiKeyLabel, // TODO: Add l10n
|
||||
labelText: 'API Key', // Placeholder
|
||||
// hintText: _isEditMode ? l10n.apiKeyEditHint : null, // TODO: Add l10n
|
||||
hintText: _isEditMode ? '留空则不更新' : null, // Placeholder
|
||||
border: const OutlineInputBorder()),
|
||||
validator: (value) {
|
||||
if (!_isEditMode &&
|
||||
(value == null || value.trim().isEmpty)) {
|
||||
// return l10n.apiKeyRequired; // TODO: Add l10n
|
||||
return 'API Key 不能为空'; // Placeholder
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- API Endpoint ---
|
||||
TextFormField(
|
||||
controller: _apiEndpointController,
|
||||
decoration: const InputDecoration(
|
||||
// labelText: l10n.apiEndpointLabel, // TODO: Add l10n
|
||||
labelText: 'API Endpoint (可选)', // Placeholder
|
||||
// hintText: l10n.apiEndpointHint, // TODO: Add l10n
|
||||
hintText: '例如: https://api.openai.com/v1', // Placeholder
|
||||
border: OutlineInputBorder()),
|
||||
// No validator, endpoint is optional
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
|
||||
// child: Text(l10n.cancel), // TODO: Add l10n
|
||||
child: const Text('取消'), // Placeholder
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _submitForm,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
// : Text(_isEditMode ? l10n.saveChanges : l10n.add), // TODO: Add l10n
|
||||
: Text(_isEditMode ? '保存更改' : '添加'), // Placeholder
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add localization strings: editConfigTitle, addConfigTitle, selectProviderHint, providerRequired, providerLabel,
|
||||
// selectModelHint, modelRequired, modelLabel, aliasLabel, aliasHint, apiKeyLabel, apiKeyEditHint, apiKeyRequired,
|
||||
// apiEndpointLabel, apiEndpointHint, cancel, saveChanges, add
|
||||
268
AINoval/lib/screens/ai_config/widgets/ai_config_list_item.dart
Normal file
268
AINoval/lib/screens/ai_config/widgets/ai_config_list_item.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
|
||||
class AiConfigListItem extends StatelessWidget {
|
||||
// Indicate if an action is pending for this item (optional, for finer control)
|
||||
|
||||
const AiConfigListItem({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onValidate,
|
||||
required this.onSetDefault,
|
||||
this.isLoading = false, // Default to false
|
||||
});
|
||||
final UserAIModelConfigModel config;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onValidate;
|
||||
final VoidCallback onSetDefault;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final disabledColor = theme.disabledColor;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surface.withAlpha(255),
|
||||
isDark
|
||||
? theme.colorScheme.surfaceContainerHighest.withAlpha(255)
|
||||
: theme.colorScheme.surfaceContainerLowest.withAlpha(255),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withAlpha(13) // 0.05 opacity
|
||||
: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withAlpha(51) // 0.2 opacity
|
||||
: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
blurRadius: 8,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.alias,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (config.isDefault)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.green.withAlpha(51) // 0.2 opacity
|
||||
: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
blurRadius: 2,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text('默认',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.green.shade300 : Colors.green.shade900,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('删除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHighest.withAlpha(77) // 0.3 opacity
|
||||
: theme.colorScheme.surfaceContainerLowest.withAlpha(128), // 0.5 opacity
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${config.provider} / ${config.modelName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? theme.colorScheme.onSurface.withAlpha(230) // 0.9 opacity
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (config.apiEndpoint != null && config.apiEndpoint!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.apiEndpoint!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(179), // 0.7 opacity
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: config.isValidated
|
||||
? (isDark ? Colors.green.withAlpha(26) : Colors.green.withAlpha(13)) // 0.1/0.05 opacity
|
||||
: (isDark ? Colors.grey.withAlpha(26) : Colors.grey.withAlpha(13)), // 0.1/0.05 opacity
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: config.isValidated
|
||||
? Colors.green.withAlpha(77) // 0.3 opacity
|
||||
: Colors.grey.withAlpha(77), // 0.3 opacity
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
config.isValidated ? Icons.check_circle : Icons.error_outline,
|
||||
color: config.isValidated
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.isValidated
|
||||
? '已验证'
|
||||
: '未验证',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: config.isValidated
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
fontStyle: config.isValidated
|
||||
? FontStyle.normal
|
||||
: FontStyle.italic,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.update,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
DateFormat.yMd().add_jm().format(config.updatedAt.toLocal()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Divider(height: 1, thickness: 0.5),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (!config.isValidated)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.sync, size: 16),
|
||||
label: const Text('验证'),
|
||||
onPressed: isLoading ? null : onValidate,
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onSecondaryContainer,
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
if (config.isValidated && !config.isDefault)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.star_border, size: 16),
|
||||
label: const Text('设为默认'),
|
||||
onPressed: isLoading ? null : onSetDefault,
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
124
AINoval/lib/screens/ai_config/widgets/ai_model_selector.dart
Normal file
124
AINoval/lib/screens/ai_config/widgets/ai_model_selector.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
|
||||
// Callback type when a config is selected
|
||||
typedef AiConfigSelectedCallback = void Function(
|
||||
UserAIModelConfigModel? selectedConfig);
|
||||
|
||||
class AiModelSelector extends StatelessWidget {
|
||||
// Allow pre-selecting a config
|
||||
|
||||
const AiModelSelector({
|
||||
super.key,
|
||||
required this.onConfigSelected,
|
||||
this.initialSelection,
|
||||
});
|
||||
final AiConfigSelectedCallback onConfigSelected;
|
||||
final UserAIModelConfigModel? initialSelection;
|
||||
|
||||
// Helper to find the config by ID in the list
|
||||
UserAIModelConfigModel? _findConfigById(
|
||||
List<UserAIModelConfigModel> configs, String? id) {
|
||||
if (id == null) return null;
|
||||
return configs.firstWhereOrNull((c) => c.id == id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// Assume AiConfigBloc is provided higher up the tree
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, state) {
|
||||
final validatedConfigs = state.validatedConfigs;
|
||||
// Determine the current selection based on initialSelection or state's default
|
||||
UserAIModelConfigModel? currentSelection =
|
||||
_findConfigById(validatedConfigs, initialSelection?.id) ??
|
||||
state.defaultConfig;
|
||||
|
||||
// Ensure the current selection is actually in the validated list
|
||||
if (currentSelection != null &&
|
||||
!validatedConfigs.any((c) => c.id == currentSelection!.id)) {
|
||||
currentSelection = validatedConfigs.firstWhereOrNull((_) => true);
|
||||
}
|
||||
|
||||
if (state.status == AiConfigStatus.loading &&
|
||||
validatedConfigs.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
if (validatedConfigs.isEmpty) {
|
||||
return const Tooltip(
|
||||
message: '前往设置添加或验证模型',
|
||||
child: Chip(
|
||||
avatar: Icon(Icons.error_outline, color: Colors.orange),
|
||||
label: Text('无可用模型'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DropdownButton<UserAIModelConfigModel>(
|
||||
value: currentSelection,
|
||||
hint: const Text('选择AI模型'),
|
||||
underline: Container(),
|
||||
onChanged: (UserAIModelConfigModel? newValue) {
|
||||
onConfigSelected(newValue);
|
||||
},
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return validatedConfigs.map<Widget>((UserAIModelConfigModel item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.smart_toy_outlined, size: 16),
|
||||
label: Text(item.alias,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
items: validatedConfigs.map<DropdownMenuItem<UserAIModelConfigModel>>(
|
||||
(UserAIModelConfigModel config) {
|
||||
return DropdownMenuItem<UserAIModelConfigModel>(
|
||||
value: config,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(config.alias),
|
||||
if (config.isDefault)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0),
|
||||
child: Icon(Icons.star, size: 14, color: Colors.amber),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'(${config.provider})',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add localization strings to .arb files:
|
||||
// - manageConfigsTooltip: '前往设置添加或验证模型'
|
||||
// - noValidatedConfigsFound: '无可用模型'
|
||||
// - selectAiModelHint: '选择AI模型'
|
||||
Reference in New Issue
Block a user