DEV3 Technologies home

Multi-service home grid

Responsive grid of service tiles with icons, optional badges and a data-driven list that adapts column count to width.

FlutterLayoutsfluttergridhomeservices

Code

import 'package:flutter/material.dart';

class ServiceTile {
  const ServiceTile({required this.label, required this.icon, required this.route, this.badge, this.enabled = true});
  final String label;
  final IconData icon;
  final String route;
  final String? badge; // e.g. "New" or "20% off"
  final bool enabled;
}

class ServiceGrid extends StatelessWidget {
  const ServiceGrid({super.key, required this.tiles, required this.onOpen});
  final List<ServiceTile> tiles;
  final void Function(ServiceTile) onOpen;

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(), // lives inside a parent scroll view
      padding: const EdgeInsets.all(16),
      // Column count follows the width: 4 on phones, more on tablets.
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 96,
        mainAxisSpacing: 16,
        crossAxisSpacing: 12,
        childAspectRatio: 0.82,
      ),
      itemCount: tiles.length,
      itemBuilder: (context, i) => _Tile(tile: tiles[i], onTap: () => onOpen(tiles[i])),
    );
  }
}

class _Tile extends StatelessWidget {
  const _Tile({required this.tile, required this.onTap});
  final ServiceTile tile;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Semantics(
      button: true,
      enabled: tile.enabled,
      label: tile.label,
      child: InkWell(
        borderRadius: BorderRadius.circular(16),
        onTap: tile.enabled ? onTap : null,
        child: Opacity(
          opacity: tile.enabled ? 1 : 0.45,
          child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
            Stack(clipBehavior: Clip.none, children: [
              Container(
                width: 56,
                height: 56,
                decoration: BoxDecoration(color: scheme.secondaryContainer, borderRadius: BorderRadius.circular(16)),
                child: Icon(tile.icon, color: scheme.onSecondaryContainer),
              ),
              if (tile.badge != null)
                PositionedDirectional(
                  top: -6,
                  end: -8,
                  child: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                    decoration: BoxDecoration(color: scheme.error, borderRadius: BorderRadius.circular(8)),
                    child: Text(tile.badge!, style: TextStyle(fontSize: 10, color: scheme.onError)),
                  ),
                ),
            ]),
            const SizedBox(height: 6),
            Text(tile.label, maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelMedium),
          ]),
        ),
      ),
    );
  }
}

How it works

What it solves

Super-app style home screens present many services at once and add or hide them without an app release. A grid built from data, rather than hand-placed widgets, makes that practical.

How it works

ServiceTile is a plain data class with a label, icon, route key, optional badge and enabled flag. ServiceGrid uses SliverGridDelegateWithMaxCrossAxisExtent, so the number of columns is calculated from the available width: four on a typical phone and more on tablets, with no breakpoints.

Each tile is an InkWell with ripple, wrapped in Semantics so screen readers announce a button with its enabled state. Disabled tiles fade and ignore taps; badges are positioned with directional offsets so they stay on the correct corner in right-to-left languages.

Adapting it

Fetch the tile list from remote config and map icon names to IconData. Route with onOpen, for example by pushing a named route or switching a tab. Because the grid uses shrinkWrap, put it inside a ListView or SingleChildScrollView along with banners and carousels.

Pitfalls

  • shrinkWrap lays out all tiles up front; for dozens of items use a sliver grid in a CustomScrollView.
  • Long translated labels are cut after two lines; verify them in your widest language.
  • Keep badge text very short.

How to use it

Copy to lib/home/. No packages.

ServiceGrid(
  tiles: const [ServiceTile(label: 'Rides', icon: Icons.local_taxi, route: '/rides', badge: 'New')],
  onOpen: (t) => Navigator.of(context).pushNamed(t.route),
)

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets