mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../data/services/mistral_ai_service.dart';
|
||||
import '../../../data/services/voice_recording_service.dart';
|
||||
import '../../../bootstrap/env.dart';
|
||||
import '../../countdown/application/countdown_controller.dart';
|
||||
import '../../goals/application/goals_controller.dart';
|
||||
import '../../../core/utils/date_time_utils.dart';
|
||||
|
||||
final aiChatControllerProvider = StateNotifierProvider<AIChatController, AIChatState>((ref) {
|
||||
final mistralService = MistralAIService(apiKey: Env.mistralApiKey);
|
||||
final voiceService = VoiceRecordingService(mistralService: mistralService);
|
||||
return AIChatController(ref, mistralService, voiceService);
|
||||
});
|
||||
|
||||
class AIChatState {
|
||||
final List<ChatMessage> messages;
|
||||
final bool isLoading;
|
||||
final bool isRecording;
|
||||
final String? error;
|
||||
final String? currentTranscription;
|
||||
final bool privacyModeEnabled;
|
||||
|
||||
AIChatState({
|
||||
this.messages = const [],
|
||||
this.isLoading = false,
|
||||
this.isRecording = false,
|
||||
this.error,
|
||||
this.currentTranscription,
|
||||
this.privacyModeEnabled = true,
|
||||
});
|
||||
|
||||
AIChatState copyWith({
|
||||
List<ChatMessage>? messages,
|
||||
bool? isLoading,
|
||||
bool? isRecording,
|
||||
String? error,
|
||||
String? currentTranscription,
|
||||
bool? privacyModeEnabled,
|
||||
}) {
|
||||
return AIChatState(
|
||||
messages: messages ?? this.messages,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isRecording: isRecording ?? this.isRecording,
|
||||
error: error ?? this.error,
|
||||
currentTranscription: currentTranscription ?? this.currentTranscription,
|
||||
privacyModeEnabled: privacyModeEnabled ?? this.privacyModeEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIChatController extends StateNotifier<AIChatState> {
|
||||
final Ref _ref;
|
||||
final MistralAIService _mistralService;
|
||||
final VoiceRecordingService _voiceService;
|
||||
|
||||
static const String _privacyModePrefsKey = 'ai_chat_privacy_mode_enabled';
|
||||
|
||||
AIChatController(this._ref, this._mistralService, this._voiceService)
|
||||
: super(AIChatState()) {
|
||||
_loadPrivacyMode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_voiceService.dispose();
|
||||
_mistralService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void setPrivacyMode(bool enabled) {
|
||||
state = state.copyWith(privacyModeEnabled: enabled);
|
||||
_savePrivacyMode(enabled);
|
||||
}
|
||||
|
||||
Future<void> _loadPrivacyMode() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getBool(_privacyModePrefsKey);
|
||||
if (stored != null) {
|
||||
state = state.copyWith(privacyModeEnabled: stored);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _savePrivacyMode(bool enabled) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_privacyModePrefsKey, enabled);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _buildUserContextDescription() {
|
||||
final countdownState = _ref.read(countdownControllerProvider);
|
||||
final goalsState = _ref.read(goalsControllerProvider);
|
||||
|
||||
final user = countdownState.user;
|
||||
|
||||
if (user == null) {
|
||||
if (state.privacyModeEnabled) {
|
||||
return 'User privacy mode is ENABLED. No countdown data is available yet.';
|
||||
}
|
||||
|
||||
return 'User privacy mode is DISABLED, but no countdown data could be loaded yet.';
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final start = user.countdownStartDate;
|
||||
final end = user.countdownEndDate;
|
||||
|
||||
String? countdownSummary;
|
||||
int? currentDay;
|
||||
int? daysRemaining;
|
||||
|
||||
if (start != null && end != null) {
|
||||
final isFinished = DateTimeUtils.isCountdownFinished(end);
|
||||
if (isFinished) {
|
||||
countdownSummary =
|
||||
'Their 1356-day countdown challenge has already finished.';
|
||||
} else {
|
||||
final remainingDuration = DateTimeUtils.calculateRemainingTime(end);
|
||||
daysRemaining = remainingDuration.inDays;
|
||||
|
||||
final totalDurationDays = end.difference(start).inDays;
|
||||
final elapsedDays = now.difference(start).inDays;
|
||||
|
||||
if (totalDurationDays > 0) {
|
||||
currentDay = elapsedDays + 1;
|
||||
}
|
||||
|
||||
final formattedRemaining =
|
||||
DateTimeUtils.formatCountdownCompact(remainingDuration);
|
||||
|
||||
if (currentDay != null) {
|
||||
countdownSummary =
|
||||
'Currently on day $currentDay of ${DateTimeUtils.countdownDays} with about $formattedRemaining remaining (approximately $daysRemaining days left).';
|
||||
} else {
|
||||
countdownSummary =
|
||||
'A 1356-day countdown challenge is active with about $formattedRemaining remaining.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.privacyModeEnabled) {
|
||||
if (countdownSummary != null) {
|
||||
return 'User privacy mode is ENABLED. Only basic countdown information is shared. $countdownSummary';
|
||||
}
|
||||
|
||||
return 'User privacy mode is ENABLED. The user has not started their 1356-day countdown yet.';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln(
|
||||
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
|
||||
buffer.writeln('Username: ${user.username}.');
|
||||
|
||||
if (countdownSummary != null) {
|
||||
buffer.writeln(countdownSummary);
|
||||
} else {
|
||||
buffer.writeln(
|
||||
'The user has not started their 1356-day countdown challenge yet.');
|
||||
}
|
||||
|
||||
final goals = goalsState.goals;
|
||||
|
||||
if (goals.isNotEmpty) {
|
||||
buffer.writeln(
|
||||
'The user has ${goals.length} active bucket list goals. Here are some examples:');
|
||||
|
||||
for (final goal in goals.take(3)) {
|
||||
buffer.writeln(
|
||||
'- Goal: "${goal.title}" (progress: ${goal.progress}%, completed: ${goal.completed}).');
|
||||
}
|
||||
|
||||
final completedGoalsCount = goals.where((g) => g.completed).length;
|
||||
if (completedGoalsCount > 0) {
|
||||
buffer.writeln(
|
||||
'They have completed $completedGoalsCount goals so far in their challenge.');
|
||||
}
|
||||
} else {
|
||||
buffer.writeln(
|
||||
'The user currently has no saved goals, or they could not be loaded.');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String message) async {
|
||||
if (message.trim().isEmpty || state.isLoading) return;
|
||||
|
||||
final userMessage = ChatMessage(
|
||||
content: message.trim(),
|
||||
role: 'user',
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMessage],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
);
|
||||
|
||||
try {
|
||||
final userContextDescription = _buildUserContextDescription();
|
||||
final response = await _mistralService.chat(
|
||||
message: message,
|
||||
conversationHistory: state.messages,
|
||||
userContext: userContextDescription,
|
||||
);
|
||||
|
||||
final aiMessage = ChatMessage(
|
||||
content: response,
|
||||
role: 'assistant',
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, aiMessage],
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording || state.isLoading) return;
|
||||
|
||||
try {
|
||||
await _voiceService.startRecording();
|
||||
state = state.copyWith(isRecording: true, error: null);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopRecording() async {
|
||||
if (!state.isRecording) return;
|
||||
|
||||
state = state.copyWith(isRecording: false, isLoading: true);
|
||||
|
||||
try {
|
||||
final audioPath = await _voiceService.stopRecording();
|
||||
|
||||
if (audioPath.isNotEmpty) {
|
||||
state = state.copyWith(currentTranscription: 'Transcribing...');
|
||||
|
||||
final transcription = await _voiceService.transcribeRecording(
|
||||
audioFilePath: audioPath,
|
||||
);
|
||||
|
||||
state = state.copyWith(currentTranscription: null);
|
||||
|
||||
if (transcription.isNotEmpty) {
|
||||
await sendMessage(transcription);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'No speech detected. Please try again.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to save recording',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isRecording: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRecording() async {
|
||||
if (!state.isRecording) return;
|
||||
|
||||
try {
|
||||
await _voiceService.cancelRecording();
|
||||
state = state.copyWith(isRecording: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
void clearMessages() {
|
||||
state = state.copyWith(messages: []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../application/ai_chat_controller.dart';
|
||||
|
||||
class AIChatScreen extends ConsumerStatefulWidget {
|
||||
const AIChatScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AIChatScreen> createState() => _AIChatScreenState();
|
||||
}
|
||||
|
||||
class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(aiChatControllerProvider);
|
||||
final controller = ref.read(aiChatControllerProvider.notifier);
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
ref.listen<AIChatState>(aiChatControllerProvider, (previous, next) {
|
||||
if (next.messages.length > (previous?.messages.length ?? 0)) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('AI Life Coach'),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () => controller.clearMessages(),
|
||||
tooltip: 'Clear chat',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Messages list
|
||||
Expanded(
|
||||
child: state.messages.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildMessagesList(state.messages),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (state.error != null) _buildErrorMessage(state.error!, controller),
|
||||
|
||||
// Transcription indicator
|
||||
if (state.currentTranscription != null)
|
||||
_buildTranscriptionIndicator(state.currentTranscription!),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.privacyModeEnabled
|
||||
? 'Privacy mode on · Only share countdown day and time remaining with the AI.'
|
||||
: 'Privacy mode off · Also share your username and goal progress for more personal coaching.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha:0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch.adaptive(
|
||||
value: state.privacyModeEnabled,
|
||||
onChanged: controller.setPrivacyMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
_buildInputArea(state, controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.psychology,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Your AI Life Coach',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ask for goal inspiration, motivation, or life advice',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSuggestionChip('Give me bucket list ideas'),
|
||||
const SizedBox(height: 8),
|
||||
_buildSuggestionChip('How to stay motivated?'),
|
||||
const SizedBox(height: 8),
|
||||
_buildSuggestionChip('Help me set meaningful goals'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionChip(String text) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_textController.text = text;
|
||||
ref.read(aiChatControllerProvider.notifier).sendMessage(text);
|
||||
_textController.clear();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesList(List messages) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isUser = message.role == 'user';
|
||||
|
||||
return _buildMessageBubble(message, isUser);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(message, bool isUser) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.psychology,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(String error, controller) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
error,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: controller.clearError,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTranscriptionIndicator(String text) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(AIChatState state, controller) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withValues(alpha:0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Voice recording indicator
|
||||
if (state.isRecording) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mic,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Recording... Tap to stop',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Input row
|
||||
Row(
|
||||
children: [
|
||||
// Voice button
|
||||
Container
|
||||
(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: state.isRecording
|
||||
? Theme.of(context).colorScheme.error.withValues(alpha:0.12)
|
||||
: Theme.of(context).colorScheme.primary.withValues(alpha:0.08),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: state.isRecording
|
||||
? () => controller.stopRecording()
|
||||
: () => controller.startRecording(),
|
||||
icon: Icon(
|
||||
state.isRecording ? Icons.stop : Icons.mic,
|
||||
color: state.isRecording
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip:
|
||||
state.isRecording ? 'Stop recording' : 'Start voice input',
|
||||
),
|
||||
),
|
||||
|
||||
// Text field
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
enabled: !state.isLoading && !state.isRecording,
|
||||
decoration: InputDecoration(
|
||||
hintText: state.isRecording
|
||||
? 'Recording voice...'
|
||||
: 'Ask for advice or inspiration...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty && !state.isLoading) {
|
||||
controller.sendMessage(value);
|
||||
_textController.clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Send button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha:state.isLoading ||
|
||||
_textController.text.trim().isEmpty
|
||||
? 0.06
|
||||
: 0.12),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
state.isLoading || _textController.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
controller.sendMessage(_textController.text);
|
||||
_textController.clear();
|
||||
},
|
||||
icon: state.isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: 'Send message',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user