y_infra 0.0.7 copy "y_infra: ^0.0.7" to clipboard
y_infra: ^0.0.7 copied to clipboard

A reusable Flutter infrastructure package — cache, storage, auth, network, database, permissions, formatters, and more.

y_infra #

A reusable Flutter infrastructure package that provides the building blocks for clean architecture Flutter applications.

Purpose #

y_infra exists to stop duplicating the same plumbing across Flutter projects. Every app ends up re-implementing cache, token storage, a Dio setup, error mapping, permission handlers, form validators, paginated/CRUD cubits, and so on. This package collects those building blocks behind small, stable interfaces so each new project can wire them together instead of rewriting them.

It is deliberately unopinionated about UI and business logic. What it gives you:

  • Interface-first: every subsystem (cache, storage, logger, notifier, repository, firebase, push) is an I… abstract class with a default implementation. Swap in your own without touching the callers.
  • Clean-architecture ready: layering mirrors core / data / domain / platform so features fit into an existing clean-arch project.
  • Composable: base cubits (BaseOperationCubit, BaseListCubit, PaginatedCubit, CrudCubit) compose with FilterableMixin; Dio interceptors compose via InterceptorPipeline; caches compose via ReadOnly / WriteOnly / NoCache variants.
  • Localisation hooks: error messages, validator messages, and locale itself are overridable so the package doesn't lock you to English.

Installation #

dependencies:
  y_infra:
    path: ../y_infra  # or from pub.dev / git
import 'package:y_infra/y_infra.dart';

What's Inside #

lib/
├── core/           Cache, errors, logging, notifier, storage, theme
├── data/           Database (SQLite), file (CSV/JSON), network (Dio)
├── domain/         Repository with queue/dedup and cache invalidation
├── auth/           Token storage, AuthCubit, auth state management
├── firebase/       Analytics, messaging, realtime database abstractions
├── push/           Push notifications (Awesome Notifications)
├── platform/       Permission handlers, connectivity, location
├── base_features/  CRUD, list, paginated, operation, locale cubits
├── components/     Reusable UI components (bottom sheet selector)
├── mixins/         SnackBarY, FilterableMixin
└── utils/          Formatters, generators, validators, routing, map

Core #

  • Cache — in-memory, type-safe, TTL + invalidation. Variants: DefaultCache, ReadOnlyCache, WriteOnlyCache, NoCache.
  • Errors — categorized AppError hierarchy with automatic DioException mapping and ErrorMessages for i18n.
  • Logging — pluggable ILogger + IPrinter (DebugLogger, ConsolePrinter).
  • Notifier — stream-based event bus for cross-layer reactive communication.
  • Storage — unified ILocalStorage with SecureStorage and SharedPreferencesStorage.
  • ThemeYInfraColors ThemeExtension for package-wide colors.

Data #

  • NetworkBaseNetworkConfig (Dio factory), InterceptorPipeline, AuthInterceptor, LoggingInterceptor, IRemoteDatasource base class.
  • Database — SQLite abstraction (IDbManager) with command builders and data mappers.
  • File — CSV/JSON file controllers (CsvFileController, JsonFileController) with path providers.

Domain #

  • IRepository — base repository with in-flight request deduplication, caching, and cache invalidation by key / prefix / type.

Auth #

  • AuthTokenStorage — persists TokenPair via ILocalStorage.
  • AuthCubit — manages AuthState (AuthInitial, AuthLoading, Authenticated, Unauthenticated) and auto-logs-out on auth failures.

Firebase #

  • IFirebaseService — aggregates analytics, messaging, realtime DB; call initApp() then initServices().
  • IFirebaseAnalyticsService — log events (FirebaseAnalyticsEvent) and set UserProperty.
  • IFirebaseMessagingService — FCM token + message listeners.
  • IFirebaseRealtimeDatabaseService — subscribe to paths with IFirebaseDatabaseEventHandler.

Push #

  • IPushNotificationManager — Awesome Notifications setup, channel config from .env, local + remote push.
  • INotificationListeners — callbacks for action / dismiss / create / display events.

Platform #

  • PermissionsCamera, Location, Notification, Storage, ExternalStorage, MotionActivity handlers.
  • ConnectivityConnectivityService with isConnected + status stream.
  • LocationLocationService with cached position + position stream.

Base Features (Cubits) #

  • BaseOperationCubit<TResult, Id> — single async operation with duplicate-call guard.
  • BaseListCubit<T> — non-paginated list loading.
  • PaginatedCubit<T, Id> — paginated list with load-more / refresh / selection.
  • CrudCubit<T, Id> — full create/read/update/delete with filtering and selection.
  • LocaleCubit — current locale with supported-language check, syncs with easy_localization.

