First commit

This commit is contained in:
aliakbar
2026-06-25 21:21:30 +03:30
parent 55d33677d9
commit 5c34cef2c3
147 changed files with 7197 additions and 0 deletions
+156
View File
@@ -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),
),
),
);
}
}