smart_suggestions_codespark 0.2.1 copy "smart_suggestions_codespark: ^0.2.1" to clipboard
smart_suggestions_codespark: ^0.2.1 copied to clipboard

On-device "related items" for Flutter — suggest similar content by meaning, fully offline, no API keys. Three-line API powered by local embeddings.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:smart_suggestions_codespark/smart_suggestions_codespark.dart';

// ── Data Models ──────────────────────────────────────────────────────────────

class Article {
  final String title;
  final String summary;
  final String category;
  final IconData icon;

  const Article({
    required this.title,
    required this.summary,
    required this.category,
    required this.icon,
  });

  Map<String, dynamic> toJson() => {
        'title': title,
        'summary': summary,
        'category': category,
        'icon': icon.codePoint,
      };

  factory Article.fromJson(Map<String, dynamic> json) => Article(
        title: json['title'] as String,
        summary: json['summary'] as String,
        category: json['category'] as String,
        icon: IconData(json['icon'] as int, fontFamily: 'MaterialIcons'),
      );
}

// ── Sample Data ──────────────────────────────────────────────────────────────

const _articles = <Article>[
  Article(
    title: 'How to Train for a Marathon',
    summary: 'A complete training plan for first-time marathon runners',
    category: 'Fitness',
    icon: Icons.directions_run,
  ),
  Article(
    title: 'Best Running Shoes for Beginners',
    summary: 'Top picks for comfortable and supportive running footwear',
    category: 'Gear',
    icon: Icons.shopping_bag,
  ),
  Article(
    title: 'Healthy Post-Workout Smoothie Recipes',
    summary: 'Delicious protein-packed smoothies to fuel recovery',
    category: 'Nutrition',
    icon: Icons.local_drink,
  ),
  Article(
    title: 'Guide to Cycling in the City',
    summary: 'Stay safe and efficient while cycling through urban areas',
    category: 'Cycling',
    icon: Icons.pedal_bike,
  ),
  Article(
    title: 'Yoga Stretches for Runners',
    summary: 'Improve flexibility and prevent injuries with these poses',
    category: 'Wellness',
    icon: Icons.self_improvement,
  ),
  Article(
    title: 'Strength Training for Endurance Athletes',
    summary: 'Build power without bulking up for long-distance performance',
    category: 'Fitness',
    icon: Icons.fitness_center,
  ),
  Article(
    title: 'Nutrition Tips for Long-Distance Running',
    summary: 'What to eat before, during, and after long runs',
    category: 'Nutrition',
    icon: Icons.restaurant,
  ),
  Article(
    title: 'How to Prevent Running Injuries',
    summary: 'Common mistakes and how to avoid them on the track',
    category: 'Health',
    icon: Icons.healing,
  ),
  Article(
    title: 'Swimming Versus Running for Fitness',
    summary: 'Comparing calorie burn, impact, and overall health benefits',
    category: 'Fitness',
    icon: Icons.pool,
  ),
  Article(
    title: 'Trail Running Gear Guide',
    summary: 'Essential equipment for off-road running adventures',
    category: 'Gear',
    icon: Icons.terrain,
  ),
  Article(
    title: 'Meditation for Athletic Performance',
    summary: 'Mental training techniques used by elite athletes',
    category: 'Wellness',
    icon: Icons.spa,
  ),
  Article(
    title: 'The Science of Hydration',
    summary: 'How much water you really need during exercise',
    category: 'Health',
    icon: Icons.water_drop,
  ),
  Article(
    title: 'Beginner\'s Guide to Rock Climbing',
    summary: 'Getting started with bouldering and rope climbing',
    category: 'Adventure',
    icon: Icons.landscape,
  ),
  Article(
    title: 'Home Gym Setup on a Budget',
    summary: 'Build an effective workout space without breaking the bank',
    category: 'Gear',
    icon: Icons.home,
  ),
  Article(
    title: 'Recovery Techniques After Intense Workouts',
    summary: 'Ice baths, foam rolling, and sleep for faster recovery',
    category: 'Health',
    icon: Icons.bed,
  ),
];

// ── App Entry ────────────────────────────────────────────────────────────────

void main() => runApp(const SmartSuggestionsPlayground());

class SmartSuggestionsPlayground extends StatelessWidget {
  const SmartSuggestionsPlayground({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Smart Suggestions Playground',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.teal,
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.teal,
        brightness: Brightness.dark,
      ),
      home: const _PlaygroundHome(),
    );
  }
}