Components #

  • BottomSheetSelectorFeature — searchable bottom-sheet selector with cubit-based selection.

Mixins #

  • FilterableMixin — generic search / sort / filter on top of any cubit.
  • SnackBarY — themed success / error snackbars.

Utils #

  • Formatters — input masks (date, phone, card, expiry, uppercase) + display (date/time, price).
  • Validators — configurable form validators with overridable messages.
  • GeneratorsIUuidProvider / UuidProvider.
  • RoutingRoutingBase, AppNavObserver.
  • MapMapLauncher (Apple Maps on iOS, Google Maps elsewhere).

Usage #

Cache #

final cache = DefaultCache();
cache.set('users', userList, type: CacheType.personal, ttl: Duration(minutes: 10));
final users = cache.get<List<User>>('users');
cache.removeByPrefix('user_');
cache.removeAllType(CacheType.personal);

Errors #

try {
  await dio.get('/endpoint');
} catch (e) {
  final error = ErrorMapper.map(e);
  print(error.category);         // ErrorCategory.network
  print(error.isRetryable);      // true
  print(error.shouldShowToUser); // true
}

// Localise error messages once at startup
ErrorMessages.instance = ErrorMessages(
  networkError: 'errors.network'.tr(),
  sessionExpired: 'errors.session_expired'.tr(),
);

Storage #

final storage = SecureStorage(FlutterSecureStorage());
await storage.save<String>('key', 'value');
await storage.save<Map>('data', {'nested': true});
final value = await storage.get<String>('key');

Logging #

final logger = DebugLogger(printer: ConsolePrinter());
logger.info('User logged in', data: LogData(tag: 'AUTH'));
logger.error('Failed to fetch', data: LogData(tag: 'API'));

Notifier #

final notifier = NotifierService();
notifier.listen('user_updated').listen((event) => print(event.data));
notifier.notify(key: 'user_updated', data: updatedUser);

Theme #

MaterialApp(
  theme: ThemeData(
    extensions: [
      YInfraColors(successColor: Colors.green, warningColor: Colors.orange),
    ],
  ),
)

Network #

final config = BaseNetworkConfig(
  baseUrl: 'https://api.example.com',
  connectTimeout: Duration(seconds: 15),
);
final dio = config.createDio();

dio.interceptors.add(
  InterceptorPipeline([
    AuthInterceptor(
      authTokenStorage: tokenStorage,
      onTokenRefresh: (refresh) => api.refreshToken(refresh),
      onAuthFailure: () => authCubit.logout(),
      retryDio: Dio(BaseOptions(baseUrl: 'https://api.example.com')),
      skipAuthPaths: ['/login', '/register'],
    ),
    LoggingInterceptor(),
  ]),
);

Pipeline rules: interceptors run in declaration order. Any one can short-circuit via handler.resolve(response) or handler.reject(error); otherwise call handler.next(...) to continue. Each interceptor also works standalone without InterceptorPipeline.

Remote Datasource #

class UserRemoteDatasource extends IRemoteDatasource {
  UserRemoteDatasource(super.authTokenStorage, super.dio);

  Future<User> getUser(int id) => doRequest(
    () async {
      final response = await dio.get('/users/$id');
      return User.fromJson(response.data);
    },
  );
}

Database #

class UserTable extends IDatabaseTable {
  const UserTable() : super('users');

  @override
  Map<String, String> get columns => {
    'id': 'INTEGER PRIMARY KEY',
    'name': 'TEXT',
    'email': 'TEXT',
  };
}

File #

final csv = CsvFileController();
final data = await csv.load('assets/data.csv');
await csv.save(DownloadPathProvider('export.csv'), converter);

Repository #

class UserRepository extends IRepository {
  UserRepository(super.notifierService);

  Future<User> getUser(int id) => queue(
    'user_$id',
    () => api.fetchUser(id),
    cache: defaultCache,
    cacheType: CacheType.personal,
    invalidatePrefix: 'user_list',
  );
}

Auth — Token Storage #

final tokenStorage = AuthTokenStorage(secureStorage);
await tokenStorage.saveTokenPair(TokenPair(
  accessToken: 'abc',
  refreshToken: 'xyz',
));
final pair = await tokenStorage.getTokenPair();

Auth — AuthCubit #

