AI界面的开发
This commit is contained in:
1
assets/svgs/image_icon.svg
Normal file
1
assets/svgs/image_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
1
assets/svgs/send_icon.svg
Normal file
1
assets/svgs/send_icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send-icon lucide-send"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -32,6 +32,8 @@ class UserStorageImpl implements UserStorage {
|
||||
@override
|
||||
Future<void> deleteUser() async {
|
||||
await _prefs.remove(_userKey);
|
||||
//返回登陆页面
|
||||
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
17
lib/features/ai/data/models/chat_message.dart
Normal file
17
lib/features/ai/data/models/chat_message.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
class ChatMessage {
|
||||
final String? text;
|
||||
final String? thinkingText;
|
||||
final File? image;
|
||||
final String? imageBase64;
|
||||
final bool isUserMessage;
|
||||
|
||||
ChatMessage({
|
||||
this.text,
|
||||
this.thinkingText,
|
||||
this.image,
|
||||
this.imageBase64,
|
||||
required this.isUserMessage,
|
||||
});
|
||||
}
|
||||
13
lib/features/ai/data/models/session.dart
Normal file
13
lib/features/ai/data/models/session.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
class Session {
|
||||
final String sessId;
|
||||
final String title;
|
||||
|
||||
Session({required this.sessId, required this.title});
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> json) {
|
||||
return Session(
|
||||
sessId: json['sessId'],
|
||||
title: json['title'],
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/features/ai/presentation/bloc/ai_cubit.dart
Normal file
178
lib/features/ai/presentation/bloc/ai_cubit.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'ai_state.dart';
|
||||
import '../../data/models/chat_message.dart';
|
||||
import '../../data/models/session.dart';
|
||||
|
||||
class AiCubit extends Cubit<AiState> {
|
||||
AiCubit() : super(const AiState()) {
|
||||
createNewSession();
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
final Dio _dio = Dio();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
static const String BASE_URL = 'http://1.95.137.212:30009';
|
||||
final String _userId = '1';
|
||||
|
||||
Future<void> fetchSessions() async {
|
||||
try {
|
||||
final response = await _dio.get('$BASE_URL/sessions', queryParameters: {'userId': _userId});
|
||||
if (response.statusCode == 200 && response.data['code'] == 0) {
|
||||
final sessionsData = response.data['sessions'] as List;
|
||||
emit(state.copyWith(sessions: sessionsData.map((data) => Session.fromJson(data)).toList()));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: "Failed to fetch sessions: $e"));
|
||||
}
|
||||
}
|
||||
|
||||
void createNewSession() {
|
||||
emit(AiState(
|
||||
sessionId: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
messages: [ChatMessage(text: "Welcome to the AI chat!", isUserMessage: false)],
|
||||
status: AiStatus.initial,
|
||||
selectedImageFile: null, // Explicitly set to null
|
||||
sessions: state.sessions, // Preserve existing sessions
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> loadSessionHistory(String sessionId) async {
|
||||
emit(state.copyWith(status: AiStatus.loading, messages: [], sessionId: sessionId));
|
||||
|
||||
try {
|
||||
final response = await _dio.get('$BASE_URL/history', queryParameters: {'sessionId': sessionId});
|
||||
if (response.statusCode == 200 && response.data['code'] == 0) {
|
||||
final messagesData = response.data['messages'] as List;
|
||||
final historyMessages = messagesData.map((data) {
|
||||
return ChatMessage(
|
||||
text: data['content'],
|
||||
thinkingText: data['role'] == 'assistant' ? data['thinking'] : null,
|
||||
imageBase64: data['role'] == 'user' ? data['image_base64'] : null,
|
||||
isUserMessage: data['role'] == 'user',
|
||||
);
|
||||
}).toList();
|
||||
emit(state.copyWith(status: AiStatus.success, messages: historyMessages.reversed.toList()));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: AiStatus.failure, error: "Failed to load session history: $e"));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> analyze({String? prompt, File? image}) async {
|
||||
if (state.status == AiStatus.loading) return;
|
||||
|
||||
String? imageBase64;
|
||||
if (image != null) {
|
||||
final bytes = await image.readAsBytes();
|
||||
imageBase64 = 'data:image/png;base64,${base64Encode(bytes)}';
|
||||
}
|
||||
|
||||
final userMessage = ChatMessage(text: prompt, image: image, isUserMessage: true);
|
||||
final aiPlaceholder = ChatMessage(text: '', thinkingText: '', isUserMessage: false);
|
||||
|
||||
// Create a new state to ensure selectedImageFile is set to null
|
||||
final newState = AiState(
|
||||
status: AiStatus.loading,
|
||||
messages: [aiPlaceholder, userMessage, ...state.messages],
|
||||
selectedImageFile: null, // Clear the selected image
|
||||
sessions: state.sessions,
|
||||
sessionId: state.sessionId,
|
||||
error: state.error,
|
||||
);
|
||||
emit(newState);
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'$BASE_URL/analyze',
|
||||
data: {
|
||||
'userId': _userId,
|
||||
'sessionId': state.sessionId,
|
||||
'userPrompt': prompt,
|
||||
'imageBase64': imageBase64,
|
||||
},
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
);
|
||||
|
||||
String currentPhase = 'thinking';
|
||||
StringBuffer thinkingBuffer = StringBuffer();
|
||||
StringBuffer answerBuffer = StringBuffer();
|
||||
|
||||
final streamSubscription = response.data.stream.listen(
|
||||
(chunk) {
|
||||
final decodedChunk = utf8.decode(chunk);
|
||||
final events = decodedChunk.split('\n\n').where((s) => s.isNotEmpty);
|
||||
|
||||
for (final event in events) {
|
||||
if (event.startsWith('data: ')) {
|
||||
final dataString = event.substring(6);
|
||||
if (dataString == '[DONE]') continue;
|
||||
try {
|
||||
final data = jsonDecode(dataString);
|
||||
final type = data['type'];
|
||||
final content = data['content'] as String;
|
||||
|
||||
if (type == 'phase') {
|
||||
currentPhase = content;
|
||||
} else if (type == 'thinking' && currentPhase == 'thinking') {
|
||||
thinkingBuffer.write(content);
|
||||
} else if (type == 'answer' && currentPhase == 'answer') {
|
||||
answerBuffer.write(content);
|
||||
}
|
||||
|
||||
final updatedMessages = List<ChatMessage>.from(state.messages);
|
||||
updatedMessages[0] = ChatMessage(
|
||||
thinkingText: thinkingBuffer.toString(),
|
||||
text: answerBuffer.toString(),
|
||||
isUserMessage: false,
|
||||
);
|
||||
emit(state.copyWith(messages: updatedMessages));
|
||||
|
||||
} catch (e) {
|
||||
print("Error parsing stream data: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
emit(state.copyWith(status: AiStatus.success));
|
||||
fetchSessions();
|
||||
},
|
||||
onError: (error) {
|
||||
final updatedMessages = List<ChatMessage>.from(state.messages);
|
||||
updatedMessages[0] = ChatMessage(text: "Error: $error", isUserMessage: false);
|
||||
emit(state.copyWith(status: AiStatus.failure, messages: updatedMessages));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
final updatedMessages = List<ChatMessage>.from(state.messages);
|
||||
updatedMessages[0] = ChatMessage(text: "Failed to connect to the server: $e", isUserMessage: false);
|
||||
emit(state.copyWith(status: AiStatus.failure, messages: updatedMessages));
|
||||
}
|
||||
}
|
||||
|
||||
void selectImage(ImageSource source) async {
|
||||
final pickedFile = await _picker.pickImage(source: source);
|
||||
if (pickedFile != null) {
|
||||
emit(state.copyWith(selectedImageFile: File(pickedFile.path)));
|
||||
}
|
||||
}
|
||||
|
||||
void deselectImage() {
|
||||
// Manually create a new state to ensure selectedImageFile is set to null,
|
||||
// bypassing the limitation of the existing copyWith method.
|
||||
emit(AiState(
|
||||
status: state.status,
|
||||
messages: state.messages,
|
||||
sessions: state.sessions,
|
||||
sessionId: state.sessionId,
|
||||
error: state.error,
|
||||
selectedImageFile: null, // This will now correctly clear the file.
|
||||
));
|
||||
}
|
||||
}
|
||||
47
lib/features/ai/presentation/bloc/ai_state.dart
Normal file
47
lib/features/ai/presentation/bloc/ai_state.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../data/models/chat_message.dart';
|
||||
import '../../data/models/session.dart';
|
||||
|
||||
enum AiStatus { initial, loading, success, failure }
|
||||
|
||||
class AiState extends Equatable {
|
||||
const AiState({
|
||||
this.status = AiStatus.initial,
|
||||
this.messages = const <ChatMessage>[],
|
||||
this.sessions = const <Session>[],
|
||||
this.selectedImageFile,
|
||||
this.sessionId,
|
||||
this.error = '',
|
||||
});
|
||||
|
||||
final AiStatus status;
|
||||
final List<ChatMessage> messages;
|
||||
final List<Session> sessions;
|
||||
final File? selectedImageFile;
|
||||
final String? sessionId;
|
||||
final String error;
|
||||
|
||||
AiState copyWith({
|
||||
AiStatus? status,
|
||||
List<ChatMessage>? messages,
|
||||
List<Session>? sessions,
|
||||
File? selectedImageFile,
|
||||
String? sessionId,
|
||||
String? error,
|
||||
}) {
|
||||
return AiState(
|
||||
status: status ?? this.status,
|
||||
messages: messages ?? this.messages,
|
||||
sessions: sessions ?? this.sessions,
|
||||
selectedImageFile: selectedImageFile ?? this.selectedImageFile,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, messages, sessions, selectedImageFile, sessionId, error];
|
||||
}
|
||||
@@ -1,10 +1,414 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../bloc/ai_cubit.dart';
|
||||
import '../bloc/ai_state.dart';
|
||||
import '../../data/models/chat_message.dart';
|
||||
import '../../data/models/session.dart';
|
||||
|
||||
// --- MAIN WIDGET ---
|
||||
|
||||
class AiPage extends StatelessWidget {
|
||||
const AiPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(body: Center(child: Text('Welcome AI page')));
|
||||
return BlocProvider(
|
||||
create: (_) => AiCubit(),
|
||||
child: const AiView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiView extends StatefulWidget {
|
||||
const AiView({super.key});
|
||||
|
||||
@override
|
||||
State<AiView> createState() => _AiViewState();
|
||||
}
|
||||
|
||||
class _AiViewState extends State<AiView> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('AI'),
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'new_session') {
|
||||
context.read<AiCubit>().createNewSession();
|
||||
} else if (value == 'history') {
|
||||
_showHistoryDialog(context);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||||
const PopupMenuItem<String>(value: 'new_session', child: Text('New Session')),
|
||||
const PopupMenuItem<String>(value: 'history', child: Text('History')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocBuilder<AiCubit, AiState>(
|
||||
builder: (context, state) {
|
||||
// Scroll to bottom when new messages are added
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
}
|
||||
});
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, index) => _buildMessageBubble(context, state.messages[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildInputArea(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(BuildContext context, ChatMessage message) {
|
||||
final isUserMessage = message.isUserMessage;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: isUserMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isUserMessage)
|
||||
const CircleAvatar(
|
||||
backgroundImage: AssetImage('assets/images/app_logo_black.png'),
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: isUserMessage ? _buildUserMessageContent(context, message) : _buildAiMessageContent(context, message),
|
||||
),
|
||||
if (isUserMessage)
|
||||
const SizedBox(width: 10), // <-- This was the missing part
|
||||
if (isUserMessage)
|
||||
const CircleAvatar(child: Icon(Icons.person, size: 20), radius: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserMessageContent(BuildContext context, ChatMessage message) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 14.0),
|
||||
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(18)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.image != null)
|
||||
GestureDetector(
|
||||
onTap: () => _showImagePreview(context, imageFile: message.image!),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Image.file(message.image!, width: 200, height: 200, fit: BoxFit.cover),
|
||||
),
|
||||
)
|
||||
else if (message.imageBase64 != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Image.memory(base64Decode(message.imageBase64!.split(',').last), width: 200, height: 200, fit: BoxFit.cover),
|
||||
),
|
||||
if (message.text != null && message.text!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(message.text!, style: const TextStyle(color: Colors.white, fontSize: 16)),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAiMessageContent(BuildContext context, ChatMessage message) {
|
||||
final state = context.watch<AiCubit>().state;
|
||||
final hasThinking = message.thinkingText != null && message.thinkingText!.isNotEmpty;
|
||||
final hasAnswer = message.text != null && message.text!.isNotEmpty;
|
||||
final showThinkingBlock = state.status == AiStatus.loading || hasThinking;
|
||||
|
||||
if (!showThinkingBlock && !hasAnswer) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showThinkingBlock)
|
||||
_buildSection(
|
||||
context,
|
||||
icon: Icons.psychology_outlined,
|
||||
title: "深度思考",
|
||||
content: message.thinkingText,
|
||||
iconColor: Colors.blue.shade700,
|
||||
showSpinner: state.status == AiStatus.loading && !hasThinking,
|
||||
),
|
||||
if (showThinkingBlock && hasAnswer)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Divider(height: 1, color: Colors.grey.shade300),
|
||||
),
|
||||
if (hasAnswer)
|
||||
_buildSection(
|
||||
context,
|
||||
icon: Icons.lightbulb_outline,
|
||||
title: "最终结论",
|
||||
content: message.text!,
|
||||
iconColor: Colors.green.shade700,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(BuildContext context, {required IconData icon, required String title, String? content, Color? iconColor, bool showSpinner = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(icon, size: 20, color: iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: iconColor)),
|
||||
if (showSpinner) ...[
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2.0, color: iconColor)),
|
||||
]
|
||||
]),
|
||||
if (content != null && content.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(content, style: const TextStyle(fontSize: 16, color: Colors.black87, height: 1.4)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(BuildContext context) {
|
||||
final state = context.watch<AiCubit>().state;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.selectedImageFile != null) _buildImagePreviewThumbnail(context),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: SvgPicture.asset('assets/svgs/image_icon.svg', width: 24, height: 24),
|
||||
onPressed: state.status == AiStatus.loading ? null : () => _handleImageSelection(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
enabled: state.status != AiStatus.loading,
|
||||
decoration: InputDecoration(
|
||||
hintText: state.status == AiStatus.loading ? 'AI is thinking...' : 'Enter a message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30.0),
|
||||
borderSide: const BorderSide(width: 0, style: BorderStyle.none),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onSubmitted: state.status == AiStatus.loading ? null : (text) => _handleSendPressed(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: SvgPicture.asset('assets/svgs/send_icon.svg', width: 24, height: 24),
|
||||
onPressed: state.status == AiStatus.loading ? null : () => _handleSendPressed(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePreviewThumbnail(BuildContext context) {
|
||||
final state = context.read<AiCubit>().state;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Image.file(state.selectedImageFile!, width: 72, height: 72, fit: BoxFit.cover),
|
||||
),
|
||||
Positioned(
|
||||
top: -12,
|
||||
right: -12,
|
||||
child: IconButton(
|
||||
icon: const CircleAvatar(
|
||||
backgroundColor: Colors.black54,
|
||||
radius: 12,
|
||||
child: Icon(Icons.close, color: Colors.white, size: 16),
|
||||
),
|
||||
onPressed: () => context.read<AiCubit>().deselectImage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSendPressed(BuildContext context) {
|
||||
final text = _textController.text;
|
||||
final selectedImageFile = context.read<AiCubit>().state.selectedImageFile;
|
||||
if (text.isEmpty && selectedImageFile == null) return;
|
||||
|
||||
context.read<AiCubit>().analyze(prompt: text, image: selectedImageFile);
|
||||
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
void _handleImageSelection(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return SafeArea(
|
||||
child: Wrap(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Gallery'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<AiCubit>().selectImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera),
|
||||
title: const Text('Camera'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<AiCubit>().selectImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showImagePreview(BuildContext context, {File? imageFile, String? imageBase64}) {
|
||||
if (imageFile == null && imageBase64 == null) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ImagePreviewPage(imageFile: imageFile, imageBase64: imageBase64),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHistoryDialog(BuildContext context) {
|
||||
final cubit = context.read<AiCubit>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('History'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: BlocBuilder<AiCubit, AiState>(
|
||||
bloc: cubit,
|
||||
builder: (context, state) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: state.sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
title: Text(state.sessions[index].title),
|
||||
onTap: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
cubit.loadSessionHistory(state.sessions[index].sessId);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close')),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- IMAGE PREVIEW PAGE ---
|
||||
|
||||
class ImagePreviewPage extends StatelessWidget {
|
||||
final File? imageFile;
|
||||
final String? imageBase64;
|
||||
|
||||
const ImagePreviewPage({super.key, this.imageFile, this.imageBase64});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ImageProvider? imageProvider;
|
||||
if (imageFile != null) {
|
||||
imageProvider = FileImage(imageFile!);
|
||||
} else if (imageBase64 != null) {
|
||||
imageProvider = MemoryImage(base64Decode(imageBase64!.split(',').last));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
child: imageProvider != null ? Image(image: imageProvider) : const Text('No Image', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/storage/user_storage.dart';
|
||||
import '../../../web_view/web_view_page.dart';
|
||||
import '../web_view/web_view_page.dart'; // 相对路径
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +35,7 @@ class MyPage extends StatelessWidget {
|
||||
SizedBox(height: 16),
|
||||
|
||||
// 退出登录按钮
|
||||
_buildLogoutButton(),
|
||||
_buildLogoutButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -172,9 +174,12 @@ class MyPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoutButton() {
|
||||
Widget _buildLogoutButton(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {},
|
||||
onPressed: () async {
|
||||
await GetIt.I<UserStorage>().deleteUser();
|
||||
context.go('/auth/login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.red,
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <isar_community_flutter_libs/isar_flutter_libs_plugin.h>
|
||||
#include <sentry_flutter/sentry_flutter_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
|
||||
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_webrtc
|
||||
isar_community_flutter_libs
|
||||
sentry_flutter
|
||||
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import flutter_webrtc
|
||||
import isar_community_flutter_libs
|
||||
import package_info_plus
|
||||
@@ -16,6 +17,7 @@ import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
|
||||
114
pubspec.lock
114
pubspec.lock
@@ -184,6 +184,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -288,6 +296,38 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,6 +365,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -447,6 +495,70 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.8.13+13"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1110,4 +1222,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
||||
@@ -82,6 +82,7 @@ dependencies:
|
||||
|
||||
# ===== 图片 =====
|
||||
flutter_svg: ^2.2.3
|
||||
image_picker: ^1.1.2
|
||||
|
||||
# ===== 屏幕适配,高刷等 =====
|
||||
# flutter_displaymode: ^0.7.0
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <isar_community_flutter_libs/isar_flutter_libs_plugin.h>
|
||||
#include <sentry_flutter/sentry_flutter_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||
IsarFlutterLibsPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_webrtc
|
||||
isar_community_flutter_libs
|
||||
sentry_flutter
|
||||
|
||||
Reference in New Issue
Block a user