filament_mobile 0.9.2 copy "filament_mobile: ^0.9.2" to clipboard
filament_mobile: ^0.9.2 copied to clipboard

Renders a Laravel Filament 5 panel as a native Flutter mobile admin.

example/lib/main.dart

import 'package:filament_mobile/filament_mobile.dart';
import 'package:filament_mobile_charts/filament_mobile_charts.dart';
import 'package:flutter/material.dart';

import 'http_filament_transport.dart';
import 'in_memory_schema_cache.dart';

/// A minimal host: index -> list -> record, over real HTTP, no styling of
/// its own — `MaterialPanelStateBuilder`'s default is what's on screen.
///
/// Point it at a live Filament panel running `gait/filament-mobile`:
///
/// ```
/// flutter run -d macos \
///   --dart-define=FILAMENT_BASE_URL=https://your-panel.test \
///   --dart-define=FILAMENT_TOKEN=<a Sanctum bearer token>
/// ```
///
/// An unset or expired/foreign token still boots the app: the panel's own
/// 401 reaches `PanelUnauthenticated`, which is exactly what this example
/// exists to demonstrate. The profile menu's "Log out" drops the token at
/// runtime and lands on the same screen, so the signed-out path is one tap
/// away instead of needing a rebuild.
void main() => runApp(const FilamentMobileExampleApp());

const _baseUrl = String.fromEnvironment('FILAMENT_BASE_URL');
const _token = String.fromEnvironment('FILAMENT_TOKEN');

/// Route prefix of the serving package: filament-mobile's default, or
/// `/api/nova-mobile` for a Laravel Nova host running gait/nova-mobile —
/// the contract is identical, only the mount point differs.
const _prefix = String.fromEnvironment(
  'FILAMENT_PREFIX',
  defaultValue: '/api/mobile-panel',
);

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'filament_mobile example',
      home: _baseUrl.isEmpty
          ? const _NotConfigured()
          : PanelHome(baseUrl: _baseUrl, token: _token),
    );
  }
}

class _NotConfigured extends StatelessWidget {
  const _NotConfigured();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Text(
            'Run with --dart-define=FILAMENT_BASE_URL=https://your-panel.test '
            '--dart-define=FILAMENT_TOKEN=<a bearer token>',
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

/// Owns the session: logged in renders a [_Session] keyed by the token (so
/// logging back in rebuilds the transport and providers from scratch),
/// logged out renders a plain signed-out screen. This example has no login
/// form — "log in" restores the compile-time token, standing in for
/// whatever real authentication a host app does.
class PanelHome extends StatefulWidget {
  const PanelHome({required this.baseUrl, required this.token, super.key});

  final String baseUrl;
  final String token;

  @override
  State<PanelHome> createState() => _PanelHomeState();
}

class _PanelHomeState extends State<PanelHome> {
  String? _sessionToken;

  @override
  void initState() {
    super.initState();
    _sessionToken = widget.token;
  }

  @override
  Widget build(BuildContext context) {
    final token = _sessionToken;
    if (token == null) {
      return Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.lock_outline, size: 48),
              const SizedBox(height: 16),
              const Text('Signed out'),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: () => setState(() => _sessionToken = widget.token),
                child: const Text('Log in'),
              ),
            ],
          ),
        ),
      );
    }
    return _Session(
      key: ValueKey(token),
      baseUrl: widget.baseUrl,
      token: token,
      onLogout: () => setState(() => _sessionToken = null),
    );
  }
}

/// One signed-in session: owns the transport and the panel provider, and
/// wires the package's screens together — navigation is the host's job, per
/// every screen's `onXTap` doc comment.
class _Session extends StatefulWidget {
  const _Session({
    required this.baseUrl,
    required this.token,
    required this.onLogout,
    super.key,
  });

  final String baseUrl;
  final String token;
  final VoidCallback onLogout;

  @override
  State<_Session> createState() => _SessionState();
}

class _SessionState extends State<_Session> {
  late final ResourceDataSource _source = RestResourceDataSource(
    transport: HttpFilamentTransport(
      baseUrl: widget.baseUrl,
      token: () => widget.token,
    ),
    prefix: _prefix,
    cache: InMemorySchemaCache(),
    // Scoped to the token, not a constant: `/schema` is per-user, so a
    // shared key would let a second signed-in user on this device open the
    // first user's cached panel index — see FilamentSchemaCache's doc
    // comment. The token is this example's only stand-in for "who's signed
    // in" (there is no separate user id here); a real host keys off its own
    // user id or a hash of the token instead of the bearer token itself.
    cacheKey: widget.token,
  );
  late final PanelProvider _panelProvider = PanelProvider(_source);
  late final DashboardProvider _dashboard = DashboardProvider(_source);