// ── Home Screen ──────────────────────────────────────────────────────────────

class _PlaygroundHome extends StatefulWidget {
  const _PlaygroundHome();

  @override
  State<_PlaygroundHome> createState() => _PlaygroundHomeState();
}

class _PlaygroundHomeState extends State<_PlaygroundHome> {
  final _suggestions = SmartSuggestions();
  SuggestionIndex<Article>? _index;

  String _status = 'Initializing model...';
  double _progress = 0;
  bool _ready = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _boot();
  }

  Future<void> _boot() async {
    try {
      // For simpler apps: final s = await SmartSuggestions.create();
      // The example below shows fine-grained progress reporting.
      await _suggestions.initialize(onProgress: (received, total) {
        if (total > 0) {
          setState(() {
            _progress = received / total;
            _status = 'Downloading model: ${(_progress * 100).toInt()}%';
          });
        }
      });
      setState(() => _status = 'Building index...');
      final index = await _suggestions.createIndex(
        items: _articles,
        textOf: (a) => '${a.title} ${a.summary}',
      );
      setState(() {
        _index = index;
        _ready = true;
      });
    } catch (e) {
      setState(() => _error = e.toString());
    }
  }

  @override
  void dispose() {
    _suggestions.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Scaffold(
        appBar: AppBar(title: const Text('Smart Suggestions')),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.error_outline,
                    size: 64, color: Theme.of(context).colorScheme.error),
                const SizedBox(height: 16),
                Text('Failed to initialize',
                    style: Theme.of(context).textTheme.titleLarge),
                const SizedBox(height: 8),
                Text(_error!, textAlign: TextAlign.center),
                const SizedBox(height: 24),
                FilledButton.icon(
                  onPressed: () {
                    setState(() {
                      _error = null;
                      _progress = 0;
                      _status = 'Initializing model...';
                    });
                    _boot();
                  },
                  icon: const Icon(Icons.refresh),
                  label: const Text('Retry'),
                ),
              ],
            ),
          ),
        ),
      );
    }

    if (!_ready) {
      return Scaffold(
        appBar: AppBar(title: const Text('Smart Suggestions')),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(48),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.auto_awesome, size: 64, color: Colors.teal),
                const SizedBox(height: 24),
                Text(_status,
                    style: Theme.of(context).textTheme.titleMedium),
                const SizedBox(height: 16),
                LinearProgressIndicator(value: _progress > 0 ? _progress : null),
              ],
            ),
          ),
        ),
      );
    }

    return _DemoNavigator(
      suggestions: _suggestions,
      index: _index!,
    );
  }
}

// ── Demo Navigator (tabs for each feature) ───────────────────────────────────

class _DemoNavigator extends StatefulWidget {
  const _DemoNavigator({required this.suggestions, required this.index});
  final SmartSuggestions suggestions;
  final SuggestionIndex<Article> index;

  @override
  State<_DemoNavigator> createState() => _DemoNavigatorState();
}

class _DemoNavigatorState extends State<_DemoNavigator> {
  int _tab = 0;

  @override
  Widget build(BuildContext context) {
    final pages = [
      _SimilarToDemo(index: widget.index),
      _FreeTextDemo(index: widget.index),
      _MultiAnchorDemo(index: widget.index),
      _DiverseDemo(index: widget.index),
      _WidgetDemo(index: widget.index),
    ];

    return Scaffold(
      appBar: AppBar(
        title: const Text('Smart Suggestions Playground'),
      ),
      body: pages[_tab],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tab,
        onDestinationSelected: (i) => setState(() => _tab = i),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.compare_arrows),
            label: 'Similar To',
          ),
          NavigationDestination(
            icon: Icon(Icons.search),
            label: 'Free Text',
          ),
          NavigationDestination(
            icon: Icon(Icons.history),
            label: 'Multi-Anchor',
          ),
          NavigationDestination(
            icon: Icon(Icons.shuffle),
            label: 'Diverse',
          ),
          NavigationDestination(
            icon: Icon(Icons.widgets),
            label: 'Widget',
          ),
        ],
      ),
    );
  }
}

// ── Tab 1: Similar To (item-to-item) ─────────────────────────────────────────

class _SimilarToDemo extends StatefulWidget {
  const _SimilarToDemo({required this.index});
  final SuggestionIndex<Article> index;

