Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c967a49e5a | |||
| 58d4cfab4d | |||
| ee0f354312 | |||
| bdaa5210f0 |
+35
-29
@@ -159,20 +159,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: Routes.conversations,
|
path: Routes.conversations,
|
||||||
builder: (_, _) => const ConversationsTabScreen(),
|
builder: (_, _) => const ConversationsTabScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projects,
|
||||||
|
builder: (_, _) => const ProjectsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.news,
|
||||||
|
builder: (_, _) => const NewsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.calendar,
|
||||||
|
builder: (_, _) => const CalendarScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: Routes.projects,
|
|
||||||
builder: (_, _) => const ProjectsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: Routes.news,
|
|
||||||
builder: (_, _) => const NewsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: Routes.calendar,
|
|
||||||
builder: (_, _) => const CalendarScreen(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -190,6 +190,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
Routes.briefing,
|
Routes.briefing,
|
||||||
Routes.knowledge,
|
Routes.knowledge,
|
||||||
Routes.conversations,
|
Routes.conversations,
|
||||||
|
Routes.projects,
|
||||||
|
Routes.news,
|
||||||
|
Routes.calendar,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||||
@@ -255,6 +258,10 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
ref.read(knowledgeProvider.notifier).refresh();
|
ref.read(knowledgeProvider.notifier).refresh();
|
||||||
case 2:
|
case 2:
|
||||||
ref.read(conversationsProvider.notifier).refresh();
|
ref.read(conversationsProvider.notifier).refresh();
|
||||||
|
case 4:
|
||||||
|
ref.read(newsProvider.notifier).refresh();
|
||||||
|
case 5:
|
||||||
|
ref.read(calendarProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,11 +278,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
for (var i = 0; i < _tabs.length; i++) {
|
for (var i = 0; i < _tabs.length; i++) {
|
||||||
if (location.startsWith(_tabs[i])) return i;
|
if (location.startsWith(_tabs[i])) return i;
|
||||||
}
|
}
|
||||||
if (location.startsWith(Routes.projects) ||
|
|
||||||
location.startsWith(Routes.news) ||
|
|
||||||
location.startsWith(Routes.calendar)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +396,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
final index = _tabIndex(location);
|
final index = _tabIndex(location);
|
||||||
|
|
||||||
// Refresh the incoming tab's data when switching between shell tabs.
|
// Refresh the incoming tab's data when switching between shell tabs.
|
||||||
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
|
if (_prevTabIndex != null && _prevTabIndex != index) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
|
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
|
||||||
}
|
}
|
||||||
_prevTabIndex = index;
|
_prevTabIndex = index;
|
||||||
@@ -413,13 +415,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
NavigationRail(
|
||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||||
if (i == 3) {
|
|
||||||
_showMoreSheet(context);
|
|
||||||
} else {
|
|
||||||
context.go(_tabs[i]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
@@ -438,9 +434,19 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
label: Text('Chat'),
|
label: Text('Chat'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.more_horiz_outlined),
|
icon: Icon(Icons.folder_outlined),
|
||||||
selectedIcon: Icon(Icons.more_horiz),
|
selectedIcon: Icon(Icons.folder),
|
||||||
label: Text('More'),
|
label: Text('Projects'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.newspaper_outlined),
|
||||||
|
selectedIcon: Icon(Icons.newspaper),
|
||||||
|
label: Text('News'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.calendar_month_outlined),
|
||||||
|
selectedIcon: Icon(Icons.calendar_month),
|
||||||
|
label: Text('Calendar'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -469,7 +475,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: index,
|
selectedIndex: index >= 3 ? 3 : index,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) {
|
||||||
if (i == 3) {
|
if (i == 3) {
|
||||||
_showMoreSheet(context);
|
_showMoreSheet(context);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class KnowledgeItem {
|
|||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
noteType: json['note_type'] as String? ?? 'note',
|
noteType: json['note_type'] as String? ?? 'note',
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
body: json['body'] as String? ?? '',
|
body: (json['snippet'] ?? json['body']) as String? ?? '',
|
||||||
tags: (json['tags'] as List<dynamic>?)
|
tags: (json['tags'] as List<dynamic>?)
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
|
|||||||
@@ -99,10 +99,23 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
||||||
|
|
||||||
// Recording / silence detection
|
// Recording / silence detection
|
||||||
|
//
|
||||||
|
// Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||||
|
// "silent" as "current level is at least _dropFromPeakDb below peak."
|
||||||
|
// This auto-calibrates to whatever mic + room the user is on rather than
|
||||||
|
// assuming a fixed ambient level. Until the peak climbs above
|
||||||
|
// _dynamicArmDb we fall back to a conservative static threshold so a
|
||||||
|
// quiet room doesn't spin forever. A grace period at the start of the
|
||||||
|
// recording gives the user time to begin speaking before silence checks
|
||||||
|
// arm.
|
||||||
int _recordingStartMs = 0;
|
int _recordingStartMs = 0;
|
||||||
int _silenceMs = 0;
|
int _silenceMs = 0;
|
||||||
static const _silenceThresholdDb = -40.0;
|
double _peakDb = -100.0;
|
||||||
static const _silenceDurationMs = 1500;
|
static const _fallbackThresholdDb = -35.0;
|
||||||
|
static const _dropFromPeakDb = 15.0;
|
||||||
|
static const _dynamicArmDb = -20.0;
|
||||||
|
static const _graceMs = 1500;
|
||||||
|
static const _silenceDurationMs = 2000;
|
||||||
static const _minRecordingMs = 300;
|
static const _minRecordingMs = 300;
|
||||||
|
|
||||||
// Voice mode callbacks
|
// Voice mode callbacks
|
||||||
@@ -236,6 +249,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
if (!state.voiceModeActive) return;
|
if (!state.voiceModeActive) return;
|
||||||
|
|
||||||
_silenceMs = 0;
|
_silenceMs = 0;
|
||||||
|
_peakDb = -100.0;
|
||||||
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
|
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
state = state.copyWith(mode: VoiceMode.recording);
|
state = state.copyWith(mode: VoiceMode.recording);
|
||||||
|
|
||||||
@@ -285,13 +299,22 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
if ((norm - state.amplitude).abs() > 0.02) {
|
if ((norm - state.amplitude).abs() > 0.02) {
|
||||||
state = state.copyWith(amplitude: norm);
|
state = state.copyWith(amplitude: norm);
|
||||||
}
|
}
|
||||||
|
if (db > _peakDb) _peakDb = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
final elapsed =
|
final elapsed =
|
||||||
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
||||||
|
// Grace period at the start — let the user begin speaking before we
|
||||||
|
// start silence-counting. Also suppresses any false triggers while
|
||||||
|
// the native recorder is still warming up.
|
||||||
|
if (elapsed < _graceMs) return;
|
||||||
if (elapsed < _minRecordingMs) return;
|
if (elapsed < _minRecordingMs) return;
|
||||||
|
|
||||||
final isSilent = !validDb || db < _silenceThresholdDb;
|
// Dynamic threshold once we've seen real speech; static fallback
|
||||||
|
// before that so a dead-silent session doesn't spin forever.
|
||||||
|
final threshold =
|
||||||
|
_peakDb > _dynamicArmDb ? _peakDb - _dropFromPeakDb : _fallbackThresholdDb;
|
||||||
|
final isSilent = !validDb || db < threshold;
|
||||||
|
|
||||||
if (isSilent) {
|
if (isSilent) {
|
||||||
_silenceMs += 200;
|
_silenceMs += 200;
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (conv) {
|
data: (conv) {
|
||||||
return Column(
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
|
Widget body = Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
@@ -392,6 +393,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
if (isWide) {
|
||||||
|
body = Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 700),
|
||||||
|
child: body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
|
|
||||||
class ConversationsTabScreen extends ConsumerWidget {
|
class ConversationsTabScreen extends ConsumerStatefulWidget {
|
||||||
const ConversationsTabScreen({super.key});
|
const ConversationsTabScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ConversationsTabScreen> createState() =>
|
||||||
|
_ConversationsTabScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConversationsTabScreenState
|
||||||
|
extends ConsumerState<ConversationsTabScreen> {
|
||||||
|
int? _selectedConvId;
|
||||||
|
|
||||||
|
Future<void> _createConversation() async {
|
||||||
|
final conv =
|
||||||
|
await ref.read(conversationsProvider.notifier).create('');
|
||||||
|
if (!mounted) return;
|
||||||
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
|
if (isWide) {
|
||||||
|
setState(() => _selectedConvId = conv.id);
|
||||||
|
} else {
|
||||||
|
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openConversation(int id) {
|
||||||
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
|
if (isWide) {
|
||||||
|
setState(() => _selectedConvId = id);
|
||||||
|
} else {
|
||||||
|
context.push(Routes.chat.replaceFirst(':id', '$id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(int id, String title) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Delete conversation?'),
|
||||||
|
content: Text('"$title" will be permanently deleted.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Delete')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
await ref.read(conversationsProvider.notifier).delete(id);
|
||||||
|
if (_selectedConvId == id) {
|
||||||
|
setState(() => _selectedConvId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final convsAsync = ref.watch(conversationsProvider);
|
final convsAsync = ref.watch(conversationsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
final listPanel = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
tooltip: 'New conversation',
|
tooltip: 'New conversation',
|
||||||
onPressed: () async {
|
onPressed: _createConversation,
|
||||||
final conv = await ref
|
|
||||||
.read(conversationsProvider.notifier)
|
|
||||||
.create('');
|
|
||||||
if (context.mounted) {
|
|
||||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Start a conversation'),
|
label: const Text('Start a conversation'),
|
||||||
onPressed: () async {
|
onPressed: _createConversation,
|
||||||
final conv = await ref
|
|
||||||
.read(conversationsProvider.notifier)
|
|
||||||
.create('');
|
|
||||||
if (context.mounted) {
|
|
||||||
context.push(
|
|
||||||
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
|
onRefresh: () =>
|
||||||
|
ref.read(conversationsProvider.notifier).refresh(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: convs.length,
|
itemCount: convs.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final c = convs[i];
|
final c = convs[i];
|
||||||
|
final selected = isWide && c.id == _selectedConvId;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
_relativeTime(c.updatedAt),
|
_relativeTime(c.updatedAt),
|
||||||
style: theme.textTheme.labelSmall,
|
style: theme.textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
|
selected: selected,
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
onPressed: () =>
|
onPressed: () => _confirmDelete(c.id, c.title),
|
||||||
_confirmDelete(context, ref, c.id, c.title),
|
|
||||||
),
|
),
|
||||||
onTap: () =>
|
onTap: () => _openConversation(c.id),
|
||||||
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmDelete(
|
if (!isWide) return listPanel;
|
||||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
|
||||||
final ok = await showDialog<bool>(
|
return Row(
|
||||||
context: context,
|
children: [
|
||||||
builder: (dialogContext) => AlertDialog(
|
SizedBox(
|
||||||
title: const Text('Delete conversation?'),
|
width: 320,
|
||||||
content: Text('"$title" will be permanently deleted.'),
|
child: listPanel,
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
const VerticalDivider(width: 1),
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
Expanded(
|
||||||
child: const Text('Cancel')),
|
child: _selectedConvId != null
|
||||||
FilledButton(
|
? ChatScreen(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
key: ValueKey(_selectedConvId),
|
||||||
child: const Text('Delete')),
|
conversationId: _selectedConvId!,
|
||||||
],
|
)
|
||||||
),
|
: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.chat_bubble_outline,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Select a conversation',
|
||||||
|
style: theme.textTheme.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
if (ok == true) {
|
|
||||||
await ref.read(conversationsProvider.notifier).delete(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
||||||
child: ListView.separated(
|
child: LayoutBuilder(
|
||||||
controller: _scrollController,
|
builder: (context, constraints) {
|
||||||
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
|
final cols = constraints.maxWidth >= 900
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
? 3
|
||||||
itemBuilder: (_, i) {
|
: constraints.maxWidth >= 600
|
||||||
if (i >= items.length) {
|
? 2
|
||||||
return const Padding(
|
: 1;
|
||||||
padding: EdgeInsets.all(16),
|
if (cols == 1) {
|
||||||
child: Center(child: CircularProgressIndicator()),
|
return ListView.separated(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
if (i >= items.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return KnowledgeItemCard(item: items[i]);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return KnowledgeItemCard(item: items[i]);
|
return CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.8,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(_, i) {
|
||||||
|
if (i >= items.length) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return KnowledgeItemCard(item: items[i])
|
||||||
|
.buildGridCard(context);
|
||||||
|
},
|
||||||
|
childCount:
|
||||||
|
items.length + (state.isLoadingBatch ? 1 : 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,34 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
|
||||||
|
if (i >= news.items.length) {
|
||||||
|
if (!news.hasMore) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Center(
|
||||||
|
child: news.loadingMore
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: _loadMore,
|
||||||
|
child: const Text('Load more'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final item = news.items[i];
|
||||||
|
return NewsCard(
|
||||||
|
item: RssItemMeta.fromNewsItem(item),
|
||||||
|
reaction: news.reactions[item.id],
|
||||||
|
snippetMaxLines: cols > 1 ? 5 : 2,
|
||||||
|
onReaction: (itemId, reaction) =>
|
||||||
|
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
|
||||||
|
onDiscuss: _openingChat.contains(item.id)
|
||||||
|
? null
|
||||||
|
: () => _handleDiscuss(item.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final newsAsync = ref.watch(newsProvider);
|
final newsAsync = ref.watch(newsProvider);
|
||||||
@@ -98,38 +126,59 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
||||||
child: ListView.builder(
|
child: LayoutBuilder(
|
||||||
padding:
|
builder: (context, constraints) {
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
final cols = constraints.maxWidth >= 900
|
||||||
itemCount: news.items.length + 1,
|
? 3
|
||||||
itemBuilder: (_, i) {
|
: constraints.maxWidth >= 600
|
||||||
if (i == news.items.length) {
|
? 2
|
||||||
if (!news.hasMore) return const SizedBox.shrink();
|
: 1;
|
||||||
return Padding(
|
if (cols == 1) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
return ListView.builder(
|
||||||
child: Center(
|
padding: const EdgeInsets.symmetric(
|
||||||
child: news.loadingMore
|
horizontal: 8, vertical: 8),
|
||||||
? const CircularProgressIndicator()
|
itemCount: news.items.length + 1,
|
||||||
: FilledButton.tonal(
|
itemBuilder: (_, i) =>
|
||||||
onPressed: _loadMore,
|
_buildNewsItem(news, i),
|
||||||
child: const Text('Load more'),
|
);
|
||||||
|
}
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate:
|
||||||
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.6,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(_, i) => _buildNewsItem(news, i, cols: cols),
|
||||||
|
childCount: news.items.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (news.hasMore)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(
|
||||||
|
child: news.loadingMore
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: _loadMore,
|
||||||
|
child: const Text('Load more'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
final item = news.items[i];
|
),
|
||||||
return NewsCard(
|
|
||||||
item: RssItemMeta.fromNewsItem(item),
|
|
||||||
reaction: news.reactions[item.id],
|
|
||||||
onReaction: (itemId, reaction) => ref
|
|
||||||
.read(newsProvider.notifier)
|
|
||||||
.toggleReaction(itemId, reaction),
|
|
||||||
onDiscuss: _openingChat.contains(item.id)
|
|
||||||
? null
|
|
||||||
: () => _handleDiscuss(item.id),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget {
|
|||||||
|
|
||||||
String? get _subtitle {
|
String? get _subtitle {
|
||||||
if (item.noteType == 'task') {
|
if (item.noteType == 'task') {
|
||||||
|
if (item.body.trim().isNotEmpty) {
|
||||||
|
final preview = item.body.trim().replaceAll('\n', ' ');
|
||||||
|
return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||||
|
}
|
||||||
if (item.dueDate != null) return 'Due ${item.dueDate}';
|
if (item.dueDate != null) return 'Due ${item.dueDate}';
|
||||||
return item.status;
|
return item.status;
|
||||||
}
|
}
|
||||||
if (item.body.trim().isEmpty) return null;
|
if (item.body.trim().isEmpty) return null;
|
||||||
final preview = item.body.trim().replaceAll('\n', ' ');
|
final preview = item.body.trim().replaceAll('\n', ' ');
|
||||||
return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview;
|
return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTap(BuildContext context) {
|
||||||
|
if (item.noteType == 'task') {
|
||||||
|
context.push('/tasks/${item.id}/edit');
|
||||||
|
} else {
|
||||||
|
context.push('/notes/${item.id}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
||||||
onTap: () {
|
onTap: () => _onTap(context),
|
||||||
if (item.noteType == 'task') {
|
);
|
||||||
context.push('/tasks/${item.id}/edit');
|
}
|
||||||
} else {
|
|
||||||
context.push('/notes/${item.id}');
|
Widget buildGridCard(BuildContext context) {
|
||||||
}
|
final scheme = Theme.of(context).colorScheme;
|
||||||
},
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: () => _onTap(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(_icon, size: 18, color: _statusColor(context)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.title.isEmpty ? '(untitled)' : item.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.titleSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_subtitle != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_subtitle!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 4,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.tags.isNotEmpty) ...[
|
||||||
|
const Spacer(),
|
||||||
|
_TagChips(tags: item.tags),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class NewsCard extends StatelessWidget {
|
|||||||
final String? reaction; // 'up' | 'down' | null
|
final String? reaction; // 'up' | 'down' | null
|
||||||
final void Function(int itemId, String reaction) onReaction;
|
final void Function(int itemId, String reaction) onReaction;
|
||||||
final VoidCallback? onDiscuss;
|
final VoidCallback? onDiscuss;
|
||||||
|
final int snippetMaxLines;
|
||||||
|
|
||||||
const NewsCard({
|
const NewsCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -61,6 +62,7 @@ class NewsCard extends StatelessWidget {
|
|||||||
required this.reaction,
|
required this.reaction,
|
||||||
required this.onReaction,
|
required this.onReaction,
|
||||||
this.onDiscuss,
|
this.onDiscuss,
|
||||||
|
this.snippetMaxLines = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _openUrl() async {
|
Future<void> _openUrl() async {
|
||||||
@@ -135,7 +137,7 @@ class NewsCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.snippet,
|
item.snippet,
|
||||||
maxLines: 2,
|
maxLines: snippetMaxLines,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
|
|||||||
Reference in New Issue
Block a user