NextStep follows a Feature-First Clean Architecture pattern combined with GetX for state management. This approach ensures scalability, maintainability, and clear separation of concerns.
- Screens: Full-screen views that users interact with
- Widgets: Reusable UI components
- Controllers: State management and UI logic using GetX
- Controllers: Business rules and use cases
- Models: Business entities
- Validation: Input validation logic
- Services: Local data storage (SharedPreferences)
- Network: API clients and HTTP requests
- Repositories: (Future implementation) Data source abstraction
lib/
│
├── main.dart # App entry point
│
├── core/ # Shared application-wide code
│ ├── config.dart # App configuration
│ ├── constants/
│ │ ├── app_color.dart # Color palette
│ │ ├── app_image.dart # Image assets (auto-generated)
│ │ └── app_strings.dart # String constants
│ ├── functions/ # Utility functions
│ ├── middlewares/
│ │ ├── auth_middleware.dart # Authentication guard
│ │ └── onboarding_middleware.dart # First-launch guard
│ ├── network/
│ │ └── api_client.dart # HTTP client wrapper
│ ├── services/
│ │ └── cache_service.dart # Local storage service
│ ├── theme/
│ │ └── app_theme.dart # Material theme config
│ └── utils/
│ ├── validators.dart # Form validation
│ └── helpers.dart # Helper functions
│
├── features/ # Feature modules (organized by feature)
│ │
│ ├── splash/
│ │ └── splash_screen.dart # Animated splash screen
│ │
│ ├── onboarding/
│ │ ├── controller/
│ │ │ └── onboarding_controller.dart
│ │ ├── onboarding_screen.dart
│ │ └── widgets/
│ │ └── onboarding_page.dart
│ │
│ ├── auth/
│ │ ├── controllers/
│ │ │ ├── login_controller.dart
│ │ │ └── register_controller.dart
│ │ ├── screens/
│ │ │ ├── login_screen.dart
│ │ │ └── register_screen.dart
│ │ └── widgets/
│ │ ├── login_form.dart
│ │ └── register_form.dart
│ │
│ ├── profile_setup/
│ │ ├── controllers/
│ │ │ └── complete_profile_controller.dart
│ │ ├── screens/
│ │ │ └── complete_profile_screen.dart
│ │ └── widgets/
│ │ ├── personal_info_step.dart
│ │ ├── work_locations_step.dart
│ │ └── job_preferences_step.dart
│ │
│ ├── home/ # Home dashboard (future)
│ ├── jobs/ # Job listings (future)
│ ├── favorites/ # Saved jobs (future)
│ ├── apply/ # Application flow (future)
│ ├── layout/ # App navigation layout (future)
│ └── settings/ # App settings (future)
│
├── routes/
│ └── app_routes.dart # Centralized route definitions
│
└── shared/ # Shared widgets across features
├── buttons/
│ ├── primary_button.dart
│ └── secondary_button.dart
├── inputs/
│ ├── custom_text_field.dart
│ └── dropdown_field.dart
├── indicator/
│ └── loading_indicator.dart
└── spacer.dart
Each feature is organized as an independent module:
feature_name/
├── controllers/ # Business logic
├── screens/ # UI layouts
├── widgets/ # Feature-specific components
└── models/ # Data models (optional)
Benefits:
- Easy to locate feature-related code
- Better code organization for teams
- Simpler feature addition/removal
- Clear module boundaries
Controller Pattern:
class LoginController extends GetxController {
// Observable state
var email = ''.obs;
var password = ''.obs;
var isLoading = false.obs;
// Services
final AuthService _authService = Get.find();
// Lifecycle
@override
void onInit() {
super.onInit();
// Initialize
}
@override
void onClose() {
// Cleanup
super.onClose();
}
// Methods
Future<void> login() async {
try {
isLoading.value = true;
await _authService.login(email.value, password.value);
Get.offAllNamed(AppRoutes.home);
} catch (e) {
Get.snackbar('Error', e.toString());
} finally {
isLoading.value = false;
}
}
}Screen Usage:
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = Get.put(LoginController());
return Scaffold(
body: Column(
children: [
Obx(() => TextField(
onChanged: (val) => controller.email.value = val,
)),
Obx(() => controller.isLoading.value
? CircularProgressIndicator()
: ElevatedButton(
onPressed: controller.login,
child: Text('Login'),
)
),
],
),
);
}
}Route Definitions:
abstract class AppRoutes {
// Route names
static const String splash = '/';
static const String login = '/login';
// Route configurations
static List<GetPage> routes = [
GetPage(
name: splash,
page: () => SplashScreen(),
),
GetPage(
name: login,
page: () => LoginScreen(),
middlewares: [AuthMiddleware()],
),
];
}Navigation Methods:
// Push new screen
Get.toNamed(AppRoutes.login);
// Replace current screen
Get.offNamed(AppRoutes.home);
// Clear stack and navigate
Get.offAllNamed(AppRoutes.splash);
// Pass arguments
Get.toNamed(AppRoutes.jobDetail, arguments: {'jobId': 123});
// Get arguments
final args = Get.arguments;Route guards for authentication and flow control:
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
// Check if user is logged in
final isLoggedIn = CacheService.to.isLoggedIn;
if (!isLoggedIn) {
return null; // Allow navigation
}
// Redirect to home if already logged in
return RouteSettings(name: AppRoutes.home);
}
}Singleton services for app-wide functionality:
class CacheService extends GetxService {
static CacheService get to => Get.find();
late SharedPreferences _prefs;
Future<CacheService> init() async {
_prefs = await SharedPreferences.getInstance();
return this;
}
// Getters
bool get isLoggedIn => _prefs.getBool('isLoggedIn') ?? false;
// Setters
Future<void> setLoggedIn(bool value) async {
await _prefs.setBool('isLoggedIn', value);
}
}Service Initialization:
Future<void> initializeServices() async {
await Get.putAsync(() => CacheService().init());
}┌─────────────┐
│ Screen │ ← User interacts
└──────┬──────┘
│ calls method
↓
┌─────────────┐
│ Controller │ ← Business logic
└──────┬──────┘
│ updates
↓
┌─────────────┐
│ Observable │ ← State changes
│ State │
└──────┬──────┘
│ notifies
↓
┌─────────────┐
│ Screen │ ← UI rebuilds
│ (Obx) │
└─────────────┘
┌─────────────┐
│ Controller │
└──────┬──────┘
│
↓
┌─────────────┐
│ Repository │ ← Data source abstraction
└──────┬──────┘
│
↓
┌─────────────┐
│ API Client │ ← HTTP requests
└──────┬──────┘
│
↓
┌─────────────┐
│ Server │
└─────────────┘
GetX provides simple dependency injection:
// Lazy initialization (created when first accessed)
Get.lazyPut(() => HomeController());
// Immediate initialization
Get.put(LoginController());
// Singleton service
Get.putAsync(() => CacheService().init());
// Access dependency
final controller = Get.find<HomeController>();var counter = 0.obs;
counter.value++;
// In UI
Obx(() => Text('Count: ${controller.counter.value}'))class ProfileController extends GetxController {
var user = UserModel().obs;
void updateUser(UserModel newUser) {
user.value = newUser;
}
}@override
void onInit() {
super.onInit();
// Called every time the value changes
ever(count, (_) => print('Count changed'));
// Called only once when value changes
once(count, (_) => print('First change'));
// Called after 1 second of inactivity
debounce(searchQuery, (_) => fetchResults());
// Wait 1 second, then call
interval(refreshToken, (_) => refreshToken());
}abstract class AppColor {
static const Color primary = Color(0xFF130160);
static const Color secondary = Color(0xFFFF6B2C);
static const Color background = Color(0xFFF9F9F9);
static const Color textPrimary = Color(0xFF000000);
static const Color textSecondary = Color(0xFF524B6B);
}class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
fontFamily: Config.fontFamily,
colorScheme: ColorScheme.light(
primary: AppColor.primary,
secondary: AppColor.secondary,
),
scaffoldBackgroundColor: AppColor.background,
);
}
}✅ Do:
- Keep controllers focused on single features
- Use descriptive names for observables
- Dispose resources in
onClose() - Use workers for reactive side effects
❌ Don't:
- Put UI widgets in controllers
- Share controllers across unrelated features
- Keep large business logic in controllers (extract to services)
✅ Do:
- Extract reusable widgets to
shared/ - Use
constconstructors when possible - Keep widget build methods small
- Use
Obx()for reactive updates
❌ Don't:
- Create widgets with too many responsibilities
- Duplicate widgets across features
- Rebuild entire screens unnecessarily
✅ Do:
- Use named routes for navigation
- Define all routes in
app_routes.dart - Use middlewares for auth checks
- Pass data via route arguments
❌ Don't:
- Navigate using direct widget instances
- Skip middleware checks
- Pass large objects through routes
✅ Do:
- Use
.obsfor simple reactive values - Use
Obx()for UI updates - Dispose controllers when not needed
- Use workers for side effects
❌ Don't:
- Overuse global state
- Forget to mark values as observable
- Create circular dependencies
Test controllers and business logic:
void main() {
group('LoginController', () {
late LoginController controller;
setUp(() {
controller = LoginController();
});
test('email validation', () {
controller.email.value = 'invalid';
expect(controller.isEmailValid, false);
controller.email.value = 'test@example.com';
expect(controller.isEmailValid, true);
});
});
}Test UI components:
void main() {
testWidgets('LoginScreen shows error on invalid input', (tester) async {
await tester.pumpWidget(MyApp());
await tester.enterText(find.byType(TextField).first, 'invalid');
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(find.text('Invalid email'), findsOneWidget);
});
}-
Repository Pattern
- Abstract data sources
- Easier testing and mocking
- Support multiple data sources
-
Use Case Layer
- Separate business logic from controllers
- Reusable business operations
- Better testability
-
Dependency Injection Container
- Centralized DI management
- Better dependency tracking
- Easier testing
-
API Response Models
- Typed API responses
- JSON serialization
- Error handling
-
Error Handling Strategy
- Centralized error management
- User-friendly error messages
- Logging and monitoring
This architecture provides a solid foundation for building a scalable Flutter application. The feature-first approach combined with GetX state management ensures code maintainability, testability, and developer productivity.
For questions or suggestions about the architecture, please open an issue or contact the development team.