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,183 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../bootstrap/env.dart';
|
||||
import '../../../data/services/mistral_ai_service.dart';
|
||||
import '../../../data/services/voice_recording_service.dart';
|
||||
|
||||
final voiceRecordingControllerProvider =
|
||||
StateNotifierProvider<VoiceRecordingController, VoiceRecordingState>((ref) {
|
||||
final mistralService = MistralAIService(apiKey: Env.mistralApiKey);
|
||||
final voiceService = VoiceRecordingService(mistralService: mistralService);
|
||||
return VoiceRecordingController(voiceService, mistralService);
|
||||
});
|
||||
|
||||
class VoiceRecordingState {
|
||||
final bool isRecording;
|
||||
final bool isProcessing;
|
||||
final Duration elapsed;
|
||||
final String? transcript;
|
||||
final String? error;
|
||||
final List<double> levels;
|
||||
|
||||
const VoiceRecordingState({
|
||||
this.isRecording = false,
|
||||
this.isProcessing = false,
|
||||
this.elapsed = Duration.zero,
|
||||
this.transcript,
|
||||
this.error,
|
||||
this.levels = const [],
|
||||
});
|
||||
|
||||
VoiceRecordingState copyWith({
|
||||
bool? isRecording,
|
||||
bool? isProcessing,
|
||||
Duration? elapsed,
|
||||
String? transcript,
|
||||
String? error,
|
||||
List<double>? levels,
|
||||
}) {
|
||||
return VoiceRecordingState(
|
||||
isRecording: isRecording ?? this.isRecording,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
elapsed: elapsed ?? this.elapsed,
|
||||
transcript: transcript ?? this.transcript,
|
||||
error: error ?? this.error,
|
||||
levels: levels ?? this.levels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VoiceRecordingController extends StateNotifier<VoiceRecordingState> {
|
||||
final VoiceRecordingService _voiceService;
|
||||
final MistralAIService _mistralService;
|
||||
final Random _random = Random();
|
||||
|
||||
Timer? _ticker;
|
||||
|
||||
VoiceRecordingController(this._voiceService, this._mistralService)
|
||||
: super(const VoiceRecordingState());
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording || state.isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _voiceService.startRecording();
|
||||
_startTicker();
|
||||
state = state.copyWith(
|
||||
isRecording: true,
|
||||
isProcessing: false,
|
||||
elapsed: Duration.zero,
|
||||
error: null,
|
||||
transcript: null,
|
||||
levels: _generateWaveform(),
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopRecording() async {
|
||||
if (!state.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
_stopTicker();
|
||||
|
||||
state = state.copyWith(
|
||||
isRecording: false,
|
||||
isProcessing: true,
|
||||
error: null,
|
||||
);
|
||||
|
||||
try {
|
||||
final audioPath = await _voiceService.stopRecording();
|
||||
if (audioPath.isEmpty) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: 'Failed to save recording',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final transcription = await _voiceService.transcribeRecording(
|
||||
audioFilePath: audioPath,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
transcript: transcription.isNotEmpty
|
||||
? transcription
|
||||
: 'No speech detected. Please try again.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRecording() async {
|
||||
if (!state.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _voiceService.cancelRecording();
|
||||
} catch (_) {}
|
||||
|
||||
_stopTicker();
|
||||
|
||||
state = state.copyWith(
|
||||
isRecording: false,
|
||||
isProcessing: false,
|
||||
elapsed: Duration.zero,
|
||||
levels: const [],
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const VoiceRecordingState();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
List<double> _generateWaveform() {
|
||||
return List<double>.generate(40, (index) {
|
||||
final base = 0.2 + _random.nextDouble() * 0.6;
|
||||
final wave = sin(index / 2).abs();
|
||||
return (base + wave) / 2;
|
||||
});
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(milliseconds: 120), (_) {
|
||||
final newElapsed = state.elapsed + const Duration(milliseconds: 120);
|
||||
state = state.copyWith(
|
||||
elapsed: newElapsed,
|
||||
levels: _generateWaveform(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTicker();
|
||||
_voiceService.dispose();
|
||||
_mistralService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../application/voice_recording_controller.dart';
|
||||
|
||||
class VoiceRecordingScreen extends ConsumerWidget {
|
||||
const VoiceRecordingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(voiceRecordingControllerProvider);
|
||||
final controller = ref.read(voiceRecordingControllerProvider.notifier);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final elapsedText = _formatDuration(state.elapsed);
|
||||
|
||||
return AppScaffold(
|
||||
title: 'Recording',
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.isRecording ? 'Recording in progress' : 'Voice notes',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha:0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
elapsedText,
|
||||
style: textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_Waveform(state: state),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha:0.04),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha:0.04),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: state.isRecording
|
||||
? colorScheme.error
|
||||
: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
state.isRecording
|
||||
? 'Listening...'
|
||||
: state.isProcessing
|
||||
? 'Transcribing your note'
|
||||
: 'Transcript',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha:0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.transcript ??
|
||||
(state.isRecording
|
||||
? 'Start speaking to capture your thoughts.'
|
||||
: 'When you finish recording, your words will appear here as clean text.'),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha:0.9),
|
||||
),
|
||||
),
|
||||
if (state.error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: controller.clearError,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
_CircleIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
onPressed: state.isRecording || state.isProcessing
|
||||
? null
|
||||
: () => controller.reset(),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.isProcessing
|
||||
? null
|
||||
: () {
|
||||
if (state.isRecording) {
|
||||
controller.stopRecording();
|
||||
} else {
|
||||
controller.startRecording();
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: state.isRecording
|
||||
? colorScheme.error
|
||||
: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
child: state.isProcessing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
state.isRecording ? Icons.stop : Icons.mic,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
state.isRecording ? 'Stop' : 'Start',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_CircleIconButton(
|
||||
icon: Icons.check,
|
||||
onPressed: state.transcript != null &&
|
||||
!state.isRecording &&
|
||||
!state.isProcessing
|
||||
? () => _copyTranscript(context, state.transcript!)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Waveform extends StatelessWidget {
|
||||
final VoiceRecordingState state;
|
||||
|
||||
const _Waveform({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final levels = state.levels.isNotEmpty
|
||||
? state.levels
|
||||
: List<double>.filled(40, 0.2);
|
||||
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final level in levels)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Container(
|
||||
height: 24 + level * 60,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface.withValues(alpha:0.08),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircleIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _CircleIconButton({
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: onPressed == null
|
||||
? colorScheme.surfaceContainerHighest.withValues(alpha:0.4)
|
||||
: colorScheme.surface,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onPressed,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: onPressed == null
|
||||
? colorScheme.onSurface.withValues(alpha:0.3)
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
final centiseconds =
|
||||
(duration.inMilliseconds.remainder(1000) ~/ 10).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds:$centiseconds';
|
||||
}
|
||||
|
||||
Future<void> _copyTranscript(BuildContext context, String transcript) async {
|
||||
await Clipboard.setData(ClipboardData(text: transcript));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Transcription copied to clipboard')),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user