Add interactive graph gestures and person insights drilldown

This commit is contained in:
Rijad Zuzo
2026-02-18 00:13:45 +01:00
parent f27b08501d
commit 818c1046f3
3 changed files with 809 additions and 100 deletions
+32
View File
@@ -2,6 +2,38 @@
Updated: 2026-02-17 Updated: 2026-02-17
## Latest Milestone (2026-02-17): Interactive Relationship Graph + Person Insights Drilldown
Upgraded the dashboard graph from static visualization to interactive
navigation:
- Graph interactions:
- `lib/features/dashboard/dashboard_view.dart`
- graph area now supports:
- pan by drag gesture
- pinch-to-zoom scaling
- long-press on person bubbles
- Long-press drilldown behavior:
- opens a dedicated person insights page with back navigation to the graph
- page sections are category-separated:
- Ideas
- Promotions & Signals
- Moments
- Reminders
- Tasks & Follow-Ups
- includes profile summary (relationship type, affinity, tags, notes)
- Added interaction test coverage:
- `test/features/dashboard/dashboard_graph_interactions_test.dart`
- verifies:
- graph contains interactive viewer
- long-press on a person node opens insights screen
- back button returns to graph
Validation for this milestone:
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-17): Obsidian-Style Relationship Graph on Dashboard ## Latest Milestone (2026-02-17): Obsidian-Style Relationship Graph on Dashboard
Reworked the initial Dashboard section to lead with a relationship network Reworked the initial Dashboard section to lead with a relationship network
+722 -100
View File
@@ -6,6 +6,8 @@ import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/features/signals/signals_controller.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class DashboardView extends ConsumerWidget { class DashboardView extends ConsumerWidget {
const DashboardView({super.key}); const DashboardView({super.key});
@@ -207,47 +209,69 @@ class _RelationshipGraphCard extends StatelessWidget {
compact: compact, compact: compact,
); );
return Stack( return InteractiveViewer(
children: <Widget>[ minScale: 0.65,
Positioned.fill( maxScale: 2.8,
child: CustomPaint( boundaryMargin: const EdgeInsets.all(220),
painter: _RelationshipEdgePainter( panEnabled: true,
center: layout.center, scaleEnabled: true,
edges: layout.edges, child: SizedBox(
), width: constraints.maxWidth,
), height: constraints.maxHeight,
), child: Stack(
...layout.nodes.map( children: <Widget>[
(_GraphNode node) => Positioned.fill(
_GraphBubble(node: node, compact: compact), child: CustomPaint(
), painter: _RelationshipEdgePainter(
_CenterNode(center: layout.center, compact: compact), center: layout.center,
if (layout.hiddenCount > 0) edges: layout.edges,
Positioned( ),
right: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'+${layout.hiddenCount} more',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: AppTheme.textSecondary),
), ),
), ),
), ...layout.nodes.map(
], (_GraphNode node) => _GraphBubble(
node: node,
compact: compact,
onLongPress: () =>
_openPersonInsights(context, node.person),
),
),
_CenterNode(center: layout.center, compact: compact),
if (layout.hiddenCount > 0)
Positioned(
right: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'+${layout.hiddenCount} more',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: AppTheme.textSecondary),
),
),
),
],
),
),
); );
}, },
), ),
), ),
), ),
const SizedBox(height: 8),
Text(
'Pinch to zoom, drag to pan, long-press a person to open insights.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 10), const SizedBox(height: 10),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -285,6 +309,16 @@ class _RelationshipGraphCard extends StatelessWidget {
} }
return result; return result;
} }
void _openPersonInsights(BuildContext context, PersonProfile person) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _PersonInsightsPage(personId: person.id);
},
),
);
}
} }
class _GraphLayout { class _GraphLayout {
@@ -474,10 +508,15 @@ class _RelationshipEdgePainter extends CustomPainter {
} }
class _GraphBubble extends StatelessWidget { class _GraphBubble extends StatelessWidget {
const _GraphBubble({required this.node, required this.compact}); const _GraphBubble({
required this.node,
required this.compact,
required this.onLongPress,
});
final _GraphNode node; final _GraphNode node;
final bool compact; final bool compact;
final VoidCallback onLongPress;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -489,79 +528,85 @@ class _GraphBubble extends StatelessWidget {
top: node.position.dy - (diameter / 2), top: node.position.dy - (diameter / 2),
child: Tooltip( child: Tooltip(
message: '${node.person.name}${node.visual.label}', message: '${node.person.name}${node.visual.label}',
child: SizedBox( child: GestureDetector(
width: labelWidth, key: ValueKey<String>('graph-node-${node.person.id}'),
child: Column( behavior: HitTestBehavior.translucent,
mainAxisSize: MainAxisSize.min, onLongPress: onLongPress,
children: <Widget>[ child: SizedBox(
Stack( width: labelWidth,
clipBehavior: Clip.none, child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
Container( children: <Widget>[
width: diameter, Stack(
height: diameter, clipBehavior: Clip.none,
decoration: BoxDecoration( children: <Widget>[
shape: BoxShape.circle, Container(
gradient: LinearGradient( width: diameter,
colors: <Color>[ height: diameter,
_shiftLightness(node.visual.color, 0.16),
_shiftLightness(node.visual.color, -0.12),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: node.visual.color.withValues(alpha: 0.24),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
alignment: Alignment.center,
child: Text(
_initials(node.person.name),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
Positioned(
right: -2,
top: -2,
child: Container(
width: compact ? 20 : 22,
height: compact ? 20 : 22,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.white, gradient: LinearGradient(
border: Border.all( colors: <Color>[
color: node.visual.color.withValues(alpha: 0.8), _shiftLightness(node.visual.color, 0.16),
_shiftLightness(node.visual.color, -0.12),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: node.visual.color.withValues(alpha: 0.24),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
alignment: Alignment.center,
child: Text(
_initials(node.person.name),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
Positioned(
right: -2,
top: -2,
child: Container(
width: compact ? 20 : 22,
height: compact ? 20 : 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: node.visual.color.withValues(alpha: 0.8),
),
),
child: Icon(
node.visual.icon,
size: compact ? 12 : 13,
color: node.visual.color,
), ),
), ),
child: Icon( ),
node.visual.icon, ],
size: compact ? 12 : 13, ),
color: node.visual.color, if (!compact) ...<Widget>[
), const SizedBox(height: 5),
Text(
_shortName(node.person.name),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: AppTheme.textSecondary,
), ),
), ),
], ],
),
if (!compact) ...<Widget>[
const SizedBox(height: 5),
Text(
_shortName(node.person.name),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
], ],
], ),
), ),
), ),
), ),
@@ -652,6 +697,583 @@ class _RelationshipLegendPill extends StatelessWidget {
} }
} }
class _PersonInsightsPage extends ConsumerWidget {
const _PersonInsightsPage({required this.personId});
final String personId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
final AsyncValue<SignalsFeed> signals = ref.watch(
signalsControllerProvider,
);
return Scaffold(
appBar: AppBar(title: const Text('Person Insights')),
body: localData.when(
data: (LocalDataState data) {
final PersonProfile? person = _findPersonById(data.people, personId);
if (person == null) {
return Center(
child: Text(
'Person was not found in local data.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
}
final List<RelationshipMoment> moments =
data.moments
.where(
(RelationshipMoment moment) => moment.personId == person.id,
)
.toList(growable: false)
..sort(
(RelationshipMoment a, RelationshipMoment b) =>
b.at.compareTo(a.at),
);
final List<RelationshipIdea> ideas =
data.ideas
.where((RelationshipIdea idea) => idea.personId == person.id)
.toList(growable: false)
..sort(
(RelationshipIdea a, RelationshipIdea b) =>
b.createdAt.compareTo(a.createdAt),
);
final List<ReminderRule> reminders =
data.reminders
.where(
(ReminderRule reminder) => reminder.personId == person.id,
)
.toList(growable: false)
..sort(
(ReminderRule a, ReminderRule b) =>
a.nextAt.compareTo(b.nextAt),
);
final List<DashboardTask> relatedTasks = _findTasksForPerson(
data.tasks,
person,
);
final List<SignalItem> personSignals =
signals.asData?.value.items
.where((SignalItem item) => item.personId == person.id)
.toList(growable: false) ??
const <SignalItem>[];
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 24,
compact ? 16 : 20,
compact ? 16 : 24,
compact ? 24 : 30,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_personProfileCard(context, person, compact),
const SizedBox(height: 14),
_InsightSectionCard(
title: 'Ideas',
icon: Icons.lightbulb_outline_rounded,
children: ideas.isEmpty
? <Widget>[
const _SectionEmpty(
label:
'No captured ideas yet for this relationship.',
),
]
: ideas
.map(
(RelationshipIdea idea) => _InsightRow(
title: idea.title,
subtitle: idea.details,
meta:
'${idea.type.name.toUpperCase()}${_shortDate(idea.createdAt)}',
),
)
.toList(growable: false),
),
const SizedBox(height: 12),
_InsightSectionCard(
title: 'Promotions & Signals',
icon: Icons.local_offer_outlined,
children: _signalRows(
context: context,
signals: personSignals,
sourceState: signals,
),
),
const SizedBox(height: 12),
_InsightSectionCard(
title: 'Moments',
icon: Icons.auto_awesome_rounded,
children: moments.isEmpty
? <Widget>[
const _SectionEmpty(
label: 'No logged moments for this person yet.',
),
]
: moments
.map(
(RelationshipMoment moment) => _InsightRow(
title: moment.title,
subtitle: moment.summary,
meta:
'${moment.type.toUpperCase()}${_shortDate(moment.at)}',
),
)
.toList(growable: false),
),
const SizedBox(height: 12),
_InsightSectionCard(
title: 'Reminders',
icon: Icons.notifications_active_outlined,
children: reminders.isEmpty
? <Widget>[
const _SectionEmpty(
label:
'No reminders configured for this relationship.',
),
]
: reminders
.map(
(ReminderRule reminder) => _InsightRow(
title: reminder.title,
subtitle: reminder.enabled
? 'Enabled'
: 'Disabled',
meta:
'${reminder.cadence.name.toUpperCase()} • Next ${_shortDate(reminder.nextAt)}',
),
)
.toList(growable: false),
),
const SizedBox(height: 12),
_InsightSectionCard(
title: 'Tasks & Follow-Ups',
icon: Icons.checklist_rounded,
children: relatedTasks.isEmpty
? <Widget>[
const _SectionEmpty(
label:
'No direct follow-up tasks found for this person.',
),
]
: relatedTasks
.map(
(DashboardTask task) => _InsightRow(
title: task.title,
subtitle: task.description,
meta:
'${task.done ? 'DONE' : 'PENDING'}${_shortDate(task.when)}',
),
)
.toList(growable: false),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return Center(
child: Text(
'Could not load person insights.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
},
),
);
}
Widget _personProfileCard(
BuildContext context,
PersonProfile person,
bool compact,
) {
final _RelationshipVisual visual = _relationshipVisualFor(
person.relationship,
);
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_insightAvatar(person: person, visual: visual),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
person.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 3),
Text(
person.relationship,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_miniMetric(
label: 'Affinity',
value: '${person.affinityScore}%',
),
_miniMetric(
label: 'Next Moment',
value: _shortDate(person.nextMoment),
),
],
),
],
)
else
Row(
children: <Widget>[
_insightAvatar(person: person, visual: visual),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
person.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 3),
Text(
person.relationship,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
_miniMetric(
label: 'Affinity',
value: '${person.affinityScore}%',
),
const SizedBox(width: 10),
_miniMetric(
label: 'Next Moment',
value: _shortDate(person.nextMoment),
),
],
),
if (person.tags.isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: person.tags
.map((String tag) => _TagPill(label: tag))
.toList(growable: false),
),
],
if (person.notes.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(
person.notes,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
],
],
),
);
}
Widget _insightAvatar({
required PersonProfile person,
required _RelationshipVisual visual,
}) {
return Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: <Color>[
_shiftLightness(visual.color, 0.16),
_shiftLightness(visual.color, -0.12),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
alignment: Alignment.center,
child: Text(
_initials(person.name),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
);
}
Widget _miniMetric({required String label, required String value}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF2F8FB),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
],
),
);
}
List<Widget> _signalRows({
required BuildContext context,
required List<SignalItem> signals,
required AsyncValue<SignalsFeed> sourceState,
}) {
if (sourceState.isLoading && sourceState.asData == null) {
return const <Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(minHeight: 3),
),
];
}
if (signals.isEmpty) {
if (sourceState.hasError) {
return <Widget>[
const _SectionEmpty(
label:
'Signals unavailable right now. Backend sync can repopulate.',
),
];
}
return <Widget>[
const _SectionEmpty(
label: 'No promotions/signals targeted to this person yet.',
),
];
}
return signals
.map(
(SignalItem item) => _InsightRow(
title: item.title,
subtitle: item.description ?? 'No description',
meta: '${item.type.toUpperCase()}${_shortDate(item.createdAt)}',
),
)
.toList(growable: false);
}
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
List<DashboardTask> _findTasksForPerson(
List<DashboardTask> tasks,
PersonProfile person,
) {
final List<String> tokens = person.name
.toLowerCase()
.split(' ')
.where((String token) => token.length > 2)
.toList(growable: false);
if (tokens.isEmpty) {
return const <DashboardTask>[];
}
return tasks
.where((DashboardTask task) {
final String fullText = '${task.title} ${task.description}'
.toLowerCase();
for (final String token in tokens) {
if (fullText.contains(token)) {
return true;
}
}
return false;
})
.toList(growable: false)
..sort((DashboardTask a, DashboardTask b) => a.when.compareTo(b.when));
}
}
class _InsightSectionCard extends StatelessWidget {
const _InsightSectionCard({
required this.title,
required this.icon,
required this.children,
});
final String title;
final IconData icon;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Icon(icon, size: 18, color: AppTheme.primary),
Text(title, style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 10),
...children,
],
),
);
}
}
class _InsightRow extends StatelessWidget {
const _InsightRow({
required this.title,
required this.subtitle,
required this.meta,
});
final String title;
final String subtitle;
final String meta;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5FAFB),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 6),
Text(
meta,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
);
}
}
class _SectionEmpty extends StatelessWidget {
const _SectionEmpty({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5FAFB),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
);
}
}
class _TagPill extends StatelessWidget {
const _TagPill({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFEFF7FA),
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
);
}
}
class _StatCard extends StatelessWidget { class _StatCard extends StatelessWidget {
const _StatCard({ const _StatCard({
required this.label, required this.label,
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
void main() {
testWidgets(
'graph supports pan/zoom shell and opens person insights on long press',
(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: DashboardView()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 600));
expect(find.text('Relationship Graph'), findsOneWidget);
expect(find.byType(InteractiveViewer), findsOneWidget);
final Finder avaNode = find.byKey(
const ValueKey<String>('graph-node-p-ava'),
);
expect(avaNode, findsOneWidget);
await tester.longPress(avaNode);
await tester.pumpAndSettle(const Duration(milliseconds: 700));
expect(find.text('Person Insights'), findsOneWidget);
expect(find.text('Promotions & Signals'), findsOneWidget);
expect(find.text('Ideas'), findsOneWidget);
expect(find.text('Moments'), findsOneWidget);
expect(find.text('Reminders'), findsOneWidget);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.text('Relationship Graph'), findsOneWidget);
},
);
}