  @override
  State<_SimilarToDemo> createState() => _SimilarToDemoState();
}

class _SimilarToDemoState extends State<_SimilarToDemo> {
  int _selectedIndex = 0;
  int _topK = 5;
  List<SuggestionResult<Article>>? _results;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _query();
  }

  Future<void> _query() async {
    setState(() => _loading = true);
    final results = await widget.index.similarTo(
      _selectedIndex,
      topK: _topK,
    );
    setState(() {
      _results = results;
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final anchor = _articles[_selectedIndex];

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('Find articles similar to an existing one',
            style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
        const SizedBox(height: 12),

        // Anchor selector
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Select anchor article:', style: theme.textTheme.labelLarge),
                const SizedBox(height: 8),
                DropdownButton<int>(
                  value: _selectedIndex,
                  isExpanded: true,
                  items: [
                    for (var i = 0; i < _articles.length; i++)
                      DropdownMenuItem(
                        value: i,
                        child: Row(
                          children: [
                            Icon(_articles[i].icon, size: 20),
                            const SizedBox(width: 8),
                            Expanded(child: Text(_articles[i].title, overflow: TextOverflow.ellipsis)),
                          ],
                        ),
                      ),
                  ],
                  onChanged: (v) {
                    if (v != null) {
                      setState(() => _selectedIndex = v);
                      _query();
                    }
                  },
                ),
                const SizedBox(height: 12),
                Row(
                  children: [
                    Text('Top K: $_topK', style: theme.textTheme.labelMedium),
                    Expanded(
                      child: Slider(
                        value: _topK.toDouble(),
                        min: 1,
                        max: 10,
                        divisions: 9,
                        label: '$_topK',
                        onChanged: (v) {
                          setState(() => _topK = v.round());
                          _query();
                        },
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
        const SizedBox(height: 8),

        // Anchor display
        Card(
          color: theme.colorScheme.primaryContainer,
          child: ListTile(
            leading: Icon(anchor.icon, color: theme.colorScheme.onPrimaryContainer),
            title: Text(anchor.title, style: TextStyle(color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold)),
            subtitle: Text(anchor.summary, style: TextStyle(color: theme.colorScheme.onPrimaryContainer)),
            trailing: Chip(label: Text(anchor.category, style: const TextStyle(fontSize: 11))),
          ),
        ),

        const SizedBox(height: 16),
        Text('Similar articles:', style: theme.textTheme.titleSmall),
        const SizedBox(height: 8),

        if (_loading)
          const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
        else if (_results != null)
          ..._results!.map((r) => _ResultCard(result: r)),
      ],
    );
  }
}

// ── Tab 2: Free-Text Search ──────────────────────────────────────────────────

class _FreeTextDemo extends StatefulWidget {
  const _FreeTextDemo({required this.index});
  final SuggestionIndex<Article> index;

  @override
  State<_FreeTextDemo> createState() => _FreeTextDemoState();
}

class _FreeTextDemoState extends State<_FreeTextDemo> {
  final _controller = TextEditingController(text: 'running shoes');
  List<SuggestionResult<Article>>? _results;
  bool _loading = false;
  int _topK = 5;

  @override
  void initState() {
    super.initState();
    _query();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _query() async {
    final text = _controller.text.trim();
    if (text.isEmpty) {
      setState(() => _results = null);
      return;
    }
    setState(() => _loading = true);
    final results = await widget.index.suggestFor(text, topK: _topK);
    if (!mounted) return;
    setState(() {
      _results = results;
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('Type anything and find related articles by meaning',
            style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
        const SizedBox(height: 12),
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                TextField(
                  controller: _controller,
                  decoration: InputDecoration(
                    labelText: 'Search by meaning',
                    hintText: 'e.g. "healthy eating", "prevent pain", "outdoor adventure"',
                    border: const OutlineInputBorder(),
                    suffixIcon: IconButton(
                      icon: const Icon(Icons.search),
                      onPressed: _query,
                    ),
                  ),
                  onSubmitted: (_) => _query(),
                ),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 8,
                  children: [
                    _QuickChip('running shoes', _controller, _query),
                    _QuickChip('healthy eating', _controller, _query),
                    _QuickChip('outdoor adventure', _controller, _query),
                    _QuickChip('muscle recovery', _controller, _query),
                    _QuickChip('mental health', _controller, _query),
                    _QuickChip('cardio workout', _controller, _query),
                  ],
                ),
                const SizedBox(height: 12),
                Row(
                  children: [
                    Text('Top K: $_topK', style: theme.textTheme.labelMedium),
                    Expanded(
                      child: Slider(
                        value: _topK.toDouble(),
                        min: 1,
                        max: 10,
                        divisions: 9,
                        label: '$_topK',
                        onChanged: (v) {
                          setState(() => _topK = v.round());
                          _query();
                        },
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
        const SizedBox(height: 16),
        if (_loading)
          const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
        else if (_results != null) ...[
          Text('Results:', style: theme.textTheme.titleSmall),
          const SizedBox(height: 8),
          ..._results!.map((r) => _ResultCard(result: r)),
        ],
      ],
    );
  }
}

class _QuickChip extends StatelessWidget {
  const _QuickChip(this.label, this.controller, this.onTap);
  final String label;
  final TextEditingController controller;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return ActionChip(
      label: Text(label, style: const TextStyle(fontSize: 12)),
      onPressed: () {
        controller.text = label;
        onTap();
      },
    );
  }
}

// ── Tab 3: Multi-Anchor (user history) ───────────────────────────────────────

class _MultiAnchorDemo extends StatefulWidget {
  const _MultiAnchorDemo({required this.index});
  final SuggestionIndex<Article> index;

  @override
  State<_MultiAnchorDemo> createState() => _MultiAnchorDemoState();
}

class _MultiAnchorDemoState extends State<_MultiAnchorDemo> {
  final _anchors = <String>['marathon training', 'yoga stretches'];
  final _anchorController = TextEditingController();
  List<SuggestionResult<Article>>? _results;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _query();
  }

  @override
  void dispose() {
    _anchorController.dispose();
    super.dispose();
  }

  Future<void> _query() async {
    if (_anchors.isEmpty) {
      setState(() => _results = null);
      return;
    }
    setState(() => _loading = true);
    final results = await widget.index.suggestLike(_anchors, topK: 5);
    if (!mounted) return;
    setState(() {
      _results = results;
      _loading = false;
    });
  }

  void _addAnchor() {
    final text = _anchorController.text.trim();
    if (text.isEmpty) return;
    setState(() => _anchors.add(text));
    _anchorController.clear();
    _query();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('Simulate user history — blend multiple interests',
            style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
        const SizedBox(height: 12),

        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('User interests (anchors):', style: theme.textTheme.labelLarge),
                const SizedBox(height: 8),
                Wrap(
                  spacing: 8,
                  runSpacing: 4,
                  children: [
                    for (var i = 0; i < _anchors.length; i++)
                      Chip(
                        label: Text(_anchors[i]),
                        deleteIcon: const Icon(Icons.close, size: 16),
                        onDeleted: () {
                          setState(() => _anchors.removeAt(i));
                          _query();
                        },
                      ),
                  ],
                ),
                const SizedBox(height: 12),
                Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _anchorController,
                        decoration: const InputDecoration(
                          labelText: 'Add interest',
                          hintText: 'e.g. "swimming"',
                          border: OutlineInputBorder(),
                          isDense: true,
                        ),
                        onSubmitted: (_) => _addAnchor(),
                      ),
                    ),
                    const SizedBox(width: 8),
                    IconButton.filled(
                      onPressed: _addAnchor,
                      icon: const Icon(Icons.add),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Wrap(
                  spacing: 8,
                  children: [
                    _SuggestionChip('cycling', _anchorController, (t) {
                      setState(() => _anchors.add(t));
                      _query();
                    }),
                    _SuggestionChip('nutrition', _anchorController, (t) {
                      setState(() => _anchors.add(t));
                      _query();
                    }),
                    _SuggestionChip('climbing', _anchorController, (t) {
                      setState(() => _anchors.add(t));
                      _query();
                    }),
                    _SuggestionChip('meditation', _anchorController, (t) {
                      setState(() => _anchors.add(t));
                      _query();
                    }),
                  ],
                ),
              ],
            ),
          ),
        ),

        const SizedBox(height: 16),
        if (_loading)
          const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
        else if (_results != null) ...[
          Text('Recommended for you:', style: theme.textTheme.titleSmall),
          const SizedBox(height: 8),
          ..._results!.map((r) => _ResultCard(result: r)),
        ],
      ],
    );
  }
}

class _SuggestionChip extends StatelessWidget {
  const _SuggestionChip(this.label, this.controller, this.onAdd);
  final String label;
  final TextEditingController controller;
  final void Function(String) onAdd;

  @override
  Widget build(BuildContext context) {
    return ActionChip(
      avatar: const Icon(Icons.add, size: 16),
      label: Text(label, style: const TextStyle(fontSize: 12)),
      onPressed: () => onAdd(label),
    );
  }
}

// ── Tab 4: Diverse (MMR) ─────────────────────────────────────────────────────

class _DiverseDemo extends StatefulWidget {
  const _DiverseDemo({required this.index});
  final SuggestionIndex<Article> index;

  @override
  State<_DiverseDemo> createState() => _DiverseDemoState();
}

class _DiverseDemoState extends State<_DiverseDemo> {
  double _lambda = 0.5;
  String _anchor = 'fitness training';
  List<SuggestionResult<Article>>? _standardResults;
  List<SuggestionResult<Article>>? _diverseResults;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _query();
  }

  Future<void> _query() async {
    setState(() => _loading = true);
    final standard = await widget.index.suggestFor(_anchor, topK: 5);
    final diverse = await widget.index.suggestDiverse(
      _anchor,
      topK: 5,
      lambda: _lambda,
    );
    if (!mounted) return;
    setState(() {
      _standardResults = standard;
      _diverseResults = diverse;
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('Compare standard vs diverse (MMR) ranking',
            style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
        const SizedBox(height: 12),
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Anchor query:', style: theme.textTheme.labelLarge),
                const SizedBox(height: 8),
                Wrap(
                  spacing: 8,
                  children: [
                    for (final q in ['fitness training', 'running gear', 'healthy living', 'outdoor sports'])
                      ChoiceChip(
                        label: Text(q),
                        selected: _anchor == q,
                        onSelected: (_) {
                          setState(() => _anchor = q);
                          _query();
                        },
                      ),
                  ],
                ),
                const SizedBox(height: 16),
                Row(
                  children: [
                    const Text('Relevance'),
                    Expanded(
                      child: Slider(
                        value: _lambda,
                        min: 0,
                        max: 1,
                        divisions: 10,
                        label: 'lambda: ${_lambda.toStringAsFixed(1)}',
                        onChanged: (v) {
                          setState(() => _lambda = v);
                          _query();
                        },
                      ),
                    ),
                    const Text('Diversity'),
                  ],
                ),
                Center(
                  child: Text(
                    'lambda = ${_lambda.toStringAsFixed(1)}',
                    style: theme.textTheme.bodySmall,
                  ),
                ),
              ],
            ),
          ),
        ),

        const SizedBox(height: 16),
        if (_loading)
          const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
        else if (_standardResults != null && _diverseResults != null) ...[
          Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Container(
                      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                      decoration: BoxDecoration(
                        color: theme.colorScheme.secondaryContainer,
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text('Standard', style: theme.textTheme.labelLarge?.copyWith(
                        color: theme.colorScheme.onSecondaryContainer,
                      )),
                    ),
                    const SizedBox(height: 8),
                    ..._standardResults!.map((r) => _CompactResultCard(result: r)),
                  ],
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Container(
                      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                      decoration: BoxDecoration(
                        color: theme.colorScheme.tertiaryContainer,
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text('Diverse (MMR)', style: theme.textTheme.labelLarge?.copyWith(
                        color: theme.colorScheme.onTertiaryContainer,
                      )),
                    ),
                    const SizedBox(height: 8),
                    ..._diverseResults!.map((r) => _CompactResultCard(result: r)),
                  ],
                ),
              ),
            ],
          ),
        ],
      ],
    );
  }
}

