Compare commits

...

4 Commits

Author SHA1 Message Date
bvandeusen c967a49e5a Release v26.04.15.1 — Tablet landscape improvements 2026-04-16 05:52:49 +00:00
bvandeusen 58d4cfab4d feat: tablet landscape improvements
Shell: move Projects/News/Calendar into ShellRoute so the NavigationRail
persists across all screens. Show all 6 nav destinations on tablet
instead of 3+More overflow. Phone bottom nav unchanged.

Chat: master-detail layout on tablet — conversations list (320px) with
inline chat panel. Tapping a conversation updates state instead of
pushing a new route.

Knowledge: responsive grid (2 cols portrait, 3 cols landscape) with
card layout showing icon, title, body preview, and tags. Fix snippet
data — read json['snippet'] which the API actually sends. Bump snippet
length from 120 to 200 chars. Tasks now show description as snippet.

News: responsive grid with the same breakpoints. Larger snippet
(5 lines) in grid mode via snippetMaxLines parameter.

Briefing: centered reading column (maxWidth 700) on wide screens,
matching the web UI layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:31:52 -04:00
bvandeusen ee0f354312 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold' (#24) from dev into main 2026-04-15 04:38:25 +00:00
bvandeusen bdaa5210f0 feat(voice): dynamic silence threshold
The previous -40 dBFS static threshold sat right on top of typical
phone mic ambient, so silence detection rarely fired and the user
always had to tap stop manually.

Silence threshold is now dynamic: track the session peak dBFS and
treat "silent" as 15 dB below peak. Auto-calibrates per mic and
environment rather than assuming a fixed ambient level.

- Grace period (1500 ms) at start so the user has time to begin
  speaking before checks arm.
- Static -35 dB fallback until the peak clears -20 dB so a dead-
  silent session doesn't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:28:04 -04:00
9 changed files with 372 additions and 128 deletions
+35 -29
View File
@@ -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);
+1 -1
View File
@@ -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() ??
+26 -3
View File
@@ -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;
+11 -1
View File
@@ -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;
}, },
), ),
); );
+97 -45
View File
@@ -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);
}
} }
} }
+49 -10
View File
@@ -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),
),
),
),
],
);
}, },
), ),
); );
+79 -30
View File
@@ -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),
);
},
),
), ),
), ),
], ],
+71 -8
View File
@@ -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),
],
],
),
),
),
); );
} }
} }
+3 -1
View File
@@ -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,