@tduncan / Cortana / commits / 235e757

feat: implement core UI components, error handling, and conversation management features

abdulmominsakib committed Mar 31, 2026 at 17:59 UTC 235e757f77abc224e005d58c5f1b24a4c219a8d6
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 |