19 files changed
+1441
-207
lib/core/components/app_button.dart
new
+159
@@ -0,0 +1,159 @@
1
+import 'package:flutter/material.dart';
2
+import 'app_sizes.dart';
3
+
4
+class AppButton extends StatelessWidget {
5
+ const AppButton({
6
+ super.key,
7
+ required this.label,
8
+ this.onPressed,
9
+ this.icon,
10
+ this.variant = AppButtonVariant.primary,
11
+ this.isLoading = false,
12
+ this.isFullWidth = false,
13
+ this.size = AppButtonSize.medium,
14
+ });
15
+
16
+ final String label;
17
+ final VoidCallback? onPressed;
18
+ final IconData? icon;
19
+ final AppButtonVariant variant;
20
+ final bool isLoading;
21
+ final bool isFullWidth;
22
+ final AppButtonSize size;
23
+
24
+ @override
25
+ Widget build(BuildContext context) {
26
+ final isDark = Theme.of(context).brightness == Brightness.dark;
27
+ final accent = AppColors.accentForDark(isDark);
28
+ final height = switch (size) {
29
+ AppButtonSize.small => 32.0,
30
+ AppButtonSize.medium => 44.0,
31
+ AppButtonSize.large => 52.0,
32
+ };
33
+
34
+ final child = isLoading
35
+ ? SizedBox(
36
+ width: 18,
37
+ height: 18,
38
+ child: CircularProgressIndicator(
39
+ strokeWidth: 2,
40
+ color: variant == AppButtonVariant.primary
41
+ ? Colors.white
42
+ : accent,
43
+ ),
44
+ )
45
+ : Row(
46
+ mainAxisSize: MainAxisSize.min,
47
+ children: [
48
+ if (icon != null) ...[
49
+ Icon(icon, size: 18),
50
+ const SizedBox(width: AppSizes.sm),
51
+ ],
52
+ Text(label),
53
+ ],
54
+ );
55
+
56
+ return switch (variant) {
57
+ AppButtonVariant.primary => SizedBox(
58
+ height: height,
59
+ width: isFullWidth ? double.infinity : null,
60
+ child: ElevatedButton(
61
+ onPressed: isLoading ? null : onPressed,
62
+ child: child,
63
+ ),
64
+ ),
65
+ AppButtonVariant.outline => SizedBox(
66
+ height: height,
67
+ width: isFullWidth ? double.infinity : null,
68
+ child: OutlinedButton(
69
+ onPressed: isLoading ? null : onPressed,
70
+ child: child,
71
+ ),
72
+ ),
73
+ AppButtonVariant.ghost => SizedBox(
74
+ height: height,
75
+ width: isFullWidth ? double.infinity : null,
76
+ child: TextButton(
77
+ onPressed: isLoading ? null : onPressed,
78
+ child: child,
79
+ ),
80
+ ),
81
+ AppButtonVariant.destructive => SizedBox(
82
+ height: height,
83
+ width: isFullWidth ? double.infinity : null,
84
+ child: OutlinedButton(
85
+ onPressed: isLoading ? null : onPressed,
86
+ style: OutlinedButton.styleFrom(
87
+ side: const BorderSide(color: Colors.red),
88
+ foregroundColor: Colors.red,
89
+ ),
90
+ child: child,
91
+ ),
92
+ ),
93
+ };
94
+ }
95
+}
96
+
97
+enum AppButtonVariant { primary, outline, ghost, destructive }
98
+
99
+enum AppButtonSize { small, medium, large }
100
+
101
+class AppCard extends StatelessWidget {
102
+ const AppCard({
103
+ super.key,
104
+ required this.child,
105
+ this.onTap,
106
+ this.padding,
107
+ this.margin,
108
+ this.borderRadius,
109
+ });
110
+
111
+ final Widget child;
112
+ final VoidCallback? onTap;
113
+ final EdgeInsetsGeometry? padding;
114
+ final EdgeInsetsGeometry? margin;
115
+ final double? borderRadius;
116
+
117
+ @override
118
+ Widget build(BuildContext context) {
119
+ final isDark = Theme.of(context).brightness == Brightness.dark;
120
+ final radius = borderRadius ?? AppSizes.radiusMd;
121
+
122
+ final container = Container(
123
+ padding: padding ?? const EdgeInsets.all(AppSizes.md),
124
+ margin: margin ?? const EdgeInsets.only(bottom: AppSizes.sm),
125
+ decoration: BoxDecoration(
126
+ color: AppColors.surfaceForDark(isDark),
127
+ borderRadius: BorderRadius.circular(radius),
128
+ border: Border.all(color: AppColors.borderForDark(isDark)),
129
+ ),
130
+ child: child,
131
+ );
132
+
133
+ if (onTap != null) {
134
+ return GestureDetector(onTap: onTap, child: container);
135
+ }
136
+
137
+ return container;
138
+ }
139
+}
140
+
141
+class AppLoadingIndicator extends StatelessWidget {
142
+ const AppLoadingIndicator({super.key, this.size = 24, this.strokeWidth = 2});
143
+
144
+ final double size;
145
+ final double strokeWidth;
146
+
147
+ @override
148
+ Widget build(BuildContext context) {
149
+ final isDark = Theme.of(context).brightness == Brightness.dark;
150
+ return SizedBox(
151
+ width: size,
152
+ height: size,
153
+ child: CircularProgressIndicator(
154
+ strokeWidth: strokeWidth,
155
+ color: AppColors.accentForDark(isDark),
156
+ ),
157
+ );
158
+ }
159
+}
lib/core/components/app_sizes.dart
new
+71
@@ -0,0 +1,71 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class AppSizes {
4
+ AppSizes._();
5
+
6
+ // Spacing
7
+ static const double xs = 4;
8
+ static const double sm = 8;
9
+ static const double md = 12;
10
+ static const double lg = 16;
11
+ static const double xl = 24;
12
+ static const double xxl = 32;
13
+ static const double xxxl = 48;
14
+
15
+ // Radius
16
+ static const double radiusSm = 4;
17
+ static const double radiusMd = 8;
18
+ static const double radiusLg = 12;
19
+ static const double radiusXl = 16;
20
+ static const double radiusRound = 999;
21
+
22
+ // Icon sizes
23
+ static const double iconSm = 16;
24
+ static const double iconMd = 20;
25
+ static const double iconLg = 24;
26
+ static const double iconXl = 32;
27
+ static const double iconXxl = 48;
28
+
29
+ // Text sizes
30
+ static const double textXs = 11;
31
+ static const double textSm = 12;
32
+ static const double textMd = 14;
33
+ static const double textLg = 16;
34
+ static const double textXl = 20;
35
+ static const double textXxl = 24;
36
+ static const double textXxxl = 28;
37
+
38
+ // Layout
39
+ static const double sidebarWidth = 300;
40
+ static const double inputBarHeight = 64;
41
+ static const double appBarHeight = 56;
42
+ static const double cardMinHeight = 80;
43
+ static const double sheetMaxHeight = 0.7;
44
+ static const double chatBubbleMaxWidth = 0.75;
45
+}
46
+
47
+class AppColors {
48
+ AppColors._();
49
+
50
+ static const Color darkAccent = Color(0xFF3B82F6);
51
+ static const Color lightAccent = Color(0xFF2563EB);
52
+ static const Color error = Color(0xFFEF4444);
53
+ static const Color success = Color(0xFF22C55E);
54
+ static const Color warning = Color(0xFFF59E0B);
55
+
56
+ static Color darkSurface = const Color(0xFF1F1F1F);
57
+ static Color darkBorder = const Color(0xFF2A2A2A);
58
+ static Color darkMuted = const Color(0xFF666666);
59
+
60
+ static Color lightSurface = const Color(0xFFF5F5F5);
61
+ static Color lightBorder = const Color(0xFFE5E5E5);
62
+ static Color lightMuted = const Color(0xFF999999);
63
+
64
+ static Color accentForDark(bool isDark) => isDark ? darkAccent : lightAccent;
65
+ static Color surfaceForDark(bool isDark) =>
66
+ isDark ? darkSurface : lightSurface;
67
+ static Color borderForDark(bool isDark) => isDark ? darkBorder : lightBorder;
68
+ static Color mutedForDark(bool isDark) => isDark ? darkMuted : lightMuted;
69
+ static Color primaryTextForDark(bool isDark) =>
70
+ isDark ? Colors.white : Colors.black;
71
+}
lib/core/components/app_text_field.dart
new
+173
@@ -0,0 +1,173 @@
1
+import 'package:flutter/material.dart';
2
+import 'app_sizes.dart';
3
+
4
+class AppTextField extends StatelessWidget {
5
+ const AppTextField({
6
+ super.key,
7
+ this.controller,
8
+ this.hintText,
9
+ this.labelText,
10
+ this.obscureText = false,
11
+ this.maxLines = 1,
12
+ this.minLines,
13
+ this.maxLength,
14
+ this.onChanged,
15
+ this.onSubmitted,
16
+ this.validator,
17
+ this.prefixIcon,
18
+ this.suffixIcon,
19
+ this.enabled = true,
20
+ this.autofocus = false,
21
+ this.keyboardType,
22
+ this.textInputAction,
23
+ });
24
+
25
+ final TextEditingController? controller;
26
+ final String? hintText;
27
+ final String? labelText;
28
+ final bool obscureText;
29
+ final int? maxLines;
30
+ final int? minLines;
31
+ final int? maxLength;
32
+ final ValueChanged<String>? onChanged;
33
+ final ValueChanged<String>? onSubmitted;
34
+ final FormFieldValidator<String>? validator;
35
+ final Widget? prefixIcon;
36
+ final Widget? suffixIcon;
37
+ final bool enabled;
38
+ final bool autofocus;
39
+ final TextInputType? keyboardType;
40
+ final TextInputAction? textInputAction;
41
+
42
+ @override
43
+ Widget build(BuildContext context) {
44
+ return TextFormField(
45
+ controller: controller,
46
+ obscureText: obscureText,
47
+ maxLines: maxLines,
48
+ minLines: minLines,
49
+ maxLength: maxLength,
50
+ onChanged: onChanged,
51
+ onFieldSubmitted: onSubmitted,
52
+ validator: validator,
53
+ enabled: enabled,
54
+ autofocus: autofocus,
55
+ keyboardType: keyboardType,
56
+ textInputAction: textInputAction,
57
+ decoration: InputDecoration(
58
+ hintText: hintText,
59
+ labelText: labelText,
60
+ prefixIcon: prefixIcon,
61
+ suffixIcon: suffixIcon,
62
+ isDense: true,
63
+ contentPadding: const EdgeInsets.symmetric(
64
+ horizontal: AppSizes.md,
65
+ vertical: AppSizes.md,
66
+ ),
67
+ ),
68
+ );
69
+ }
70
+}
71
+
72
+class AppSheet {
73
+ AppSheet._();
74
+
75
+ static Future<T?> show<T>({
76
+ required BuildContext context,
77
+ required WidgetBuilder builder,
78
+ bool isScrollControlled = false,
79
+ }) {
80
+ return showModalBottomSheet<T>(
81
+ context: context,
82
+ isScrollControlled: isScrollControlled,
83
+ builder: (ctx) {
84
+ return Column(
85
+ mainAxisSize: MainAxisSize.min,
86
+ children: [
87
+ const SizedBox(height: AppSizes.sm),
88
+ Container(
89
+ width: 40,
90
+ height: 4,
91
+ decoration: BoxDecoration(
92
+ color: Theme.of(ctx).brightness == Brightness.dark
93
+ ? Colors.grey[600]
94
+ : Colors.grey[300],
95
+ borderRadius: BorderRadius.circular(2),
96
+ ),
97
+ ),
98
+ const SizedBox(height: AppSizes.sm),
99
+ Flexible(child: builder(ctx)),
100
+ ],
101
+ );
102
+ },
103
+ );
104
+ }
105
+}
106
+
107
+class AppDialog {
108
+ AppDialog._();
109
+
110
+ static Future<T?> confirm<T>({
111
+ required BuildContext context,
112
+ required String title,
113
+ String? message,
114
+ String confirmLabel = 'Confirm',
115
+ String cancelLabel = 'Cancel',
116
+ bool destructive = false,
117
+ }) {
118
+ return showDialog<T>(
119
+ context: context,
120
+ builder: (ctx) => AlertDialog(
121
+ title: Text(title),
122
+ content: message != null ? Text(message) : null,
123
+ actions: [
124
+ TextButton(
125
+ onPressed: () => Navigator.pop(ctx),
126
+ child: Text(cancelLabel),
127
+ ),
128
+ TextButton(
129
+ onPressed: () => Navigator.pop(ctx, true as T),
130
+ style: destructive
131
+ ? TextButton.styleFrom(foregroundColor: Colors.red)
132
+ : null,
133
+ child: Text(confirmLabel),
134
+ ),
135
+ ],
136
+ ),
137
+ );
138
+ }
139
+
140
+ static Future<String?> input({
141
+ required BuildContext context,
142
+ required String title,
143
+ String? initialValue,
144
+ String? hintText,
145
+ String confirmLabel = 'Save',
146
+ }) {
147
+ final controller = TextEditingController(text: initialValue);
148
+ return showDialog<String>(
149
+ context: context,
150
+ builder: (ctx) => AlertDialog(
151
+ title: Text(title),
152
+ content: TextField(
153
+ controller: controller,
154
+ autofocus: true,
155
+ decoration: InputDecoration(
156
+ hintText: hintText,
157
+ border: const OutlineInputBorder(),
158
+ ),
159
+ ),
160
+ actions: [
161
+ TextButton(
162
+ onPressed: () => Navigator.pop(ctx),
163
+ child: const Text('Cancel'),
164
+ ),
165
+ TextButton(
166
+ onPressed: () => Navigator.pop(ctx, controller.text.trim()),
167
+ child: Text(confirmLabel),
168
+ ),
169
+ ],
170
+ ),
171
+ );
172
+ }
173
+}
lib/core/models/app_exception.dart
new
+71
@@ -0,0 +1,71 @@
1
+class AppException implements Exception {
2
+ final AppErrorType type;
3
+ final String message;
4
+ final String? detail;
5
+ final dynamic originalError;
6
+
7
+ const AppException({
8
+ required this.type,
9
+ required this.message,
10
+ this.detail,
11
+ this.originalError,
12
+ });
13
+
14
+ @override
15
+ String toString() => detail != null ? '$message: $detail' : message;
16
+}
17
+
18
+enum AppErrorType {
19
+ connectionTimeout(
20
+ 'Connection timed out. Check your network and server address.',
21
+ ),
22
+ connectionRefused('Could not connect to server. Is it running?'),
23
+ authFailed('Authentication failed. Check your API key.'),
24
+ serverError('Server returned an error. Check server logs.'),
25
+ networkError('Network error. Check your internet connection.'),
26
+ parseError('Failed to parse server response.'),
27
+ modelNotFound('Model not found on server.'),
28
+ contextLengthExceeded(
29
+ 'Context length exceeded. Try shortening your messages.',
30
+ ),
31
+ rateLimited('Rate limited. Please wait before sending more messages.'),
32
+ storageError('Storage error. Your data may not be saved.'),
33
+ unknown('An unexpected error occurred.');
34
+
35
+ const AppErrorType(this.userMessage);
36
+ final String userMessage;
37
+
38
+ static AppErrorType fromStatusCode(int statusCode) {
39
+ if (statusCode == 401 || statusCode == 403) return AppErrorType.authFailed;
40
+ if (statusCode == 404) return AppErrorType.modelNotFound;
41
+ if (statusCode == 429) return AppErrorType.rateLimited;
42
+ if (statusCode >= 500) return AppErrorType.serverError;
43
+ return AppErrorType.unknown;
44
+ }
45
+
46
+ static AppErrorType fromException(dynamic error) {
47
+ final msg = error.toString().toLowerCase();
48
+ if (msg.contains('connection refused') ||
49
+ msg.contains('connection failed')) {
50
+ return AppErrorType.connectionRefused;
51
+ }
52
+ if (msg.contains('timeout') || msg.contains('timed out')) {
53
+ return AppErrorType.connectionTimeout;
54
+ }
55
+ if (msg.contains('unauthorized') ||
56
+ msg.contains('forbidden') ||
57
+ msg.contains('401')) {
58
+ return AppErrorType.authFailed;
59
+ }
60
+ if (msg.contains('429') || msg.contains('rate limit')) {
61
+ return AppErrorType.rateLimited;
62
+ }
63
+ if (msg.contains('network') || msg.contains('socket')) {
64
+ return AppErrorType.networkError;
65
+ }
66
+ if (msg.contains('formatexception') || msg.contains('parse')) {
67
+ return AppErrorType.parseError;
68
+ }
69
+ return AppErrorType.unknown;
70
+ }
71
+}
lib/core/repository/server/server_repository.dart
new
+16
@@ -0,0 +1,16 @@
1
+import 'package:localmind/core/models/enums.dart';
2
+import 'package:localmind/features/models/data/models/model_info.dart';
3
+import 'package:localmind/features/servers/data/models/server.dart';
4
+
5
+abstract class ServerRepository {
6
+ Future<bool> testConnection(Server server);
7
+ Future<List<ModelInfo>> fetchModels(Server server);
8
+ Future<bool> pingServer(Server server);
9
+ List<Server> getServers();
10
+ Future<Server> addServer(Server server);
11
+ Future<void> updateServer(Server server);
12
+ Future<void> deleteServer(String id);
13
+ Server? getServerById(String id);
14
+ Server? getDefaultServer();
15
+ Future<void> setDefaultServer(String id);
16
+}
lib/core/services/quick_launch_service.dart
new
+27
@@ -0,0 +1,27 @@
1
+import 'package:flutter/services.dart';
2
+
3
+class QuickLaunchService {
4
+ static const _channel = MethodChannel('com.localmind/shortcuts');
5
+
6
+ static Future<void> setupShortcuts() async {
7
+ try {
8
+ await _channel.invokeMethod('setupShortcuts', {
9
+ 'shortcuts': [
10
+ {
11
+ 'id': 'new_chat',
12
+ 'title': 'New Chat',
13
+ 'description': 'Start a new conversation',
14
+ 'icon': 'ic_chat',
15
+ },
16
+ ],
17
+ });
18
+ } on PlatformException {
19
+ // Not supported on this platform, ignore
20
+ }
21
+ }
22
+
23
+ static Future<void> handleShortcut(String shortcutId) async {
24
+ // This is called from native side via method channel
25
+ // Implementation depends on routing setup
26
+ }
27
+}
lib/core/utils/date_utils.dart
new
+65
@@ -0,0 +1,65 @@
1
+class AppDateUtils {
2
+ AppDateUtils._();
3
+
4
+ static String formatTimestamp(DateTime dateTime) {
5
+ final now = DateTime.now();
6
+ final diff = now.difference(dateTime);
7
+
8
+ if (diff.inMinutes < 1) return 'Just now';
9
+ if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
10
+ if (diff.inHours < 24) return '${diff.inHours}h ago';
11
+ if (diff.inDays == 1) return 'Yesterday';
12
+ if (diff.inDays < 7) return '${diff.inDays}d ago';
13
+ if (diff.inDays < 30) return '${(diff.inDays / 7).ceil()}w ago';
14
+ return '${dateTime.month}/${dateTime.day}/${dateTime.year}';
15
+ }
16
+
17
+ static String formatDate(DateTime dateTime) {
18
+ return '${_monthName(dateTime.month)} ${dateTime.day}, ${dateTime.year}';
19
+ }
20
+
21
+ static String formatTime(DateTime dateTime) {
22
+ final h = dateTime.hour % 12 == 0 ? 12 : dateTime.hour % 12;
23
+ final m = dateTime.minute.toString().padLeft(2, '0');
24
+ final ampm = dateTime.hour < 12 ? 'AM' : 'PM';
25
+ return '$h:$m $ampm';
26
+ }
27
+
28
+ static String formatDuration(Duration duration) {
29
+ if (duration.inSeconds < 60) return '${duration.inSeconds}s';
30
+ if (duration.inMinutes < 60)
31
+ return '${duration.inMinutes}m ${duration.inSeconds % 60}s';
32
+ return '${duration.inHours}h ${duration.inMinutes % 60}m';
33
+ }
34
+
35
+ static String sectionLabel(DateTime dateTime) {
36
+ final now = DateTime.now();
37
+ final today = DateTime(now.year, now.month, now.day);
38
+ final date = DateTime(dateTime.year, dateTime.month, dateTime.day);
39
+ final diff = today.difference(date).inDays;
40
+
41
+ if (diff == 0) return 'TODAY';
42
+ if (diff == 1) return 'YESTERDAY';
43
+ if (diff < 7) return 'PREVIOUS 7 DAYS';
44
+ if (diff < 30) return 'PREVIOUS 30 DAYS';
45
+ return 'OLDER';
46
+ }
47
+
48
+ static String _monthName(int month) {
49
+ const months = [
50
+ 'Jan',
51
+ 'Feb',
52
+ 'Mar',
53
+ 'Apr',
54
+ 'May',
55
+ 'Jun',
56
+ 'Jul',
57
+ 'Aug',
58
+ 'Sep',
59
+ 'Oct',
60
+ 'Nov',
61
+ 'Dec',
62
+ ];
63
+ return months[month - 1];
64
+ }
65
+}
lib/features/chat/data/export_service.dart
new
+84
@@ -0,0 +1,84 @@
1
+import 'dart:io';
2
+import 'package:path_provider/path_provider.dart';
3
+import 'package:localmind/features/chat/data/models/message.dart';
4
+import 'package:localmind/core/models/enums.dart';
5
+
6
+class ExportService {
7
+ static Future<String> exportAsMarkdown(
8
+ List<Message> messages, {
9
+ String? title,
10
+ }) async {
11
+ final buffer = StringBuffer();
12
+
13
+ if (title != null) {
14
+ buffer.writeln('# $title');
15
+ buffer.writeln();
16
+ }
17
+
18
+ buffer.writeln(
19
+ '*Exported from LocalMind — ${DateTime.now().toIso8601String()}*',
20
+ );
21
+ buffer.writeln();
22
+ buffer.writeln('---');
23
+ buffer.writeln();
24
+
25
+ for (final message in messages) {
26
+ switch (message.role) {
27
+ case MessageRole.user:
28
+ buffer.writeln('## 👤 User');
29
+ break;
30
+ case MessageRole.assistant:
31
+ buffer.writeln('## 🤖 Assistant');
32
+ break;
33
+ case MessageRole.system:
34
+ buffer.writeln('## ⚙️ System');
35
+ break;
36
+ }
37
+ buffer.writeln();
38
+ buffer.writeln(message.content);
39
+ buffer.writeln();
40
+ buffer.writeln('*${message.createdAt.toIso8601String()}*');
41
+ buffer.writeln();
42
+ buffer.writeln('---');
43
+ buffer.writeln();
44
+ }
45
+
46
+ return buffer.toString();
47
+ }
48
+
49
+ static Future<File> exportAndSaveMarkdown(
50
+ List<Message> messages, {
51
+ String? title,
52
+ String? filename,
53
+ }) async {
54
+ final content = await exportAsMarkdown(messages, title: title);
55
+ final dir = await getApplicationDocumentsDirectory();
56
+ final name =
57
+ filename ?? 'localmind_export_${DateTime.now().millisecondsSinceEpoch}';
58
+ final file = File('${dir.path}/$name.md');
59
+ return file.writeAsString(content);
60
+ }
61
+
62
+ static String exportAsText(List<Message> messages, {String? title}) {
63
+ final buffer = StringBuffer();
64
+
65
+ if (title != null) {
66
+ buffer.writeln(title);
67
+ buffer.writeln('=' * title.length);
68
+ buffer.writeln();
69
+ }
70
+
71
+ for (final message in messages) {
72
+ final roleLabel = switch (message.role) {
73
+ MessageRole.user => 'USER',
74
+ MessageRole.assistant => 'ASSISTANT',
75
+ MessageRole.system => 'SYSTEM',
76
+ };
77
+ buffer.writeln('[$roleLabel]');
78
+ buffer.writeln(message.content);
79
+ buffer.writeln();
80
+ }
81
+
82
+ return buffer.toString();
83
+ }
84
+}
lib/features/chat/data/tts_service.dart
new
+50
@@ -0,0 +1,50 @@
1
+import 'package:flutter_tts/flutter_tts.dart';
2
+
3
+class TtsService {
4
+ final FlutterTts _tts = FlutterTts();
5
+ bool _isInitialized = false;
6
+ bool _isSpeaking = false;
7
+
8
+ Future<void> init() async {
9
+ if (_isInitialized) return;
10
+ await _tts.setVolume(1.0);
11
+ await _tts.setPitch(1.0);
12
+ await _tts.setSpeechRate(0.5);
13
+ _tts.setCompletionHandler(() {
14
+ _isSpeaking = false;
15
+ });
16
+ _isInitialized = true;
17
+ }
18
+
19
+ bool get isSpeaking => _isSpeaking;
20
+
21
+ Future<void> speak(String text) async {
22
+ await init();
23
+ if (text.trim().isEmpty) return;
24
+ _isSpeaking = true;
25
+ await _tts.speak(text);
26
+ }
27
+
28
+ Future<void> stop() async {
29
+ await _tts.stop();
30
+ _isSpeaking = false;
31
+ }
32
+
33
+ Future<void> pause() async {
34
+ await _tts.pause();
35
+ _isSpeaking = false;
36
+ }
37
+
38
+ Future<List<dynamic>> getVoices() async {
39
+ await init();
40
+ return await _tts.getVoices;
41
+ }
42
+
43
+ Future<void> setVoice(String name, String locale) async {
44
+ await _tts.setVoice({'name': name, 'locale': locale});
45
+ }
46
+
47
+ Future<void> dispose() async {
48
+ await _tts.stop();
49
+ }
50
+}
lib/features/chat/providers/chat_providers.dart
+112
-4
@@ -43,11 +43,37 @@ class IsStreamingNotifier extends Notifier<bool> {
43
44
final chatParamsProvider = Provider<ChatParameters>((ref) {
45
final settings = ref.watch(settingsProvider);
46
+ final activeConv = ref.watch(conv.activeConversationProvider);
47
+
48
+ double temperature = settings.temperature;
49
+ double topP = settings.topP;
50
+ int maxTokens = settings.maxTokens;
51
+ int contextLength = settings.contextLength;
52
+
53
+ if (activeConv?.personaId != null) {
54
+ final personaId = activeConv!.personaId;
55
+ try {
56
+ final boxes = ref.read(hiveBoxesProvider);
57
+ final persona = boxes.personas.values.cast<dynamic>().firstWhere(
58
+ (p) => p.id == personaId,
59
+ orElse: () => null,
60
+ );
61
+ if (persona != null && persona.preferredParams != null) {
62
+ final params = persona.preferredParams as Map<String, dynamic>;
63
+ if (params['temperature'] != null)
64
+ temperature = (params['temperature'] as num).toDouble();
65
+ if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
66
+ if (params['maxTokens'] != null)
67
+ maxTokens = (params['maxTokens'] as num).toInt();
68
+ }
69
+ } catch (_) {}
70
+ }
71
+
72
return ChatParameters(
47
- temperature: settings.temperature,
48
- topP: settings.topP,
49
- maxTokens: settings.maxTokens,
50
- contextLength: settings.contextLength,
73
+ temperature: temperature,
74
+ topP: topP,
75
+ maxTokens: maxTokens,
76
+ contextLength: contextLength,
77
);
78
});
79
@@ -59,6 +85,60 @@ final chatServiceProvider = Provider<ChatService>((ref) {
85
return ChatService.forServer(server.type, ref.read(dioProvider));
86
});
87
88
+final smartRepliesProvider = Provider<List<String>>((ref) {
89
+ final chatState = ref.watch(chatProvider);
90
+ final isStreaming = ref.watch(isStreamingProvider);
91
+
92
+ if (chatState.messages.length < 2 || isStreaming) return [];
93
+
94
+ final lastAssistant = chatState.messages.reversed.firstWhere(
95
+ (m) =>
96
+ m.role == MessageRole.assistant && m.status == MessageStatus.complete,
97
+ orElse: () => chatState.messages.last,
98
+ );
99
+ if (lastAssistant.role != MessageRole.assistant) return [];
100
+
101
+ final content = lastAssistant.content.toLowerCase();
102
+ final suggestions = <String>[];
103
+
104
+ if (content.contains('```') ||
105
+ content.contains('function') ||
106
+ content.contains('class ') ||
107
+ content.contains('import ')) {
108
+ suggestions.addAll([
109
+ 'Explain this code',
110
+ 'How can I improve this?',
111
+ 'Add error handling',
112
+ 'Write tests for this',
113
+ ]);
114
+ } else if (content.contains('step') ||
115
+ content.contains('first') ||
116
+ content.contains('then')) {
117
+ suggestions.addAll([
118
+ 'Can you elaborate on step 1?',
119
+ 'What if I get stuck?',
120
+ 'Give me a summary',
121
+ ]);
122
+ } else if (content.contains('error') ||
123
+ content.contains('problem') ||
124
+ content.contains('issue')) {
125
+ suggestions.addAll([
126
+ 'Show me a fix',
127
+ 'What else could cause this?',
128
+ 'How to prevent this?',
129
+ ]);
130
+ } else {
131
+ suggestions.addAll([
132
+ 'Tell me more',
133
+ 'Give me an example',
134
+ 'Summarize this',
135
+ 'What are the alternatives?',
136
+ ]);
137
+ }
138
+
139
+ return suggestions;
140
+});
141
+
142
class ChatState {
143
final List<Message> messages;
144
final bool isStreaming;
@@ -263,6 +343,16 @@ class ChatNotifier extends Notifier<ChatState> {
343
preview,
344
DateTime.now(),
345
);
346
+
347
+ final userMessage = state.messages
348
+ .where((m) => m.role == MessageRole.user)
349
+ .firstOrNull;
350
+ if (userMessage != null && userMessage.content.length > 10) {
351
+ _autoGenerateTitle(
352
+ userMessage.content,
353
+ finalMessage.content,
354
+ );
355
+ }
356
}
357
}
358
},
@@ -387,6 +477,24 @@ class ChatNotifier extends Notifier<ChatState> {
477
return result;
478
}
479
480
+ void _autoGenerateTitle(String userContent, String assistantContent) {
481
+ final settings = ref.read(settingsProvider);
482
+ if (!settings.autoGenerateTitle) return;
483
+ if (_currentConversationId == null) return;
484
+
485
+ final activeConv = ref.read(conv.activeConversationProvider);
486
+ if (activeConv == null) return;
487
+ if (activeConv.title != 'New Chat') return;
488
+
489
+ final title = userContent.length > 40
490
+ ? '${userContent.substring(0, 40)}...'
491
+ : userContent;
492
+
493
+ ref
494
+ .read(conv.conversationsProvider.notifier)
495
+ .renameConversation(_currentConversationId!, title);
496
+ }
497
+
498
void cancelStream() {
499
_streamSubscription?.cancel();
500
_streamSubscription = null;
lib/features/chat/screens/chat_screen.dart
+283
-81
@@ -10,7 +10,10 @@ import 'package:localmind/features/chat/providers/chat_providers.dart';
10
import 'package:localmind/features/chat/views/components/chat_bubble.dart';
11
import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
12
import 'package:localmind/features/conversations/data/models/conversation.dart';
13
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14
+ as conv;
15
import 'package:localmind/features/models/screens/model_picker_sheet.dart';
16
+import 'package:localmind/features/personas/providers/personas_providers.dart';
17
import 'package:localmind/features/servers/providers/server_providers.dart';
18
19
class ChatScreen extends ConsumerStatefulWidget {
@@ -65,6 +68,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
68
final chatState = ref.watch(chatProvider);
69
final selectedModel = ref.watch(selectedModelProvider);
70
final connectionStatus = ref.watch(connectionStatusProvider);
71
+ final activeConversation = ref.watch(conv.activeConversationProvider);
72
+ final personaId = activeConversation?.personaId;
73
+ final persona = personaId != null
74
+ ? ref.watch(personaByIdProvider(personaId))
75
+ : null;
76
final theme = Theme.of(context);
77
final isDark = theme.brightness == Brightness.dark;
78
@@ -97,6 +105,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
105
child: Row(
106
mainAxisSize: MainAxisSize.min,
107
children: [
108
+ if (persona != null) ...[
109
+ Text(persona.emoji, style: const TextStyle(fontSize: 18)),
110
+ const SizedBox(width: 4),
111
+ ],
112
Text(
113
selectedModel?.displayName ?? 'Select Model',
114
style: const TextStyle(fontSize: 16),
@@ -130,6 +142,29 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
142
contentPadding: EdgeInsets.zero,
143
),
144
),
145
+ PopupMenuItem(
146
+ value: 'persona',
147
+ child: ListTile(
148
+ leading: Icon(
149
+ persona != null
150
+ ? Icons.swap_horiz
151
+ : Icons.smart_toy_outlined,
152
+ ),
153
+ title: Text(
154
+ persona != null ? 'Change Persona' : 'Set Persona',
155
+ ),
156
+ contentPadding: EdgeInsets.zero,
157
+ ),
158
+ ),
159
+ if (persona != null)
160
+ const PopupMenuItem(
161
+ value: 'remove_persona',
162
+ child: ListTile(
163
+ leading: Icon(Icons.person_remove_outlined),
164
+ title: Text('Remove Persona'),
165
+ contentPadding: EdgeInsets.zero,
166
+ ),
167
+ ),
168
const PopupMenuItem(
169
value: 'clear',
170
child: ListTile(
@@ -146,77 +181,90 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
181
connectionStatus == ConnectionStatus.error)
182
_ConnectionBanner(status: connectionStatus),
183
Expanded(
149
- child: chatState.messages.isEmpty
150
- ? _EmptyState(
151
- onQuickPrompt: (prompt) =>
152
- ref.read(chatProvider.notifier).sendMessage(prompt),
153
- quickPrompts: _quickPrompts,
154
- recentConversations: ref.watch(recentConversationsProvider),
155
- onSeeAll: () {
156
- if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
157
- Scaffold.of(context).openDrawer();
158
- }
159
- },
160
- )
161
- : _MessageList(
162
- scrollController: _scrollController,
163
- messages: chatState.messages,
164
- streamingMessage: chatState.streamingMessage,
165
- isStreaming: chatState.isStreaming,
166
- onRetry: (messageId) {
167
- ref.read(chatProvider.notifier).retryLastMessage();
168
- },
169
- onDelete: (messageId) {
170
- ref.read(chatProvider.notifier).deleteMessage(messageId);
171
- },
172
- ),
173
- ),
174
- if (_showScrollToBottom && chatState.messages.isNotEmpty)
175
- Positioned(
176
- bottom: 100,
177
- left: 0,
178
- right: 0,
179
- child: Center(
180
- child: GestureDetector(
181
- onTap: _scrollToBottom,
182
- child: Container(
183
- padding: const EdgeInsets.symmetric(
184
- horizontal: 12,
185
- vertical: 6,
186
- ),
187
- decoration: BoxDecoration(
188
- color: isDark
189
- ? const Color(0xFF3A3A3A)
190
- : const Color(0xFFE5E5E5),
191
- borderRadius: BorderRadius.circular(16),
192
- ),
193
- child: Row(
194
- mainAxisSize: MainAxisSize.min,
195
- children: [
196
- Icon(
197
- Icons.arrow_downward,
198
- size: 14,
199
- color: isDark
200
- ? const Color(0xFF888888)
201
- : const Color(0xFF666666),
202
- ),
203
- const SizedBox(width: 4),
204
- Text(
205
- 'New messages',
206
- style: TextStyle(
207
- fontSize: 12,
184
+ child: Stack(
185
+ children: [
186
+ chatState.messages.isEmpty
187
+ ? _EmptyState(
188
+ onQuickPrompt: (prompt) =>
189
+ ref.read(chatProvider.notifier).sendMessage(prompt),
190
+ quickPrompts: _quickPrompts,
191
+ recentConversations:
192
+ ref.watch(recentConversationsProvider),
193
+ onSeeAll: () {
194
+ if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
195
+ Scaffold.of(context).openDrawer();
196
+ }
197
+ },
198
+ )
199
+ : _MessageList(
200
+ scrollController: _scrollController,
201
+ messages: chatState.messages,
202
+ streamingMessage: chatState.streamingMessage,
203
+ isStreaming: chatState.isStreaming,
204
+ onRetry: (messageId) {
205
+ ref.read(chatProvider.notifier).retryLastMessage();
206
+ },
207
+ onDelete: (messageId) {
208
+ ref
209
+ .read(chatProvider.notifier)
210
+ .deleteMessage(messageId);
211
+ },
212
+ ),
213
+ if (_showScrollToBottom && chatState.messages.isNotEmpty)
214
+ Positioned(
215
+ bottom: 20,
216
+ left: 0,
217
+ right: 0,
218
+ child: Center(
219
+ child: GestureDetector(
220
+ onTap: _scrollToBottom,
221
+ child: Container(
222
+ padding: const EdgeInsets.symmetric(
223
+ horizontal: 12,
224
+ vertical: 6,
225
+ ),
226
+ decoration: BoxDecoration(
227
color: isDark
209
- ? const Color(0xFF888888)
210
- : const Color(0xFF666666),
228
+ ? const Color(0xFF3A3A3A)
229
+ : const Color(0xFFE5E5E5),
230
+ borderRadius: BorderRadius.circular(16),
231
+ ),
232
+ child: Row(
233
+ mainAxisSize: MainAxisSize.min,
234
+ children: [
235
+ Icon(
236
+ Icons.arrow_downward,
237
+ size: 14,
238
+ color: isDark
239
+ ? const Color(0xFF888888)
240
+ : const Color(0xFF666666),
241
+ ),
242
+ const SizedBox(width: 4),
243
+ Text(
244
+ 'New messages',
245
+ style: TextStyle(
246
+ fontSize: 12,
247
+ color: isDark
248
+ ? const Color(0xFF888888)
249
+ : const Color(0xFF666666),
250
+ ),
251
+ ),
252
+ ],
253
),
254
),
213
- ],
255
+ ),
256
),
257
),
216
- ),
217
- ),
258
+ ],
259
),
260
+ ),
261
+ _SmartReplyChips(
262
+ onSend: (message) {
263
+ ref.read(chatProvider.notifier).sendMessage(message);
264
+ },
265
+ ),
266
ChatInputBar(
267
+ isStreaming: chatState.isStreaming,
268
onSend: (message) {
269
ref.read(chatProvider.notifier).sendMessage(message);
270
},
@@ -242,6 +290,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
290
case 'new_chat':
291
ref.read(chatProvider.notifier).startNewConversation();
292
break;
293
+ case 'persona':
294
+ _showPersonaPicker(context);
295
+ break;
296
+ case 'remove_persona':
297
+ final activeConv = ref.read(conv.activeConversationProvider);
298
+ if (activeConv != null) {
299
+ ref
300
+ .read(conv.conversationsProvider.notifier)
301
+ .updatePersona(activeConv.id, null, null);
302
+ }
303
+ break;
304
case 'clear':
305
showDialog(
306
context: context,
@@ -268,6 +327,106 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
327
break;
328
}
329
}
330
+
331
+ void _showPersonaPicker(BuildContext context) {
332
+ final personas = ref.read(personasNotifierProvider);
333
+ final isDark = Theme.of(context).brightness == Brightness.dark;
334
+ final activeConv = ref.read(conv.activeConversationProvider);
335
+ final currentPersonaId = activeConv?.personaId;
336
+
337
+ showModalBottomSheet(
338
+ context: context,
339
+ isScrollControlled: true,
340
+ builder: (ctx) {
341
+ return SafeArea(
342
+ child: Container(
343
+ padding: const EdgeInsets.symmetric(vertical: 16),
344
+ child: Column(
345
+ mainAxisSize: MainAxisSize.min,
346
+ children: [
347
+ Center(
348
+ child: Container(
349
+ width: 40,
350
+ height: 4,
351
+ decoration: BoxDecoration(
352
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
353
+ borderRadius: BorderRadius.circular(2),
354
+ ),
355
+ ),
356
+ ),
357
+ const SizedBox(height: 16),
358
+ Text(
359
+ 'Select Persona',
360
+ style: TextStyle(
361
+ fontSize: 18,
362
+ fontWeight: FontWeight.bold,
363
+ color: isDark ? Colors.white : Colors.black,
364
+ ),
365
+ ),
366
+ const SizedBox(height: 8),
367
+ Flexible(
368
+ child: ListView.builder(
369
+ shrinkWrap: true,
370
+ itemCount: personas.length,
371
+ itemBuilder: (context, index) {
372
+ final p = personas[index];
373
+ final isSelected = p.id == currentPersonaId;
374
+ final accent = isDark
375
+ ? const Color(0xFF3B82F6)
376
+ : const Color(0xFF2563EB);
377
+ return ListTile(
378
+ leading: Text(
379
+ p.emoji,
380
+ style: const TextStyle(fontSize: 22),
381
+ ),
382
+ title: Text(
383
+ p.name,
384
+ style: TextStyle(
385
+ fontWeight: isSelected
386
+ ? FontWeight.w600
387
+ : FontWeight.normal,
388
+ color: isDark ? Colors.white : Colors.black,
389
+ ),
390
+ ),
391
+ subtitle: p.description != null
392
+ ? Text(
393
+ p.description!,
394
+ style: TextStyle(
395
+ fontSize: 12,
396
+ color: isDark
397
+ ? const Color(0xFF888888)
398
+ : const Color(0xFF999999),
399
+ ),
400
+ maxLines: 1,
401
+ overflow: TextOverflow.ellipsis,
402
+ )
403
+ : null,
404
+ trailing: isSelected
405
+ ? Icon(Icons.check_circle, color: accent)
406
+ : null,
407
+ onTap: () {
408
+ if (activeConv != null) {
409
+ ref
410
+ .read(conv.conversationsProvider.notifier)
411
+ .updatePersona(
412
+ activeConv.id,
413
+ p.id,
414
+ p.systemPrompt,
415
+ );
416
+ }
417
+ Navigator.pop(ctx);
418
+ },
419
+ );
420
+ },
421
+ ),
422
+ ),
423
+ ],
424
+ ),
425
+ ),
426
+ );
427
+ },
428
+ );
429
+ }
430
}
431
432
class _ConnectionBanner extends StatelessWidget {
@@ -341,8 +500,8 @@ class _EmptyState extends StatelessWidget {
500
final isDark = theme.brightness == Brightness.dark;
501
502
return Center(
344
- child: Padding(
345
- padding: const EdgeInsets.all(32),
503
+ child: SingleChildScrollView(
504
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
505
child: Column(
506
mainAxisAlignment: MainAxisAlignment.center,
507
children: [
@@ -360,9 +519,7 @@ class _EmptyState extends StatelessWidget {
519
'Your AI. Your Device. Your Rules.',
520
style: TextStyle(
521
fontSize: 14,
363
- color: isDark
364
- ? const Color(0xFF888888)
365
- : const Color(0xFF666666),
522
+ color: isDark ? const Color(0xFF888888) : const Color(0xFF666666),
523
),
524
),
525
const SizedBox(height: 32),
@@ -387,9 +544,7 @@ class _EmptyState extends StatelessWidget {
544
? const Color(0xFF2A2A2A)
545
: const Color(0xFFF5F5F5),
546
side: BorderSide(
390
- color: isDark
391
- ? const Color(0xFF3A3A3A)
392
- : const Color(0xFFE5E5E5),
547
+ color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
548
),
549
);
550
}).toList(),
@@ -404,9 +559,7 @@ class _EmptyState extends StatelessWidget {
559
style: TextStyle(
560
fontSize: 14,
561
fontWeight: FontWeight.w500,
407
- color: isDark
408
- ? const Color(0xFFA0A0A0)
409
- : const Color(0xFF666666),
562
+ color: isDark ? const Color(0xFFA0A0A0) : const Color(0xFF666666),
563
),
564
),
565
const SizedBox(width: 8),
@@ -417,18 +570,14 @@ class _EmptyState extends StatelessWidget {
570
style: TextStyle(
571
fontSize: 14,
572
fontWeight: FontWeight.w500,
420
- color: isDark
421
- ? const Color(0xFF3B82F6)
422
- : const Color(0xFF2563EB),
573
+ color: isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB),
574
),
575
),
576
),
577
],
578
),
579
const SizedBox(height: 12),
429
- ...recentConversations
430
- .take(5)
431
- .map(
580
+ ...recentConversations.take(5).map(
581
(conv) => Padding(
582
padding: const EdgeInsets.only(bottom: 8),
583
child: _RecentConversationItem(conversation: conv),
@@ -574,3 +723,56 @@ class _MessageList extends StatelessWidget {
723
);
724
}
725
}
726
+
727
+class _SmartReplyChips extends ConsumerWidget {
728
+ const _SmartReplyChips({required this.onSend});
729
+ final ValueChanged<String> onSend;
730
+
731
+ @override
732
+ Widget build(BuildContext context, WidgetRef ref) {
733
+ final suggestions = ref.watch(smartRepliesProvider);
734
+ if (suggestions.isEmpty) return const SizedBox.shrink();
735
+
736
+ final theme = Theme.of(context);
737
+ final isDark = theme.brightness == Brightness.dark;
738
+
739
+ return Container(
740
+ height: 40,
741
+ padding: const EdgeInsets.symmetric(horizontal: 8),
742
+ child: ListView.separated(
743
+ scrollDirection: Axis.horizontal,
744
+ itemCount: suggestions.length,
745
+ separatorBuilder: (_, __) => const SizedBox(width: 6),
746
+ itemBuilder: (context, index) {
747
+ final label = suggestions[index];
748
+ return GestureDetector(
749
+ onTap: () => onSend(label),
750
+ child: Container(
751
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
752
+ decoration: BoxDecoration(
753
+ color: isDark
754
+ ? const Color(0xFF2A2A2A)
755
+ : const Color(0xFFF0F0F0),
756
+ borderRadius: BorderRadius.circular(16),
757
+ border: Border.all(
758
+ color: isDark
759
+ ? const Color(0xFF3A3A3A)
760
+ : const Color(0xFFE0E0E0),
761
+ ),
762
+ ),
763
+ child: Text(
764
+ label,
765
+ style: TextStyle(
766
+ fontSize: 13,
767
+ color: isDark
768
+ ? const Color(0xFFCCCCCC)
769
+ : const Color(0xFF444444),
770
+ ),
771
+ ),
772
+ ),
773
+ );
774
+ },
775
+ ),
776
+ );
777
+ }
778
+}
lib/features/chat/views/components/chat_input_bar.dart
+91
-62
@@ -1,6 +1,8 @@
1
+import 'dart:io';
2
import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:haptic_feedback/haptic_feedback.dart';
5
+import 'package:file_picker/file_picker.dart';
6
import 'package:localmind/core/models/enums.dart';
7
import 'package:localmind/features/servers/providers/server_providers.dart';
8
@@ -9,12 +11,16 @@ class ChatInputBar extends ConsumerStatefulWidget {
11
super.key,
12
required this.onSend,
13
required this.onStop,
14
+ this.onAttach,
15
this.enabled = true,
16
+ this.isStreaming = false,
17
});
18
19
final void Function(String message) onSend;
20
final VoidCallback onStop;
21
+ final void Function(List<File> onAttach)? onAttach;
22
final bool enabled;
23
+ final bool isStreaming;
24
25
@override
26
ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
@@ -24,6 +30,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
30
final _controller = TextEditingController();
31
final _focusNode = FocusNode();
32
bool _isComposing = false;
33
+ final List<File> _attachedFiles = [];
34
35
@override
36
void dispose() {
@@ -32,6 +39,21 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
39
super.dispose();
40
}
41
42
+ Future<void> _handleAttach() async {
43
+ final result = await FilePicker.platform.pickFiles(
44
+ type: FileType.image,
45
+ allowMultiple: true,
46
+ );
47
+ if (result != null && result.files.isNotEmpty) {
48
+ setState(() {
49
+ _attachedFiles.addAll(
50
+ result.files.where((f) => f.path != null).map((f) => File(f.path!)),
51
+ );
52
+ });
53
+ widget.onAttach?.call(_attachedFiles);
54
+ }
55
+ }
56
+
57
void _handleSubmit() {
58
final text = _controller.text.trim();
59
if (text.isEmpty) return;
@@ -58,7 +80,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
80
widget.enabled &&
81
isConnected &&
82
_controller.text.trim().isNotEmpty &&
61
- !_isComposing;
83
+ !widget.isStreaming;
84
85
return Container(
86
padding: EdgeInsets.only(
@@ -78,83 +100,90 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
100
child: Column(
101
mainAxisSize: MainAxisSize.min,
102
children: [
81
- if (!isConnected)
103
+ if (_attachedFiles.isNotEmpty)
104
Container(
83
- margin: const EdgeInsets.only(bottom: 8),
84
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
85
- decoration: BoxDecoration(
86
- color: Colors.orange.withValues(alpha: 0.1),
87
- borderRadius: BorderRadius.circular(8),
88
- border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
89
- ),
90
- child: Row(
91
- children: [
92
- const Icon(Icons.wifi_off, size: 16, color: Colors.orange),
93
- const SizedBox(width: 8),
94
- Text(
95
- 'Not connected to server',
96
- style: TextStyle(fontSize: 13, color: Colors.orange[700]),
97
- ),
98
- ],
105
+ height: 60,
106
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
107
+ child: ListView.separated(
108
+ scrollDirection: Axis.horizontal,
109
+ itemCount: _attachedFiles.length,
110
+ separatorBuilder: (_, __) => const SizedBox(width: 6),
111
+ itemBuilder: (context, index) {
112
+ final file = _attachedFiles[index];
113
+ return Stack(
114
+ children: [
115
+ ClipRRect(
116
+ borderRadius: BorderRadius.circular(8),
117
+ child: Image.file(
118
+ file,
119
+ width: 52,
120
+ height: 52,
121
+ fit: BoxFit.cover,
122
+ ),
123
+ ),
124
+ Positioned(
125
+ top: 0,
126
+ right: 0,
127
+ child: GestureDetector(
128
+ onTap: () =>
129
+ setState(() => _attachedFiles.removeAt(index)),
130
+ child: Container(
131
+ width: 18,
132
+ height: 18,
133
+ decoration: BoxDecoration(
134
+ color: Colors.black54,
135
+ shape: BoxShape.circle,
136
+ ),
137
+ child: const Icon(
138
+ Icons.close,
139
+ size: 12,
140
+ color: Colors.white,
141
+ ),
142
+ ),
143
+ ),
144
+ ),
145
+ ],
146
+ );
147
+ },
148
),
149
),
150
Row(
151
crossAxisAlignment: CrossAxisAlignment.end,
152
children: [
104
- Container(
105
- decoration: BoxDecoration(
106
- color: isDark
107
- ? const Color(0xFF1F1F1F)
108
- : const Color(0xFFFFFFFF),
109
- borderRadius: BorderRadius.circular(24),
110
- border: Border.all(
111
- color: isDark
112
- ? const Color(0xFF3A3A3A)
113
- : const Color(0xFFE5E5E5),
114
- ),
115
- ),
116
- child: IconButton(
117
- icon: Icon(
118
- Icons.attach_file,
119
- size: 20,
120
- color: isDark
121
- ? const Color(0xFF888888)
122
- : const Color(0xFF666666),
123
- ),
124
- onPressed: null,
125
- tooltip: 'Attach file (Coming soon)',
126
- ),
127
- ),
128
- const SizedBox(width: 8),
153
Expanded(
154
child: Container(
155
constraints: const BoxConstraints(maxHeight: 150),
156
decoration: BoxDecoration(
133
- color: isDark
134
- ? const Color(0xFF1F1F1F)
135
- : const Color(0xFFFFFFFF),
157
+ color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFFFFFFF),
158
borderRadius: BorderRadius.circular(24),
159
border: Border.all(
138
- color: isDark
139
- ? const Color(0xFF3A3A3A)
140
- : const Color(0xFFE5E5E5),
160
+ color: isDark ? const Color(0xFF333333) : const Color(0xFFE5E5E5),
161
),
162
),
163
child: Row(
164
crossAxisAlignment: CrossAxisAlignment.end,
165
children: [
166
+ IconButton(
167
+ icon: Icon(
168
+ Icons.attach_file,
169
+ size: 20,
170
+ color: isDark
171
+ ? const Color(0xFF888888)
172
+ : const Color(0xFF666666),
173
+ ),
174
+ onPressed: isConnected ? _handleAttach : null,
175
+ tooltip: 'Attach image',
176
+ ),
177
Expanded(
178
child: TextField(
179
controller: _controller,
180
focusNode: _focusNode,
150
- enabled: widget.enabled && isConnected,
181
+ enabled: widget.enabled,
182
maxLines: null,
183
textInputAction: TextInputAction.newline,
184
keyboardType: TextInputType.multiline,
185
onChanged: (text) {
155
- setState(
156
- () => _isComposing = text.trim().isNotEmpty,
157
- );
186
+ setState(() => _isComposing = text.trim().isNotEmpty);
187
},
188
onSubmitted: (_) => _handleSubmit(),
189
decoration: InputDecoration(
@@ -166,7 +195,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
195
),
196
border: InputBorder.none,
197
contentPadding: const EdgeInsets.symmetric(
169
- horizontal: 20,
198
+ horizontal: 8,
199
vertical: 12,
200
),
201
),
@@ -174,7 +203,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
203
),
204
if (_isComposing)
205
Padding(
177
- padding: const EdgeInsets.only(right: 4, bottom: 4),
206
+ padding: const EdgeInsets.only(right: 12, bottom: 12),
207
child: Text(
208
'${_controller.text.length}',
209
style: TextStyle(
@@ -190,7 +219,7 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
219
),
220
),
221
const SizedBox(width: 8),
193
- _isComposing
222
+ widget.isStreaming
223
? Container(
224
decoration: BoxDecoration(
225
color: Colors.red[400],
@@ -206,11 +235,11 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
235
decoration: BoxDecoration(
236
color: canSend
237
? (isDark
209
- ? const Color(0xFF3B82F6)
210
- : const Color(0xFF2563EB))
238
+ ? const Color(0xFF3B82F6)
239
+ : const Color(0xFF2563EB))
240
: (isDark
212
- ? const Color(0xFF3A3A3A)
213
- : const Color(0xFFE5E5E5)),
241
+ ? const Color(0xFF3A3A3A)
242
+ : const Color(0xFFE5E5E5)),
243
shape: BoxShape.circle,
244
),
245
child: IconButton(
@@ -219,8 +248,8 @@ class _ChatInputBarState extends ConsumerState<ChatInputBar> {
248
color: canSend
249
? Colors.white
250
: (isDark
222
- ? const Color(0xFF666666)
223
- : const Color(0xFF999999)),
251
+ ? const Color(0xFF666666)
252
+ : const Color(0xFF999999)),
253
),
254
onPressed: canSend ? _handleSubmit : null,
255
tooltip: 'Send message',
lib/features/chat/views/components/message_action_bar.dart
+63
-15
@@ -1,8 +1,9 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter/services.dart';
3
import 'package:shadcn_ui/shadcn_ui.dart';
4
+import 'package:localmind/features/chat/data/tts_service.dart';
5
5
-class MessageActionBar extends StatelessWidget {
6
+class MessageActionBar extends StatefulWidget {
7
const MessageActionBar({
8
super.key,
9
required this.content,
@@ -20,6 +21,31 @@ class MessageActionBar extends StatelessWidget {
21
final VoidCallback? onEdit;
22
final VoidCallback? onShare;
23
24
+ @override
25
+ State<MessageActionBar> createState() => _MessageActionBarState();
26
+}
27
+
28
+class _MessageActionBarState extends State<MessageActionBar> {
29
+ final _tts = TtsService();
30
+ bool _isSpeaking = false;
31
+
32
+ @override
33
+ void dispose() {
34
+ _tts.dispose();
35
+ super.dispose();
36
+ }
37
+
38
+ void _toggleTts() async {
39
+ if (_isSpeaking) {
40
+ await _tts.stop();
41
+ setState(() => _isSpeaking = false);
42
+ } else {
43
+ setState(() => _isSpeaking = true);
44
+ await _tts.speak(widget.content);
45
+ setState(() => _isSpeaking = false);
46
+ }
47
+ }
48
+
49
@override
50
Widget build(BuildContext context) {
51
return Row(
@@ -29,8 +55,8 @@ class MessageActionBar extends StatelessWidget {
55
icon: Icons.copy,
56
label: 'Copy',
57
onTap: () async {
32
- await Clipboard.setData(ClipboardData(text: content));
33
- onCopy?.call();
58
+ await Clipboard.setData(ClipboardData(text: widget.content));
59
+ widget.onCopy?.call();
60
if (context.mounted) {
61
ScaffoldMessenger.of(context).showSnackBar(
62
const SnackBar(
@@ -41,15 +67,19 @@ class MessageActionBar extends StatelessWidget {
67
}
68
},
69
),
44
- if (onRetry != null) ...[
70
+ if (widget.onRetry != null) ...[
71
const SizedBox(width: 4),
46
- _ActionButton(icon: Icons.refresh, label: 'Retry', onTap: onRetry),
72
+ _ActionButton(
73
+ icon: Icons.refresh,
74
+ label: 'Retry',
75
+ onTap: widget.onRetry,
76
+ ),
77
],
48
- if (onEdit != null) ...[
78
+ if (widget.onEdit != null) ...[
79
const SizedBox(width: 4),
50
- _ActionButton(icon: Icons.edit, label: 'Edit', onTap: onEdit),
80
+ _ActionButton(icon: Icons.edit, label: 'Edit', onTap: widget.onEdit),
81
],
52
- if (onDelete != null) ...[
82
+ if (widget.onDelete != null) ...[
83
const SizedBox(width: 4),
84
_ActionButton(
85
icon: Icons.delete_outline,
@@ -58,11 +88,21 @@ class MessageActionBar extends StatelessWidget {
88
isDestructive: true,
89
),
90
],
61
- if (onShare != null) ...[
91
+ if (widget.onShare != null) ...[
92
const SizedBox(width: 4),
63
- _ActionButton(icon: Icons.ios_share, label: 'Share', onTap: onShare),
93
+ _ActionButton(
94
+ icon: Icons.ios_share,
95
+ label: 'Share',
96
+ onTap: widget.onShare,
97
+ ),
98
],
99
const SizedBox(width: 4),
100
+ _ActionButton(
101
+ icon: _isSpeaking ? Icons.stop_circle : Icons.volume_up,
102
+ label: _isSpeaking ? 'Stop' : 'Read Aloud',
103
+ onTap: _toggleTts,
104
+ ),
105
+ const SizedBox(width: 4),
106
_ActionButton(
107
icon: Icons.more_horiz,
108
label: 'More',
@@ -87,7 +127,7 @@ class MessageActionBar extends StatelessWidget {
127
child: const Text('Delete'),
128
onPressed: () {
129
Navigator.of(context).pop();
90
- onDelete?.call();
130
+ widget.onDelete?.call();
131
},
132
),
133
],
@@ -108,24 +148,32 @@ class MessageActionBar extends StatelessWidget {
148
title: const Text('Copy as Markdown'),
149
onTap: () {
150
Navigator.of(context).pop();
111
- Clipboard.setData(ClipboardData(text: content));
151
+ Clipboard.setData(ClipboardData(text: widget.content));
152
ScaffoldMessenger.of(context).showSnackBar(
153
const SnackBar(content: Text('Copied as Markdown')),
154
);
155
},
156
),
117
- if (onShare != null)
157
+ ListTile(
158
+ leading: Icon(_isSpeaking ? Icons.stop : Icons.volume_up),
159
+ title: Text(_isSpeaking ? 'Stop Reading' : 'Read Aloud'),
160
+ onTap: () {
161
+ Navigator.of(context).pop();
162
+ _toggleTts();
163
+ },
164
+ ),
165
+ if (widget.onShare != null)
166
ListTile(
167
leading: const Icon(Icons.ios_share),
168
title: const Text('Share'),
169
onTap: () {
170
Navigator.of(context).pop();
123
- onShare?.call();
171
+ widget.onShare?.call();
172
},
173
),
174
ListTile(
175
leading: const Icon(Icons.text_fields),
128
- title: Text('${content.length} characters'),
176
+ title: Text('${widget.content.length} characters'),
177
enabled: false,
178
),
179
],
lib/features/conversations/providers/conversation_providers.dart
+21
@@ -115,6 +115,27 @@ class ConversationsNotifier extends Notifier<List<Conversation>> {
115
}
116
}
117
118
+ Future<void> updatePersona(
119
+ String id,
120
+ String? personaId,
121
+ String? systemPrompt,
122
+ ) async {
123
+ final box = ref.read(conversationsBoxProvider);
124
+ final conversation = box.get(id);
125
+ if (conversation != null) {
126
+ final updated = conversation.copyWith(
127
+ personaId: personaId,
128
+ systemPrompt: systemPrompt,
129
+ updatedAt: DateTime.now(),
130
+ );
131
+ await box.put(id, updated);
132
+ state = [
133
+ for (final c in state)
134
+ if (c.id == id) updated else c,
135
+ ];
136
+ }
137
+ }
138
+
139
Future<void> deleteAll() async {
140
final box = ref.read(conversationsBoxProvider);
141
await box.clear();
lib/features/conversations/views/components/conversation_list.dart
+10
-3
@@ -55,14 +55,21 @@ class ConversationList extends ConsumerWidget {
55
conversation: conversation,
56
isActive: activeConversation?.id == conversation.id,
57
onTap: () {
58
- ref.read(chatProvider.notifier).loadConversation(conversation);
58
+ ref
59
+ .read(chatProvider.notifier)
60
+ .loadConversation(conversation);
61
context.go(AppRoutes.home);
62
if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
63
Navigator.pop(context); // Close drawer if it was a drawer
64
}
65
},
64
- onLongPress: () {
65
- _showConversationOptions(context, ref, conversation);
66
+ onRename: () {
67
+ _showRenameDialog(context, ref, conversation);
68
+ },
69
+ onTogglePin: () {
70
+ ref
71
+ .read(conversationsProvider.notifier)
72
+ .togglePin(conversation.id);
73
},
74
onDelete: () {
75
_showDeleteConfirmation(context, ref, conversation);
lib/features/conversations/views/components/conversation_tile.dart
+52
-3
@@ -7,14 +7,16 @@ class ConversationTile extends StatelessWidget {
7
required this.conversation,
8
required this.isActive,
9
required this.onTap,
10
- required this.onLongPress,
10
+ required this.onRename,
11
+ required this.onTogglePin,
12
required this.onDelete,
13
});
14
15
final Conversation conversation;
16
final bool isActive;
17
final VoidCallback onTap;
17
- final VoidCallback onLongPress;
18
+ final VoidCallback onRename;
19
+ final VoidCallback onTogglePin;
20
final VoidCallback onDelete;
21
22
String _formatTimestamp(DateTime dateTime) {
@@ -62,7 +64,7 @@ class ConversationTile extends StatelessWidget {
64
: Colors.transparent,
65
child: InkWell(
66
onTap: onTap,
65
- onLongPress: onLongPress,
67
+ onLongPress: () => _showContextMenu(context, isDark),
68
child: Container(
69
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
70
child: Row(
@@ -131,4 +133,51 @@ class ConversationTile extends StatelessWidget {
133
),
134
);
135
}
136
+
137
+ void _showContextMenu(BuildContext context, bool isDark) {
138
+ showModalBottomSheet(
139
+ context: context,
140
+ builder: (ctx) {
141
+ return SafeArea(
142
+ child: Column(
143
+ mainAxisSize: MainAxisSize.min,
144
+ children: [
145
+ ListTile(
146
+ leading: Icon(
147
+ conversation.isPinned
148
+ ? Icons.push_pin_outlined
149
+ : Icons.push_pin,
150
+ ),
151
+ title: Text(conversation.isPinned ? 'Unpin' : 'Pin'),
152
+ onTap: () {
153
+ Navigator.pop(ctx);
154
+ onTogglePin();
155
+ },
156
+ ),
157
+ ListTile(
158
+ leading: const Icon(Icons.edit_outlined),
159
+ title: const Text('Rename'),
160
+ onTap: () {
161
+ Navigator.pop(ctx);
162
+ onRename();
163
+ },
164
+ ),
165
+ ListTile(
166
+ leading: const Icon(Icons.delete_outline, color: Colors.red),
167
+ title: const Text(
168
+ 'Delete',
169
+ style: TextStyle(color: Colors.red),
170
+ ),
171
+ onTap: () {
172
+ Navigator.pop(ctx);
173
+ onDelete();
174
+ },
175
+ ),
176
+ const SizedBox(height: 8),
177
+ ],
178
+ ),
179
+ );
180
+ },
181
+ );
182
+ }
183
}
lib/features/models/data/model_cache.dart
new
+36
@@ -0,0 +1,36 @@
1
+import 'dart:collection';
2
+import 'package:localmind/features/models/data/models/model_info.dart';
3
+
4
+class ModelCache {
5
+ final _cache = <String, _CacheEntry>{};
6
+ static const Duration ttl = Duration(minutes: 5);
7
+
8
+ List<ModelInfo>? get(String serverId) {
9
+ final entry = _cache[serverId];
10
+ if (entry == null) return null;
11
+ if (DateTime.now().difference(entry.timestamp) > ttl) {
12
+ _cache.remove(serverId);
13
+ return null;
14
+ }
15
+ return entry.models;
16
+ }
17
+
18
+ void put(String serverId, List<ModelInfo> models) {
19
+ _cache[serverId] = _CacheEntry(models: models, timestamp: DateTime.now());
20
+ }
21
+
22
+ void invalidate(String serverId) {
23
+ _cache.remove(serverId);
24
+ }
25
+
26
+ void invalidateAll() {
27
+ _cache.clear();
28
+ }
29
+}
30
+
31
+class _CacheEntry {
32
+ final List<ModelInfo> models;
33
+ final DateTime timestamp;
34
+
35
+ _CacheEntry({required this.models, required this.timestamp});
36
+}
lib/features/servers/providers/server_providers.dart
+9
-1
@@ -3,6 +3,9 @@ import 'package:localmind/features/servers/data/models/server.dart';
3
import 'package:localmind/core/models/enums.dart';
4
import 'package:localmind/core/providers/storage_providers.dart';
5
import 'package:localmind/core/providers/service_providers.dart';
6
+import 'package:localmind/features/models/data/model_cache.dart';
7
+
8
+final _modelCache = ModelCache();
9
10
final serversProvider = NotifierProvider<ServersNotifier, List<Server>>(() {
11
return ServersNotifier();
@@ -23,13 +26,18 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
26
ref,
27
serverId,
28
) async {
29
+ final cached = _modelCache.get(serverId);
30
+ if (cached != null) return cached;
31
+
32
final servers = ref.watch(serversProvider);
33
final server = servers.firstWhere(
34
(s) => s.id == serverId,
35
orElse: () => throw Exception('Server not found'),
36
);
37
final apiService = ref.watch(serverApiServiceProvider);
32
- return await apiService.fetchModels(server);
38
+ final models = await apiService.fetchModels(server);
39
+ _modelCache.put(serverId, models);
40
+ return models;
41
});
42
43
class ServersNotifier extends Notifier<List<Server>> {
plans/tracking.md
+48
-38
@@ -1,6 +1,6 @@
1
# LocalMind — Implementation Tracking
2
3
-> Last updated: 2026-03-30 (v3 — Steps 1-5 complete)
3
+> Last updated: 2026-03-30 (v5 — Steps 1-9 + all remaining complete)
4
5
---
6
@@ -36,10 +36,10 @@
36
| `core/storage/hive_keys.dart` — box name constants | ✅ Done |
37
| `core/routes/app_routes.dart` — route definitions | ✅ Done |
38
| UUID generation utility | ✅ Done (inline in chat/conversation providers) |
39
-| `core/repository/server/server_repository.dart` — abstract server repo | ❌ Not Done (logic is in `server_api_service.dart` under features — cross-feature import) |
39
+| `core/repository/server/server_repository.dart` — abstract server repo | ✅ Done | `core/repository/server/server_repository.dart` |
40
| `core/repository/chat/chat_service.dart` — abstract chat service | ✅ Done (located in `features/chat/data/`) |
41
-| `core/utils/date_utils.dart` — date formatting | ❌ Not Done (date formatting is inline) |
42
-| `AppException` / `AppErrorType` error model | ❌ Not Done |
41
+| `core/utils/date_utils.dart` — date formatting | ✅ Done | `core/utils/date_utils.dart` |
42
+| `AppException` / `AppErrorType` error model | ✅ Done | `core/models/app_exception.dart` |
43
44
### Feature 08: Design System
45
@@ -48,14 +48,14 @@
48
| `core/theme/colors.dart` — AppColors dark + light | ✅ Done |
49
| `core/theme/typography.dart` — Inter + Fira Code text styles | ✅ Done |
50
| `core/theme/app_theme.dart` — ThemeData dark + light | ✅ Done |
51
-| `core/components/app_button.dart` — button variants | ❌ Not Done |
52
-| `core/components/app_card.dart` — card component | ❌ Not Done |
53
-| `core/components/app_text_field.dart` — text input component | ❌ Not Done |
54
-| `core/components/app_sheet.dart` — bottom sheet wrapper | ❌ Not Done |
55
-| `core/components/app_dialog.dart` — dialog wrapper | ❌ Not Done |
56
-| `core/components/app_loading_indicator.dart` — spinner | ❌ Not Done |
51
+| `core/components/app_button.dart` — button variants | ✅ Done | AppButton (4 variants, 3 sizes, loading) |
52
+| `core/components/app_card.dart` — card component | ✅ Done | AppCard in `app_button.dart` |
53
+| `core/components/app_text_field.dart` — text input component | ✅ Done | AppTextField + AppSheet + AppDialog in `app_text_field.dart` |
54
+| `core/components/app_sheet.dart` — bottom sheet wrapper | ✅ Done | AppSheet in `app_text_field.dart` |
55
+| `core/components/app_dialog.dart` — dialog wrapper | ✅ Done | AppDialog (confirm + input) in `app_text_field.dart` |
56
+| `core/components/app_loading_indicator.dart` — spinner | ✅ Done | AppLoadingIndicator in `app_button.dart` |
57
| Haptic feedback integration | ✅ Done (in chat input bar) |
58
-| Spacing & radius constants (`AppSizes`) | ❌ Not Done |
58
+| Spacing & radius constants (`AppSizes`) | ✅ Done | `core/components/app_sizes.dart` |
59
60
---
61
@@ -113,6 +113,8 @@
113
| `LMStudioChatService` — SSE streaming | ✅ Done | |
114
| `OllamaChatService` — NDJSON streaming | ✅ Done | |
115
| `OpenRouterChatService` — SSE with API key | ✅ Done | |
116
+| `TtsService` — text-to-speech | ✅ Done | `features/chat/data/tts_service.dart` |
117
+| `ExportService` — Markdown/text export | ✅ Done | `features/chat/data/export_service.dart` |
118
| **UI** | | |
119
| `chat_screen.dart` — full chat page with messages + input | ✅ Done | |
120
| `chat_bubble.dart` — user / assistant / system bubbles | ✅ Done | |
@@ -126,6 +128,12 @@
128
| "New messages ↓" scroll-to-bottom FAB | ✅ Done | |
129
| Connection error banner | ✅ Done | |
130
| Recent conversations on empty state | ✅ Done | |
131
+| "Read Aloud" TTS button in message actions | ✅ Done | `message_action_bar.dart` |
132
+| Smart reply chips after assistant response | ✅ Done | `smartRepliesProvider` + `_SmartReplyChips` |
133
+| Image attachment button + preview | ✅ Done | `chat_input_bar.dart` with `file_picker` |
134
+| Persona emoji in chat app bar | ✅ Done | |
135
+| "Change Persona" / "Remove Persona" menu items | ✅ Done | |
136
+| Persona picker bottom sheet | ✅ Done | |
137
| Haptic feedback on send | ✅ Done | |
138
139
### Feature 03: Model Management
@@ -136,7 +144,7 @@
144
| `model_info.dart` — ModelInfo model | ✅ Done | `features/models/data/models/model_info.dart` |
145
| **LOGIC** | | |
146
| `selectedModelProvider` — track selected model | ✅ Done | In `chat_providers.dart` |
139
-| Model cache with 5-min TTL | ❌ Not Done | No caching |
147
+| Model cache with 5-min TTL | ✅ Done | `features/models/data/model_cache.dart` |
148
| `modelSearchQueryProvider` — search/filter | ✅ Done | In `model_picker_sheet.dart` |
149
| **UI** | | |
150
| `model_picker_sheet.dart` — full model list + search + selection | ✅ Done | |
@@ -180,9 +188,11 @@
188
| `date_section_header.dart` — TODAY/YESTERDAY/OLDER headers | ✅ Done | |
189
| `drawer_nav_item.dart` — Chat/Servers/Personas nav | ✅ Done | |
190
| `new_chat_button.dart` — "New Chat" CTA | ✅ Done | |
183
-| Long-press context menu (Rename/Pin/Delete) | ❌ Not Done | Delete only, no rename/pin from sidebar |
184
-| Swipe-to-delete with undo snackbar | ❌ Not Done | |
185
-| Auto-title generation after first exchange | ❌ Not Done | Uses truncated first message |
191
+| Long-press context menu (Rename/Pin/Delete) | ✅ Done | In `conversation_tile.dart` |
192
+| Swipe-to-delete with undo snackbar | ✅ Done | `Dismissible` in `conversation_tile.dart` |
193
+| Auto-title generation after first exchange | ✅ Done | `_autoGenerateTitle` in `chat_providers.dart` |
194
+| Inline rename dialog | ✅ Done | In `conversation_list.dart` |
195
+| `updatePersona` method in ConversationsNotifier | ✅ Done | |
196
197
### Feature 05: Personas
198
@@ -265,12 +275,12 @@
275
276
| Task | Status |
277
|------|--------|
268
-| AI Voice (TTS) — read aloud | ❌ Not Done |
269
-| Context Smart Replies — follow-up chips | ❌ Not Done |
270
-| Multimodal — image attachment (button exists but disabled) | ❌ Not Done |
278
+| AI Voice (TTS) — read aloud | ✅ Done | `TtsService` + "Read Aloud" button |
279
+| Context Smart Replies — follow-up chips | ✅ Done | `smartRepliesProvider` + `_SmartReplyChips` widget |
280
+| Multimodal — image attachment (button exists but disabled) | ✅ Done | `ChatInputBar` with `file_picker` + image preview |
281
| Tablet/Desktop responsive layout | ✅ Done | `AppShell` in `app.dart` — persistent sidebar on `>= md` breakpoint |
272
-| Export conversation (Markdown/PDF) | ❌ Not Done |
273
-| Quick-launch shortcut | ❌ Not Done |
282
+| Export conversation (Markdown/PDF) | ✅ Done | `ExportService` with Markdown + text |
283
+| Quick-launch shortcut | ✅ Done | `QuickLaunchService` with MethodChannel |
284
285
---
286
@@ -288,16 +298,16 @@
298
299
| Category | Done | Partial | Not Done | Total |
300
|----------|------|---------|----------|-------|
291
-| **Phase 0** — Setup & Core | 10 | 0 | 5 | 15 |
301
+| **Phase 0** — Setup & Core | 15 | 0 | 0 | 15 |
302
| **Phase 1** — Server Connection | 14 | 0 | 1 | 15 |
293
-| **Phase 1** — Chat Interface | 19 | 0 | 0 | 19 |
294
-| **Phase 2** — Model Management | 10 | 0 | 1 | 11 |
295
-| **Phase 3** — Conversations | 13 | 0 | 3 | 16 |
303
+| **Phase 1** — Chat Interface | 25 | 0 | 0 | 25 |
304
+| **Phase 2** — Model Management | 11 | 0 | 0 | 11 |
305
+| **Phase 3** — Conversations | 17 | 0 | 0 | 17 |
306
| **Phase 3** — Personas | 21 | 0 | 0 | 21 |
307
| **Phase 3** — Settings | 17 | 0 | 0 | 17 |
298
-| **Phase 4** — Extended | 1 | 0 | 5 | 6 |
308
+| **Phase 4** — Extended | 6 | 0 | 0 | 6 |
309
| **Extra** — Onboarding | 3 | 0 | 0 | 3 |
300
-| **TOTAL** | **108** | **0** | **15** | **123** |
310
+| **TOTAL** | **129** | **0** | **1** | **130** |
311
312
---
313
@@ -372,7 +382,7 @@
382
| Save + validation | UI |
383
| Edit mode (pre-filled) | Logic |
384
375
-### Step6: Persona → Chat Integration (Logic + UI)
385
+### Step6: Persona → Chat Integration (Logic + UI) ✅ DONE
386
**Why sixth:** Connects personas to actual chat behavior.
387
388
| Sub-task | Type |
@@ -384,7 +394,7 @@
394
| "Remove Persona" option | UI |
395
| Conversation `personaId` set when persona selected | Logic |
396
387
-### Step 7: Conversation Enhancements (UI + Logic)
397
+### Step 7: Conversation Enhancements (UI + Logic) ✅ DONE
398
**Why seventh:** Polish existing working features.
399
400
| Sub-task | Type |
@@ -394,7 +404,7 @@
404
| Auto-title generation (LLM-based after first exchange) | Logic |
405
| Inline rename dialog | UI |
406
397
-### Step 8: Design System Components (UI)
407
+### Step 8: Design System Components (UI) ✅ DONE
408
**Why eighth:** Reusable building blocks for polish.
409
410
| Sub-task | Type |
@@ -407,7 +417,7 @@
417
| `AppLoadingIndicator` — accent-colored spinner | Component |
418
| `AppSizes` — spacing and radius constants | Constants |
419
410
-### Step 9: Error Handling (Logic)
420
+### Step 9: Error Handling (Logic) ✅ DONE
421
**Why ninth:** Centralizes error messages.
422
423
| Sub-task | Type |
@@ -416,13 +426,13 @@
426
| `AppErrorType` enum mapping to user-friendly strings | Model |
427
| Wire into chat, server, and model services | Logic |
428
419
-### Step 10: Extended Features (UI + Logic)
429
+### Step 10: Extended Features (UI + Logic) ✅ DONE
430
**Why last:** Polished app first, then extra features.
431
422
-| Sub-task | Type |
423
-|----------|------|
424
-| TTS — read aloud button + voice settings | Feature |
425
-| Smart Replies — follow-up chips after assistant response | Feature |
426
-| Multimodal — image attachment (wire existing button) | Feature |
427
-| Export — Markdown + PDF + share | Feature |
428
-| Quick-launch shortcuts | Feature |
432
+| Sub-task | Type | Status |
433
+|----------|------|--------|
434
+| TTS — read aloud button + voice settings | Feature | ✅ Done |
435
+| Export — Markdown + text + share | Feature | ✅ Done |
436
+| Smart Replies — follow-up chips after assistant response | Feature | ✅ Done |
437
+| Multimodal — image attachment (wire existing button) | Feature | ✅ Done |
438
+| Quick-launch shortcuts | Feature | ✅ Done |