AuthCubit manages authentication state. It does NOT perform login — that's project-specific. Call onAuthenticated after a successful login.

final authCubit = AuthCubit(
  tokenStorage: tokenStorage,
  notifier: notifierService,       // optional: listens for auth failures
  authFailureKey: 'auth_failure',  // key that AuthInterceptor notifies on
);

await authCubit.checkAuth();               // on app startup
authCubit.onAuthenticated(tokens);          // after successful login
await authCubit.logout();                   // explicit logout

BlocBuilder<AuthCubit, AuthState>(
  builder: (context, state) => switch (state) {
    Authenticated() => HomePage(),
    Unauthenticated(reason: final reason) => LoginPage(reason: reason),
    AuthLoading() => SplashScreen(),
    _ => SplashScreen(),
  },
)

Auth states are base class — extend them in your project:

class AuthenticatedWithUser extends Authenticated {
  final User user;
  const AuthenticatedWithUser(this.user);
}

Firebase #

IFirebaseService aggregates the three sub-services. Subclass it and provide whichever ones you need.

class AppFirebaseService extends IFirebaseService {
  AppFirebaseService()
      : super(
          analytics: AppAnalyticsService(),
          messaging: AppMessagingService(MyMessageListener(), MyForegroundConfig()),
          realTimeDb: AppRealtimeDbService(),
        );
}

await IFirebaseService.initApp();   // Firebase.initializeApp()
AppFirebaseService().initServices(); // analytics/messaging/realTimeDb .init()

Extend the sub-services:

class AppAnalyticsService extends IFirebaseAnalyticsService {
  @override
  void init() { /* set collection enabled, default params, etc. */ }

  @override
  void logEvent(FirebaseAnalyticsEvent event) =>
      analyticsInstance.logEvent(name: event.name, parameters: event.params);

  @override
  void setUserProperty(UserProperty property) =>
      analyticsInstance.setUserProperty(name: property.name, value: property.value);
}

Push Notifications #

IPushNotificationManager wraps Awesome Notifications. Channels are read from a .env file via CHANNEL_CONFIGURATION_LIST + per-channel keys (e.g. DEFAULT_CHANNEL_KEY, DEFAULT_CHANNEL_NAME, DEFAULT_IMPORTANCE).

class AppPushManager extends IPushNotificationManager {
  AppPushManager()
      : super(
          notificationListeners: AppNotificationListeners(...),
          envFilePath: '.env',
          iconPath: 'resource://drawable/notification_icon',
        );

  @override
  void pushLocal(ICustomRemoteMessage message, {PushNotificationChannelConfig? channelConfig}) {
    // build and show a local notification from `message`
  }

  @override
  void pushRemote(ICustomRemoteMessage message, {PushNotificationChannelConfig? channelConfig}) {
    // handle an incoming remote message
  }
}

await AppPushManager().init();

Example .env:

CHANNEL_CONFIGURATION_LIST=DEFAULT,ALERTS
DEFAULT_CHANNEL_KEY=default
DEFAULT_CHANNEL_NAME=General
DEFAULT_CHANNEL_DESCRIPTION=General notifications
DEFAULT_IMPORTANCE=Max

Permissions #

final camera = CameraPermissionHandler();
if (await camera.g2g) { /* granted */ }
await camera.askPermIfNeeded();

Available handlers: Camera, Location, Notification, Storage, ExternalStorage, MotionActivity.

Connectivity #

final connectivity = ConnectivityService();
final isOnline = await connectivity.isConnected;
connectivity.onStatusChange.listen((status) => print(status));

Location #

final location = LocationService(positionCacheDuration: Duration(minutes: 5));
await location.init();
final position = await location.position;
final stream = await location.positionStream;

Operation Cubit #

class DeleteItemCubit extends BaseOperationCubit<void, int> {
  final ItemRepository _repo;
  DeleteItemCubit(this._repo);

  Future<void> delete(int id) => execute(
    targetId: id,
    operation: () => _repo.delete(id),
  );
}

States: OperationInitialOperationInProgressOperationSuccess<T, Id> / OperationFailure.

List Cubit #

class StoresCubit extends BaseListCubit<Store> {
  final StoreRepository _repo;
  StoresCubit(this._repo);

  @override
  Future<List<Store>> fetchItems() => _repo.getStores();
}

Paginated Cubit #

class ProductsCubit extends PaginatedCubit<Product, int> {
  final ProductRepository _repo;
  ProductsCubit(this._repo);

  @override
  int getId(Product item) => item.id;

