Skip to content

Commit dc7aa24

Browse files
committed
feat: implement search highlighting across terminal tiles and expandable sections
1 parent b910854 commit dc7aa24

File tree

8 files changed

+814
-66
lines changed

8 files changed

+814
-66
lines changed

lib/screens/terminal/terminal_page.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
120120
separatorBuilder: (_, __) => const Divider(height: 1),
121121
itemBuilder: (ctx, i) {
122122
final e = filtered[filtered.length - 1 - i];
123+
final searchQuery = _searchCtrl.text.trim();
123124
String requestName = '';
124125
if (e.source == TerminalSource.js &&
125126
e.requestId != null) {
@@ -140,20 +141,23 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
140141
return JsLogTile(
141142
entry: e,
142143
showTimestamp: _showTimestamps,
144+
searchQuery: searchQuery,
143145
requestName:
144146
requestName.isNotEmpty ? requestName : null,
145147
);
146148
case TerminalSource.network:
147149
return NetworkLogTile(
148150
entry: e,
149151
showTimestamp: _showTimestamps,
152+
searchQuery: searchQuery,
150153
requestName:
151154
requestName.isNotEmpty ? requestName : null,
152155
);
153156
case TerminalSource.system:
154157
return SystemLogTile(
155158
entry: e,
156159
showTimestamp: _showTimestamps,
160+
searchQuery: searchQuery,
157161
);
158162
}
159163
},
Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
import 'package:flutter/material.dart';
22

33
class ExpandableSection extends StatefulWidget {
4-
const ExpandableSection(
5-
{super.key, required this.title, required this.child});
4+
const ExpandableSection({
5+
super.key,
6+
required this.title,
7+
required this.child,
8+
this.initiallyOpen = false,
9+
this.forceOpen,
10+
this.highlightQuery,
11+
});
612

713
final String title;
814
final Widget child;
15+
final bool initiallyOpen;
16+
final bool? forceOpen;
17+
final String? highlightQuery;
918

1019
@override
1120
State<ExpandableSection> createState() => _ExpandableSectionState();
1221
}
1322

1423
class _ExpandableSectionState extends State<ExpandableSection> {
15-
bool _open = false;
24+
late bool _open = widget.initiallyOpen;
25+
26+
@override
27+
void didUpdateWidget(covariant ExpandableSection oldWidget) {
28+
super.didUpdateWidget(oldWidget);
29+
// If forceOpen toggles from null to a value or changes, reflect it
30+
if (widget.forceOpen != null && widget.forceOpen != oldWidget.forceOpen) {
31+
_open = widget.forceOpen!;
32+
}
33+
}
1634

1735
@override
1836
Widget build(BuildContext context) {
@@ -22,30 +40,61 @@ class _ExpandableSectionState extends State<ExpandableSection> {
2240
InkWell(
2341
enableFeedback: false,
2442
borderRadius: BorderRadius.circular(3),
25-
onTap: () => setState(() => _open = !_open),
43+
onTap: () {
44+
if (widget.forceOpen == null) {
45+
setState(() => _open = !_open);
46+
}
47+
},
2648
child: Padding(
2749
padding: const EdgeInsets.symmetric(horizontal: 4),
2850
child: Row(
2951
children: [
30-
Expanded(
31-
child: Text(
32-
widget.title,
33-
style: Theme.of(context).textTheme.titleSmall,
34-
),
35-
),
52+
Expanded(child: _buildTitle(context)),
3653
Icon(
37-
_open ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
54+
(widget.forceOpen ?? _open)
55+
? Icons.keyboard_arrow_up
56+
: Icons.keyboard_arrow_down,
3857
),
3958
],
4059
),
4160
),
4261
),
43-
if (_open)
62+
if (widget.forceOpen ?? _open)
4463
Padding(
4564
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
4665
child: widget.child,
4766
),
4867
],
4968
);
5069
}
70+
71+
Widget _buildTitle(BuildContext context) {
72+
final q = widget.highlightQuery?.trim();
73+
final style = Theme.of(context).textTheme.titleSmall;
74+
if (q == null || q.isEmpty) {
75+
return Text(widget.title, style: style);
76+
}
77+
final lower = widget.title.toLowerCase();
78+
final lowerQ = q.toLowerCase();
79+
final spans = <TextSpan>[];
80+
int start = 0;
81+
int idx;
82+
final hlStyle = style?.copyWith(
83+
background: Paint()
84+
..color = Theme.of(context)
85+
.colorScheme
86+
.tertiaryContainer
87+
.withValues(alpha: 0.8),
88+
fontWeight: FontWeight.w600,
89+
);
90+
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
91+
if (idx > start) spans.add(TextSpan(text: widget.title.substring(start, idx), style: style));
92+
spans.add(TextSpan(text: widget.title.substring(idx, idx + lowerQ.length), style: hlStyle));
93+
start = idx + lowerQ.length;
94+
}
95+
if (start < widget.title.length) {
96+
spans.add(TextSpan(text: widget.title.substring(start), style: style));
97+
}
98+
return RichText(text: TextSpan(children: spans, style: style));
99+
}
51100
}