// ── Tab 5: SuggestionsList Widget Demo ───────────────────────────────────────

class _WidgetDemo extends StatefulWidget {
  const _WidgetDemo({required this.index});
  final SuggestionIndex<Article> index;

  @override
  State<_WidgetDemo> createState() => _WidgetDemoState();
}

class _WidgetDemoState extends State<_WidgetDemo> {
  int _anchorIndex = 0;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final anchor = _articles[_anchorIndex];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
          child: Text('Drop-in SuggestionsList widget',
              style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
        ),
        Padding(
          padding: const EdgeInsets.all(16),
          child: Card(
            color: theme.colorScheme.primaryContainer,
            child: ListTile(
              leading: Icon(anchor.icon, color: theme.colorScheme.onPrimaryContainer),
              title: Text(anchor.title, style: TextStyle(
                color: theme.colorScheme.onPrimaryContainer,
                fontWeight: FontWeight.bold,
              )),
              subtitle: Text(anchor.summary, style: TextStyle(
                color: theme.colorScheme.onPrimaryContainer,
              )),
              trailing: PopupMenuButton<int>(
                icon: Icon(Icons.swap_vert, color: theme.colorScheme.onPrimaryContainer),
                tooltip: 'Change anchor',
                onSelected: (i) => setState(() => _anchorIndex = i),
                itemBuilder: (context) => [
                  for (var i = 0; i < _articles.length; i++)
                    PopupMenuItem(
                      value: i,
                      child: Row(
                        children: [
                          Icon(_articles[i].icon, size: 18),
                          const SizedBox(width: 8),
                          Expanded(child: Text(_articles[i].title, overflow: TextOverflow.ellipsis)),
                        ],
                      ),
                    ),
                ],
              ),
            ),
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Text('Related articles (via SuggestionsList widget):',
              style: theme.textTheme.titleSmall),
        ),
        Expanded(
          child: SuggestionsList<Article>(
            index: widget.index,
            anchorIndex: _anchorIndex,
            topK: 5,
            itemBuilder: (context, r) => Card(
              margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
              child: ListTile(
                leading: CircleAvatar(
                  backgroundColor: theme.colorScheme.secondaryContainer,
                  child: Icon(r.item.icon, size: 20,
                      color: theme.colorScheme.onSecondaryContainer),
                ),
                title: Text(r.item.title),
                subtitle: Text(r.item.summary, maxLines: 1, overflow: TextOverflow.ellipsis),
                trailing: _ScoreBadge(score: r.score),
              ),
            ),
            onItemTap: (r) {
              setState(() => _anchorIndex = r.index);
            },
          ),
        ),
      ],
    );
  }
}