  @override
  void initState() {
    super.initState();
    // PanelIndexScreen used to trigger this load; with the resources moved
    // into the drawer, the session owns it — same untouched-provider guard
    // as the package's own screens.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_panelProvider.status.isInitial) _panelProvider.load();
    });
  }

  /// The same icons the Filament sidebar shows for these resources, in
  /// their closest Material shapes — the contract carries no icon, so the
  /// mapping is the host's, keyed on the resource key. An unknown resource
  /// gets a neutral folder.
  IconData _iconFor(ResourceSchema resource) => switch (resource.key) {
    'articles' => Icons.archive_outlined,
    'categories' => Icons.sell_outlined,
    'customers' => Icons.people_alt_outlined,
    'orders' => Icons.shopping_cart_outlined,
    'products' => Icons.view_in_ar_outlined,
    _ => Icons.folder_outlined,
  };

  @override
  Widget build(BuildContext context) {
    // One home page, like the web panel: the dashboard is the body and the
    // resources live in a drawer. DashboardScreen owns no Scaffold/AppBar
    // of its own, and charts come from the companion package's builder —
    // same wiring as main_demo.dart.
    return Scaffold(
      appBar: AppBar(
        title: const Text('Dashboard'),
        actions: [
          PopupMenuButton<void>(
            tooltip: 'Profile',
            itemBuilder: (context) => [
              PopupMenuItem<void>(
                onTap: widget.onLogout,
                child: const Row(
                  children: [
                    Icon(Icons.logout),
                    SizedBox(width: 12),
                    Text('Log out'),
                  ],
                ),
              ),
            ],
            child: const Padding(
              padding: EdgeInsetsDirectional.only(end: 12),
              child: CircleAvatar(child: Icon(Icons.person)),
            ),
          ),
        ],
      ),
      drawer: Drawer(child: _sidebar()),
      body: DashboardScreen(
        provider: _dashboard,
        chartBuilder: flChartBuilder(strings: const FilamentChartStrings()),
        // A stat whose web ->url() targets an opted-in resource arrives
        // with its resourceKey — tapping the tile opens that resource's
        // list, the phone's equivalent of the web tile's link.
        onStatTap: (resourceKey) {
          final resource = _panelProvider.panel?.resource(resourceKey);
          if (resource == null) return;
          Navigator.of(context).push(
            MaterialPageRoute(builder: (context) => _resourceList(resource)),
          );
        },
      ),
    );
  }

  /// The web panel's sidebar, phone-sized: panel title on top, one entry
  /// per resource the user may see. Rebuilt from the provider so entries
  /// appear as soon as `/schema` resolves.
  Widget _sidebar() {
    return ListenableBuilder(
      listenable: _panelProvider,
      builder: (context, _) {
        final panel = _panelProvider.panel;
        return ListView(
          padding: EdgeInsets.zero,
          children: [
            DrawerHeader(
              child: Align(
                alignment: AlignmentDirectional.bottomStart,
                child: Text(
                  panel?.title ?? '…',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
              ),
            ),
            if (panel == null)
              const Padding(
                padding: EdgeInsets.all(24),
                child: Center(child: CircularProgressIndicator()),
              )
            else
              for (final MapEntry(key: group, value: resources) in _grouped(
                panel.resources,
              ).entries) ...[
                if (group != null)
                  Padding(
                    padding: const EdgeInsetsDirectional.fromSTEB(
                      16,
                      16,
                      16,
                      4,
                    ),
                    child: Text(
                      group,
                      style: Theme.of(context).textTheme.labelSmall?.copyWith(
                        color: Theme.of(context).colorScheme.onSurfaceVariant,
                        fontWeight: FontWeight.w600,
                        letterSpacing: 0.6,
                      ),
                    ),
                  ),
                for (final resource in resources)
                  ListTile(
                    leading: Icon(_iconFor(resource)),
                    title: Text(resource.labels.plural),
                    trailing: switch (resource.badge) {
                      final badge? => _badgeChip(badge),
                      null => null,
                    },
                    onTap: () {
                      Navigator.of(context).pop(); // close the drawer
                      Navigator.of(context).push(
                        MaterialPageRoute(
                          builder: (context) => _resourceList(resource),
                        ),
                      );
                    },
                  ),
              ],
          ],
        );
      },
    );
  }

  /// Resources by sidebar group, ungrouped first — the same order the web
  /// panel and PanelIndexScreen use.
  Map<String?, List<ResourceSchema>> _grouped(List<ResourceSchema> resources) {
    final grouped = <String?, List<ResourceSchema>>{
      for (final resource in resources)
        if (resource.group == null) null: [],
    };
    for (final resource in resources) {
      (grouped[resource.group] ??= []).add(resource);
    }
    return grouped;
  }

  /// The web sidebar's count badge, phone-sized: value on a tinted pill in
  /// the badge's semantic colour.
  Widget _badgeChip(ResourceBadge badge) {
    final color = switch (badge.color) {
      'success' => Colors.green,
      'warning' => Colors.orange,
      'danger' => Colors.red,
      'info' => Colors.blue,
      _ => Theme.of(context).colorScheme.primary,
    };
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(
        badge.value,
        style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
      ),
    );
  }

  Widget _resourceList(ResourceSchema resource) {
    // Hoisted for the same reason as viewProvider below: the create flow
    // refreshes this provider after a save, so the list shows the new row.
    final listProvider = ResourceListProvider(
      source: _source,
      resource: resource,
    );
    return ResourceListScreen(
      provider: listProvider,
      // Like onEditTap: the package renders no create button unless the
      // host wires this — and it stays hidden when the resource's
      // permissions.create is false.
      onCreateTap: () async {
        await Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ResourceFormScreen(
              provider: ResourceFormProvider(
                source: _source,
                resource: resource,
                strings: const FilamentStrings(),
              ),
            ),
          ),
        );
        await listProvider.refresh();
      },
      onRecordTap: (record) => Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => _recordView(resource, record.id),
        ),
      ),
    );
  }

  /// One record's view screen with every affordance wired — shared by the
  /// list's row tap and by targeted entries, which navigate here for a
  /// DIFFERENT resource ('category.name' opening that category), so this
  /// takes the resource explicitly rather than closing over one.
  Widget _recordView(ResourceSchema resource, Object recordId) {
    // Owned here, not inline in the route builder, so the edit flow can
    // reload the same provider after a save and the reopened screen shows
    // the updated record rather than the stale fetch.
    final viewProvider = ResourceViewProvider(
      source: _source,
      resource: resource,
      id: recordId,
    );
    return ResourceViewScreen(
      provider: viewProvider,
      // The package renders no edit button unless the host wires this — an
      // unwired affordance would render and silently no-op, per
      // ResourceViewScreen.onEditTap's doc.
      onEditTap: (record) async {
        await Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ResourceFormScreen(
              provider: ResourceFormProvider(
                source: _source,
                resource: resource,
                strings: const FilamentStrings(),
                recordId: record.id,
              ),
            ),
          ),
        );
        await viewProvider.load(keepPrevious: true);
      },
      // `onLinkTap` makes a rich-text link tappable — this package takes no
      // URL-launcher dependency, so opening it is entirely the host's call.
      // This example has no such dependency either, so it echoes the href
      // instead of opening it; a host wiring a real `url_launcher` call
      // goes here.
      registry: EntryRegistry.defaults(
        onLinkTap: (href) => ScaffoldMessenger.of(
          context,
        ).showSnackBar(SnackBar(content: Text('Link tapped: $href'))),
        // A targeted entry ('category.name') navigates to the related
        // record — recursive on purpose: the category's own view gets the
        // same wiring, so a chain of relations stays navigable.
        onRelatedTap: (resourceKey, relatedId) {
          final target = _panelProvider.panel?.resource(resourceKey);
          if (target == null) return;
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) => _recordView(target, relatedId),
            ),
          );
        },
      ),
      onSeeAllTap: (relation, ownerId) => Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => RelationListScreen(
            provider: RelationListProvider(
              source: _source,
              resourceKey: resource.key,
              id: ownerId,
              relation: relation,
            ),
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
813
downloads

Documentation

API reference

Publisher

verified publishergaitco.com

Weekly Downloads

Renders a Laravel Filament 5 panel as a native Flutter mobile admin.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

equatable, flutter

More

Packages that depend on filament_mobile