diff --git a/packages/dashboard/.gitignore b/packages/dashboard/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/packages/dashboard/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/dashboard/.metadata b/packages/dashboard/.metadata new file mode 100644 index 00000000..ef58f9e3 --- /dev/null +++ b/packages/dashboard/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: macos + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: web + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md new file mode 100644 index 00000000..dbe8faaa --- /dev/null +++ b/packages/dashboard/README.md @@ -0,0 +1,22 @@ +# Remix dashboard + +A polished product dashboard demonstrating Remix's Fortal theme and components. + +## Preview + +### Desktop + +![Fortal dashboard overview on desktop](screenshots/overview-desktop.png) + +### Compact + +![Fortal dashboard overview at the compact breakpoint](screenshots/overview-compact.png) + +From the repository root: + +```sh +fvm dart run melos bootstrap +cd packages/dashboard +fvm flutter run -d macos +# or: fvm flutter run -d chrome +``` diff --git a/packages/dashboard/analysis_options.yaml b/packages/dashboard/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/packages/dashboard/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/dashboard/lib/data/activity.dart b/packages/dashboard/lib/data/activity.dart new file mode 100644 index 00000000..2b9f9b77 --- /dev/null +++ b/packages/dashboard/lib/data/activity.dart @@ -0,0 +1,40 @@ +import 'models.dart'; + +const activityEvents = [ + ActivityEvent( + title: 'New customer', + detail: 'Camila joined the Business plan', + relativeTime: '12 min ago', + kind: .customer, + ), + ActivityEvent( + title: 'Payment received', + detail: 'ORD-1048 · \$1,249.00', + relativeTime: '38 min ago', + kind: .payment, + ), + ActivityEvent( + title: 'Order requires review', + detail: 'ORD-1047 is awaiting confirmation', + relativeTime: '1 hr ago', + kind: .alert, + ), + ActivityEvent( + title: 'Order fulfilled', + detail: 'ORD-1046 shipped to Ava Wilson', + relativeTime: '3 hrs ago', + kind: .order, + ), + ActivityEvent( + title: 'New customer', + detail: 'Henry accepted his invitation', + relativeTime: '5 hrs ago', + kind: .customer, + ), + ActivityEvent( + title: 'Refund processed', + detail: 'ORD-1045 · \$189.00', + relativeTime: 'Yesterday', + kind: .payment, + ), +]; diff --git a/packages/dashboard/lib/data/customers.dart b/packages/dashboard/lib/data/customers.dart new file mode 100644 index 00000000..5d90cc70 --- /dev/null +++ b/packages/dashboard/lib/data/customers.dart @@ -0,0 +1,196 @@ +import 'models.dart'; + +final customers = [ + Customer( + id: 'cus_001', + name: 'Olivia Martin', + email: 'olivia@northstar.co', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 1, 8), + ), + Customer( + id: 'cus_002', + name: 'Jackson Lee', + email: 'jackson@monument.dev', + plan: 'Pro', + status: .active, + joinedAt: DateTime(2025, 1, 21), + ), + Customer( + id: 'cus_003', + name: 'Sophia Brown', + email: 'sophia@loomworks.io', + plan: 'Starter', + status: .invited, + joinedAt: DateTime(2025, 2, 4), + ), + Customer( + id: 'cus_004', + name: 'Noah Williams', + email: 'noah@horizon.studio', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 2, 18), + ), + Customer( + id: 'cus_005', + name: 'Emma Davis', + email: 'emma@fieldnote.co', + plan: 'Pro', + status: .suspended, + joinedAt: DateTime(2025, 3, 2), + ), + Customer( + id: 'cus_006', + name: 'Liam Garcia', + email: 'liam@kindred.app', + plan: 'Starter', + status: .active, + joinedAt: DateTime(2025, 3, 14), + ), + Customer( + id: 'cus_007', + name: 'Ava Wilson', + email: 'ava@brightpath.com', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 4, 1), + ), + Customer( + id: 'cus_008', + name: 'Ethan Martinez', + email: 'ethan@paperplane.dev', + plan: 'Pro', + status: .invited, + joinedAt: DateTime(2025, 4, 11), + ), + Customer( + id: 'cus_009', + name: 'Mia Anderson', + email: 'mia@evergreen.design', + plan: 'Starter', + status: .active, + joinedAt: DateTime(2025, 4, 29), + ), + Customer( + id: 'cus_010', + name: 'Lucas Thomas', + email: 'lucas@relaylabs.io', + plan: 'Pro', + status: .active, + joinedAt: DateTime(2025, 5, 7), + ), + Customer( + id: 'cus_011', + name: 'Isabella Taylor', + email: 'isabella@assembly.co', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 5, 23), + ), + Customer( + id: 'cus_012', + name: 'Mason Moore', + email: 'mason@oakline.com', + plan: 'Starter', + status: .suspended, + joinedAt: DateTime(2025, 6, 3), + ), + Customer( + id: 'cus_013', + name: 'Amelia Jackson', + email: 'amelia@archway.dev', + plan: 'Pro', + status: .active, + joinedAt: DateTime(2025, 6, 19), + ), + Customer( + id: 'cus_014', + name: 'Logan White', + email: 'logan@stillwater.io', + plan: 'Business', + status: .invited, + joinedAt: DateTime(2025, 7, 2), + ), + Customer( + id: 'cus_015', + name: 'Harper Harris', + email: 'harper@wildwood.co', + plan: 'Pro', + status: .active, + joinedAt: DateTime(2025, 7, 17), + ), + Customer( + id: 'cus_016', + name: 'Elijah Clark', + email: 'elijah@commonthread.app', + plan: 'Starter', + status: .active, + joinedAt: DateTime(2025, 8, 5), + ), + Customer( + id: 'cus_017', + name: 'Evelyn Lewis', + email: 'evelyn@sunroom.design', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 8, 22), + ), + Customer( + id: 'cus_018', + name: 'James Walker', + email: 'james@granite.dev', + plan: 'Pro', + status: .suspended, + joinedAt: DateTime(2025, 9, 9), + ), + Customer( + id: 'cus_019', + name: 'Luna Hall', + email: 'luna@afterglow.co', + plan: 'Starter', + status: .active, + joinedAt: DateTime(2025, 9, 25), + ), + Customer( + id: 'cus_020', + name: 'Benjamin Allen', + email: 'ben@wayfinder.io', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 10, 8), + ), + Customer( + id: 'cus_021', + name: 'Sofia Young', + email: 'sofia@roam.studio', + plan: 'Pro', + status: .invited, + joinedAt: DateTime(2025, 10, 24), + ), + Customer( + id: 'cus_022', + name: 'Henry King', + email: 'henry@tandem.app', + plan: 'Starter', + status: .active, + joinedAt: DateTime(2025, 11, 6), + ), + Customer( + id: 'cus_023', + name: 'Camila Wright', + email: 'camila@workbench.co', + plan: 'Business', + status: .active, + joinedAt: DateTime(2025, 11, 21), + ), + Customer( + id: 'cus_024', + name: 'Alexander Scott', + email: 'alex@signalworks.dev', + plan: 'Pro', + status: .active, + joinedAt: DateTime(2025, 12, 4), + ), +]; diff --git a/packages/dashboard/lib/data/models.dart b/packages/dashboard/lib/data/models.dart new file mode 100644 index 00000000..23b3a7fa --- /dev/null +++ b/packages/dashboard/lib/data/models.dart @@ -0,0 +1,56 @@ +enum CustomerStatus { active, invited, suspended } + +enum OrderStatus { paid, pending, refunded, cancelled } + +class Customer { + const Customer({ + required this.id, + required this.name, + required this.email, + required this.plan, + required this.status, + required this.joinedAt, + }); + + final String id; + final String name; + final String email; + final String plan; + final CustomerStatus status; + final DateTime joinedAt; + + String get initials => + name.split(' ').take(2).map((part) => part.substring(0, 1)).join(); +} + +class Order { + const Order({ + required this.id, + required this.customer, + required this.date, + required this.amount, + required this.status, + }); + + final String id; + final String customer; + final DateTime date; + final double amount; + final OrderStatus status; +} + +class ActivityEvent { + const ActivityEvent({ + required this.title, + required this.detail, + required this.relativeTime, + required this.kind, + }); + + final String title; + final String detail; + final String relativeTime; + final ActivityKind kind; +} + +enum ActivityKind { customer, order, payment, alert } diff --git a/packages/dashboard/lib/data/orders.dart b/packages/dashboard/lib/data/orders.dart new file mode 100644 index 00000000..20e7885d --- /dev/null +++ b/packages/dashboard/lib/data/orders.dart @@ -0,0 +1,214 @@ +import 'models.dart'; + +final orders = [ + Order( + id: 'ORD-1048', + customer: 'Olivia Martin', + date: DateTime(2026, 7, 18), + amount: 1249, + status: .paid, + ), + Order( + id: 'ORD-1047', + customer: 'Jackson Lee', + date: DateTime(2026, 7, 17), + amount: 349, + status: .pending, + ), + Order( + id: 'ORD-1046', + customer: 'Ava Wilson', + date: DateTime(2026, 7, 16), + amount: 2780, + status: .paid, + ), + Order( + id: 'ORD-1045', + customer: 'Emma Davis', + date: DateTime(2026, 7, 15), + amount: 189, + status: .refunded, + ), + Order( + id: 'ORD-1044', + customer: 'Noah Williams', + date: DateTime(2026, 7, 13), + amount: 940, + status: .paid, + ), + Order( + id: 'ORD-1043', + customer: 'Mia Anderson', + date: DateTime(2026, 7, 12), + amount: 64, + status: .cancelled, + ), + Order( + id: 'ORD-1042', + customer: 'Lucas Thomas', + date: DateTime(2026, 7, 10), + amount: 512, + status: .paid, + ), + Order( + id: 'ORD-1041', + customer: 'Isabella Taylor', + date: DateTime(2026, 7, 9), + amount: 1575, + status: .pending, + ), + Order( + id: 'ORD-1040', + customer: 'Liam Garcia', + date: DateTime(2026, 7, 8), + amount: 128, + status: .paid, + ), + Order( + id: 'ORD-1039', + customer: 'Harper Harris', + date: DateTime(2026, 7, 6), + amount: 749, + status: .paid, + ), + Order( + id: 'ORD-1038', + customer: 'Mason Moore', + date: DateTime(2026, 7, 4), + amount: 88, + status: .refunded, + ), + Order( + id: 'ORD-1037', + customer: 'Amelia Jackson', + date: DateTime(2026, 7, 2), + amount: 2300, + status: .paid, + ), + Order( + id: 'ORD-1036', + customer: 'Logan White', + date: DateTime(2026, 6, 30), + amount: 419, + status: .pending, + ), + Order( + id: 'ORD-1035', + customer: 'Evelyn Lewis', + date: DateTime(2026, 6, 28), + amount: 965, + status: .paid, + ), + Order( + id: 'ORD-1034', + customer: 'James Walker', + date: DateTime(2026, 6, 26), + amount: 210, + status: .cancelled, + ), + Order( + id: 'ORD-1033', + customer: 'Luna Hall', + date: DateTime(2026, 6, 24), + amount: 145, + status: .paid, + ), + Order( + id: 'ORD-1032', + customer: 'Benjamin Allen', + date: DateTime(2026, 6, 22), + amount: 3200, + status: .paid, + ), + Order( + id: 'ORD-1031', + customer: 'Sofia Young', + date: DateTime(2026, 6, 20), + amount: 579, + status: .pending, + ), + Order( + id: 'ORD-1030', + customer: 'Henry King', + date: DateTime(2026, 6, 18), + amount: 92, + status: .paid, + ), + Order( + id: 'ORD-1029', + customer: 'Camila Wright', + date: DateTime(2026, 6, 16), + amount: 1180, + status: .refunded, + ), + Order( + id: 'ORD-1028', + customer: 'Alexander Scott', + date: DateTime(2026, 6, 14), + amount: 680, + status: .paid, + ), + Order( + id: 'ORD-1027', + customer: 'Sophia Brown', + date: DateTime(2026, 6, 12), + amount: 235, + status: .pending, + ), + Order( + id: 'ORD-1026', + customer: 'Ethan Martinez', + date: DateTime(2026, 6, 10), + amount: 1640, + status: .paid, + ), + Order( + id: 'ORD-1025', + customer: 'Elijah Clark', + date: DateTime(2026, 6, 8), + amount: 119, + status: .cancelled, + ), + Order( + id: 'ORD-1024', + customer: 'Olivia Martin', + date: DateTime(2026, 6, 6), + amount: 890, + status: .paid, + ), + Order( + id: 'ORD-1023', + customer: 'Ava Wilson', + date: DateTime(2026, 6, 4), + amount: 420, + status: .paid, + ), + Order( + id: 'ORD-1022', + customer: 'Noah Williams', + date: DateTime(2026, 6, 2), + amount: 1999, + status: .pending, + ), + Order( + id: 'ORD-1021', + customer: 'Mia Anderson', + date: DateTime(2026, 5, 30), + amount: 79, + status: .refunded, + ), + Order( + id: 'ORD-1020', + customer: 'Lucas Thomas', + date: DateTime(2026, 5, 28), + amount: 1340, + status: .paid, + ), + Order( + id: 'ORD-1019', + customer: 'Isabella Taylor', + date: DateTime(2026, 5, 26), + amount: 299, + status: .paid, + ), +]; diff --git a/packages/dashboard/lib/main.dart b/packages/dashboard/lib/main.dart new file mode 100644 index 00000000..615dd6d0 --- /dev/null +++ b/packages/dashboard/lib/main.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import 'shell/dashboard_shell.dart'; +import 'theme/scroll_behavior.dart'; +import 'theme/theme_scope.dart'; +import 'theme/theme_settings.dart'; + +void main() => runApp(const DashboardApp()); + +class DashboardApp extends StatefulWidget { + const DashboardApp({super.key, this.initialSettings = const ThemeSettings()}); + + final ThemeSettings initialSettings; + + @override + State createState() => _DashboardAppState(); +} + +class _DashboardAppState extends State + with WidgetsBindingObserver { + late ThemeSettings _settings = widget.initialSettings; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangePlatformBrightness() { + if (_settings.appearance == .system) setState(() {}); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final brightness = switch (_settings.appearance) { + .light => Brightness.light, + .dark => Brightness.dark, + .system => WidgetsBinding.instance.platformDispatcher.platformBrightness, + }; + return ThemeScope( + settings: _settings, + onChanged: (settings) => setState(() => _settings = settings), + child: FortalScope( + key: const ValueKey('dashboard-fortal-scope'), + accent: _settings.accentColor, + gray: _settings.grayColor, + brightness: brightness, + radius: _settings.radius, + scaling: _settings.scaling, + child: MaterialApp( + title: 'Remix Dashboard', + debugShowCheckedModeBanner: false, + scrollBehavior: const AppScrollBehavior(), + themeMode: _settings.themeMode, + theme: ThemeData(brightness: .light, useMaterial3: true), + darkTheme: ThemeData(brightness: .dark, useMaterial3: true), + themeAnimationDuration: Duration.zero, + home: const DashboardShell(), + ), + ), + ); + } +} diff --git a/packages/dashboard/lib/pages/customers_page.dart b/packages/dashboard/lib/pages/customers_page.dart new file mode 100644 index 00000000..c8dc184c --- /dev/null +++ b/packages/dashboard/lib/pages/customers_page.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../data/customers.dart'; +import '../data/models.dart'; +import '../widgets/action_popover.dart'; +import '../widgets/data_grid.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/page_header.dart'; +import '../widgets/toast.dart'; + +class CustomersPage extends StatefulWidget { + const CustomersPage({super.key, this.globalQuery = ''}); + + final String globalQuery; + + @override + State createState() => _CustomersPageState(); +} + +class _CustomersPageState extends State { + String _query = ''; + DataGridSort _sort = const DataGridSort('joined', .descending); + Set _selectedIds = {}; + int _page = 0; + int _rowsPerPage = 10; + + @override + Widget build(BuildContext context) { + final globalQuery = widget.globalQuery.toLowerCase(); + final filtered = customers.where((customer) { + final haystack = '${customer.name} ${customer.email} ${customer.plan}' + .toLowerCase(); + return haystack.contains(_query) && haystack.contains(globalQuery); + }).toList(); + filtered.sort(_compareCustomers); + final maxPage = filtered.isEmpty + ? 0 + : (filtered.length - 1) ~/ _rowsPerPage; + final safePage = _page.clamp(0, maxPage); + final start = safePage * _rowsPerPage; + final visible = filtered.skip(start).take(_rowsPerPage).toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: .stretch, + spacing: 20, + children: [ + PageHeader( + title: 'Customers', + description: 'Manage customer access, plans, and account status.', + actions: FortalButton( + onPressed: () => + showToast(context, message: 'Customer invitation started'), + label: 'Add customer', + leadingIcon: Icons.add, + ), + ), + LayoutBuilder( + builder: (context, constraints) { + final search = SizedBox( + width: constraints.maxWidth < 320 ? constraints.maxWidth : 300, + child: FortalTextField( + key: const ValueKey('customer-search'), + leading: const Icon(Icons.search, size: 18), + hintText: 'Search customers…', + onChanged: (value) => setState(() { + _query = value.trim().toLowerCase(); + _page = 0; + }), + ), + ); + final selection = _selectedIds.isEmpty + ? null + : Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FortalBadge( + size: .size2, + child: Text('${_selectedIds.length} selected'), + ), + FortalButton.ghost( + size: .size1, + onPressed: () => showToast( + context, + message: + 'Export prepared for ${_selectedIds.length} customers', + ), + label: 'Export', + ), + FortalButton.ghost( + size: .size1, + onPressed: () => showToast( + context, + message: + '${_selectedIds.length} customers archived', + actionLabel: 'Undo', + ), + label: 'Archive', + ), + ], + ); + if (constraints.maxWidth < 700) { + return Column( + crossAxisAlignment: .start, + spacing: 10, + children: [search, ?selection], + ); + } + return Row(children: [search, const Spacer(), ?selection]); + }, + ), + DataGrid( + key: const ValueKey('data-grid-customers'), + rows: visible, + columns: _columns, + sort: _sort, + onSortChanged: (sort) => setState(() { + _sort = sort; + _page = 0; + }), + rowId: (customer) => customer.id, + selectedIds: _selectedIds, + onSelectionChanged: (ids) => setState(() => _selectedIds = ids), + totalRows: filtered.length, + page: safePage, + rowsPerPage: _rowsPerPage, + onPageChanged: (page) => setState(() => _page = page), + onRowsPerPageChanged: (count) => setState(() { + _rowsPerPage = count; + _page = 0; + }), + emptyBuilder: (_) => const EmptyState( + icon: Icons.person_search_outlined, + title: 'No customers found', + body: 'Try a different name or email address.', + ), + ), + ], + ), + ); + } + + int _compareCustomers(Customer a, Customer b) { + final result = switch (_sort.columnId) { + 'name' => a.name.compareTo(b.name), + _ => a.joinedAt.compareTo(b.joinedAt), + }; + return _sort.direction == .ascending ? result : -result; + } + + List> get _columns => [ + DataGridColumn( + id: 'name', + label: 'Customer', + sortable: true, + flex: 2, + cellBuilder: (context, customer) => Row( + mainAxisSize: .min, + spacing: 9, + children: [ + FortalAvatar(size: .size2, label: customer.initials), + Flexible(child: _PrimaryText(customer.name)), + ], + ), + ), + DataGridColumn( + id: 'email', + label: 'Email', + flex: 2, + cellBuilder: (_, customer) => _SecondaryText(customer.email), + ), + DataGridColumn( + id: 'plan', + label: 'Plan', + width: 110, + cellBuilder: (_, customer) => _PrimaryText(customer.plan), + ), + DataGridColumn( + id: 'status', + label: 'Status', + width: 110, + cellBuilder: (_, customer) => _CustomerStatusBadge(customer.status), + ), + DataGridColumn( + id: 'joined', + label: 'Joined', + sortable: true, + width: 118, + cellBuilder: (_, customer) => _SecondaryText(_date(customer.joinedAt)), + ), + DataGridColumn( + id: 'actions', + label: '', + width: 46, + align: .right, + cellBuilder: (context, customer) => DashboardActionPopover( + key: ValueKey('customer-actions-${customer.id}'), + semanticLabel: 'Actions for ${customer.name}', + positioning: const OverlayPositionConfig( + side: .bottom, + alignment: .end, + sideOffset: 4, + ), + trigger: const Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.more_horiz, size: 18), + ), + actions: const [ + DashboardAction(value: 'view', label: 'View profile'), + DashboardAction(value: 'email', label: 'Send email'), + DashboardAction( + value: 'archive', + label: 'Archive', + dividerBefore: true, + ), + ], + onSelected: (value) => + showToast(context, message: '$value selected for ${customer.name}'), + ), + ), + ]; +} + +class _CustomerStatusBadge extends StatelessWidget { + const _CustomerStatusBadge(this.status); + final CustomerStatus status; + + @override + Widget build(BuildContext context) { + final (label, color) = switch (status) { + .active => ('Active', FortalAccentColor.green), + .invited => ('Invited', FortalAccentColor.blue), + .suspended => ('Suspended', FortalAccentColor.red), + }; + return FortalScope( + accent: color, + hasBackground: false, + child: FortalBadge(label: label), + ); + } +} + +class _PrimaryText extends StatelessWidget { + const _PrimaryText(this.text); + final String text; + + @override + Widget build(BuildContext context) => StyledText( + text, + style: TextStyler(style: FortalTokens.text2.mix()) + .fontWeight(.w500) + .color(FortalTokens.gray12()) + .maxLines(1) + .overflow(.ellipsis), + ); +} + +class _SecondaryText extends StatelessWidget { + const _SecondaryText(this.text); + final String text; + + @override + Widget build(BuildContext context) => StyledText( + text, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()).maxLines(1).overflow(.ellipsis), + ); +} + +String _date(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[value.month - 1]} ${value.day}, ${value.year}'; +} diff --git a/packages/dashboard/lib/pages/gallery/gallery_actions_page.dart b/packages/dashboard/lib/pages/gallery/gallery_actions_page.dart new file mode 100644 index 00000000..1d20436d --- /dev/null +++ b/packages/dashboard/lib/pages/gallery/gallery_actions_page.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/gallery_scaffold.dart'; +import '../../widgets/toast.dart'; + +class GalleryActionsPage extends StatefulWidget { + const GalleryActionsPage({super.key}); + + @override + State createState() => _GalleryActionsPageState(); +} + +class _GalleryActionsPageState extends State { + bool _selected = true; + + @override + Widget build(BuildContext context) { + return GalleryPage( + title: 'Actions', + intro: 'Interactive actions across every Fortal variant and size.', + sections: [ + GallerySection( + label: 'Button', + description: + 'Classic, solid, soft, surface, outline, and ghost actions.', + child: GalleryMatrix( + rows: FortalButtonVariant.values.map(galleryLabel).toList(), + columns: FortalButtonSize.values.map(galleryLabel).toList(), + cellBuilder: (context, row, column) => FortalButton( + variant: FortalButtonVariant.values[row], + size: FortalButtonSize.values[column], + onPressed: () => showToast(context, message: 'Button pressed'), + label: 'Button', + ), + ), + ), + GallerySection( + label: 'Icon button', + description: + 'Compact icon-only controls with complete focus semantics.', + child: GalleryMatrix( + rows: FortalIconButtonVariant.values.map(galleryLabel).toList(), + columns: FortalIconButtonSize.values.map(galleryLabel).toList(), + cellBuilder: (context, row, column) => FortalIconButton( + variant: FortalIconButtonVariant.values[row], + size: FortalIconButtonSize.values[column], + semanticLabel: 'Add item', + onPressed: () => showToast(context, message: 'Item added'), + icon: Icons.add, + ), + ), + ), + GallerySection( + label: 'Toggle', + description: 'Ghost and outline toggles remain fully interactive.', + child: GalleryMatrix( + rows: FortalToggleVariant.values.map(galleryLabel).toList(), + columns: FortalToggleSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => FortalToggle( + variant: FortalToggleVariant.values[row], + size: FortalToggleSize.values[column], + selected: _selected, + icon: Icons.format_bold, + label: 'Bold', + onChanged: (value) => setState(() => _selected = value), + ), + ), + ), + GallerySection( + label: 'States', + description: + 'Disabled and loading behavior uses the same component API.', + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + const FortalButton( + enabled: false, + onPressed: null, + label: 'Disabled', + ), + FortalButton(loading: true, onPressed: () {}, label: 'Saving'), + FortalIconButton( + enabled: false, + semanticLabel: 'Disabled favorite', + onPressed: () {}, + icon: Icons.favorite_border, + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/dashboard/lib/pages/gallery/gallery_display_page.dart b/packages/dashboard/lib/pages/gallery/gallery_display_page.dart new file mode 100644 index 00000000..40afb2c9 --- /dev/null +++ b/packages/dashboard/lib/pages/gallery/gallery_display_page.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/gallery_scaffold.dart'; + +class GalleryDisplayPage extends StatefulWidget { + const GalleryDisplayPage({super.key}); + + @override + State createState() => _GalleryDisplayPageState(); +} + +class _GalleryDisplayPageState extends State { + bool _spinnersRunning = false; + + @override + Widget build(BuildContext context) { + return GalleryPage( + title: 'Data Display', + intro: 'Rich surfaces and status components for product interfaces.', + sections: [ + GallerySection( + label: 'Avatar', + description: + 'Two visual variants across all nine Radix-compatible sizes.', + child: GalleryMatrix( + rows: FortalAvatarVariant.values.map(galleryLabel).toList(), + columns: FortalAvatarSize.values.map(galleryLabel).toList(), + cellWidth: 120, + cellBuilder: (_, row, column) => FortalAvatar( + variant: FortalAvatarVariant.values[row], + size: FortalAvatarSize.values[column], + label: 'RF', + ), + ), + ), + GallerySection( + label: 'Badge', + description: + 'Status labels in solid, soft, surface, and outline variants.', + child: GalleryMatrix( + rows: FortalBadgeVariant.values.map(galleryLabel).toList(), + columns: FortalBadgeSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => FortalBadge( + variant: FortalBadgeVariant.values[row], + size: FortalBadgeSize.values[column], + child: const Text('Active'), + ), + ), + ), + GallerySection( + label: 'Card', + description: + 'Surface, classic, and ghost containers across five spacing sizes.', + child: GalleryMatrix( + rows: FortalCardVariant.values.map(galleryLabel).toList(), + columns: FortalCardSize.values.map(galleryLabel).toList(), + cellWidth: 200, + cellBuilder: (_, row, column) => SizedBox( + width: 160, + child: FortalCard( + variant: FortalCardVariant.values[row], + size: FortalCardSize.values[column], + child: const Text('Card content'), + ), + ), + ), + ), + GallerySection( + label: 'Callout', + description: 'Contextual information in every variant and size.', + child: GalleryMatrix( + rows: FortalCalloutVariant.values.map(galleryLabel).toList(), + columns: FortalCalloutSize.values.map(galleryLabel).toList(), + cellWidth: 230, + cellBuilder: (_, row, column) => SizedBox( + width: 200, + child: FortalCallout( + variant: FortalCalloutVariant.values[row], + size: FortalCalloutSize.values[column], + child: const Text('A helpful callout message.'), + ), + ), + ), + ), + GallerySection( + label: 'Progress', + description: + 'Determinate progress with classic, surface, and soft treatments.', + child: GalleryMatrix( + rows: FortalProgressVariant.values.map(galleryLabel).toList(), + columns: FortalProgressSize.values.map(galleryLabel).toList(), + cellWidth: 210, + cellBuilder: (_, row, column) => SizedBox( + width: 170, + child: FortalProgress( + variant: FortalProgressVariant.values[row], + size: FortalProgressSize.values[column], + value: 0.68, + semanticsLabel: '68 percent complete', + ), + ), + ), + ), + GallerySection( + label: 'Spinner', + description: + 'Animation is stopped by default so automated tests remain settleable.', + child: Row( + spacing: 18, + children: [ + FortalButton.soft( + size: .size1, + onPressed: () => + setState(() => _spinnersRunning = !_spinnersRunning), + label: _spinnersRunning ? 'Stop' : 'Start', + ), + for (final size in FortalSpinnerSize.values) + if (_spinnersRunning) + FortalSpinner(size: size, semanticsLabel: 'Loading example') + else + const Icon(Icons.check, size: 16), + ], + ), + ), + GallerySection( + label: 'Divider', + description: 'Horizontal dividers at all four inset sizes.', + child: Column( + spacing: 14, + children: [ + for (final size in FortalDividerSize.values) + Row( + spacing: 12, + children: [ + SizedBox(width: 64, child: Text(galleryLabel(size))), + Expanded(child: FortalDivider(size: size)), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/dashboard/lib/pages/gallery/gallery_forms_page.dart b/packages/dashboard/lib/pages/gallery/gallery_forms_page.dart new file mode 100644 index 00000000..4c52480c --- /dev/null +++ b/packages/dashboard/lib/pages/gallery/gallery_forms_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/gallery_scaffold.dart'; + +class GalleryFormsPage extends StatefulWidget { + const GalleryFormsPage({super.key}); + + @override + State createState() => _GalleryFormsPageState(); +} + +class _GalleryFormsPageState extends State { + bool _checked = true; + bool _switched = true; + int _radio = 1; + String _alignment = 'left'; + double _slider = 58; + + @override + Widget build(BuildContext context) { + return GalleryPage( + title: 'Forms & Inputs', + intro: 'Production-ready fields and selection controls in every preset.', + sections: [ + GallerySection( + label: 'Text field', + description: + 'All field variants and sizes, with labels and helper content.', + child: GalleryMatrix( + rows: FortalTextFieldVariant.values.map(galleryLabel).toList(), + columns: FortalTextFieldSize.values.map(galleryLabel).toList(), + cellWidth: 210, + cellBuilder: (_, row, column) => FortalTextField( + variant: FortalTextFieldVariant.values[row], + size: FortalTextFieldSize.values[column], + hintText: 'Type something…', + leading: const Icon(Icons.search, size: 16), + ), + ), + ), + GallerySection( + label: 'Select', + description: 'Every visual variant across the three sizes.', + child: GalleryMatrix( + rows: FortalSelectVariant.values.map(galleryLabel).toList(), + columns: FortalSelectSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => FortalSelect( + variant: FortalSelectVariant.values[row], + size: FortalSelectSize.values[column], + trigger: const RemixSelectTrigger(placeholder: 'Fruit'), + items: const [ + RemixSelectItem(value: 'apple', label: 'Apple'), + RemixSelectItem(value: 'orange', label: 'Orange'), + RemixSelectItem(value: 'pear', label: 'Pear'), + ], + selectedValue: 'apple', + onChanged: (_) {}, + ), + ), + ), + GallerySection( + label: 'Toggle group', + description: + 'Single-selection groups in soft and surface treatments.', + child: GalleryMatrix( + rows: FortalToggleGroupVariant.values.map(galleryLabel).toList(), + columns: FortalToggleGroupSize.values.map(galleryLabel).toList(), + cellWidth: 230, + cellBuilder: (_, row, column) => FortalToggleGroup( + variant: FortalToggleGroupVariant.values[row], + size: FortalToggleGroupSize.values[column], + selectedValue: _alignment, + semanticLabel: 'Text alignment', + items: const [ + RemixToggleGroupItem( + value: 'left', + icon: Icons.format_align_left, + semanticLabel: 'Left', + ), + RemixToggleGroupItem( + value: 'center', + icon: Icons.format_align_center, + semanticLabel: 'Center', + ), + RemixToggleGroupItem( + value: 'right', + icon: Icons.format_align_right, + semanticLabel: 'Right', + ), + ], + onChanged: (value) { + if (value != null) setState(() => _alignment = value); + }, + ), + ), + ), + GallerySection( + label: 'Checkbox', + description: 'Classic, surface, and soft checkbox recipes.', + child: GalleryMatrix( + rows: FortalCheckboxVariant.values.map(galleryLabel).toList(), + columns: FortalCheckboxSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => FortalCheckbox( + variant: FortalCheckboxVariant.values[row], + size: FortalCheckboxSize.values[column], + selected: _checked, + semanticLabel: 'Example checkbox', + onChanged: (value) => setState(() => _checked = value ?? false), + ), + ), + ), + GallerySection( + label: 'Radio', + description: 'Radio selection shown across every variant and size.', + child: GalleryMatrix( + rows: FortalRadioVariant.values.map(galleryLabel).toList(), + columns: FortalRadioSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => RemixRadioGroup( + groupValue: _radio, + onChanged: (value) { + if (value != null) setState(() => _radio = value); + }, + child: FortalRadio( + variant: FortalRadioVariant.values[row], + size: FortalRadioSize.values[column], + value: 1, + ), + ), + ), + ), + GallerySection( + label: 'Switch', + description: 'Binary settings controls with all visual treatments.', + child: GalleryMatrix( + rows: FortalSwitchVariant.values.map(galleryLabel).toList(), + columns: FortalSwitchSize.values.map(galleryLabel).toList(), + cellBuilder: (_, row, column) => FortalSwitch( + variant: FortalSwitchVariant.values[row], + size: FortalSwitchSize.values[column], + selected: _switched, + semanticLabel: 'Example switch', + onChanged: (value) => setState(() => _switched = value), + ), + ), + ), + GallerySection( + label: 'Slider', + description: 'Discrete single-thumb sliders in every Fortal recipe.', + child: GalleryMatrix( + rows: FortalSliderVariant.values.map(galleryLabel).toList(), + columns: FortalSliderSize.values.map(galleryLabel).toList(), + cellWidth: 210, + cellBuilder: (_, row, column) => SizedBox( + width: 170, + child: FortalSlider( + variant: FortalSliderVariant.values[row], + size: FortalSliderSize.values[column], + value: _slider, + min: 0, + max: 100, + snapDivisions: 100, + onChanged: (value) => setState(() => _slider = value), + ), + ), + ), + ), + const GallerySection( + label: 'States', + description: 'Validation, disabled, and indeterminate states.', + child: Wrap( + spacing: 16, + runSpacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 240, + child: FortalTextField( + error: true, + label: 'Workspace slug', + hintText: 'remix', + helperText: 'That slug is already in use.', + ), + ), + FortalCheckbox( + selected: null, + tristate: true, + semanticLabel: 'Indeterminate checkbox', + ), + FortalSwitch( + selected: false, + enabled: false, + semanticLabel: 'Disabled switch', + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/dashboard/lib/pages/gallery/gallery_navigation_page.dart b/packages/dashboard/lib/pages/gallery/gallery_navigation_page.dart new file mode 100644 index 00000000..74843ec0 --- /dev/null +++ b/packages/dashboard/lib/pages/gallery/gallery_navigation_page.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/gallery_scaffold.dart'; + +class GalleryNavigationPage extends StatelessWidget { + const GalleryNavigationPage({super.key}); + + @override + Widget build(BuildContext context) => GalleryPage( + title: 'Navigation', + intro: 'Tabs and disclosure patterns for organizing dense interfaces.', + sections: [ + GallerySection( + label: 'Tabs', + description: 'Both tab sizes with live keyboard and pointer selection.', + child: GalleryMatrix( + rows: const ['Tabs'], + columns: FortalTabsSize.values.map(galleryLabel).toList(), + cellWidth: 320, + cellBuilder: (_, _, column) => + _TabsDemo(size: FortalTabsSize.values[column]), + ), + ), + GallerySection( + label: 'Accordion', + description: 'Surface and soft disclosure items across three sizes.', + child: GalleryMatrix( + rows: FortalAccordionVariant.values.map(galleryLabel).toList(), + columns: FortalAccordionSize.values.map(galleryLabel).toList(), + cellWidth: 300, + cellBuilder: (_, row, column) => _AccordionDemo( + variant: FortalAccordionVariant.values[row], + size: FortalAccordionSize.values[column], + ), + ), + ), + ], + ); +} + +class _TabsDemo extends StatefulWidget { + const _TabsDemo({required this.size}); + final FortalTabsSize size; + + @override + State<_TabsDemo> createState() => _TabsDemoState(); +} + +class _TabsDemoState extends State<_TabsDemo> { + String _selected = 'overview'; + + @override + Widget build(BuildContext context) => RemixTabs( + selectedTabId: _selected, + onChanged: (value) => setState(() => _selected = value), + child: Column( + crossAxisAlignment: .stretch, + spacing: 10, + children: [ + FortalTabBar( + child: Row( + children: [ + FortalTab( + size: widget.size, + tabId: 'overview', + label: 'Overview', + ), + FortalTab( + size: widget.size, + tabId: 'activity', + label: 'Activity', + ), + ], + ), + ), + FortalTabView( + tabId: 'overview', + child: const Padding( + padding: EdgeInsets.all(8), + child: Text('Overview content'), + ), + ), + FortalTabView( + tabId: 'activity', + child: const Padding( + padding: EdgeInsets.all(8), + child: Text('Activity content'), + ), + ), + ], + ), + ); +} + +class _AccordionDemo extends StatefulWidget { + const _AccordionDemo({required this.variant, required this.size}); + final FortalAccordionVariant variant; + final FortalAccordionSize size; + + @override + State<_AccordionDemo> createState() => _AccordionDemoState(); +} + +class _AccordionDemoState extends State<_AccordionDemo> { + final _controller = RemixAccordionController(max: 1); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => RemixAccordionGroup( + controller: _controller, + initialExpandedValues: const ['details'], + child: Column( + children: [ + FortalAccordion( + variant: widget.variant, + size: widget.size, + value: 'details', + title: 'What is Fortal?', + child: const Text( + 'A Radix-inspired theme and component system for Flutter.', + ), + ), + FortalAccordion( + variant: widget.variant, + size: widget.size, + value: 'tokens', + title: 'Does it support tokens?', + child: const Text( + 'Every recipe resolves through the active Mix scope.', + ), + ), + ], + ), + ); +} diff --git a/packages/dashboard/lib/pages/gallery/gallery_overlays_page.dart b/packages/dashboard/lib/pages/gallery/gallery_overlays_page.dart new file mode 100644 index 00000000..d0c7c976 --- /dev/null +++ b/packages/dashboard/lib/pages/gallery/gallery_overlays_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../../widgets/gallery_scaffold.dart'; +import '../../widgets/toast.dart'; + +class GalleryOverlaysPage extends StatelessWidget { + const GalleryOverlaysPage({super.key}); + + @override + Widget build(BuildContext context) { + return GalleryPage( + title: 'Overlays', + intro: + 'Real dialog, popover, tooltip, and menu triggers for every recipe.', + sections: [ + GallerySection( + label: 'Dialog', + description: 'The complete four-size dialog scale.', + child: GalleryMatrix( + rows: const ['Dialog'], + columns: FortalDialogSize.values.map(galleryLabel).toList(), + cellBuilder: (context, _, column) => FortalButton.soft( + size: .size1, + onPressed: () => showRemixDialog( + context: context, + builder: (dialogContext) => FortalDialog( + size: FortalDialogSize.values[column], + title: 'Invite teammates', + description: 'Share this workspace with your collaborators.', + actions: [ + FortalButton.soft( + onPressed: () => Navigator.of(dialogContext).pop(), + label: 'Cancel', + ), + FortalButton( + onPressed: () => Navigator.of(dialogContext).pop(), + label: 'Send invite', + ), + ], + child: const Padding( + padding: EdgeInsets.only(top: 12), + child: FortalTextField(hintText: 'teammate@example.com'), + ), + ), + ), + label: 'Open', + ), + ), + ), + GallerySection( + label: 'Popover', + description: 'Anchored content with all four padding presets.', + child: Wrap( + spacing: 14, + runSpacing: 14, + children: [ + for (final size in FortalPopoverSize.values) + FortalPopover( + size: size, + semanticLabel: 'Open ${galleryLabel(size)} popover', + popoverChild: const SizedBox( + width: 250, + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Text('Quick note'), + Text( + 'Popover content inherits the active Fortal scope.', + ), + ], + ), + ), + child: _OverlayTrigger('Popover ${galleryLabel(size)}'), + ), + ], + ), + ), + GallerySection( + label: 'Tooltip', + description: + 'Hover or long-press the trigger to reveal contextual help.', + child: FortalTooltip( + tooltipSemantics: 'Keyboard shortcut Command K', + tooltipChild: const Text('Search · ⌘K'), + child: const _OverlayTrigger('Hover for shortcut'), + ), + ), + GallerySection( + label: 'Menu', + description: 'Solid and soft menus at both supported density sizes.', + child: GalleryMatrix( + rows: FortalMenuVariant.values.map(galleryLabel).toList(), + columns: FortalMenuSize.values.map(galleryLabel).toList(), + cellBuilder: (context, row, column) => FortalMenu( + variant: FortalMenuVariant.values[row], + size: FortalMenuSize.values[column], + trigger: const RemixMenuTrigger( + label: 'Open menu', + icon: Icons.more_horiz, + ), + items: const >[ + RemixMenuItem(value: 'duplicate', label: 'Duplicate'), + RemixMenuItem(value: 'share', label: 'Share'), + RemixMenuDivider(), + RemixMenuItem(value: 'archive', label: 'Archive'), + ], + onSelected: (value) => + showToast(context, message: '$value selected'), + ), + ), + ), + ], + ); + } +} + +class _OverlayTrigger extends StatelessWidget { + const _OverlayTrigger(this.label); + final String label; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.accentA3, context), + border: Border.all( + color: MixScope.tokenOf(FortalTokens.accentA6, context), + ), + borderRadius: BorderRadius.all( + MixScope.tokenOf(FortalTokens.radius3, context), + ), + ), + child: StyledText( + label, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w500).color(FortalTokens.accent11()), + ), + ); +} diff --git a/packages/dashboard/lib/pages/orders_page.dart b/packages/dashboard/lib/pages/orders_page.dart new file mode 100644 index 00000000..799c89c4 --- /dev/null +++ b/packages/dashboard/lib/pages/orders_page.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../data/models.dart'; +import '../data/orders.dart'; +import '../widgets/action_popover.dart'; +import '../widgets/data_grid.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/page_header.dart'; +import '../widgets/toast.dart'; + +enum _OrderFilter { all, paid, pending, refunded, cancelled } + +class OrdersPage extends StatefulWidget { + const OrdersPage({super.key, this.globalQuery = ''}); + + final String globalQuery; + + @override + State createState() => _OrdersPageState(); +} + +class _OrdersPageState extends State { + _OrderFilter _filter = .all; + DataGridSort _sort = const DataGridSort('date', .descending); + int _page = 0; + int _rowsPerPage = 10; + + @override + Widget build(BuildContext context) { + final filtered = orders.where((order) { + final statusMatches = + _filter == .all || order.status.name == _filter.name; + final query = widget.globalQuery.toLowerCase(); + final queryMatches = '${order.id} ${order.customer} ${order.status.name}' + .toLowerCase() + .contains(query); + return statusMatches && queryMatches; + }).toList()..sort(_compareOrders); + final maxPage = filtered.isEmpty + ? 0 + : (filtered.length - 1) ~/ _rowsPerPage; + final safePage = _page.clamp(0, maxPage); + final visible = filtered + .skip(safePage * _rowsPerPage) + .take(_rowsPerPage) + .toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: .stretch, + spacing: 20, + children: [ + PageHeader( + title: 'Orders', + description: 'Review transactions and fulfillment status.', + actions: FortalButton( + onPressed: () => + showToast(context, message: 'Order export prepared'), + label: 'Export', + leadingIcon: Icons.download_outlined, + ), + ), + Align( + alignment: .centerLeft, + child: SingleChildScrollView( + scrollDirection: .horizontal, + child: FortalToggleGroup<_OrderFilter>( + semanticLabel: 'Filter orders by status', + selectedValue: _filter, + items: const [ + RemixToggleGroupItem(value: .all, label: 'All'), + RemixToggleGroupItem(value: .paid, label: 'Paid'), + RemixToggleGroupItem(value: .pending, label: 'Pending'), + RemixToggleGroupItem(value: .refunded, label: 'Refunded'), + RemixToggleGroupItem(value: .cancelled, label: 'Cancelled'), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _filter = value; + _page = 0; + }); + } + }, + ), + ), + ), + DataGrid( + key: const ValueKey('data-grid-orders'), + rows: visible, + columns: _columns, + sort: _sort, + onSortChanged: (sort) => setState(() { + _sort = sort; + _page = 0; + }), + totalRows: filtered.length, + page: safePage, + rowsPerPage: _rowsPerPage, + onPageChanged: (page) => setState(() => _page = page), + onRowsPerPageChanged: (count) => setState(() { + _rowsPerPage = count; + _page = 0; + }), + emptyBuilder: (_) => const EmptyState( + icon: Icons.receipt_long_outlined, + title: 'No matching orders', + body: 'Choose another status to see more orders.', + ), + ), + ], + ), + ); + } + + int _compareOrders(Order a, Order b) { + final result = switch (_sort.columnId) { + 'amount' => a.amount.compareTo(b.amount), + _ => a.date.compareTo(b.date), + }; + return _sort.direction == .ascending ? result : -result; + } + + List> get _columns => [ + DataGridColumn( + id: 'id', + label: 'Order', + width: 120, + cellBuilder: (_, order) => _CellText(order.id, primary: true), + ), + DataGridColumn( + id: 'customer', + label: 'Customer', + flex: 2, + cellBuilder: (_, order) => _CellText(order.customer, primary: true), + ), + DataGridColumn( + id: 'date', + label: 'Date', + width: 120, + sortable: true, + cellBuilder: (_, order) => _CellText(_date(order.date)), + ), + DataGridColumn( + id: 'amount', + label: 'Amount', + width: 120, + align: .right, + sortable: true, + cellBuilder: (_, order) => + _CellText('\$${order.amount.toStringAsFixed(2)}', primary: true), + ), + DataGridColumn( + id: 'status', + label: 'Status', + width: 118, + cellBuilder: (_, order) => _OrderStatusBadge(order.status), + ), + DataGridColumn( + id: 'actions', + label: '', + width: 46, + align: .right, + cellBuilder: (context, order) => DashboardActionPopover( + key: ValueKey('order-actions-${order.id}'), + semanticLabel: 'Actions for ${order.id}', + positioning: const OverlayPositionConfig( + side: .bottom, + alignment: .end, + sideOffset: 4, + ), + trigger: const Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.more_horiz, size: 18), + ), + actions: const [ + DashboardAction(value: 'view', label: 'View order'), + DashboardAction(value: 'receipt', label: 'Download receipt'), + DashboardAction( + value: 'refund', + label: 'Issue refund', + dividerBefore: true, + ), + ], + onSelected: (value) => + showToast(context, message: '$value selected for ${order.id}'), + ), + ), + ]; +} + +class _OrderStatusBadge extends StatelessWidget { + const _OrderStatusBadge(this.status); + final OrderStatus status; + + @override + Widget build(BuildContext context) { + final color = switch (status) { + .paid => FortalAccentColor.green, + .pending => FortalAccentColor.amber, + .refunded => FortalAccentColor.red, + .cancelled => FortalAccentColor.gray, + }; + final label = '${status.name[0].toUpperCase()}${status.name.substring(1)}'; + return FortalScope( + accent: color, + hasBackground: false, + child: FortalBadge(label: label), + ); + } +} + +class _CellText extends StatelessWidget { + const _CellText(this.text, {this.primary = false}); + final String text; + final bool primary; + + @override + Widget build(BuildContext context) => StyledText( + text, + style: TextStyler(style: FortalTokens.text2.mix()) + .fontWeight(primary ? .w500 : .w400) + .color(primary ? FortalTokens.gray12() : FortalTokens.gray11()) + .maxLines(1) + .overflow(.ellipsis), + ); +} + +String _date(DateTime value) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[value.month - 1]} ${value.day}, ${value.year}'; +} diff --git a/packages/dashboard/lib/pages/overview_page.dart b/packages/dashboard/lib/pages/overview_page.dart new file mode 100644 index 00000000..1c57fa99 --- /dev/null +++ b/packages/dashboard/lib/pages/overview_page.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../data/activity.dart'; +import '../data/models.dart'; +import '../data/orders.dart'; +import '../widgets/data_grid.dart'; +import '../widgets/page_header.dart'; +import '../widgets/stat_card.dart'; + +class OverviewPage extends StatelessWidget { + const OverviewPage({super.key, required this.onViewOrders}); + + final VoidCallback onViewOrders; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: .stretch, + spacing: 24, + children: [ + const PageHeader( + title: 'Overview', + description: 'A snapshot of your workspace performance.', + ), + Wrap( + spacing: 16, + runSpacing: 16, + children: const [ + StatCard( + label: 'Revenue', + value: '\$84,420', + delta: 12.4, + icon: Icons.payments_outlined, + ), + StatCard( + label: 'Active customers', + value: '2,420', + delta: 8.2, + icon: Icons.people_outline, + ), + StatCard( + label: 'Open orders', + value: '184', + delta: -2.8, + icon: Icons.inventory_2_outlined, + ), + StatCard( + label: 'Fulfillment', + value: '94.2%', + delta: 4.1, + icon: Icons.local_shipping_outlined, + progress: 94.2, + ), + ], + ), + if (constraints.maxWidth >= 1050) + Row( + crossAxisAlignment: .start, + spacing: 20, + children: [ + const Expanded(child: _ActivityCard()), + Expanded(child: _RecentOrders(onViewOrders: onViewOrders)), + ], + ) + else + Column( + crossAxisAlignment: .stretch, + spacing: 20, + children: [ + const _ActivityCard(), + _RecentOrders(onViewOrders: onViewOrders), + ], + ), + ], + ), + ), + ); + } +} + +class _ActivityCard extends StatelessWidget { + const _ActivityCard(); + + @override + Widget build(BuildContext context) { + return FortalCard( + size: .size2, + child: Column( + crossAxisAlignment: .stretch, + children: [ + const SectionLabel('Recent activity'), + const SizedBox(height: 8), + for (final (index, event) in activityEvents.indexed) ...[ + _ActivityRow(event), + if (index != activityEvents.length - 1) + Container( + height: 1, + color: MixScope.tokenOf(FortalTokens.grayA5, context), + ), + ], + ], + ), + ); + } +} + +class _ActivityRow extends StatelessWidget { + const _ActivityRow(this.event); + final ActivityEvent event; + + @override + Widget build(BuildContext context) { + final (icon, color) = switch (event.kind) { + .customer => (Icons.person_add_alt, FortalAccentColor.blue), + .order => (Icons.local_shipping_outlined, FortalAccentColor.indigo), + .payment => (Icons.payments_outlined, FortalAccentColor.green), + .alert => (Icons.error_outline, FortalAccentColor.amber), + }; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + spacing: 12, + children: [ + _ActivityIcon(icon: icon, color: color), + Expanded( + child: Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + StyledText( + event.title, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + StyledText( + event.detail, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + ), + StyledText( + event.relativeTime, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray10()), + ), + ], + ), + ); + } +} + +class _ActivityIcon extends StatelessWidget { + const _ActivityIcon({required this.icon, required this.color}); + + final IconData icon; + final FortalAccentColor color; + + @override + Widget build(BuildContext context) { + final radix = switch (color) { + .blue => blue, + .green => green, + .amber => amber, + _ => indigo, + }; + final scale = FortalTheme.of(context).isDark ? radix.dark : radix.light; + return Container( + width: 32, + height: 32, + alignment: .center, + decoration: BoxDecoration( + color: scale.scale.alphaStep(3), + shape: .circle, + ), + child: Icon(icon, size: 16, color: scale.scale.step(11)), + ); + } +} + +class _RecentOrders extends StatelessWidget { + const _RecentOrders({required this.onViewOrders}); + final VoidCallback onViewOrders; + + @override + Widget build(BuildContext context) { + return FortalCard( + size: .size2, + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + Row( + children: [ + const Expanded(child: SectionLabel('Recent orders')), + FortalButton.ghost( + key: const ValueKey('overview-view-orders'), + size: .size1, + onPressed: onViewOrders, + label: 'View all', + trailingIcon: Icons.arrow_forward, + ), + ], + ), + DataGrid( + rows: orders.take(5).toList(), + minimumWidth: 560, + columns: [ + DataGridColumn( + id: 'id', + label: 'Order', + width: 105, + cellBuilder: (_, order) => _OrderText(order.id, primary: true), + ), + DataGridColumn( + id: 'customer', + label: 'Customer', + flex: 2, + cellBuilder: (_, order) => _OrderText(order.customer), + ), + DataGridColumn( + id: 'amount', + label: 'Amount', + width: 100, + align: .right, + cellBuilder: (_, order) => _OrderText( + '\$${order.amount.toStringAsFixed(2)}', + primary: true, + ), + ), + DataGridColumn( + id: 'status', + label: 'Status', + width: 94, + cellBuilder: (_, order) => FortalScope( + accent: order.status == .paid ? .green : .amber, + hasBackground: false, + child: FortalBadge(label: order.status.name), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _OrderText extends StatelessWidget { + const _OrderText(this.value, {this.primary = false}); + final String value; + final bool primary; + + @override + Widget build(BuildContext context) => StyledText( + value, + style: TextStyler(style: FortalTokens.text1.mix()) + .fontWeight(primary ? .w600 : .w400) + .color(primary ? FortalTokens.gray12() : FortalTokens.gray11()) + .maxLines(1) + .overflow(.ellipsis), + ); +} diff --git a/packages/dashboard/lib/pages/settings_page.dart b/packages/dashboard/lib/pages/settings_page.dart new file mode 100644 index 00000000..81dcb6cf --- /dev/null +++ b/packages/dashboard/lib/pages/settings_page.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../widgets/page_header.dart'; +import '../widgets/theme_panel.dart'; +import '../widgets/toast.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final _nameController = TextEditingController(text: 'Leo Farias'); + final _emailController = TextEditingController(text: 'leo@remix.dev'); + String _language = 'English (US)'; + bool _productUpdates = true; + bool _weeklyDigest = false; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Align( + alignment: .topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: .stretch, + spacing: 20, + children: [ + const PageHeader( + title: 'Settings', + description: 'Manage your profile, preferences, and workspace.', + ), + FortalCard( + size: .size3, + child: Column( + crossAxisAlignment: .stretch, + spacing: 16, + children: [ + const _CardHeading( + title: 'Profile', + description: + 'Your personal details and communication preferences.', + ), + Row( + crossAxisAlignment: .start, + spacing: 14, + children: [ + Expanded( + child: FortalTextField( + controller: _nameController, + label: 'Name', + hintText: 'Your name', + ), + ), + Expanded( + child: FortalTextField( + controller: _emailController, + label: 'Email', + helperText: 'Domain verification is pending.', + error: true, + keyboardType: .emailAddress, + ), + ), + ], + ), + FortalSelect( + trigger: const RemixSelectTrigger( + placeholder: 'Language', + ), + selectedValue: _language, + items: const [ + RemixSelectItem( + value: 'English (US)', + label: 'English (US)', + ), + RemixSelectItem( + value: 'English (UK)', + label: 'English (UK)', + ), + RemixSelectItem(value: 'Spanish', label: 'Spanish'), + RemixSelectItem( + value: 'Portuguese', + label: 'Portuguese', + ), + ], + onChanged: (value) { + if (value != null) setState(() => _language = value); + }, + ), + _PreferenceRow( + title: 'Product updates', + description: 'News about features and improvements.', + trailing: FortalSwitch( + selected: _productUpdates, + semanticLabel: 'Receive product updates', + onChanged: (value) => + setState(() => _productUpdates = value), + ), + ), + _PreferenceRow( + title: 'Weekly digest', + description: + 'A summary of workspace activity each Monday.', + trailing: FortalCheckbox( + selected: _weeklyDigest, + semanticLabel: 'Receive weekly digest', + onChanged: (value) => + setState(() => _weeklyDigest = value ?? false), + ), + ), + Align( + alignment: .centerLeft, + child: FortalButton( + key: const ValueKey('save-profile'), + onPressed: () => showToast( + context, + message: 'Profile settings saved', + ), + label: 'Save changes', + ), + ), + ], + ), + ), + FortalCard( + size: .size3, + child: Column( + crossAxisAlignment: .stretch, + spacing: 16, + children: const [ + _CardHeading( + title: 'Appearance', + description: + 'Tune every Fortal theme parameter in real time.', + ), + FortalCallout( + icon: Icons.auto_awesome_outlined, + text: 'Changes apply live across the entire dashboard.', + ), + ThemePanel(), + ], + ), + ), + FortalCard( + size: .size3, + child: Column( + crossAxisAlignment: .stretch, + spacing: 16, + children: [ + const _CardHeading( + title: 'Danger zone', + description: + 'Destructive workspace actions cannot be undone.', + ), + Align( + alignment: .centerLeft, + child: FortalScope( + accent: .red, + hasBackground: false, + child: FortalButton.outline( + onPressed: _confirmDelete, + label: 'Delete workspace', + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Future _confirmDelete() async { + final confirmed = await showRemixDialog( + context: context, + builder: (context) => FortalDialog( + title: 'Delete workspace?', + description: + 'This demo keeps your data safe, but a real action would be permanent.', + actions: [ + FortalButton.soft( + onPressed: () => Navigator.of(context).pop(false), + label: 'Cancel', + ), + FortalScope( + accent: .red, + hasBackground: false, + child: FortalButton( + onPressed: () => Navigator.of(context).pop(true), + label: 'Delete', + ), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + showToast( + context, + message: 'Demo workspace was not deleted', + icon: Icons.info_outline, + ); + } +} + +class _CardHeading extends StatelessWidget { + const _CardHeading({required this.title, required this.description}); + final String title; + final String description; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: .start, + spacing: 3, + children: [ + StyledText( + title, + style: TextStyler( + style: FortalTokens.text4.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + StyledText( + description, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ); +} + +class _PreferenceRow extends StatelessWidget { + const _PreferenceRow({ + required this.title, + required this.description, + required this.trailing, + }); + + final String title; + final String description; + final Widget trailing; + + @override + Widget build(BuildContext context) => Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + StyledText( + title, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + StyledText( + description, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + ), + trailing, + ], + ); +} diff --git a/packages/dashboard/lib/shell/dashboard_page.dart b/packages/dashboard/lib/shell/dashboard_page.dart new file mode 100644 index 00000000..e0a95eb9 --- /dev/null +++ b/packages/dashboard/lib/shell/dashboard_page.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +const dashboardCompactBreakpoint = 720.0; + +enum DashboardSection { + overview('Workspace'), + data('Data'), + settings('Manage'), + components('Components'); + + const DashboardSection(this.label); + final String label; +} + +enum DashboardPage { + overview( + DashboardSection.overview, + 'Overview', + Icons.space_dashboard_outlined, + ), + customers(DashboardSection.data, 'Customers', Icons.people_outline), + orders(DashboardSection.data, 'Orders', Icons.receipt_long_outlined), + settings(DashboardSection.settings, 'Settings', Icons.settings_outlined), + galleryActions( + DashboardSection.components, + 'Actions', + Icons.smart_button_outlined, + ), + galleryForms( + DashboardSection.components, + 'Forms & Inputs', + Icons.text_fields_outlined, + ), + galleryDisplay( + DashboardSection.components, + 'Data Display', + Icons.view_agenda_outlined, + ), + galleryOverlays( + DashboardSection.components, + 'Overlays', + Icons.layers_outlined, + ), + galleryNavigation( + DashboardSection.components, + 'Navigation', + Icons.segment_outlined, + ); + + const DashboardPage(this.section, this.label, this.icon); + + final DashboardSection section; + final String label; + final IconData icon; +} diff --git a/packages/dashboard/lib/shell/dashboard_shell.dart b/packages/dashboard/lib/shell/dashboard_shell.dart new file mode 100644 index 00000000..2ff548d8 --- /dev/null +++ b/packages/dashboard/lib/shell/dashboard_shell.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../pages/customers_page.dart'; +import '../pages/gallery/gallery_actions_page.dart'; +import '../pages/gallery/gallery_display_page.dart'; +import '../pages/gallery/gallery_forms_page.dart'; +import '../pages/gallery/gallery_navigation_page.dart'; +import '../pages/gallery/gallery_overlays_page.dart'; +import '../pages/orders_page.dart'; +import '../pages/overview_page.dart'; +import '../pages/settings_page.dart'; +import 'dashboard_page.dart'; +import 'sidebar.dart'; +import 'top_bar.dart'; + +class DashboardShell extends StatefulWidget { + const DashboardShell({super.key}); + + @override + State createState() => _DashboardShellState(); +} + +class _DashboardShellState extends State { + final _scaffoldKey = GlobalKey(); + DashboardPage _selected = .overview; + String _searchQuery = ''; + + void _select(DashboardPage page) { + setState(() => _selected = page); + _scaffoldKey.currentState?.closeDrawer(); + } + + @override + Widget build(BuildContext context) { + final compact = + MediaQuery.sizeOf(context).width < dashboardCompactBreakpoint; + final pages = [ + OverviewPage(onViewOrders: () => _select(.orders)), + CustomersPage(globalQuery: _searchQuery), + OrdersPage(globalQuery: _searchQuery), + const SettingsPage(), + const GalleryActionsPage(), + const GalleryFormsPage(), + const GalleryDisplayPage(), + const GalleryOverlaysPage(), + const GalleryNavigationPage(), + ]; + + return Scaffold( + key: _scaffoldKey, + backgroundColor: Colors.transparent, + drawer: compact + ? Drawer( + width: 256, + semanticLabel: 'Dashboard navigation', + child: Sidebar(selected: _selected, onSelected: _select), + ) + : null, + body: Row( + children: [ + if (!compact) Sidebar(selected: _selected, onSelected: _select), + Expanded( + child: Column( + children: [ + TopBar( + page: _selected, + onMenuPressed: compact + ? () => _scaffoldKey.currentState?.openDrawer() + : null, + onSearchChanged: (value) => + setState(() => _searchQuery = value.trim().toLowerCase()), + ), + Expanded( + child: IndexedStack(index: _selected.index, children: pages), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/dashboard/lib/shell/sidebar.dart b/packages/dashboard/lib/shell/sidebar.dart new file mode 100644 index 00000000..85d20d70 --- /dev/null +++ b/packages/dashboard/lib/shell/sidebar.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:remix/remix.dart'; + +import '../widgets/action_popover.dart'; +import '../widgets/toast.dart'; +import 'dashboard_page.dart'; + +class Sidebar extends StatelessWidget { + const Sidebar({super.key, required this.selected, required this.onSelected}); + + final DashboardPage selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final panel = MixScope.tokenOf(FortalTokens.colorPanelSolid, context); + final border = MixScope.tokenOf(FortalTokens.grayA5, context); + + return Container( + width: 256, + decoration: BoxDecoration( + color: panel, + border: Border(right: BorderSide(color: border)), + ), + child: SafeArea( + child: Column( + crossAxisAlignment: .stretch, + children: [ + const _Brand(), + Container(height: 1, color: border), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 20), + child: Column( + crossAxisAlignment: .stretch, + children: [ + for (final section in DashboardSection.values) ...[ + _SectionLabel(section.label), + for (final page in DashboardPage.values.where( + (page) => page.section == section, + )) + _NavItem( + key: ValueKey('nav-${page.name}'), + page: page, + selected: page == selected, + onPressed: () => onSelected(page), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + ), + Container(height: 1, color: border), + const _Profile(), + ], + ), + ), + ); + } +} + +class _Brand extends StatelessWidget { + const _Brand(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + spacing: 12, + children: [ + Container( + width: 34, + height: 34, + alignment: .center, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.accent9, context), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: const Icon( + Icons.auto_awesome, + color: Colors.white, + size: 18, + ), + ), + StyledText( + 'Remix', + style: TextStyler( + style: FortalTokens.text5.mix(), + ).fontWeight(.w700).color(FortalTokens.gray12()), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.label); + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 6), + child: StyledText( + label.toUpperCase(), + style: TextStyler( + style: FortalTokens.text1.mix(), + ).fontWeight(.w600).letterSpacing(0.7).color(FortalTokens.gray11()), + ), + ); + } +} + +class _NavItem extends StatefulWidget { + const _NavItem({ + super.key, + required this.page, + required this.selected, + required this.onPressed, + }); + + final DashboardPage page; + final bool selected; + final VoidCallback onPressed; + + @override + State<_NavItem> createState() => _NavItemState(); +} + +class _NavItemState extends State<_NavItem> { + bool _focused = false; + bool _hovered = false; + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final accent = widget.selected + ? MixScope.tokenOf( + _pressed ? FortalTokens.accentA5 : FortalTokens.accentA3, + context, + ) + : MixScope.tokenOf( + _pressed + ? FortalTokens.grayA4 + : _hovered + ? FortalTokens.grayA3 + : FortalTokens.grayA1, + context, + ); + final foreground = MixScope.tokenOf( + widget.selected ? FortalTokens.accent12 : FortalTokens.gray12, + context, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Semantics( + button: true, + selected: widget.selected, + label: widget.page.label, + excludeSemantics: true, + onTap: widget.onPressed, + child: Focus( + onFocusChange: (value) => setState(() => _focused = value), + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space)) { + widget.onPressed(); + return .handled; + } + return .ignored; + }, + child: Builder( + builder: (focusContext) => MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapCancel: () => setState(() => _pressed = false), + onTapUp: (_) => setState(() => _pressed = false), + onTap: () { + Focus.of(focusContext).requestFocus(); + widget.onPressed(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: double.infinity, + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.all(Radius.circular(6)), + border: _focused + ? Border.all( + color: MixScope.tokenOf( + FortalTokens.focus8, + context, + ), + width: 2, + ) + : null, + ), + child: Row( + spacing: 8, + children: [ + Icon(widget.page.icon, size: 16, color: foreground), + Expanded( + child: StyledText( + widget.page.label, + style: TextStyler(style: FortalTokens.text2.mix()) + .fontWeight(.w500) + .color(foreground) + .maxLines(1) + .overflow(TextOverflow.ellipsis), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _Profile extends StatelessWidget { + const _Profile(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(14), + child: DashboardActionPopover( + key: const ValueKey('sidebar-account-trigger'), + semanticLabel: 'Workspace account menu', + trigger: Row( + spacing: 10, + children: [ + const FortalAvatar(label: 'LF', size: .size2), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + StyledText( + 'Leo Farias', + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + StyledText( + 'leo@remix.dev', + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + ), + Icon( + Icons.more_horiz, + size: 18, + color: MixScope.tokenOf(FortalTokens.gray11, context), + ), + ], + ), + actions: const [ + DashboardAction(value: 'profile', label: 'View profile'), + DashboardAction(value: 'preferences', label: 'Preferences'), + DashboardAction( + value: 'signout', + label: 'Sign out', + dividerBefore: true, + ), + ], + onSelected: (value) => showToast( + context, + message: value == 'signout' + ? 'Signed out of demo' + : '${value[0].toUpperCase()}${value.substring(1)} opened', + ), + ), + ); + } +} diff --git a/packages/dashboard/lib/shell/top_bar.dart b/packages/dashboard/lib/shell/top_bar.dart new file mode 100644 index 00000000..8b3c4242 --- /dev/null +++ b/packages/dashboard/lib/shell/top_bar.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../data/activity.dart'; +import '../theme/theme_scope.dart'; +import '../widgets/action_popover.dart'; +import '../widgets/theme_panel.dart'; +import '../widgets/toast.dart'; +import 'dashboard_page.dart'; + +class TopBar extends StatefulWidget { + const TopBar({ + super.key, + required this.page, + required this.onSearchChanged, + this.onMenuPressed, + }); + + final DashboardPage page; + final ValueChanged onSearchChanged; + final VoidCallback? onMenuPressed; + + @override + State createState() => _TopBarState(); +} + +class _TopBarState extends State { + final _notificationsController = MenuController(); + final _themeController = MenuController(); + + @override + Widget build(BuildContext context) { + final border = MixScope.tokenOf(FortalTokens.grayA6, context); + final compact = + MediaQuery.sizeOf(context).width < dashboardCompactBreakpoint; + return Container( + height: 64, + padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 24), + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.colorPanelSolid, context), + border: Border(bottom: BorderSide(color: border)), + ), + child: Row( + spacing: 12, + children: [ + if (widget.onMenuPressed case final onMenuPressed?) + FortalIconButton.ghost( + key: const ValueKey('dashboard-menu'), + semanticLabel: 'Open navigation', + onPressed: onMenuPressed, + icon: Icons.menu, + ), + if (MediaQuery.sizeOf(context).width > 900) ...[ + StyledText( + widget.page.section.label, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()), + ), + Icon( + Icons.chevron_right, + size: 14, + color: MixScope.tokenOf(FortalTokens.gray8, context), + ), + ], + Flexible( + child: StyledText( + widget.page.label, + style: TextStyler(style: FortalTokens.text4.mix()) + .fontWeight(.w700) + .color(FortalTokens.gray12()) + .maxLines(1) + .overflow(.ellipsis), + ), + ), + const Spacer(), + if (MediaQuery.sizeOf(context).width > 1000) + SizedBox( + width: 260, + child: FortalTextField( + key: const ValueKey('global-search'), + leading: const Icon(Icons.search, size: 18), + hintText: 'Search…', + onChanged: widget.onSearchChanged, + ), + ), + FortalIconButton.ghost( + key: const ValueKey('theme-quick-toggle'), + semanticLabel: 'Toggle dark mode', + onPressed: () { + final theme = ThemeScope.of(context); + final isDark = FortalTheme.of(context).isDark; + theme.onChanged( + theme.settings.copyWith(appearance: isDark ? .light : .dark), + ); + }, + icon: FortalTheme.of(context).isDark + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined, + ), + FortalPopover( + controller: _notificationsController, + openOnTap: false, + semanticLabel: 'Notifications', + positioning: const OverlayPositionConfig( + side: .bottom, + alignment: .end, + sideOffset: 8, + ), + popoverChild: SizedBox( + width: 330, + child: Column( + crossAxisAlignment: .stretch, + spacing: 10, + children: [ + Row( + children: [ + Expanded( + child: StyledText( + 'Notifications', + style: TextStyler( + style: FortalTokens.text3.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + ), + FortalButton.ghost( + size: .size1, + onPressed: () { + _notificationsController.close(); + showToast( + context, + message: 'All notifications marked read', + ); + }, + label: 'Mark all read', + ), + ], + ), + for (final event in activityEvents.take(4)) + Row( + crossAxisAlignment: .start, + spacing: 9, + children: [ + Container( + width: 7, + height: 7, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: MixScope.tokenOf( + FortalTokens.accent9, + context, + ), + shape: .circle, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + StyledText( + event.title, + style: + TextStyler(style: FortalTokens.text2.mix()) + .fontWeight(.w600) + .color(FortalTokens.gray12()), + ), + StyledText( + event.relativeTime, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + ), + ], + ), + ], + ), + ), + child: FortalIconButton.ghost( + semanticLabel: 'Notifications', + onPressed: _toggleNotifications, + icon: Icons.notifications_none, + iconBuilder: (context, spec, icon) => Stack( + clipBehavior: .none, + children: [ + StyledIcon( + icon: icon, + styleSpec: StyleSpec(spec: spec), + ), + Positioned( + right: -1, + top: -1, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.accent9, context), + shape: .circle, + ), + ), + ), + ], + ), + ), + ), + FortalPopover( + controller: _themeController, + openOnTap: false, + semanticLabel: 'Theme settings', + positioning: const OverlayPositionConfig( + side: .bottom, + alignment: .end, + sideOffset: 8, + ), + popoverChild: SizedBox( + width: 400, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 650), + child: const SingleChildScrollView(child: ThemePanel()), + ), + ), + child: FortalIconButton.ghost( + key: const ValueKey('theme-panel-trigger'), + semanticLabel: 'Theme settings', + onPressed: _toggleTheme, + icon: Icons.palette_outlined, + ), + ), + DashboardActionPopover( + key: const ValueKey('topbar-account-trigger'), + semanticLabel: 'Account menu', + positioning: const OverlayPositionConfig( + side: .bottom, + alignment: .end, + sideOffset: 8, + ), + trigger: const FortalAvatar(label: 'LF', size: .size2), + actions: const [ + DashboardAction(value: 'profile', label: 'Profile'), + DashboardAction(value: 'preferences', label: 'Preferences'), + DashboardAction( + value: 'signout', + label: 'Sign out', + dividerBefore: true, + ), + ], + onSelected: (value) => showToast( + context, + message: value == 'signout' + ? 'Signed out of demo' + : 'Opened $value', + ), + ), + ], + ), + ); + } + + void _toggleNotifications() { + _notificationsController.isOpen + ? _notificationsController.close() + : _notificationsController.open(); + } + + void _toggleTheme() { + _themeController.isOpen + ? _themeController.close() + : _themeController.open(); + } +} diff --git a/packages/dashboard/lib/theme/scroll_behavior.dart b/packages/dashboard/lib/theme/scroll_behavior.dart new file mode 100644 index 00000000..9686cde3 --- /dev/null +++ b/packages/dashboard/lib/theme/scroll_behavior.dart @@ -0,0 +1,12 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class AppScrollBehavior extends MaterialScrollBehavior { + const AppScrollBehavior(); + + @override + Set get dragDevices => { + ...super.dragDevices, + PointerDeviceKind.mouse, + }; +} diff --git a/packages/dashboard/lib/theme/theme_scope.dart b/packages/dashboard/lib/theme/theme_scope.dart new file mode 100644 index 00000000..000286ff --- /dev/null +++ b/packages/dashboard/lib/theme/theme_scope.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +import 'theme_settings.dart'; + +class ThemeScope extends InheritedWidget { + const ThemeScope({ + super.key, + required this.settings, + required this.onChanged, + required super.child, + }); + + final ThemeSettings settings; + final ValueChanged onChanged; + + static ThemeScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'ThemeScope is missing above this context.'); + return scope!; + } + + @override + bool updateShouldNotify(ThemeScope oldWidget) => + settings != oldWidget.settings || onChanged != oldWidget.onChanged; +} diff --git a/packages/dashboard/lib/theme/theme_settings.dart b/packages/dashboard/lib/theme/theme_settings.dart new file mode 100644 index 00000000..7c109590 --- /dev/null +++ b/packages/dashboard/lib/theme/theme_settings.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +@immutable +class ThemeSettings { + const ThemeSettings({ + this.appearance = .system, + this.accentColor = .indigo, + this.grayColor = .slate, + this.radius = .medium, + this.scaling = .percent100, + }); + + final ThemeMode appearance; + final FortalAccentColor accentColor; + final FortalGrayColor grayColor; + final FortalRadius radius; + final FortalScaling scaling; + + ThemeMode get themeMode => appearance; + + // ThemeMode.system is app state rather than a FortalThemeConfig value, so the + // dashboard keeps a concrete settings object that can be copied atomically. + ThemeSettings copyWith({ + ThemeMode? appearance, + FortalAccentColor? accentColor, + FortalGrayColor? grayColor, + FortalRadius? radius, + FortalScaling? scaling, + }) { + return ThemeSettings( + appearance: appearance ?? this.appearance, + accentColor: accentColor ?? this.accentColor, + grayColor: grayColor ?? this.grayColor, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + ); + } +} diff --git a/packages/dashboard/lib/widgets/action_popover.dart b/packages/dashboard/lib/widgets/action_popover.dart new file mode 100644 index 00000000..083f7b16 --- /dev/null +++ b/packages/dashboard/lib/widgets/action_popover.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +@immutable +class DashboardAction { + const DashboardAction({ + required this.value, + required this.label, + this.dividerBefore = false, + }); + + final String value; + final String label; + final bool dividerBefore; +} + +/// An example-local action surface for product controls whose trigger is +/// richer than the data-driven trigger supported by [FortalMenu]. +/// +/// The component gallery continues to use [FortalMenu] directly. This +/// composition keeps dashboard-specific avatar and kebab triggers on the +/// current public Remix API without expanding the library surface. +class DashboardActionPopover extends StatefulWidget { + const DashboardActionPopover({ + super.key, + required this.semanticLabel, + required this.trigger, + required this.actions, + required this.onSelected, + this.positioning = const OverlayPositionConfig( + side: OverlaySide.bottom, + alignment: OverlayAlignment.start, + sideOffset: 4, + collisionPadding: EdgeInsets.all(10), + ), + this.width = 180, + }); + + final String semanticLabel; + final Widget trigger; + final List actions; + final ValueChanged onSelected; + final OverlayPositionConfig positioning; + final double width; + + @override + State createState() => _DashboardActionPopoverState(); +} + +class _DashboardActionPopoverState extends State { + final MenuController _controller = MenuController(); + + @override + Widget build(BuildContext context) { + final divider = MixScope.tokenOf(FortalTokens.grayA6, context); + + return FortalPopover( + size: .size1, + controller: _controller, + semanticLabel: widget.semanticLabel, + positioning: widget.positioning, + popoverChild: SizedBox( + width: widget.width, + child: Column( + crossAxisAlignment: .stretch, + spacing: 2, + children: [ + for (final action in widget.actions) ...[ + if (action.dividerBefore) + Container( + height: 1, + margin: const EdgeInsets.symmetric(vertical: 4), + color: divider, + ), + RemixButton( + key: ValueKey('dashboard-action-${action.value}'), + label: action.label, + onPressed: () { + _controller.close(); + widget.onSelected(action.value); + }, + style: fortalButtonStyle( + variant: .ghost, + size: .size1, + ).mainAxisSize(.max).mainAxisAlignment(.start), + ), + ], + ], + ), + ), + child: widget.trigger, + ); + } +} diff --git a/packages/dashboard/lib/widgets/data_grid.dart b/packages/dashboard/lib/widgets/data_grid.dart new file mode 100644 index 00000000..4cd034f5 --- /dev/null +++ b/packages/dashboard/lib/widgets/data_grid.dart @@ -0,0 +1,394 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import 'empty_state.dart'; + +enum DataGridSortDirection { ascending, descending } + +class DataGridSort { + const DataGridSort(this.columnId, this.direction); + + final String columnId; + final DataGridSortDirection direction; +} + +class DataGridColumn { + const DataGridColumn({ + required this.id, + required this.label, + required this.cellBuilder, + this.width, + this.flex = 1, + this.align = .left, + this.sortable = false, + }) : assert(width != null || flex > 0); + + final String id; + final String label; + final Widget Function(BuildContext context, T row) cellBuilder; + final double? width; + final int flex; + final TextAlign align; + final bool sortable; +} + +class DataGrid extends StatelessWidget { + const DataGrid({ + super.key, + required this.rows, + required this.columns, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.page = 0, + this.rowsPerPage = 10, + this.onPageChanged, + this.onRowsPerPageChanged, + this.minimumWidth = 840, + this.emptyBuilder, + }); + + final List rows; + final List> columns; + final DataGridSort? sort; + final ValueChanged? onSortChanged; + final String Function(T row)? rowId; + final Set selectedIds; + final ValueChanged>? onSelectionChanged; + final int? totalRows; + final int page; + final int rowsPerPage; + final ValueChanged? onPageChanged; + final ValueChanged? onRowsPerPageChanged; + final double minimumWidth; + final WidgetBuilder? emptyBuilder; + + bool get _selectable => rowId != null && onSelectionChanged != null; + + @override + Widget build(BuildContext context) { + final border = MixScope.tokenOf(FortalTokens.grayA5, context); + final radius = MixScope.tokenOf(FortalTokens.radius4, context); + return Container( + clipBehavior: .antiAlias, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.colorPanelSolid, context), + border: Border.all(color: border), + borderRadius: BorderRadius.all(radius), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final width = math.max(minimumWidth, constraints.maxWidth); + return SingleChildScrollView( + scrollDirection: .horizontal, + child: SizedBox( + width: width, + child: Column( + mainAxisSize: .min, + children: [ + _buildHeader(context), + if (rows.isEmpty) + (emptyBuilder?.call(context) ?? + const EmptyState( + icon: Icons.search_off_outlined, + title: 'No results found', + body: 'Try changing your filters or search query.', + )) + else + SizedBox( + height: rows.length * 48, + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: rows.length, + itemBuilder: (context, index) => _DataGridRow( + row: rows[index], + columns: columns, + selectable: _selectable, + selected: + _selectable && + selectedIds.contains(rowId!(rows[index])), + onSelected: _selectable + ? (selected) => _toggleRow(rows[index], selected) + : null, + ), + ), + ), + if (totalRows != null) _buildFooter(context), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final rowIds = rowId == null ? const [] : rows.map(rowId!).toList(); + final selectedOnPage = rowIds.where(selectedIds.contains).length; + final selectedValue = selectedOnPage == 0 + ? false + : selectedOnPage == rowIds.length + ? true + : null; + + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.grayA2, context), + border: Border( + bottom: BorderSide( + color: MixScope.tokenOf(FortalTokens.grayA6, context), + ), + ), + ), + child: Row( + children: [ + if (_selectable) + SizedBox( + width: 42, + child: Center( + child: FortalCheckbox( + key: const ValueKey('grid-select-all'), + size: .size1, + selected: selectedValue, + tristate: true, + semanticLabel: 'Select all rows', + onChanged: (_) => _toggleAll( + select: selectedOnPage != rowIds.length, + rowIds: rowIds, + ), + ), + ), + ), + for (final column in columns) + _ColumnSlot( + width: column.width, + flex: column.flex, + align: column.align, + child: column.sortable + ? FortalButton.ghost( + key: ValueKey('sort-${column.id}'), + size: .size1, + onPressed: () => _sortBy(column.id), + label: column.label, + trailingIcon: sort?.columnId == column.id + ? sort!.direction == .ascending + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down + : Icons.unfold_more, + ) + : StyledText( + column.label, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).fontWeight(.w600).color(FortalTokens.gray11()), + ), + ), + ], + ), + ); + } + + Widget _buildFooter(BuildContext context) { + final total = totalRows!; + final start = total == 0 ? 0 : page * rowsPerPage + 1; + final end = math.min((page + 1) * rowsPerPage, total); + final maxPage = total == 0 ? 0 : (total - 1) ~/ rowsPerPage; + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: MixScope.tokenOf(FortalTokens.grayA6, context), + ), + ), + ), + child: Row( + children: [ + StyledText( + 'Rows per page', + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + const SizedBox(width: 8), + FortalSelect( + size: .size1, + variant: .ghost, + trigger: const RemixSelectTrigger(placeholder: '10'), + items: const [ + RemixSelectItem(value: 5, label: '5'), + RemixSelectItem(value: 10, label: '10'), + RemixSelectItem(value: 20, label: '20'), + ], + selectedValue: rowsPerPage, + onChanged: (value) { + if (value != null) onRowsPerPageChanged?.call(value); + }, + ), + const Spacer(), + StyledText( + '$start–$end of $total', + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + const SizedBox(width: 10), + FortalIconButton.ghost( + key: const ValueKey('grid-previous'), + size: .size1, + semanticLabel: 'Previous page', + enabled: page > 0, + onPressed: () => onPageChanged?.call(page - 1), + icon: Icons.chevron_left, + ), + FortalIconButton.ghost( + key: const ValueKey('grid-next'), + size: .size1, + semanticLabel: 'Next page', + enabled: page < maxPage, + onPressed: () => onPageChanged?.call(page + 1), + icon: Icons.chevron_right, + ), + ], + ), + ); + } + + void _sortBy(String columnId) { + final direction = + sort?.columnId == columnId && + sort?.direction == DataGridSortDirection.ascending + ? DataGridSortDirection.descending + : DataGridSortDirection.ascending; + onSortChanged?.call(DataGridSort(columnId, direction)); + } + + void _toggleRow(T row, bool selected) { + final next = {...selectedIds}; + final id = rowId!(row); + selected ? next.add(id) : next.remove(id); + onSelectionChanged?.call(next); + } + + void _toggleAll({required bool select, required List rowIds}) { + final next = {...selectedIds}; + select ? next.addAll(rowIds) : next.removeAll(rowIds); + onSelectionChanged?.call(next); + } +} + +class _DataGridRow extends StatefulWidget { + const _DataGridRow({ + required this.row, + required this.columns, + required this.selectable, + required this.selected, + required this.onSelected, + }); + + final T row; + final List> columns; + final bool selectable; + final bool selected; + final ValueChanged? onSelected; + + @override + State<_DataGridRow> createState() => _DataGridRowState(); +} + +class _DataGridRowState extends State<_DataGridRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final background = widget.selected + ? MixScope.tokenOf( + _hovered ? FortalTokens.accentA4 : FortalTokens.accentA3, + context, + ) + : _hovered + ? MixScope.tokenOf(FortalTokens.grayA3, context) + : Colors.transparent; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: background, + border: Border( + bottom: BorderSide( + color: MixScope.tokenOf(FortalTokens.grayA5, context), + ), + ), + ), + child: Row( + children: [ + if (widget.selectable) + SizedBox( + width: 42, + child: Center( + child: FortalCheckbox( + key: const ValueKey('grid-row-checkbox'), + size: .size1, + selected: widget.selected, + semanticLabel: 'Select row', + onChanged: (value) => + widget.onSelected?.call(value ?? false), + ), + ), + ), + for (final column in widget.columns) + _ColumnSlot( + width: column.width, + flex: column.flex, + align: column.align, + child: column.cellBuilder(context, widget.row), + ), + ], + ), + ), + ); + } +} + +class _ColumnSlot extends StatelessWidget { + const _ColumnSlot({ + required this.width, + required this.flex, + required this.align, + required this.child, + }); + + final double? width; + final int flex; + final TextAlign align; + final Widget child; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: align == .right + ? Alignment.centerRight + : Alignment.centerLeft, + child: child, + ), + ); + return width == null + ? Expanded(flex: flex, child: content) + : SizedBox(width: width, child: content); + } +} diff --git a/packages/dashboard/lib/widgets/empty_state.dart b/packages/dashboard/lib/widgets/empty_state.dart new file mode 100644 index 00000000..f80f3b7b --- /dev/null +++ b/packages/dashboard/lib/widgets/empty_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.body, + this.action, + }); + + final IconData icon; + final String title; + final String body; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 44, horizontal: 24), + child: Column( + mainAxisSize: .min, + spacing: 10, + children: [ + Container( + width: 46, + height: 46, + alignment: .center, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.gray4, context), + shape: .circle, + ), + child: Icon( + icon, + color: MixScope.tokenOf(FortalTokens.gray9, context), + ), + ), + StyledText( + title, + style: TextStyler(style: FortalTokens.text3.mix()) + .fontWeight(.w600) + .color(FortalTokens.gray12()) + .textAlign(.center), + ), + StyledText( + body, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()).textAlign(.center), + ), + if (action case final action?) ...[ + const SizedBox(height: 4), + action, + ], + ], + ), + ), + ), + ); + } +} diff --git a/packages/dashboard/lib/widgets/gallery_scaffold.dart b/packages/dashboard/lib/widgets/gallery_scaffold.dart new file mode 100644 index 00000000..171fd155 --- /dev/null +++ b/packages/dashboard/lib/widgets/gallery_scaffold.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import 'page_header.dart'; + +class GalleryPage extends StatelessWidget { + const GalleryPage({ + super.key, + required this.title, + required this.intro, + required this.sections, + }); + + final String title; + final String intro; + final List sections; + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: .stretch, + spacing: 20, + children: [ + PageHeader(title: title, description: intro), + ...sections, + ], + ), + ); +} + +class GallerySection extends StatelessWidget { + const GallerySection({ + super.key, + required this.label, + required this.description, + required this.child, + }); + + final String label; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) => FortalCard( + size: .size2, + child: Column( + crossAxisAlignment: .stretch, + spacing: 14, + children: [ + SectionLabel(label), + StyledText( + description, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()), + ), + child, + ], + ), + ); +} + +class GalleryMatrix extends StatelessWidget { + const GalleryMatrix({ + super.key, + required this.rows, + required this.columns, + required this.cellBuilder, + this.cellWidth = 200, + }); + + final List rows; + final List columns; + final Widget Function(BuildContext context, int row, int column) cellBuilder; + final double cellWidth; + + @override + Widget build(BuildContext context) { + final border = MixScope.tokenOf(FortalTokens.grayA5, context); + return SingleChildScrollView( + scrollDirection: .horizontal, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: border), + borderRadius: BorderRadius.all( + MixScope.tokenOf(FortalTokens.radius3, context), + ), + ), + child: Column( + crossAxisAlignment: .start, + children: [ + _MatrixRow( + border: border, + label: '', + cells: [for (final column in columns) _MatrixLabel(column)], + cellWidth: cellWidth, + ), + for (final (rowIndex, row) in rows.indexed) + _MatrixRow( + border: border, + label: row, + cells: [ + for (var column = 0; column < columns.length; column++) + cellBuilder(context, rowIndex, column), + ], + cellWidth: cellWidth, + ), + ], + ), + ), + ); + } +} + +class _MatrixRow extends StatelessWidget { + const _MatrixRow({ + required this.border, + required this.label, + required this.cells, + required this.cellWidth, + }); + + final Color border; + final String label; + final List cells; + final double cellWidth; + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: border)), + ), + child: Row( + children: [ + SizedBox(width: 112, child: _MatrixLabel(label)), + for (final cell in cells) + Container( + width: cellWidth, + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.all(10), + alignment: .center, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: border)), + ), + child: cell, + ), + ], + ), + ); +} + +class _MatrixLabel extends StatelessWidget { + const _MatrixLabel(this.label); + final String label; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(10), + child: StyledText( + label, + style: TextStyler( + style: FortalTokens.text1.mix(), + ).fontWeight(.w600).color(FortalTokens.gray11()), + ), + ); +} + +String galleryLabel(Enum value) { + final name = value.name.replaceAllMapped( + RegExp('([A-Z])'), + (match) => ' ${match.group(1)!.toLowerCase()}', + ); + return '${name[0].toUpperCase()}${name.substring(1)}'; +} diff --git a/packages/dashboard/lib/widgets/page_header.dart b/packages/dashboard/lib/widgets/page_header.dart new file mode 100644 index 00000000..65fc4925 --- /dev/null +++ b/packages/dashboard/lib/widgets/page_header.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class PageHeader extends StatelessWidget { + const PageHeader({ + super.key, + required this.title, + required this.description, + this.actions, + }); + + final String title; + final String description; + final Widget? actions; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: .start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + StyledText( + title, + style: TextStyler( + style: FortalTokens.text6.mix(), + ).fontWeight(.w700).color(FortalTokens.gray12()), + ), + StyledText( + description, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + ), + ?actions, + ], + ); + } +} + +class SectionLabel extends StatelessWidget { + const SectionLabel(this.label, {super.key}); + final String label; + + @override + Widget build(BuildContext context) { + return StyledText( + label, + style: TextStyler( + style: FortalTokens.text4.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ); + } +} diff --git a/packages/dashboard/lib/widgets/stat_card.dart b/packages/dashboard/lib/widgets/stat_card.dart new file mode 100644 index 00000000..0c014114 --- /dev/null +++ b/packages/dashboard/lib/widgets/stat_card.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class StatCard extends StatelessWidget { + const StatCard({ + super.key, + required this.label, + required this.value, + required this.delta, + required this.icon, + this.progress, + }); + + final String label; + final String value; + final double delta; + final IconData icon; + final double? progress; + + @override + Widget build(BuildContext context) { + final positive = delta >= 0; + return FortalCard( + size: .size2, + child: SizedBox( + width: 220, + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + Row( + children: [ + Expanded( + child: StyledText( + label, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).color(FortalTokens.gray11()), + ), + ), + Container( + width: 34, + height: 34, + alignment: .center, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.accentA3, context), + borderRadius: BorderRadius.all( + MixScope.tokenOf(FortalTokens.radius3, context), + ), + ), + child: Icon( + icon, + size: 18, + color: MixScope.tokenOf(FortalTokens.accent11, context), + ), + ), + ], + ), + StyledText( + value, + style: TextStyler( + style: FortalTokens.text7.mix(), + ).fontWeight(.w700).color(FortalTokens.gray12()), + ), + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FortalScope( + accent: positive ? .green : .red, + hasBackground: false, + child: FortalBadge( + label: + '${positive ? '↑' : '↓'} ${delta.abs().toStringAsFixed(1)}%', + ), + ), + StyledText( + 'vs last month', + style: TextStyler( + style: FortalTokens.text1.mix(), + ).color(FortalTokens.gray11()), + ), + ], + ), + if (progress case final value?) + FortalProgress( + value: value / 100, + size: .size1, + semanticsLabel: '$label progress', + ), + ], + ), + ), + ); + } +} diff --git a/packages/dashboard/lib/widgets/theme_panel.dart b/packages/dashboard/lib/widgets/theme_panel.dart new file mode 100644 index 00000000..df8c38da --- /dev/null +++ b/packages/dashboard/lib/widgets/theme_panel.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme_scope.dart'; +import '../theme/theme_settings.dart'; + +class ThemePanel extends StatelessWidget { + const ThemePanel({super.key}); + + @override + Widget build(BuildContext context) { + final scope = ThemeScope.of(context); + final settings = scope.settings; + return SizedBox( + key: const ValueKey('theme-panel'), + width: 360, + child: Column( + crossAxisAlignment: .stretch, + spacing: 18, + children: [ + _Control( + label: 'Appearance', + child: Align( + alignment: .centerLeft, + child: FortalToggleGroup( + size: .size1, + selectedValue: settings.appearance, + semanticLabel: 'Theme appearance', + items: const [ + RemixToggleGroupItem(value: .system, label: 'System'), + RemixToggleGroupItem(value: .light, label: 'Light'), + RemixToggleGroupItem(value: .dark, label: 'Dark'), + ], + onChanged: (value) { + if (value != null) { + scope.onChanged(settings.copyWith(appearance: value)); + } + }, + ), + ), + ), + _Control( + label: 'Accent color', + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final accent in FortalAccentColor.values) + _AccentSwatch( + key: ValueKey('accent-${accent.name}'), + accent: accent, + selected: accent == settings.accentColor, + onPressed: () => + scope.onChanged(settings.copyWith(accentColor: accent)), + ), + ], + ), + ), + _Control( + label: 'Gray color', + child: FortalSelect( + trigger: const RemixSelectTrigger(placeholder: 'Choose gray'), + selectedValue: settings.grayColor, + items: [ + for (final gray in FortalGrayColor.values) + RemixSelectItem(value: gray, label: _label(gray.name)), + ], + onChanged: (value) { + if (value != null) { + scope.onChanged(settings.copyWith(grayColor: value)); + } + }, + ), + ), + _Control( + label: 'Radius', + child: Row( + spacing: 10, + children: [ + Expanded( + child: FortalToggleGroup( + size: .size1, + selectedValue: settings.radius, + semanticLabel: 'Theme radius', + items: const [ + RemixToggleGroupItem(value: .none, label: 'None'), + RemixToggleGroupItem(value: .small, label: 'S'), + RemixToggleGroupItem(value: .medium, label: 'M'), + RemixToggleGroupItem(value: .large, label: 'L'), + RemixToggleGroupItem(value: .full, label: 'Full'), + ], + onChanged: (value) { + if (value != null) { + scope.onChanged(settings.copyWith(radius: value)); + } + }, + ), + ), + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: MixScope.tokenOf(FortalTokens.accent9, context), + borderRadius: BorderRadius.all( + MixScope.tokenOf(FortalTokens.radius3, context), + ), + ), + ), + ], + ), + ), + _Control( + label: 'Scaling', + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: FortalToggleGroup( + size: .size1, + selectedValue: settings.scaling, + semanticLabel: 'Theme scaling', + items: const [ + RemixToggleGroupItem(value: .percent90, label: '90%'), + RemixToggleGroupItem(value: .percent95, label: '95%'), + RemixToggleGroupItem(value: .percent100, label: '100%'), + RemixToggleGroupItem(value: .percent105, label: '105%'), + RemixToggleGroupItem(value: .percent110, label: '110%'), + ], + onChanged: (value) { + if (value != null) { + scope.onChanged(settings.copyWith(scaling: value)); + } + }, + ), + ), + ), + Align( + alignment: .centerLeft, + child: FortalButton.ghost( + key: const ValueKey('theme-reset'), + size: .size1, + onPressed: () => scope.onChanged(const ThemeSettings()), + label: 'Reset theme', + leadingIcon: Icons.restart_alt, + ), + ), + ], + ), + ); + } +} + +class _Control extends StatelessWidget { + const _Control({required this.label, required this.child}); + final String label; + final Widget child; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: .stretch, + spacing: 8, + children: [ + StyledText( + label, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w600).color(FortalTokens.gray12()), + ), + child, + ], + ); +} + +class _AccentSwatch extends StatefulWidget { + const _AccentSwatch({ + super.key, + required this.accent, + required this.selected, + required this.onPressed, + }); + + final FortalAccentColor accent; + final bool selected; + final VoidCallback onPressed; + + @override + State<_AccentSwatch> createState() => _AccentSwatchState(); +} + +class _AccentSwatchState extends State<_AccentSwatch> { + bool _focused = false; + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final ring = MixScope.tokenOf(FortalTokens.focus8, context); + return Semantics( + button: true, + selected: widget.selected, + label: '${_label(widget.accent.name)} accent', + child: Focus( + onFocusChange: (value) => setState(() => _focused = value), + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space)) { + widget.onPressed(); + return .handled; + } + return .ignored; + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onPressed, + child: FortalScope( + accent: widget.accent, + hasBackground: false, + child: Builder( + builder: (context) => AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 30, + height: 30, + alignment: .center, + decoration: BoxDecoration( + color: MixScope.tokenOf( + _hovered ? FortalTokens.accent10 : FortalTokens.accent9, + context, + ), + shape: .circle, + border: widget.selected || _focused + ? Border.all(color: ring, width: 2) + : null, + ), + child: widget.selected + ? Icon( + Icons.check, + size: 15, + color: MixScope.tokenOf( + FortalTokens.accentContrast, + context, + ), + ) + : null, + ), + ), + ), + ), + ), + ), + ); + } +} + +String _label(String name) => '${name[0].toUpperCase()}${name.substring(1)}'; diff --git a/packages/dashboard/lib/widgets/toast.dart b/packages/dashboard/lib/widgets/toast.dart new file mode 100644 index 00000000..78e19b44 --- /dev/null +++ b/packages/dashboard/lib/widgets/toast.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +void showToast( + BuildContext context, { + required String message, + IconData icon = Icons.check_circle_outline, + String? actionLabel, + VoidCallback? onAction, +}) { + final overlay = Overlay.of(context); + final mixScope = MixScope.maybeOf(context); + final fortalTheme = FortalTheme.maybeOf(context); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) { + Widget child = _ToastBody( + message: message, + icon: icon, + actionLabel: actionLabel, + onAction: () { + onAction?.call(); + if (entry.mounted) entry.remove(); + }, + ); + if (mixScope != null) { + child = MixScope( + tokens: mixScope.tokens, + orderOfModifiers: mixScope.orderOfModifiers, + child: child, + ); + } + if (fortalTheme != null) { + child = FortalTheme(data: fortalTheme, child: child); + } + return Positioned(right: 24, bottom: 24, child: child); + }, + ); + overlay.insert(entry); + Timer(const Duration(seconds: 4), () { + if (entry.mounted) entry.remove(); + }); +} + +class _ToastBody extends StatelessWidget { + const _ToastBody({ + required this.message, + required this.icon, + required this.actionLabel, + required this.onAction, + }); + + final String message; + final IconData icon; + final String? actionLabel; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: FortalCard.classic( + size: .size2, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Row( + mainAxisSize: .min, + spacing: 10, + children: [ + Icon( + icon, + size: 19, + color: MixScope.tokenOf(FortalTokens.accent11, context), + ), + Flexible( + child: StyledText( + message, + style: TextStyler( + style: FortalTokens.text2.mix(), + ).fontWeight(.w500).color(FortalTokens.gray12()), + ), + ), + if (actionLabel case final label?) + FortalButton.ghost( + size: .size1, + onPressed: onAction, + label: label, + ), + ], + ), + ), + ), + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 16 * (1 - value)), + child: child, + ), + ), + ); + } +} diff --git a/packages/dashboard/macos/.gitignore b/packages/dashboard/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/packages/dashboard/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/dashboard/macos/Flutter/Flutter-Debug.xcconfig b/packages/dashboard/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/packages/dashboard/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/dashboard/macos/Flutter/Flutter-Release.xcconfig b/packages/dashboard/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/packages/dashboard/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/dashboard/macos/Runner.xcodeproj/project.pbxproj b/packages/dashboard/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..e8b0e464 --- /dev/null +++ b/packages/dashboard/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* dashboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dashboard.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* dashboard.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* dashboard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bitwild.remix.dashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dashboard"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bitwild.remix.dashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dashboard"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bitwild.remix.dashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/dashboard"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/dashboard/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/dashboard/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/dashboard/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/dashboard/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/dashboard/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..ee55b4d5 --- /dev/null +++ b/packages/dashboard/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/dashboard/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/dashboard/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/dashboard/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/dashboard/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/dashboard/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/dashboard/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/dashboard/macos/Runner/AppDelegate.swift b/packages/dashboard/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/packages/dashboard/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/packages/dashboard/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/dashboard/macos/Runner/Base.lproj/MainMenu.xib b/packages/dashboard/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/packages/dashboard/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/dashboard/macos/Runner/Configs/AppInfo.xcconfig b/packages/dashboard/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..d68178e9 --- /dev/null +++ b/packages/dashboard/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Remix Dashboard + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.bitwild.remix.dashboard + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 Bitwild. All rights reserved. diff --git a/packages/dashboard/macos/Runner/Configs/Debug.xcconfig b/packages/dashboard/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/packages/dashboard/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/dashboard/macos/Runner/Configs/Release.xcconfig b/packages/dashboard/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/packages/dashboard/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/dashboard/macos/Runner/Configs/Warnings.xcconfig b/packages/dashboard/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/packages/dashboard/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/dashboard/macos/Runner/DebugProfile.entitlements b/packages/dashboard/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/packages/dashboard/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/dashboard/macos/Runner/Info.plist b/packages/dashboard/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/packages/dashboard/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/dashboard/macos/Runner/MainFlutterWindow.swift b/packages/dashboard/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/packages/dashboard/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/dashboard/macos/Runner/Release.entitlements b/packages/dashboard/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/packages/dashboard/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/dashboard/macos/RunnerTests/RunnerTests.swift b/packages/dashboard/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/packages/dashboard/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/dashboard/pubspec.yaml b/packages/dashboard/pubspec.yaml new file mode 100644 index 00000000..867ee73b --- /dev/null +++ b/packages/dashboard/pubspec.yaml @@ -0,0 +1,21 @@ +name: dashboard +description: Workspace-only Fortal dashboard example showcasing Remix theming and components. +publish_to: "none" +version: 0.1.0+1 +resolution: workspace + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + remix: + path: ../remix + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/dashboard/screenshots/overview-compact.png b/packages/dashboard/screenshots/overview-compact.png new file mode 100644 index 00000000..31ed78f7 Binary files /dev/null and b/packages/dashboard/screenshots/overview-compact.png differ diff --git a/packages/dashboard/screenshots/overview-desktop.png b/packages/dashboard/screenshots/overview-desktop.png new file mode 100644 index 00000000..2d0d2133 Binary files /dev/null and b/packages/dashboard/screenshots/overview-desktop.png differ diff --git a/packages/dashboard/test/app_smoke_test.dart b/packages/dashboard/test/app_smoke_test.dart new file mode 100644 index 00000000..63a7ba3a --- /dev/null +++ b/packages/dashboard/test/app_smoke_test.dart @@ -0,0 +1,343 @@ +import 'dart:ui' as ui; + +import 'package:dashboard/main.dart'; +import 'package:dashboard/shell/dashboard_shell.dart'; +import 'package:dashboard/theme/theme_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + testWidgets('renders the dashboard inside one app and one shell', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + expect(find.byType(MaterialApp), findsOneWidget); + expect(find.byType(Scaffold), findsOneWidget); + expect( + find.byKey(const ValueKey('dashboard-fortal-scope')), + findsOneWidget, + ); + expect(find.text('Overview'), findsWidgets); + }); + + testWidgets('account surfaces preserve their avatar and profile triggers', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + expect( + find.byKey(const ValueKey('sidebar-account-trigger')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('topbar-account-trigger')), + findsOneWidget, + ); + expect(find.text('Account actions'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('sidebar-account-trigger'))); + await tester.pump(); + + expect(find.text('View profile'), findsOneWidget); + expect(find.text('Preferences'), findsOneWidget); + expect(find.text('Sign out'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('customer rows retain compact kebab actions', (tester) async { + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byKey(const ValueKey('nav-customers')).first); + await tester.pump(); + + final trigger = find.byKey(const ValueKey('customer-actions-cus_024')); + expect(trigger, findsOneWidget); + expect(tester.getSize(trigger).width, lessThanOrEqualTo(46)); + + await tester.ensureVisible(trigger); + await tester.tap(trigger); + await tester.pump(); + + expect(find.text('View profile'), findsOneWidget); + expect(find.text('Send email'), findsOneWidget); + expect(find.text('Archive'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact layout uses a drawer without rendering overflows', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const DashboardApp()); + + expect(find.byKey(const ValueKey('dashboard-menu')), findsWidgets); + expect(tester.takeException(), isNull); + + await tester.tap(find.byIcon(Icons.menu)); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect( + tester.state(find.byType(Scaffold)).isDrawerOpen, + isTrue, + ); + + await tester.tap(find.byKey(const ValueKey('nav-galleryForms')).first); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + expect( + find.text( + 'Production-ready fields and selection controls in every preset.', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact settings render without horizontal overflow', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byIcon(Icons.menu)); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + await tester.tap(find.byKey(const ValueKey('nav-settings')).first); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + expect(find.byKey(const ValueKey('theme-panel')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('sidebar destinations expose one accessible name', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(const DashboardApp()); + + final overviewSemantics = find + .descendant( + of: find.byKey(const ValueKey('nav-overview')).first, + matching: find.byType(Semantics), + ) + .first; + final overviewNode = tester.getSemantics(overviewSemantics); + expect(overviewNode.label, 'Overview'); + expect( + overviewNode.getSemanticsData().hasAction(ui.SemanticsAction.tap), + isTrue, + ); + semantics.dispose(); + }); + + testWidgets('customers page renders an interactive paginated grid', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + await tester.tap(find.byKey(const ValueKey('nav-customers')).first); + await tester.pump(); + + expect(find.byKey(const ValueKey('data-grid-customers')), findsOneWidget); + expect(find.text('1–10 of 24'), findsOneWidget); + }); + + testWidgets('overview presents metrics, activity, and recent orders', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + expect(find.text('\$84,420'), findsOneWidget); + expect(find.text('Recent activity'), findsOneWidget); + expect(find.text('Recent orders'), findsOneWidget); + expect(find.text('View all'), findsOneWidget); + }); + + testWidgets('settings exposes the complete live theme controls', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + await tester.tap(find.byKey(const ValueKey('nav-settings')).first); + await tester.pump(); + + expect(find.byKey(const ValueKey('theme-panel')), findsOneWidget); + expect(find.text('Accent color'), findsOneWidget); + expect(find.text('Gray color'), findsOneWidget); + expect(find.text('Radius'), findsOneWidget); + expect(find.text('Scaling'), findsOneWidget); + }); + + testWidgets('actions gallery exposes button, icon button, and toggle', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + await tester.tap(find.byKey(const ValueKey('nav-galleryActions')).first); + await tester.pump(); + + expect(find.text('Button'), findsWidgets); + expect(find.text('Icon button'), findsOneWidget); + expect(find.text('Toggle'), findsOneWidget); + }); + + testWidgets('every sidebar destination renders without replacing the shell', ( + tester, + ) async { + await tester.pumpWidget(const DashboardApp()); + + const destinations = { + 'overview': 'A snapshot of your workspace performance.', + 'customers': 'Manage customer access, plans, and account status.', + 'orders': 'Review transactions and fulfillment status.', + 'settings': 'Manage your profile, preferences, and workspace.', + 'galleryActions': + 'Interactive actions across every Fortal variant and size.', + 'galleryForms': + 'Production-ready fields and selection controls in every preset.', + 'galleryDisplay': + 'Rich surfaces and status components for product interfaces.', + 'galleryOverlays': + 'Real dialog, popover, tooltip, and menu triggers for every recipe.', + 'galleryNavigation': + 'Tabs and disclosure patterns for organizing dense interfaces.', + }; + + for (final entry in destinations.entries) { + final nav = find.byKey(ValueKey('nav-${entry.key}')).first; + await tester.ensureVisible(nav); + await tester.tap(nav); + await tester.pump(); + + expect(find.byType(DashboardShell), findsOneWidget); + expect(find.text(entry.value), findsOneWidget); + } + }); + + testWidgets( + 'quick toggle keeps Material and Fortal brightness synchronized', + (tester) async { + await tester.pumpWidget(const DashboardApp()); + final shell = tester.element(find.byType(DashboardShell)); + + expect(Theme.of(shell).brightness, FortalTheme.of(shell).brightness); + final before = FortalTheme.of(shell).isDark; + + await tester.tap(find.byKey(const ValueKey('theme-quick-toggle')).first); + await tester.pump(); + + final updatedShell = tester.element(find.byType(DashboardShell)); + expect(FortalTheme.of(updatedShell).isDark, isNot(before)); + expect( + Theme.of(updatedShell).brightness, + FortalTheme.of(updatedShell).brightness, + ); + }, + ); + + testWidgets('supports an explicit dark initial theme', (tester) async { + await tester.pumpWidget( + const DashboardApp(initialSettings: ThemeSettings(appearance: .dark)), + ); + + final shell = tester.element(find.byType(DashboardShell)); + expect(FortalTheme.of(shell).isDark, isTrue); + expect(Theme.of(shell).brightness, Brightness.dark); + }); + + testWidgets('theme panel changes the resolved accent live', (tester) async { + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byKey(const ValueKey('nav-settings')).first); + await tester.pump(); + + final grass = find.byKey(const ValueKey('accent-grass')); + await tester.ensureVisible(grass); + await tester.tap(grass); + await tester.pump(); + + final shell = tester.element(find.byType(DashboardShell)); + expect(FortalTheme.of(shell).accent, FortalAccentColor.grass); + }); + + testWidgets('grid sorts, selects the page, and paginates', (tester) async { + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byKey(const ValueKey('nav-customers')).first); + await tester.pump(); + + final sortName = find.byKey(const ValueKey('sort-name')).first; + await tester.tap(sortName); + await tester.pump(); + await tester.tap(sortName); + await tester.pump(); + expect(find.text('Sofia Young'), findsOneWidget); + + final selectAll = find.byKey(const ValueKey('grid-select-all')).first; + await tester.tap(selectAll); + await tester.pump(); + expect(find.text('10 selected'), findsOneWidget); + + final next = find.byKey(const ValueKey('grid-next')).first; + await tester.ensureVisible(next); + await tester.tap(next); + await tester.pump(); + expect(find.text('11–20 of 24'), findsOneWidget); + }); + + testWidgets('grid checkboxes preserve their Fortal square size', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byKey(const ValueKey('nav-customers')).first); + await tester.pump(); + + expect( + tester.getSize(find.byKey(const ValueKey('grid-select-all')).first), + const Size.square(14), + ); + expect( + tester.getSize(find.byKey(const ValueKey('grid-row-checkbox')).first), + const Size.square(14), + ); + }); + + testWidgets('top bar search filters the active data page', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.byKey(const ValueKey('nav-customers')).first); + await tester.pump(); + + final search = find.byKey(const ValueKey('global-search')); + expect(search, findsWidgets); + await tester.enterText( + find.descendant(of: search, matching: find.byType(EditableText)), + 'northstar', + ); + await tester.pump(); + + expect(find.text('1–1 of 1'), findsOneWidget); + expect(find.text('Olivia Martin'), findsOneWidget); + }); +} diff --git a/packages/dashboard/web/favicon.png b/packages/dashboard/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/packages/dashboard/web/favicon.png differ diff --git a/packages/dashboard/web/icons/Icon-192.png b/packages/dashboard/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/packages/dashboard/web/icons/Icon-192.png differ diff --git a/packages/dashboard/web/icons/Icon-512.png b/packages/dashboard/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/packages/dashboard/web/icons/Icon-512.png differ diff --git a/packages/dashboard/web/icons/Icon-maskable-192.png b/packages/dashboard/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/packages/dashboard/web/icons/Icon-maskable-192.png differ diff --git a/packages/dashboard/web/icons/Icon-maskable-512.png b/packages/dashboard/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/packages/dashboard/web/icons/Icon-maskable-512.png differ diff --git a/packages/dashboard/web/index.html b/packages/dashboard/web/index.html new file mode 100644 index 00000000..bf3e08b0 --- /dev/null +++ b/packages/dashboard/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + Remix Dashboard + + + + + + + diff --git a/packages/dashboard/web/manifest.json b/packages/dashboard/web/manifest.json new file mode 100644 index 00000000..dbcc359e --- /dev/null +++ b/packages/dashboard/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Remix Dashboard", + "short_name": "Remix Dashboard", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Polished Fortal dashboard demo app showcasing Remix theming and components.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/pubspec.yaml b/pubspec.yaml index e2cfa499..c296b5f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dev_dependencies: yaml: ^3.1.3 workspace: + - packages/dashboard - packages/demo - packages/example - packages/playground