diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..0b65f96 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,15 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + // Versões estáveis e compatíveis com Flutter 3.x + classpath("com.android.tools.build:gradle:7.4.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") + } +} + allprojects { repositories { google() diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 4dfd444..0d7c5aa 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Mar 13 16:43:00 WET 2026 +#Sat Mar 14 16:08:23 WET 2026 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..a8b39ad 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,12 +1,9 @@ pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + properties.getProperty("flutter.sdk") ?: throw GradleException("Flutter SDK not found in local.properties") + } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") @@ -17,10 +14,11 @@ pluginManagement { } } +// No ficheiro android/settings.gradle plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.android.application") version "8.3.0" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..c6e975d Binary files /dev/null and b/assets/logo.png differ diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index b61c426..21c4011 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'dart:async'; class ChatScreen extends StatefulWidget { const ChatScreen({super.key}); @@ -22,235 +23,168 @@ class _ChatScreenState extends State with TickerProviderStateMixin { final TextEditingController _textController = TextEditingController(); final ScrollController _scrollController = ScrollController(); bool _isTyping = false; + late AnimationController _typingController; + + @override + void initState() { + super.initState(); + _typingController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + + Timer(const Duration(seconds: 2), () => _checkAvailableModels()); + } + + Future _checkAvailableModels() async { + try { + final response = await http.get( + Uri.parse('http://89.114.196.110:11434/api/tags'), + ).timeout(const Duration(seconds: 15)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + print("Modelos ok"); + } + } catch (e) { + print("Erro ao listar modelos: $e"); + } + } + + @override + void dispose() { + _typingController.dispose(); + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + // Função para limpar o chat + void _clearChat() { + setState(() { + _messages.clear(); + _isTyping = false; + }); + } Future _handleSubmitted(String text) async { - if (text.trim().isEmpty) return; + // BLOQUEIO: Se já estiver a escrever (isTyping), ignora o clique + if (text.trim().isEmpty || _isTyping) return; _textController.clear(); setState(() { _messages.insert(0, ChatMessage(text: text)); - _isTyping = true; + _isTyping = true; // Ativa o bloqueio }); try { - // Faz o pedido para o IP usando o formato compatível com OpenAI + final url = Uri.parse('http://89.114.196.110:11434/v1/chat/completions'); + final response = await http .post( - Uri.parse('http://192.168.60.134:11434/v1/chat/completions'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'model': 'qwen3:4b', // Um modelo disponível no servidor - 'messages': [ - {'role': 'user', 'content': text}, - ], - 'stream': false, - }), - ) - .timeout(const Duration(seconds: 30)); + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'model': 'tinyllama', + 'messages': [{'role': 'user', 'content': text}], + 'stream': false, + }), + ) + .timeout(const Duration(seconds: 60)); if (response.statusCode == 200) { final data = jsonDecode(response.body); - final reply = - data['choices'][0]['message']['content'] ?? - 'Sem resposta do modelo.'; + final reply = data['choices'][0]['message']['content'] ?? 'Sem resposta.'; if (mounted) { setState(() { - _isTyping = false; + _isTyping = false; // Liberta o bloqueio _messages.insert(0, ChatMessage(text: reply, isAssistant: true)); }); } } else { - throw Exception( - 'Erro no servidor: HTTP ${response.statusCode} - ${response.body}', - ); + throw Exception('Erro ${response.statusCode}'); } } catch (e) { if (mounted) { setState(() { - _isTyping = false; + _isTyping = false; // Liberta o bloqueio mesmo em caso de erro _messages.insert( 0, - ChatMessage( - text: - "Não foi possível comunicar com o modelo.\nVerifique se o IP está acessível.\nDetalhes: $e", - isAssistant: true, - ), + ChatMessage(text: "Erro: $e", isAssistant: true), ); }); } } } + // ... (Mantenha _buildMessage, _buildAvatar, _buildTypingIndicator e _buildAnimatedDot iguais) Widget _buildMessage(ChatMessage message) { bool isAssistant = message.isAssistant; return Padding( padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), child: Row( - mainAxisAlignment: isAssistant - ? MainAxisAlignment.start - : MainAxisAlignment.end, + mainAxisAlignment: isAssistant ? MainAxisAlignment.start : MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (isAssistant) - Container( - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)], // Mint & Blue - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF8ad5c9).withOpacity(0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: const CircleAvatar( - backgroundColor: Colors.transparent, - radius: 16, - child: Icon(Icons.auto_awesome, color: Colors.white, size: 18), - ), - ), + if (isAssistant) _buildAvatar(Icons.auto_awesome), Flexible( child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20.0, - vertical: 14.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 14.0), decoration: BoxDecoration( gradient: isAssistant - ? const LinearGradient( - colors: [ - Color(0xFFF1F5F9), - Color(0xFFE2E8F0), - ], // Light bubbles - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : const LinearGradient( - colors: [ - Color(0xFF8ad5c9), - Color(0xFF57a7ed), - ], // Mint & Blue bubbles - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), + ? const LinearGradient(colors: [Color(0xFFF1F5F9), Color(0xFFE2E8F0)]) + : const LinearGradient(colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)]), borderRadius: BorderRadius.only( topLeft: const Radius.circular(20), topRight: const Radius.circular(20), bottomLeft: Radius.circular(isAssistant ? 4 : 20), bottomRight: Radius.circular(isAssistant ? 20 : 4), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - border: Border.all( - color: isAssistant - ? Colors.black.withOpacity(0.05) - : Colors.transparent, - width: 1, - ), ), child: Text( message.text, - style: TextStyle( - color: isAssistant ? Colors.black87 : Colors.white, - fontSize: 15, - height: 1.4, - ), + style: TextStyle(color: isAssistant ? Colors.black87 : Colors.white), ), ), ), - if (!isAssistant) - Container( - margin: const EdgeInsets.only(left: 12), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [ - Color(0xFF57a7ed), - Color(0xFF3A8BD1), - ], // User avatar gradient - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF57a7ed).withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: const CircleAvatar( - backgroundColor: Colors.transparent, - radius: 16, - child: Icon(Icons.person, color: Colors.white, size: 18), - ), - ), + if (!isAssistant) _buildAvatar(Icons.person), ], ), ); } + Widget _buildAvatar(IconData icon) { + return Container( + margin: EdgeInsets.only(left: icon == Icons.person ? 12 : 0, right: icon == Icons.auto_awesome ? 12 : 0), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient(colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)]), + ), + child: CircleAvatar(backgroundColor: Colors.transparent, radius: 16, child: Icon(icon, color: Colors.white, size: 18)), + ); + } + Widget _buildTypingIndicator() { return Padding( padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ + _buildAvatar(Icons.auto_awesome), Container( - margin: const EdgeInsets.only(right: 12), - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)], // Mint & Blue - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: const CircleAvatar( - backgroundColor: Colors.transparent, - radius: 16, - child: Icon(Icons.auto_awesome, color: Colors.white, size: 18), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), decoration: BoxDecoration( - color: const Color( - 0xFFF1F5F9, - ), // Typing bubble matching assistant light bubble - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(20), - ), - border: Border.all(color: Colors.black.withOpacity(0.05)), + color: const Color(0xFFF1F5F9), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(20)), ), child: Row( mainAxisSize: MainAxisSize.min, - children: [ - _buildDot(0), - const SizedBox(width: 4), - _buildDot(1), - const SizedBox(width: 4), - _buildDot(2), - ], + children: [_buildAnimatedDot(0), const SizedBox(width: 4), _buildAnimatedDot(1), const SizedBox(width: 4), _buildAnimatedDot(2)], ), ), ], @@ -258,21 +192,15 @@ class _ChatScreenState extends State with TickerProviderStateMixin { ); } - Widget _buildDot(int index) { - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 600), - builder: (context, double value, child) { + Widget _buildAnimatedDot(int index) { + return AnimatedBuilder( + animation: _typingController, + builder: (context, child) { + double value = (_typingController.value + (index * 0.15)) % 1.0; + double intensity = 1.0 - (value - 0.5).abs() * 2; return Opacity( - opacity: (value + (index * 0.3)) % 1.0, - child: Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - ), + opacity: 0.3 + (0.7 * intensity), + child: Transform.translate(offset: Offset(0, -3 * intensity), child: Container(width: 6, height: 6, decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle))), ); }, ); @@ -285,78 +213,51 @@ class _ChatScreenState extends State with TickerProviderStateMixin { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.8), // Light backdrop - border: Border( - top: BorderSide( - color: Colors.black.withOpacity(0.05), - width: 0.5, - ), - ), + color: Colors.white.withOpacity(0.8), + border: Border(top: BorderSide(color: Colors.black.withOpacity(0.05), width: 0.5)), ), child: SafeArea( child: Row( children: [ + // BOTÃO APAGAR CHAT + IconButton( + icon: const Icon(Icons.delete_sweep_rounded, color: Colors.redAccent), + onPressed: _isTyping ? null : _clearChat, // Desativa enquanto digita + ), Expanded( child: Container( decoration: BoxDecoration( - color: const Color( - 0xFFF1F5F9, - ), // Light input box background + color: const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(30.0), border: Border.all(color: Colors.black.withOpacity(0.05)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], ), child: TextField( controller: _textController, + enabled: !_isTyping, // Bloqueia o campo de texto onSubmitted: _handleSubmitted, - style: const TextStyle(color: Colors.black87), decoration: InputDecoration( - hintText: "Message EPVChat!...", - hintStyle: TextStyle( - color: Colors.black.withOpacity(0.4), - ), + hintText: _isTyping ? "Aguarde a resposta..." : "Mensagem EPVChat...", border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 14.0, - ), + contentPadding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 14.0), ), ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), Container( decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFF8ad5c9), - Color(0xFF57a7ed), - ], // Mint & Blue send button + gradient: LinearGradient( + colors: _isTyping + ? [Colors.grey, Colors.grey] // Cor de desativado + : [const Color(0xFF8ad5c9), const Color(0xFF57a7ed)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0xFF8ad5c9).withOpacity(0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], ), child: IconButton( - icon: const Icon( - Icons.send_rounded, - color: Colors.white, - size: 20, - ), - onPressed: () => _handleSubmitted(_textController.text), + icon: const Icon(Icons.send_rounded, color: Colors.white), + onPressed: _isTyping ? null : () => _handleSubmitted(_textController.text), ), ), ], @@ -369,83 +270,56 @@ class _ChatScreenState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return Scaffold( - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - child: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: AppBar( - title: ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - colors: [ - Color(0xFF8ad5c9), - Color(0xFF57a7ed), - ], // Text gradient header - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: const Text( - 'EPVChat!', - style: TextStyle( - fontWeight: FontWeight.w800, - fontSize: 22, - letterSpacing: -0.5, + backgroundColor: Colors.white, + body: Column( + children: [ + Expanded( + child: ListView( + controller: _scrollController, + reverse: true, // Mantém a lógica de mensagens novas em baixo + children: [ + // As mensagens vêm primeiro (no reverse: true, o topo da lista é o fundo do ecrã) + ...(_isTyping ? [_buildTypingIndicator()] : []), + ..._messages.map((msg) => _buildMessage(msg)), + + // O LOGO E O SOMBREADO AGORA SÃO O ÚLTIMO ITEM DO LISTVIEW + // Quando o utilizador sobe o chat, eles sobem junto + Padding( + padding: const EdgeInsets.only(bottom: 50, top: 20), + child: Stack( + alignment: Alignment.center, + children: [ + // O Sombreado Verde + Container( + height: screenWidth * 0.8, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + const Color(0xFF8ad5c9).withOpacity(0.4), + const Color(0xFF8ad5c9).withOpacity(0.0), + ], + ), + ), + ), + // O Logo + Image.asset( + 'assets/logo.png', + height: 170, + fit: BoxFit.contain, + ), + ], ), ), - ), - backgroundColor: Colors.white.withOpacity( - 0.7, - ), // Light Header backgrop - elevation: 0, - centerTitle: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container( - color: Colors.black.withOpacity(0.05), - height: 1.0, - ), - ), + ], ), ), - ), - ), - body: Container( - decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment.topCenter, - radius: 1.5, - colors: [ - Colors.white, - Color(0xFFF8FAFC), // Slate 50 - ], - ), - ), - child: SafeArea( - bottom: false, - child: Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric(vertical: 20.0), - reverse: true, - itemCount: _messages.length + (_isTyping ? 1 : 0), - itemBuilder: (_, int index) { - if (_isTyping && index == 0) { - return _buildTypingIndicator(); - } - int messageIndex = _isTyping ? index - 1 : index; - return _buildMessage(_messages[messageIndex]); - }, - ), - ), - _buildTextComposer(), - ], - ), - ), + _buildTextComposer(), + ], ), ); } -} +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4b40485..791ffae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,90 +1,24 @@ name: app description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: ^3.11.1 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 http: ^1.6.0 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + assets: + - assets/logo.png