SKILL.md
Flutter GetX Architecture & Folder Structure Guidelines (GetX Pattern)
This guideline is based on the official getcli tool and the recommended architecture from getxpattern, adapted and supplemented for practical projects. All new pages, feature additions, and UI refactoring operations MUST strictly adhere to these guidelines.
1. Core Directory Architecture
lib/
├── app/
│ ├── modules/ # ★ Core business code organized by feature (Feature-First)
│ │ └── {module_name}/ # Specific module (e.g., home, trade, search)
│ │ ├── bindings/ # Dependency injection for the current module (Binding)
│ │ │ └── {module_name}_binding.dart
│ │ ├── controllers/ # Business logic for the current module (Controller)
│ │ │ └── {module_name}_controller.dart
│ │ ├── views/ # Main UI view for the current module (View)
│ │ │ └── {module_name}_view.dart
│ │ └── widgets/ # Private UI components specific to the current module (Widgets)
│ │ ├── {module_name}_banner_widget.dart
│ │ └── {module_name}_product_list_widget.dart
│ │
│ ├── data/ # Data layer: responsible for data fetching and persistence
│ │ ├── models/ # Globally shared data models (reusable across modules)
│ │ ├── providers/ # Data providers (Dio wrappers, API requests, local storage ops)
│ │ └── services/ # Background persistent services (exist with App lifecycle, e.g., AuthService)
│ │
│ └── routes/ # Route management (automatically maintained by get_cli)
│ ├── app_pages.dart # GetPage list: Mapping between route path ↔ View ↔ Binding
│ └── app_routes.dart # Route path constants (part of app_pages.dart)
│
├── core/ # Core layer: Global infrastructure (no business logic)
│ ├── theme/ # Themes and styles (app_theme.dart, app_colors.dart)
│ ├── values/ # Constants (strings, enums, regex, etc.)
│ └── utils/ # Utility classes (date formatting, encryption, etc.)
│
├── components/ # Global common UI components reusable across modules
│ ├── custom_button.dart
│ └── loading_dialog.dart
│
└── main.dart # Application entry point
2. Module Structure — The Four Pillars (Strict Enforcement)
When get_cli uses the get create page:name command to generate a module, it automatically creates bindings, controllers, and views subfolders. Based on this, this project additionally mandates a widgets/ directory, forming a closed loop of Four Pillars.
| No. | Directory/File | Responsibility | Key Constraints |
|---|---|---|---|
| 1 | views/{name}_view.dart |
UI Skeleton: Assembles the page, references widgets. | ❌ NO network requests, dialog logic, or non-pure UI code in the View. |
| 2 | widgets/ |
Module's Private Component Library: Large independent areas split from the page. | One file per large area, named {module}{area}widget.dart. |
| 3 | controllers/{name}_controller.dart |
Business Brain: API calls, form validation, state management. | Use .obs for reactive state, keep variables private with public getters where possible. |
| 4 | bindings/{name}_binding.dart |
Dependency Management Hub: Injects the Controller. | Use Get.lazyPut() to ensure instantiation only when used. |
2.1 When to create an independent Module?
- When it has independent business logic (e.g., needs its own Controller to manage independent state).
- When it is a main trunk page switched via bottom navigation bar / drawer (e.g., Home, Community, Profile).
- When a sub-feature's logic is complex enough that it shouldn't be stuffed into a single Widget of the parent module (e.g.,
publish_productundertrade).
2.2 Widget Splitting Rules
Core Principle: Prevent a single View file from getting too long (consider splitting if it exceeds 200 lines) to keep the view layer clean.
- Before building a page, analyze the design mockup to identify relatively independent large areas.
- Extract these areas into independent Widget files and place them in the sibling
widgets/directory. - The View layer is only responsible for composing these Widgets like building blocks.
Naming Convention: {modulename}{areaname}widget.dart
Example (Using Home Module as an example):
home/
├── bindings/home_binding.dart
├── controllers/home_controller.dart
├── views/home_view.dart ← Only the skeleton, references the widgets below
└── widgets/
├── home_header_widget.dart ← Top Logo + Search Bar
├── home_top_menu_widget.dart ← Icon menus like Leaderboard / Activity
├── home_banner_widget.dart ← Carousel Banner
├── home_announcement_widget.dart ← Announcement Bar
├── home_big_cards_widget.dart ← Large functional cards
├── home_category_tabs_widget.dart ← Business category Tab bar
└── home_product_list_widget.dart ← Content waterfall list
3. get_cli-Based Coding Standards
3.1 View Layer: Must extend GetView<T>
The View template generated by get_cli defaults to extending GetView<XxxController>. This project strictly follows this convention:
// ✅ CORRECT: Extends GetView, automatically holds the controller reference
class HomeView extends GetView<HomeController> {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Directly use controller to access business logic
Obx(() => Text(controller.userName.value)),
],
),
);
}
}
// ❌ INCORRECT: Manual Get.find, loses type safety
class HomeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = Get.find<HomeController>(); // Redundant and non-standard
...
}
}
3.2 Controller Layer: GetxController + Reactive State
class HomeController extends GetxController {
// --- Reactive State (Recommended: private variables + public getters) ---
final _isLoading = true.obs;
bool get isLoading => _isLoading.value;
final _records = <RecordModel>[].obs;
List<RecordModel> get records => _records;
// --- Lifecycle ---
@override
void onInit() {
super.onInit();
_loadData(); // Fetch data on page initialization
}
@override
void onClose() {
// Release resources: cancel subscriptions, close streams, etc.
super.onClose();
}
// --- Business Methods ---
Future<void> _loadData() async {
_isLoading.value = true;
try {
_records.value = await DataProvider.getRecords();
} finally {
_isLoading.value = false;
}
}
}
3.3 Binding Layer: Bindings Dependency Injection
class HomeBinding extends Bindings {
@override
void dependencies() {
// Lazy injection: instantiated when entering the route, automatically destroyed when leaving
Get.lazyPut<HomeController>(() => HomeController());
}
}
3.4 Routing Layer: Named Routes (Mandatory)
This project uses part files to split route definitions, which is exactly consistent with the structure generated by get_cli.
approutes.dart (as a part file of apppages.dart):
part of 'app_pages.dart';
abstract class Routes {
Routes._();
static const HOME = _Paths.HOME;
static const SEARCH = _Paths.SEARCH;
}
abstract class _Paths {
static const HOME = '/home';
static const SEARCH = '/search';
}
app_pages.dart:
import 'package:get/get.dart';
part 'app_routes.dart';
class AppPages {
AppPages._();
static const INITIAL = Routes.HOME;
static final routes = [
GetPage(
name: _Paths.HOME,
page: () => const HomeView(),
binding: HomeBinding(),
),
];
}
Route Navigation Rules (Strict Rules):
| Scenario | Correct Syntax | Forbidden Syntax |
|---|---|---|
| Navigate to a new page | Get.toNamed(Routes.SEARCH) |
Get.to(() => SearchView()) |
| Navigate and replace current page | Get.offNamed(Routes.HOME) |
Get.off(() => HomeView()) |
| Navigate and clear stack | Get.offAllNamed(Routes.HOME) |
Get.offAll(() => HomeView()) |
| Pass arguments | Get.toNamed(Routes.DETAIL, arguments: {'id': '123'}) |
|
| Retrieve arguments | final args = Get.arguments as Map<String, dynamic>; |
— |
| Return with result | Get.back(result: data) |
— |
4. Component Ownership: Global vs Module Private
Global Components lib/components/ |
Module Private Components modules/*/widgets/ |
|---|---|
| Zero business coupling, can be directly reused in another project. | Dependent on the current module's specific Model / Controller / Route logic. |
| e.g., Gradient buttons, network images, Loading dialogs, Empty state pages. | e.g., Specific business cards (depends on BusinessModel), custom category Tab bars. |
5. Data Layer (data/) Responsibility Division
| Subdirectory | Responsibility | Lifecycle |
|---|---|---|
models/ |
Dart data models corresponding to backend APIs. Shared across modules. | None (Pure data structure) |
providers/ |
The ONLY place allowed to make HTTP requests or read/write local storage. | Created on demand |
services/ |
Singleton services that start with the APP and reside permanently in the background. | Get.put(XxxService(), permanent: true) |
Criteria: If a Model is referenced by more than two modules, it belongs in data/models/, not as a module's private asset.
6. get_cli Common Commands Quick Reference
# Create a new module (automatically generates bindings + controllers + views + registers route)
get create page:trade
# Create a sub-module under an existing module
get create page:publish_product on trade
# Generate Controller only
get create controller:payment on trade
# Generate View only
get create view:payment_success on trade
# Generate Model from JSON
get generate model on home with assets/models/user.json
# Generate i18n translation files
get generate locales assets/locales
# Sort imports and format
get sort
pubspec.yaml Optional Configurations:
# File name separator (default is underscore, can be changed to dot)
get_cli:
separator: "_" # my_controller_controller.dart (if dot is selected: my_controller.controller.dart)
# Whether to generate sub-folders (default true)
get_cli:
sub_folder: false # Flat structure, do not generate bindings/ controllers/ views/ subdirectories
⚠️ This project maintains the default
sub_folder: trueconfiguration, which means retainingbindings/,controllers/, andviews/subfolders within each module.
7. Implementation Workflow (3 Steps for New Features)
- Define Data (Data Layer)
- After confirming the backend API, handle the response entity class in app/data/models/. - Encapsulate the request methods in app/data/providers/.
- Create Module (Module Layer)
- Use get create page:xxx or manually create the bindings/, controllers/, and views/ trio. - Immediately register the route in routes/approutes.dart and routes/apppages.dart. - Call the Provider written in step 1 within the Controller.
- Build UI (View + Widget Layer)
- Analyze the design mockup and split the page into large areas within the widgets/ folder. - The View layer acts only as a skeleton, assembling Widgets like building blocks. - Use or create common basic components from lib/components/. - All event callbacks → controller.doSomething(), do not handle logic in the View.
8. Complex Page Splitting and Fat Controller Decomposition Guide
To address the bloating of a single GetxController (Fat Controller) caused by complex pages, we define 5 dimensions of decoupling strategies and a decision tree.
Conditions triggering this guideline:
- A single Controller being written or refactored is approaching or exceeds 300~500 lines.
- The page contains multiple independent heavy business logics (e.g., home page simultaneously containing a carousel, complex cascading lists, and dialog logic).
- Discovering a massive amount of network requests and data transformation logic directly written in the Controller.
🎯 Core Splitting Decision Tree
When encountering the need to add complex logic or refactor a giant Controller, please follow these priorities to think and execute:
🥇 Priority 1: Sink data processing logic to Provider / Repository
- Scenario: The Controller becomes huge because it's filled with HTTP request code, JSON to Model code, data cleansing/restructuring algorithms, and local storage read/writes.
- Action: Peel off all
dio.get/postand complex algorithms to theapp/data/providers/layer or independent UseCase classes. - Architectural Effect: The Controller returns to its essence as a "state dispatcher", keeping only: Call interface ➡️ Get Model (or exception) ➡️ Modify
.obsvariables to respond to UI.
🥈 Priority 2: Return pure UI state to native StatefulWidget
- Scenario: Unnecessary global/cross-component states are introduced merely to implement pure visual interactions. For example: password field clear/obscure text toggle, expand/collapse panel animation state, input field focus feedback.
- Action: Do NOT create variables like
isPasswordVisible.obsin the GetxController! Wrap the corresponding building block as aStatefulWidgetand usesetState()to manage its internal temporary closed-loop state. - Architectural Effect: Greatly reduces meaningless
obsvariables in the Controller, avoiding permanent memory residency.
🥉 Priority 3: Horizontal Slicing - Split out dedicated Sub-Controllers by UI Block
- Scenario: The current page is an aggregation page pieced together from several unrelated core business blocks (e.g., Home page's [Top Nav] + [Search Bar] + [Carousel] + [Waterfall]).
- Action:
1. Don't handle everything in HomeController. The main Controller only does external framework coordination. 2. For specific areas (like the Carousel), create a new HomeBannerController. 3. Inject it in the current module's Binding file (Get.lazyPut(() => HomeBannerController())). 4. The corresponding homebannerwidget.dart extends GetView<HomeBannerController> to implement self-contained data.
- Architectural Effect: Achieves logical isolation of in-page components, facilitating concurrent development by multiple people without code conflicts.
🏅 Priority 4: Deep Slicing - Extract into Sub-modules
- Scenario: A piece of complex logic not only has massive internal state but also involves independent page navigation flows, independent deep routing, or full-screen dialogs (e.g., a "Publish Form" page popping up in a business flow).
- Action: Use the get_cli command
get create page:XXX on YYY. This will generate a new module with its own independent Routing, Bindings, Controllers, and Views lifecycle inside the existing parent module (YYY). - Architectural Effect: Completely destroys massive feature branch states when the route is popped from the stack, preventing memory leaks.
🎖️ Priority 5: Extract into Global Service or Component
- Scenario: The complex logic is globally available and frequently reused in multiple places. For example: A globally floating player control bar, IM long connection message listener, global data settlement.
- Action:
- Pure headless service (logic): Extract into a GetxService and inject it globally with permanent: true. - Component with global UI presentation: Place in the top-level lib/components/, letting it mount or create its own partial Controller internally to be self-sufficient.
- Architectural Effect: Avoids writing these shared logics everywhere in the Controllers of various independent pages.