First commit
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'screens/devices_screen.dart';
|
||||
import 'screens/documents_screen.dart';
|
||||
import 'screens/upload_screen.dart';
|
||||
import 'screens/chat_screen.dart';
|
||||
import 'providers/app_state.dart';
|
||||
|
||||
void main() {
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState()..loadDevices(),
|
||||
child: MaterialApp(
|
||||
title: 'هم هوش',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
fontFamily: 'Vazirmatn', // 👈 Set the Persian font globally
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.teal,
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: IconThemeData(color: Colors.teal),
|
||||
titleTextStyle: TextStyle(color: Colors.teal, fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
backgroundColor: const Color(0xFF8BC34A),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: const BorderSide(color: Color(0xFF8BC34A), width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
home: MainScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin {
|
||||
int _selectedIndex = 0;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
final List<Widget> _pages = [
|
||||
DevicesScreen(),
|
||||
DocumentsScreen(),
|
||||
UploadScreen(),
|
||||
ChatScreen(),
|
||||
];
|
||||
|
||||
final List<BottomNavigationBarItem> _navItems = [
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.devices), label: 'دستگاهها'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.folder_open), label: 'اسناد'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.cloud_upload), label: 'بارگذاری'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.chat), label: 'گفتگو'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
|
||||
_fadeAnimation = CurvedAnimation(parent: _animationController, curve: Curves.easeInOut);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabTapped(int index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
colors: [Color(0xFF1B5E20), Color(0xFF43A047), Color(0xFF0288D1)],
|
||||
stops: [0.0, 0.4, 1.0],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: _pages[_selectedIndex],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -3))],
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: _onTabTapped,
|
||||
items: _navItems,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.white.withOpacity(0.96),
|
||||
selectedItemColor: const Color(0xFF0288D1),
|
||||
unselectedItemColor: Colors.grey[500],
|
||||
showSelectedLabels: true,
|
||||
showUnselectedLabels: true,
|
||||
elevation: 0,
|
||||
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
unselectedLabelStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class ChatResponse {
|
||||
final String finalAnswer;
|
||||
final Map<String, String> rawResponses;
|
||||
|
||||
ChatResponse({required this.finalAnswer, required this.rawResponses});
|
||||
|
||||
factory ChatResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ChatResponse(
|
||||
finalAnswer: json['final_answer'],
|
||||
rawResponses: Map<String, String>.from(json['raw_responses']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class Device {
|
||||
final String ip;
|
||||
final int port;
|
||||
|
||||
Device({required this.ip, required this.port});
|
||||
|
||||
factory Device.fromJson(Map<String, dynamic> json) {
|
||||
return Device(
|
||||
ip: json['ip'],
|
||||
port: json['port'],
|
||||
);
|
||||
}
|
||||
|
||||
String get address => '$ip:$port';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class DocumentInfo {
|
||||
final String docId;
|
||||
final int chunks;
|
||||
|
||||
DocumentInfo({required this.docId, required this.chunks});
|
||||
|
||||
factory DocumentInfo.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentInfo(
|
||||
docId: json['doc_id'],
|
||||
chunks: json['chunks'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class UploadResponse {
|
||||
final String message;
|
||||
final String docId;
|
||||
final int chunksIndexed;
|
||||
|
||||
UploadResponse({required this.message, required this.docId, required this.chunksIndexed});
|
||||
|
||||
factory UploadResponse.fromJson(Map<String, dynamic> json) {
|
||||
return UploadResponse(
|
||||
message: json['message'],
|
||||
docId: json['doc_id'],
|
||||
chunksIndexed: json['chunks_indexed'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/document.dart';
|
||||
import '../models/chat_response.dart';
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
final ApiService _api = ApiService();
|
||||
|
||||
List<Device> _devices = [];
|
||||
Map<String, List<DocumentInfo>> _documentsByDevice = {};
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
List<Device> get devices => _devices;
|
||||
Map<String, List<DocumentInfo>> get documentsByDevice => _documentsByDevice;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
// Load devices
|
||||
Future<void> loadDevices() async {
|
||||
_setLoading(true);
|
||||
_errorMessage = null;
|
||||
try {
|
||||
_devices = await _api.getDevices();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Register a new device
|
||||
Future<bool> registerDevice(String ip, int port) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
await _api.registerDevice(ip, port);
|
||||
await loadDevices();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a device
|
||||
Future<bool> removeDevice(String ip) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
await _api.removeDevice(ip);
|
||||
await loadDevices();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all documents (for all devices)
|
||||
Future<void> loadAllDocuments() async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
_documentsByDevice = await _api.getAllDocuments();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload PDF to a specific device
|
||||
Future<bool> uploadPdf(String deviceIp, File pdfFile) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
await _api.uploadPdf(deviceIp, pdfFile);
|
||||
await loadAllDocuments(); // refresh doc list
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Ask a question (chat)
|
||||
Future<ChatResponse> askQuestion(String question) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
return await _api.askQuestion(question);
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
rethrow;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _setLoading(bool loading) {
|
||||
_isLoading = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_state.dart';
|
||||
import '../models/chat_response.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _questionController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<Map<String, String>> _messages = [];
|
||||
bool _isAsking = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_questionController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearChat() {
|
||||
setState(() {
|
||||
_messages.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(AppState appState) async {
|
||||
final question = _questionController.text.trim();
|
||||
if (question.isEmpty) return;
|
||||
|
||||
print('Sending question: $question');
|
||||
|
||||
setState(() {
|
||||
_messages.add({'role': 'user', 'content': question});
|
||||
_questionController.clear();
|
||||
_isAsking = true;
|
||||
});
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final response = await appState.askQuestion(question);
|
||||
print('Received response: ${response.finalAnswer}');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_messages.add({'role': 'assistant', 'content': response.finalAnswer});
|
||||
_isAsking = false;
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_messages.add({'role': 'assistant', 'content': 'خطا: ${e.toString()}'});
|
||||
_isAsking = false;
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = Provider.of<AppState>(context);
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
// Simple title (no AppBar)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: const Text(
|
||||
'💬 گفتگو باهم هوش',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white30, thickness: 1),
|
||||
// Messages list
|
||||
Expanded(
|
||||
child: _messages.isEmpty && !_isAsking
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length + (_isAsking ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _messages.length && _isAsking) {
|
||||
return _buildTypingIndicator();
|
||||
}
|
||||
final message = _messages[index];
|
||||
return _buildMessageBubble(
|
||||
message['content']!,
|
||||
isUser: message['role'] == 'user',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Input area
|
||||
_buildInputArea(appState),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.question_answer, size: 80, color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'از هم هوش هر سوالی دارید بپرسید',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingIndicator() {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0288D1)),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('در حال تفکر...', style: TextStyle(color: Color(0xFF0288D1))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(String text, {required bool isUser}) {
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: isUser
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF43A047), Color(0xFF0288D1)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
color: isUser ? null : Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(20),
|
||||
topRight: const Radius.circular(20),
|
||||
bottomLeft: isUser ? const Radius.circular(8) : const Radius.circular(20),
|
||||
bottomRight: isUser ? const Radius.circular(20) : const Radius.circular(8),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black87,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(AppState appState) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -2)),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _questionController,
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 16),
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
textDirection: TextDirection.rtl,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'سوال خود را بنویسید...',
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(appState),
|
||||
onChanged: (_) => setState(() {}), // rebuild when text changes
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Reactive button: enabled only when text is not empty and not already asking
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _questionController,
|
||||
builder: (context, value, child) {
|
||||
final isEnabled = value.text.trim().isNotEmpty && !_isAsking;
|
||||
return ElevatedButton(
|
||||
onPressed: isEnabled
|
||||
? () {
|
||||
print('Send button pressed');
|
||||
_sendMessage(appState);
|
||||
}
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
backgroundColor: const Color(0xFF0288D1),
|
||||
disabledBackgroundColor: Colors.grey,
|
||||
),
|
||||
child: _isAsking
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send_rounded, color: Colors.white, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_state.dart';
|
||||
import '../models/device.dart';
|
||||
import '../widgets/animated_button.dart';
|
||||
import '../widgets/device_card.dart';
|
||||
|
||||
class DevicesScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = Provider.of<AppState>(context);
|
||||
final devices = appState.devices;
|
||||
final isLoading = appState.isLoading;
|
||||
final error = appState.errorMessage;
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
: error != null
|
||||
? Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.error_outline, size: 80, color: Colors.red[300]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'خطا در دریافت دستگاهها',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedButton(
|
||||
width: 200,
|
||||
height: 56,
|
||||
onPressed: () => appState.loadDevices(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.refresh, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('تلاش مجدد', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: devices.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.devices_other, size: 80, color: Colors.white54),
|
||||
SizedBox(height: 16),
|
||||
Text('هیچ دستگاه ثبتشدهای وجود ندارد', style: TextStyle(color: Colors.white70, fontSize: 18)),
|
||||
SizedBox(height: 8),
|
||||
Text('برای افزودن، دکمه + را بزنید', style: TextStyle(color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, index) => DeviceCard(
|
||||
device: devices[index],
|
||||
onDelete: () => _confirmDelete(context, devices[index].ip, appState),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showAddDeviceDialog(context),
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: const Text('افزودن دستگاه', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
backgroundColor: const Color(0xFF00B4DB),
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDeviceDialog(BuildContext context) {
|
||||
final ipController = TextEditingController();
|
||||
final portController = TextEditingController(text: '8080');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFf5f7fa), Color(0xFFc3cfe2)]),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('➕ افزودن Orange Pi جدید', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: ipController,
|
||||
decoration: const InputDecoration(labelText: 'آدرس IP', prefixIcon: Icon(Icons.dns)),
|
||||
autofocus: true,
|
||||
textDirection: TextDirection.ltr,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: portController,
|
||||
decoration: const InputDecoration(labelText: 'پورت', prefixIcon: Icon(Icons.settings_ethernet)),
|
||||
keyboardType: TextInputType.number,
|
||||
textDirection: TextDirection.ltr,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف', style: TextStyle(color: Colors.grey))),
|
||||
const SizedBox(width: 12),
|
||||
AnimatedButton(
|
||||
onPressed: () async {
|
||||
final ip = ipController.text.trim();
|
||||
final port = int.tryParse(portController.text.trim()) ?? 8080;
|
||||
if (ip.isNotEmpty) {
|
||||
final success = await Provider.of<AppState>(context, listen: false).registerDevice(ip, port);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ثبتنام ناموفق بود')));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('ثبت', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, String ip, AppState appState) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
title: const Text('حذف دستگاه', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: Text('آیا $ip را برای همیشه حذف میکنید؟'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف', style: TextStyle(color: Colors.grey))),
|
||||
AnimatedButton(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFE53935), Color(0xFFB71C1C)]),
|
||||
onPressed: () async {
|
||||
await appState.removeDevice(ip);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('حذف', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_state.dart';
|
||||
import '../widgets/document_card.dart';
|
||||
|
||||
class DocumentsScreen extends StatefulWidget {
|
||||
@override
|
||||
State<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends State<DocumentsScreen> {
|
||||
String? _selectedDeviceIp;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Provider.of<AppState>(context, listen: false).loadAllDocuments();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = Provider.of<AppState>(context);
|
||||
final allDocs = appState.documentsByDevice;
|
||||
|
||||
final deviceIps = allDocs.keys.toList();
|
||||
final selectedIp =
|
||||
_selectedDeviceIp ?? (deviceIps.isNotEmpty ? deviceIps.first : null);
|
||||
if (_selectedDeviceIp == null && selectedIp != null)
|
||||
_selectedDeviceIp = selectedIp;
|
||||
|
||||
final documentsForSelected =
|
||||
(selectedIp != null && allDocs.containsKey(selectedIp))
|
||||
? allDocs[selectedIp]!
|
||||
: <dynamic>[];
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
if (deviceIps.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: selectedIp,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'انتخاب Orange Pi',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: deviceIps
|
||||
.map((ip) => DropdownMenuItem(value: ip, child: Text(ip)))
|
||||
.toList(),
|
||||
onChanged: (newIp) =>
|
||||
setState(() => _selectedDeviceIp = newIp),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (appState.isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (documentsForSelected.isEmpty)
|
||||
const Center(
|
||||
child: Text('هیچ سندی برای این دستگاه یافت نشد.'),
|
||||
)
|
||||
else
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: documentsForSelected.length,
|
||||
itemBuilder: (ctx, idx) => DocumentCard(
|
||||
doc: documentsForSelected[idx],
|
||||
deviceIp: selectedIp!,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: () => appState.loadAllDocuments(),
|
||||
child: const Icon(Icons.refresh),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_state.dart';
|
||||
import '../widgets/animated_button.dart';
|
||||
|
||||
class UploadScreen extends StatefulWidget {
|
||||
@override
|
||||
State<UploadScreen> createState() => _UploadScreenState();
|
||||
}
|
||||
|
||||
class _UploadScreenState extends State<UploadScreen> {
|
||||
String? _selectedDeviceIp;
|
||||
File? _selectedFile;
|
||||
bool _uploading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = Provider.of<AppState>(context);
|
||||
final devices = appState.devices;
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.router,
|
||||
size: 52,
|
||||
color: Color(0xFF0288D1),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'انتخاب Orange Pi',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedDeviceIp,
|
||||
hint: const Text('انتخاب دستگاه'),
|
||||
items: devices
|
||||
.map(
|
||||
(d) => DropdownMenuItem(
|
||||
value: d.ip,
|
||||
child: Text(d.ip),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (ip) =>
|
||||
setState(() => _selectedDeviceIp = ip),
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
prefixIcon: const Icon(Icons.computer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
if (mounted)
|
||||
setState(
|
||||
() => _selectedFile = File(result.files.single.path!),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF0288D1).withOpacity(0.5),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_selectedFile == null
|
||||
? Icons.picture_as_pdf
|
||||
: Icons.check_circle,
|
||||
color: _selectedFile == null
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFF43A047),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_selectedFile == null
|
||||
? 'برای انتخاب PDF ضربه بزنید'
|
||||
: _selectedFile!.path.split('/').last,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: _selectedFile == null
|
||||
? FontWeight.normal
|
||||
: FontWeight.w500,
|
||||
color: _selectedFile == null
|
||||
? Colors.grey[700]
|
||||
: const Color(0xFF2E7D32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_selectedDeviceIp != null && _selectedFile != null)
|
||||
AnimatedButton(
|
||||
width: 200,
|
||||
height: 56,
|
||||
onPressed: _uploading
|
||||
? null
|
||||
: () => _performUpload(appState),
|
||||
isLoading: _uploading,
|
||||
child: const Text(
|
||||
'🚀 بارگذاری',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 200,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'انتخاب دستگاه و PDF',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _performUpload(AppState appState) async {
|
||||
if (!mounted) return;
|
||||
setState(() => _uploading = true);
|
||||
final success = await appState.uploadPdf(
|
||||
_selectedDeviceIp!,
|
||||
_selectedFile!,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _uploading = false);
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('✅ بارگذاری موفق')));
|
||||
setState(() => _selectedFile = null);
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(appState.errorMessage ?? 'بارگذاری ناموفق')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/document.dart';
|
||||
import '../models/chat_response.dart';
|
||||
import '../models/upload_response.dart';
|
||||
|
||||
class ApiService {
|
||||
static const String baseUrl =
|
||||
'http://192.168.1.1:8000'; // CHANGE to your laptop's IP
|
||||
final Dio _dio;
|
||||
|
||||
ApiService()
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
),
|
||||
) {
|
||||
// Allow self-signed certificates if needed (for local dev)
|
||||
(_dio.httpClientAdapter as IOHttpClientAdapter).onHttpClientCreate =
|
||||
(client) {
|
||||
client.badCertificateCallback = (cert, host, port) => true;
|
||||
return client;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Devices ==========
|
||||
Future<List<Device>> getDevices() async {
|
||||
try {
|
||||
final response = await _dio.get('/devices/list');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> devicesJson = response.data['devices'];
|
||||
return devicesJson.map((json) => Device.fromJson(json)).toList();
|
||||
}
|
||||
throw Exception('Failed to load devices');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Network error: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerDevice(String ip, int port) async {
|
||||
try {
|
||||
await _dio.post('/devices/register', data: {'ip': ip, 'port': port});
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Register failed: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeDevice(String ip) async {
|
||||
try {
|
||||
await _dio.post('/devices/remove', data: {'ip': ip});
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Remove failed: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Documents ==========
|
||||
Future<Map<String, List<DocumentInfo>>> getAllDocuments() async {
|
||||
try {
|
||||
final response = await _dio.get('/devices/documents');
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> byDevice =
|
||||
response.data['documents_by_device'];
|
||||
final result = <String, List<DocumentInfo>>{};
|
||||
byDevice.forEach((ip, docs) {
|
||||
if (docs is List) {
|
||||
result[ip] = docs.map((d) => DocumentInfo.fromJson(d)).toList();
|
||||
} else {
|
||||
result[ip] = [];
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
throw Exception('Failed to load documents');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Network error: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<DocumentInfo>>> getDocumentsForDevice(
|
||||
String ip,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/devices/documents',
|
||||
queryParameters: {'device_ip': ip},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> byDevice =
|
||||
response.data['documents_by_device'];
|
||||
final result = <String, List<DocumentInfo>>{};
|
||||
byDevice.forEach((ip, docs) {
|
||||
if (docs is List) {
|
||||
result[ip] = docs.map((d) => DocumentInfo.fromJson(d)).toList();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
throw Exception('Failed to load documents for device');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Network error: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Upload ==========
|
||||
Future<UploadResponse> uploadPdf(String deviceIp, File pdfFile) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
pdfFile.path,
|
||||
filename: pdfFile.path.split('/').last,
|
||||
),
|
||||
});
|
||||
final response = await _dio.post(
|
||||
'/devices/$deviceIp/upload',
|
||||
data: formData,
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return UploadResponse.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Upload failed with status ${response.statusCode}');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Upload error: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Chat ==========
|
||||
Future<ChatResponse> askQuestion(String question) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/ask',
|
||||
data: {'question': question},
|
||||
options: Options(
|
||||
sendTimeout: const Duration(seconds: 120),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return ChatResponse.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Chat request failed');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Network error: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
// In lib/services/api_service.dart
|
||||
Future<void> deleteDocument(String deviceIp, String docId) async {
|
||||
try {
|
||||
await _dio.delete('/devices/$deviceIp/documents/$docId');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Delete failed: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnimatedButton extends StatefulWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final Widget child;
|
||||
final bool isLoading;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final Gradient? gradient;
|
||||
|
||||
const AnimatedButton({
|
||||
this.onPressed,
|
||||
required this.child,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.gradient,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedButton> createState() => _AnimatedButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedButtonState extends State<AnimatedButton> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 100));
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(_controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
if (widget.onPressed != null && !widget.isLoading) {
|
||||
widget.onPressed!();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gradient = widget.gradient ?? const LinearGradient(
|
||||
colors: [Color(0xFF2E7D32), Color(0xFF0288D1)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => _controller.forward(),
|
||||
onTapUp: (_) => _controller.reverse(),
|
||||
onTapCancel: () => _controller.reverse(),
|
||||
onTap: _handleTap,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 4, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: widget.isLoading
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white))
|
||||
: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/device.dart';
|
||||
|
||||
class DeviceCard extends StatelessWidget {
|
||||
final Device device;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const DeviceCard({required this.device, required this.onDelete, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.computer),
|
||||
title: Text(device.ip),
|
||||
subtitle: Text('Port: ${device.port}'),
|
||||
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: onDelete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/document.dart';
|
||||
import '../services/api_service.dart'; // to call the delete API
|
||||
|
||||
class DocumentCard extends StatelessWidget {
|
||||
final DocumentInfo doc;
|
||||
final String deviceIp; // pass the device IP to know which Pi to delete from
|
||||
|
||||
const DocumentCard({required this.doc, required this.deviceIp, super.key});
|
||||
|
||||
Future<void> _deleteDocument(BuildContext context) async {
|
||||
// Show confirmation dialog
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('حذف سند'),
|
||||
content: Text('آیا از حذف سند "${doc.docId}" مطمئن هستید؟'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('انصراف')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('حذف', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true) return;
|
||||
|
||||
try {
|
||||
final api = ApiService();
|
||||
await api.deleteDocument(deviceIp, doc.docId);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('سند با موفقیت حذف شد')));
|
||||
// Refresh the documents list (call parent callback)
|
||||
// We'll need a callback to refresh the screen
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در حذف: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description, color: Color(0xFF0288D1)),
|
||||
title: Text(doc.docId, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text('${doc.chunks} قطعه'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
onPressed: () => _deleteDocument(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user