lib/widgets/highlight_text.dart

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import 'package:flutter/material.dart';
2+
3+
/// Widget to highlight occurrences of [query] inside [text].
4+
/// Case-insensitive; all matches highlighted.
5+
class HighlightedSelectableText extends StatelessWidget {
6+
const HighlightedSelectableText({
7+
super.key,
8+
required this.text,
9+
this.query,
10+
this.style,
11+
});
12+
final String text;
13+
final String? query;
14+
final TextStyle? style;
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
final q = query?.trim();
19+
if (q == null || q.isEmpty) {
20+
return SelectableText(text, style: style);
21+
}
22+
final lower = text.toLowerCase();
23+
final lowerQ = q.toLowerCase();
24+
final spans = <TextSpan>[];
25+
int start = 0;
26+
int idx;
27+
final base = style ?? DefaultTextStyle.of(context).style;
28+
final bgColor = Theme.of(context).colorScheme.secondaryContainer;
29+
final highlightStyle = base.copyWith(
30+
fontWeight: FontWeight.w600,
31+
background: Paint()
32+
..color = bgColor.withValues(alpha: 0.85)
33+
..style = PaintingStyle.fill,
34+
color: base.color,
35+
);
36+
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
37+
if (idx > start) {
38+
spans.add(TextSpan(text: text.substring(start, idx), style: base));
39+
}
40+
spans.add(TextSpan(
41+
text: text.substring(idx, idx + lowerQ.length),
42+
style: highlightStyle,
43+
));
44+
start = idx + lowerQ.length;
45+
}
46+
if (start < text.length) {
47+
spans.add(TextSpan(text: text.substring(start), style: base));
48+
}
49+
return SelectableText.rich(TextSpan(children: spans));
50+
}
51+
}
52+
53+
/// Helper to produce highlighted spans for inline RichText content.
54+
List<InlineSpan> buildHighlightedSpans(
55+
String text,
56+
BuildContext context,
57+
String? query, {
58+
TextStyle? baseStyle,
59+
}) {
60+
final q = query?.trim();
61+
if (q == null || q.isEmpty) {
62+
return [TextSpan(text: text, style: baseStyle)];
63+
}
64+
final lower = text.toLowerCase();
65+
final lowerQ = q.toLowerCase();
66+
final spans = <InlineSpan>[];
67+
int start = 0;
68+
int idx;
69+
final base = baseStyle ?? DefaultTextStyle.of(context).style;
70+
final highlightStyle = base.copyWith(
71+
fontWeight: FontWeight.w600,
72+
background: Paint()
73+
..color = Theme.of(context)
74+
.colorScheme
75+
.secondaryContainer
76+
.withValues(alpha: 0.85)
77+
..style = PaintingStyle.fill,
78+
);
79+
while ((idx = lower.indexOf(lowerQ, start)) != -1) {
80+
if (idx > start) {
81+
spans.add(TextSpan(text: text.substring(start, idx), style: base));
82+
}
83+
spans.add(TextSpan(
84+
text: text.substring(idx, idx + lowerQ.length),
85+
style: highlightStyle,
86+
));
87+
start = idx + lowerQ.length;
88+
}
89+
if (start < text.length) {
90+
spans.add(TextSpan(text: text.substring(start), style: base));
91+
}
92+
return spans;
93+
}

0 commit comments

Comments
 (0)