  @override
  Future<PaginatedResponse<Product>> fetchPage(int page, int pageSize) =>
      _repo.getProducts(page: page, pageSize: pageSize);
}

CRUD Cubit #

class UsersCubit extends CrudCubit<User, int> {
  final UserRepository _repo;
  UsersCubit(this._repo);

  @override
  int getId(User item) => item.id;

  @override
  Future<List<User>> fetchItems() => _repo.getUsers();

  Future<void> createUser(CreateUserDto dto) => performSave(
    operation: () => _repo.create(dto),
    successMessage: 'User created',
    updateList: (user) => addToList(user),
  );

  Future<void> deleteUser(int id) => performDelete(
    operation: () => _repo.delete(id),
    id: id,
    successMessage: 'User deleted',
  );
}

Locale Cubit #

final localeCubit = LocaleCubit(supportedCodes: {'en', 'tr', 'de'});

localeCubit.isSupported('fr');                      // false
localeCubit.changeLocale(context, 'tr');            // sets locale + calls context.setLocale

FilterableMixin #

class ProductsCubit extends PaginatedCubit<Product, int> with FilterableMixin {
  @override
  void onFiltersChanged() => refresh();

  @override
  Future<PaginatedResponse<Product>> fetchPage(int page, int pageSize) =>
      repo.getProducts(page: page, search: searchQuery, sortBy: sortBy);
}

// Also works with BaseListCubit
class StoresCubit extends BaseListCubit<Store> with FilterableMixin {
  @override
  void onFiltersChanged() => refresh();
}

SnackBarY #

class MyWidget extends StatelessWidget with SnackBarY {
  void onTap(BuildContext context) {
    displaySuccessSnack(context: context, message: 'Saved!');
    displayErrorSnack(context: context, message: 'Something went wrong');
  }
}

Bottom Sheet Selector #

BottomSheetSelectorFeature<City>(
  child: BottomSheetSelectorContainer(
    emptyChild: Text('Select a city'),
    childBuilder: (city) => Text(city.name),
    bottomSheet: SearchableBottomSheetList(
      itemsProvider: () => repository.getCities(),
      itemBuilder: (city) => ListTile(title: Text(city.name)),
      searchHint: 'Search...',
      searchFilter: (city, query) => city.name.toLowerCase().contains(query),
    ),
  ),
)

Formatters #

Input formatters (for TextFormField):

  • SeparatorInputFormatter — configurable base for masked input
  • DateInputFormatterDD/MM/YYYY
  • UpperCaseInputFormatter
  • PhoneNumberInputFormatter555 555 55 55
  • CreditCardNumberInputFormatter
  • CardExpiryInputFormatterMM/YY

Display formatters:

const DateTimeFormatter().dateFormatter(DateTime.now(), context);   // '17 April 2026'
const DateTimeFormatter().timeFormatter(DateTime.now(), context);   // '14:32'
const DateTimeFormatter().formatSeconds(125);                       // '2:05'

PriceFormatter.configure(symbol: '₺', symbolAfter: true, decimalDigits: 2);
PriceFormatter.format(199.9);                      // '199,90 ₺'
PriceFormatter.formatRange(10, 50, symbol: '€');   // '10,00 € - 50,00 €'
PriceFormatter.formatDiscount(200, 150);           // '-25%'

Validators #

final v = Validators(
  messages: ValidatorMessages(
    required: 'Required',
    invalidEmail: 'Invalid email',
  ),
);

TextFormField(validator: v.email());
TextFormField(validator: v.password(minLength: 8));
TextFormField(validator: v.mustMatch(() => passwordController.text));

Map Launcher #

final mapLauncher = MapLauncher(errorMessage: 'Could not open maps');
mapLauncher.open(context: context, latitude: 41.0, longitude: 29.0);

Opens Apple Maps on iOS, Google Maps on other platforms. Override appleMapsUrlBuilder / googleMapsUrlBuilder to customise URL schemes.

Routing #

class AppRouter extends RoutingBase {
  const AppRouter(super.navigatorKey);

  @override
  Route generateRoute(RouteSettings settings) => switch (settings.name) {
    '/home' => materialRouting(HomePage(), settings),
    '/details' => iosRouting(DetailsPage(), settings),
    '/modal' => opacityRoute(ModalPage(), settings),
    _ => materialRouting(NotFoundPage(), settings),
  };
}

MaterialApp(
  navigatorKey: navigatorKey,
  onGenerateRoute: AppRouter(navigatorKey).generateRoute,
  navigatorObservers: [AppNavObserver()],
)

License #

MIT