// ── Shared Widgets ───────────────────────────────────────────────────────────

class _ResultCard extends StatelessWidget {
  const _ResultCard({required this.result});
  final SuggestionResult<Article> result;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      margin: const EdgeInsets.only(bottom: 8),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: theme.colorScheme.secondaryContainer,
          child: Icon(result.item.icon, size: 20,
              color: theme.colorScheme.onSecondaryContainer),
        ),
        title: Text(result.item.title),
        subtitle: Text(result.item.summary, maxLines: 2, overflow: TextOverflow.ellipsis),
        trailing: _ScoreBadge(score: result.score),
      ),
    );
  }
}

class _CompactResultCard extends StatelessWidget {
  const _CompactResultCard({required this.result});
  final SuggestionResult<Article> result;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      margin: const EdgeInsets.only(bottom: 4),
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Row(
          children: [
            Icon(result.item.icon, size: 18, color: theme.colorScheme.primary),
            const SizedBox(width: 8),
            Expanded(
              child: Text(
                result.item.title,
                style: theme.textTheme.bodySmall,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
            ),
            _ScoreBadge(score: result.score, compact: true),
          ],
        ),
      ),
    );
  }
}

class _ScoreBadge extends StatelessWidget {
  const _ScoreBadge({required this.score, this.compact = false});
  final double score;
  final bool compact;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final color = score > 0.7
        ? Colors.green
        : score > 0.4
            ? Colors.orange
            : Colors.red;

    return Container(
      padding: EdgeInsets.symmetric(
        horizontal: compact ? 6 : 8,
        vertical: compact ? 2 : 4,
      ),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: color.withValues(alpha: 0.4)),
      ),
      child: Text(
        score.toStringAsFixed(2),
        style: (compact ? theme.textTheme.labelSmall : theme.textTheme.labelMedium)
            ?.copyWith(color: color, fontWeight: FontWeight.bold),
      ),
    );
  }
}
2
likes
160
points
15
downloads
screenshot

Documentation

API reference

Publisher

verified publisherksaikiran.dev

Weekly Downloads

On-device "related items" for Flutter — suggest similar content by meaning, fully offline, no API keys. Three-line API powered by local embeddings.

Homepage
Repository (GitHub)
View/report issues

Topics

#recommendations #similar-items #offline-ai #embeddings #local-ai

License

MIT (license)

Dependencies

ai_core_codespark, flutter

More

Packages that depend on smart_suggestions_codespark