Chat about this codebase

AI-powered code exploration

Online

Project Overview

Remitance is a cross-platform Flutter mobile application that streamlines secure money transfers. It offers biometric authentication, local data storage, and comprehensive transaction management out of the box. Developers can build, customize, and extend Remitance for personal finance, peer-to-peer payments, and remittance services.

What Is Remitance?

  • A Flutter-based mobile app targeting Android and iOS
  • Enables users to send, receive, and track transactions
  • Integrates biometric login for enhanced security
  • Persists data locally using Hive for offline support

Why You Should Care

  • Accelerate development of payment and finance features
  • Leverage production-ready authentication and storage
  • Customize UI, business logic, and transaction flows
  • Maintain a single codebase across platforms

Primary Use Cases

  • Peer-to-peer fund transfers with real-time status updates
  • Offline transaction recording and later synchronization
  • Secure user login via fingerprint or face recognition
  • Extensible transaction rules (limits, fees, notifications)

Key Technologies

  • Flutter & Dart: UI framework and language
  • hive: Lightweight local key-value database
  • local_auth: Biometric authentication plugin
  • flutter_svg & adaptive_splash: Asset handling and customizable launch screens
  • Cross-platform support: Shared code for Android and iOS

This overview helps you understand Remitance’s purpose, core features, and technology stack. Use the following sections to dive into setup, configuration, and code structure.

Getting Started

Follow these steps to clone the Remitance App and run it on Android or iOS as quickly as possible.

Prerequisites

  • Flutter SDK ≥ 3.0 (with Dart)
  • Android SDK & Android Studio (or CLI tools)
  • Xcode (for iOS)
  • A connected device or emulator/simulator

1. Clone & Install Dependencies

# Clone the repo
git clone https://github.com/Barok-Getachew/remitance-app.git
cd remitance-app

# Fetch Dart & Flutter packages
flutter pub get

2. Configure App Identifiers

Android

Open android/app/build.gradle and set your applicationId under the defaultConfig block:

defaultConfig {
    applicationId "com.yourcompany.remitance"
    minSdkVersion 23
    targetSdkVersion 33
    versionCode 1
    versionName "1.0"
}

If you have custom signing keys, configure them in signingConfigs and reference in your release buildType.

iOS

Open ios/Runner/Info.plist and update the CFBundleIdentifier:

<key>CFBundleIdentifier</key>
<string>com.yourcompany.remitance</string>

Then open ios/Runner.xcworkspace in Xcode, select your Team under Signing & Capabilities.

3. Fastest Path: Run on Android

  1. Launch an Android emulator or connect a device:
    flutter emulators --launch Pixel_4_API_30
    
  2. Run the app:
    flutter run -d emulator-5554
    

4. Fastest Path: Run on iOS

  1. Launch the iOS Simulator:
    open -a Simulator
    
  2. Run the app:
    flutter run -d ios
    

5. What Happens Under the Hood

  • lib/main.dart initializes Hive, locks device to portrait, and sets optimal display mode on Android.
  • pubspec.yaml defines dependencies (e.g. Hive, biometric_auth), assets (icons, splash), and environment constraints.

You should see the Remitance App home screen within seconds. From here, explore lib/ to customize authentication, storage, and UI.

Application Architecture

This section describes how the app composes state management, dependency injection, persistence, routing, and error handling using GetX and Hive.

1. State Management

We use GetX’s reactive controllers to manage UI state and business logic.

Example: HomeController

import 'package:get/get.dart';
import 'package:remitance_app/src/domain/usecases/get_transactions_usecase.dart';

class HomeController extends GetxController {
  final GetTransactionsUseCase _getTransactions;
  var transactions = <Transaction>[].obs;
  var isLoading = false.obs;
  var errorMessage = RxnString();

  HomeController({ required GetTransactionsUseCase getTransactions })
    : _getTransactions = getTransactions;

  @override
  void onInit() {
    super.onInit();
    fetchTransactions();
  }

  Future<void> fetchTransactions() async {
    isLoading.value = true;
    final result = await _getTransactions(); // returns Either<Failure, List<Transaction>>
    result.fold(
      (failure) => errorMessage.value = failure.message,
      (list) => transactions.assignAll(list),
    );
    isLoading.value = false;
  }
}

Bind HomeController via HomeBinding to inject its use-case (see Dependency Injection).

2. Dependency Injection

We declare and organize dependencies in GetX Bindings:

  • Use Get.put(..., permanent: true) for singletons (e.g. HiveController)
  • Use Get.lazyPut(...) for on-demand objects (data sources, repositories, use-cases, controllers)

lib/src/app/Bindings/hive_binding.dart

import 'package:get/get.dart';
import 'package:remitance_app/src/app/controller/hivecontroller.dart';

class HiveBinding extends Bindings {
  @override
  void dependencies() {
    // Opens all Hive boxes once, app-wide
    Get.putAsync<HiveController>(() async {
      final ctl = HiveController();
      await ctl.onInit();
      return ctl;
    }, permanent: true);
  }
}

Module Binding example (Transactions):

import 'package:get/get.dart';
import 'package:remitance_app/src/app/controller/hivecontroller.dart';
import 'package:remitance_app/src/data/datasources/transaction_local_data_source.dart';
import 'package:remitance_app/src/data/repositories/transaction_repository_impl.dart';
import 'package:remitance_app/src/domain/usecases/get_transactions_usecase.dart';
import 'package:remitance_app/src/presentation/controllers/home_controller.dart';

class HomeBinding extends Bindings {
  @override
  void dependencies() {
    final hive = Get.find<HiveController>();
    final box = hive.getBox(BoxType.transactions);

    // 1. Local data source
    Get.lazyPut<TransactionLocalDataSource>(
      () => TransactionLocalDataSourceImpl(box),
    );

    // 2. Repository
    Get.lazyPut<TransactionRepository>(
      () => TransactionRepositoryImpl(local: Get.find()),
    );

    // 3. Use-case
    Get.lazyPut(() => GetTransactionsUseCase(Get.find()));

    // 4. Controller
    Get.lazyPut(() => HomeController(getTransactions: Get.find()));
  }
}

3. Persistence with HiveController

HiveController handles opening and retrieving boxes by type.

lib/src/app/controller/hivecontroller.dart

import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:remitance_app/src/core/enum/box_type.dart';

class HiveController extends GetxController {
  final isReady = false.obs;
  late Box settingsBox, cacheBox, accountBox, profileBox, transactionBox;

  Future<void> onInit() async {
    // Register adapters before openBox if needed
    settingsBox    = await Hive.openBox('settings');
    cacheBox       = await Hive.openBox('cache');
    accountBox     = await Hive.openBox('account');
    profileBox     = await Hive.openBox('profile');
    transactionBox = await Hive.openBox('transactions');
    isReady.value = true;
  }

  Box getBox(BoxType type) {
    switch (type) {
      case BoxType.settings:    return settingsBox;
      case BoxType.cache:       return cacheBox;
      case BoxType.account:     return accountBox;
      case BoxType.profile:     return profileBox;
      case BoxType.transactions:return transactionBox;
    }
  }
}

Usage in main.dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Get.putAsync<HiveController>(
    () async {
      final ctrl = HiveController();
      await ctrl.onInit();
      return ctrl;
    },
    permanent: true,
  );
  runApp(MyApp());
}

4. Routing

All named routes and bindings live in lib/src/core/routes/app_pages.dart.

Define route names:

class AppRoutes {
  static const home     = '/home';
  static const login    = '/login';
  static const settings = '/settings';
  // add more...
}

Register pages with bindings and transitions:

class AppPages {
  static final pages = <GetPage>[
    GetPage(
      name: AppRoutes.login,
      page: () => LoginPage(),
      binding: AuthBinding(),
      transition: Transition.fade,
    ),
    GetPage(
      name: AppRoutes.home,
      page: () => HomePage(),
      binding: HomeBinding(),
      transition: Transition.rightToLeft,
    ),
    // ...
  ];
}

Configure GetMaterialApp in MyApp:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      initialBinding: HiveBinding(),
      initialRoute: AppRoutes.login,
      getPages: AppPages.pages,
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system,
    );
  }
}

Navigate with:

  • Get.toNamed(AppRoutes.settings)
  • Get.offNamed(AppRoutes.home)
  • Get.offAllNamed(AppRoutes.login)

5. Error Handling

lib/src/core/error/error.dart exports standardized exceptions and failures:

abstract class Failure {
  final String message;
  Failure(this.message);
}

class CacheFailure extends Failure {
  CacheFailure(String msg): super(msg);
}

class ServerFailure extends Failure {
  ServerFailure(String msg): super(msg);
}

class AppException implements Exception {
  final String message;
  AppException(this.message);
}

Map exceptions to failures in data layer:

Future<Either<Failure, User>> login(String email, String pw) async {
  try {
    final dto = await remoteDataSource.login(email, pw);
    return Right(dto.toDomain());
  } on AppException catch (e) {
    return Left(ServerFailure(e.message));
  } catch (e) {
    return Left(ServerFailure('Unexpected error'));
  }
}

Handle failures in controllers:

final result = await loginUseCase(email, pw);
result.fold(
  (failure) => Get.snackbar('Error', failure.message),
  (user) => Get.offNamed(AppRoutes.home),
);

This layered architecture ensures clear separation of concerns, easy testing, and maintainable growth.

Feature Guides

Hands-on guides for major business features: managing user data, persisting transactions, and fetching exchange rates. Each guide covers data flow, UI flow, and extension points.

Managing Local User Data with Hive

Purpose
Provide a Hive-based data source for persisting and retrieving user credentials and profile data on the local device.

1. UserLocalDataSource API

abstract class UserLocalDataSource {
  /// Retrieves a stored user matching [email] and [password].
  /// Returns null if no user exists, or throws
  /// OneorMoreFieldIsInvalidException on bad password.
  Future<UserModel?> getUser(String email, String password);

  /// Saves a new [user]. Throws UserAlreadyExistsException
  /// if a user with the same email already exists.
  Future<bool> saveUser(UserModel user);
}

2. Implementation Details

  • Uses a Hive Box keyed by user email.
  • Stores user data as a Map<String, dynamic>:
    • id, name, email, password, balance
  • On login, trims the incoming password and compares it to the stored value.
  • On save, checks for existing email before writing.
class UserLocalDataSourceImpl implements UserLocalDataSource {
  final Box userBox;
  UserLocalDataSourceImpl({ required this.userBox });

  @override
  Future<UserModel?> getUser(String email, String password) async {
    if (!userBox.containsKey(email)) return null;
    final data = userBox.get(email);
    if (data is! Map) return null;

    if (data['password'] != password.trim()) {
      throw OneorMoreFieldIsInvalidException();
    }
    return UserModel(
      id: data['id'],
      name: data['name'],
      email: data['email'],
      password: data['password'],
      balance: data['balance'] ?? 0.0,
    );
  }

  @override
  Future<bool> saveUser(UserModel user) async {
    if (userBox.get(user.email) != null) {
      throw UserAlreadyExistsException();
    }
    final userMap = {
      'id': user.id,
      'name': user.name,
      'email': user.email,
      'password': user.password,
      'balance': user.balance,
    };
    await userBox.put(user.email, userMap);
    final saved = userBox.get(user.email);
    return saved != null && saved['email'] == user.email;
  }
}

3. Error Handling

  • OneorMoreFieldIsInvalidException
    Thrown when a stored user is found but the password does not match.
  • UserAlreadyExistsException
    Thrown when attempting to register with an email that already exists in Hive.

4. Wiring into Repository & Use Cases

  1. Open the Hive box (e.g., in your app’s initialization or binding):
await Hive.openBox('users');
final userBox = Hive.box('users');

Get.lazyPut<UserLocalDataSource>(
  () => UserLocalDataSourceImpl(userBox: userBox),
);
Get.lazyPut<UserRepository>(
  () => UserRepositoryImpl(localDataSource: Get.find()),
);
Get.lazyPut(() => LoginUseCase(Get.find()));
Get.lazyPut(() => RegisterUseCase(Get.find()));
  1. Register a new user:
final newUser = UserEntity(
  id: Uuid().v4(),
  name: 'Alice Doe',
  email: 'alice@example.com',
  balance: 0.0,
);
// Internally maps to UserModel.create(...)
final success = await registerUseCase.execute(newUser, 'securePass123');
if (success) {
  // Navigate to login or home
}
  1. Log in an existing user:
try {
  final user = await loginUseCase.execute('alice@example.com', 'securePass123');
  if (user != null) {
    // proceed to home
  } else {
    // handle “user not found”
  }
} on OneorMoreFieldIsInvalidException {
  // handle invalid password case
}

5. Practical Tips

  • Trim user inputs before calling getUser to avoid whitespace mismatches.
  • For production, hash passwords or use a secure vault instead of storing raw passwords.
  • Use consistent key naming (email) for the Hive box.
  • Wrap Hive calls in try/catch to surface storage errors beyond defined exceptions.

Local Transaction Persistence and Balance Updates

Purpose
Explain how transactions are saved to Hive and how user balances adjust for “topup” and “send” transactions.

Core Implementation

class TransactionLocalDataSourceImpl implements TransactionLocalDataSource {
  final Box transactionBox;

  TransactionLocalDataSourceImpl({required this.transactionBox});

  @override
  Future<bool> addTransaction(TransactionModel transaction) async {
    final hiveController = Get.find<HiveController>();
    final accountBox = hiveController.getBox(BoxType.account);

    // 1. Persist the transaction
    await transactionBox.put(transaction.id, transaction.toMap());
    final saved = transactionBox.get(transaction.id) as Map;

    // 2. Balance update logic
    if (saved['type'] == 'topup') {
      final userMap = Map<String, dynamic>.from(
        accountBox.get(transaction.currentUserEmail) ?? {},
      );
      userMap['balance'] = (userMap['balance'] ?? 0) + transaction.amount;
      await accountBox.put(transaction.currentUserEmail, userMap);
    } else if (saved['type'] == 'send') {
      final senderMap = Map<String, dynamic>.from(
        accountBox.get(transaction.currentUserEmail) ?? {},
      );
      final receiverMap = Map<String, dynamic>.from(
        accountBox.get(transaction.receiverUserEmail) ?? {},
      );
      senderMap['balance'] = (senderMap['balance'] ?? 0) - transaction.amount;
      receiverMap['balance'] = (receiverMap['balance'] ?? 0) + transaction.amount;
      await accountBox.put(transaction.currentUserEmail, senderMap);
      await accountBox.put(transaction.receiverUserEmail, receiverMap);
    }

    // 3. Confirm persistence
    return saved['id'] == transaction.id;
  }

  @override
  Future<List<TransactionModel>> getTransactions() async {
    try {
      return transactionBox.values
        .map((e) => TransactionModel.fromMap(Map<String, dynamic>.from(e)))
        .toList();
    } catch (e) {
      throw Exception('Failed to fetch transactions: $e');
    }
  }
}

Practical Usage

  1. Dependency Injection

    await Hive.openBox(BoxType.transaction.name);
    await Hive.openBox(BoxType.account.name);
    Get.put(HiveController());
    Get.put(TransactionLocalDataSourceImpl(
      transactionBox: Hive.box(BoxType.transaction.name),
    ));
    
  2. Calling from Repository

    final success = await transactionRepository.addTransaction(myTransactionEntity);
    
  3. Error Handling

    • Throws on getTransactions() failure.
    • Returns false from addTransaction if the persistence check fails.

Tips

  • Register HiveController before saving transactions.
  • Validate that both sender and receiver accounts exist to avoid null balances.
  • Use enum values (TransactionType.topup.name, .send.name) to drive logic branches.

Exchange Remote Data Source

Purpose
Provide an abstraction over HTTP calls to fetch real‐time currency exchange rates from the Frankfurter API, handling network errors and JSON deserialization.

Interface Definition

abstract class ExchangeRemoteDataSource {
  Future<ExchangeRateModel> getLatestRates(String from, String to);
}

Implementation Highlights

class ExchangeRemoteDataSourceImpl implements ExchangeRemoteDataSource {
  final GetConnect getConnect;

  ExchangeRemoteDataSourceImpl({ required this.getConnect });

  @override
  Future<ExchangeRateModel> getLatestRates(String from, String to) async {
    try {
      final response = await getConnect.get(
        "https://api.frankfurter.dev/v1/latest?base=$from&symbols=$to",
      );

      if (response.hasError) {
        throw Exception('Failed to fetch rates: ${response.statusText}');
      }

      return ExchangeRateModel.fromJson(response.body);
    } on SocketException {
      throw SocketException('No Internet connection. Please check your network.');
    } catch (e) {
      throw Exception('Unexpected error: $e');
    }
  }
}

Practical Usage

  1. Register in GetX (ensure GetConnect is available):

    Get.put<GetConnect>(GetConnect(), permanent: true);
    Get.lazyPut<ExchangeRemoteDataSource>(
      () => ExchangeRemoteDataSourceImpl(getConnect: Get.find()),
    );
    
  2. Inject into your repository:

    Get.lazyPut<ExchangeRepository>(
      () => ExchangeRepositoryImpl(remoteDataSource: Get.find<ExchangeRemoteDataSource>()),
    );
    
  3. Fetch rates in your use case or service:

    final dataSource = Get.find<ExchangeRemoteDataSource>();
    try {
      final model = await dataSource.getLatestRates('USD', 'EUR');
      print('1 USD = ${model.rates['EUR']} EUR');
    } catch (e) {
      // handle network or parsing errors
    }
    

Tips & Considerations

  • Configure custom headers (e.g., API keys) on GetConnect before registering.
  • To mock API responses in tests, extend ExchangeRemoteDataSource and override getLatestRates.
  • Ensure your ExchangeRateModel matches the Frankfurter API schema for JSON deserialization.

UI, Theming & Localization

Everything related to look-and-feel and language support so teams can rebrand or localize quickly.


1. Theming

Overview

Define light and dark ThemeData with custom color schemes, typography (Google Fonts), and component shapes in lib/src/core/theme/theme.dart. Apply these themes globally or locally.

1.1 Import and Apply in MaterialApp

import 'package:flutter/material.dart';
import 'package:remitance_app/src/core/theme/theme.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Remitance',
      theme: lightTheme,           // Light mode
      darkTheme: darkTheme,        // Dark mode
      themeMode: ThemeMode.system, // Follow system setting
      home: HomePage(),
    );
  }
}

1.2 Accessing ColorScheme & TextTheme

@override
Widget build(BuildContext context) {
  final colors = Theme.of(context).colorScheme;
  final texts  = Theme.of(context).textTheme;

  return Scaffold(
    backgroundColor: colors.background,
    appBar: AppBar(
      backgroundColor: colors.primary,
      title: Text('Dashboard', style: texts.headlineLarge),
    ),
    body: Padding(
      padding: const EdgeInsets.all(16),
      child: Text('Welcome back!', style: texts.bodyLarge),
    ),
  );
}

1.3 Overriding Theme Locally

Theme(
  data: Theme.of(context).copyWith(
    colorScheme: Theme.of(context).colorScheme.copyWith(primary: Colors.red),
    textTheme: Theme.of(context).textTheme.apply(
      bodyColor: Colors.redAccent,
      displayColor: Colors.redAccent,
    ),
  ),
  child: ElevatedButton(
    onPressed: () {},
    child: Text('Alert'),
  ),
)

Practical Tips

  • Use named styles (headlineLarge, bodyMedium) for consistency.
  • Wrap parts of UI in Theme to tweak a single component.
  • Apply ElevatedButtonTheme and CardTheme from your theme for uniform shapes.

2. CustomTextField Widget

A versatile text input with styling, icons, validation, focus management, and formatters.

2.1 Basic Usage

import 'package:flutter/material.dart';
import 'package:remitance_app/src/common/widgets/custom_text_field.dart';

class LoginForm extends StatelessWidget {
  final _usernameController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return CustomTextField(
      controller: _usernameController,
      labelText: 'Username',
      hintText: 'Enter your username',
      prefixIcon: Icons.person,
      validator: (value) {
        if (value == null || value.isEmpty) {
          return 'Username is required';
        }
        return null;
      },
    );
  }
}

2.2 Advanced Features

CustomTextField(
  controller: _amountCtr,
  labelText: 'Amount',
  hintText: '0.00',
  keyboardType: TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
  suffixIcon: Icons.attach_money,
  onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
  decoration: InputDecoration(
    filled: true,
    fillColor: Theme.of(context).colorScheme.surfaceVariant,
  ),
)

3. AppSnackBar Utility

Use ShowSnackBar.showSnackBar() to display transient messages with optional action and error styling.

3.1 Simple Message

import 'package:flutter/material.dart';
import 'package:remitance_app/src/common/widgets/app_snack_bar.dart';

ElevatedButton(
  onPressed: () {
    ShowSnackBar.showSnackBar(
      context: context,
      message: 'Profile updated successfully',
    );
  },
  child: Text('Update'),
);

3.2 With Action & Custom Duration

ShowSnackBar.showSnackBar(
  context: context,
  message: 'Failed to save settings',
  isError: true,
  duration: Duration(seconds: 5),
  actionLabel: 'Retry',
  onAction: () {
    // retry logic
  },
);

4. Localization

English strings live in lib/localization/En.dart as a Map<String, String>. Use with your localization engine (e.g. GetX).

4.1 lib/localization/En.dart

// lib/localization/En.dart
const Map<String, String> en = {
  'home.title'        : 'Welcome Home',
  'home.subtitle'     : 'Glad you’re back!',
  'login.username'    : 'Username',
  'login.password'    : 'Password',
  'login.button'      : 'Log In',
  'button.cancel'     : 'Cancel',
  'button.confirm'    : 'Confirm',
  'error.network'     : 'Network error. Please try again.',
  'error.unauthorized': 'You must be signed in to continue.',
};

4.2 Registering with GetX

import 'package:get/get.dart';
import 'package:remitance_app/localization/En.dart';
import 'package:remitance_app/localization/Es.dart';

class AppTranslations extends Translations {
  @override
  Map<String, Map<String, String>> get keys => {
        'en_US': en,
        'es_ES': es,
      };
}

// In GetMaterialApp:
GetMaterialApp(
  translations: AppTranslations(),
  locale: Locale('en', 'US'),
  fallbackLocale: Locale('en', 'US'),
  // ...
);

4.3 Accessing Strings in Widgets

import 'package:get/get.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('home.title'.tr)),
      body: Center(child: Text('home.subtitle'.tr)),
    );
  }
}

4.4 Adding a New Key

  1. Choose a namespace: profile.
  2. Add to En.dart:
    'profile.edit_button': 'Edit Profile',
    
  3. Add to other locale files (Es.dart, etc.).
  4. Use in code:
    Text('profile.edit_button'.tr)
    

Best Practices

  • Group keys by feature or screen.
  • Use dot notation (feature.element) consistently.
  • Prefer interpolation over concatenation:
    // In En.dart
    'welcome_user': 'Hello, %s!';
    
    // In code
    Text('welcome_user'.trArgs(['Alice']));
    

Development & Deployment

This section covers your daily workflow, automated testing, Android/iOS release steps, and guidelines for community contributions.


1. Local Development Workflow

1.1 Project Setup

git clone https://github.com/Barok-Getachew/remitance-app.git
cd remitance-app
flutter pub get

1.2 Code Quality

  • Static analysis: configured in analysis_options.yaml (follows Flutter recommended lints)
flutter analyze
  • Auto-formatting:
flutter format .

1.3 Running the App

  • On a connected device or emulator:
flutter run -d <device_id>
  • To target specific flavors (Android):
flutter run --flavor dev -d <device_id>

2. Automated Testing

2.1 Widget Tests

See test/widget_test.dart for a counter increment example.

import 'package:flutter_test/flutter_test.dart';
import 'package:remitance/src/my_app/page/my_app.dart';

void main() {
  testWidgets('Counter increments smoke test', (tester) async {
    await tester.pumpWidget(const RemitanceApp());
    expect(find.text('0'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();
    expect(find.text('1'), findsOneWidget);
  });
}

Run widget tests:

flutter test test/widget_test.dart

2.2 Unit & Integration Tests

  • Place unit tests under test/ with _test.dart suffix
  • For integration tests, use integration_test/ directory and run:
flutter drive \
  --driver integration_test/driver.dart \
  --target integration_test/app_test.dart

3. Android Release (Play Store)

3.1 Versioning & Identifiers

In android/app/build.gradle, update:

defaultConfig {
    applicationId "com.mycompany.remitance"
    versionCode flutterVersionCode.toInteger()
    versionName flutterVersionName
}

Sync with pubspec.yaml:

version: 1.2.0+12  # 1.2.0 → versionName, 12 → versionCode

3.2 Signing Config

  1. Generate a keystore:
    keytool -genkey -v -keystore ~/remitance.jks \
      -alias remitance_alias -keyalg RSA -keysize 2048 -validity 10000
    
  2. Store credentials in android/key.properties:
    storePassword=<keystore_password>
    keyPassword=<key_password>
    keyAlias=remitance_alias
    storeFile=/Users/<you>/remitance.jks
    
  3. In android/app/build.gradle:
    signingConfigs {
      release {
        keyProperties.load(file("key.properties").newDataInputStream())
        storeFile file(keyProperties['storeFile'])
        storePassword keyProperties['storePassword']
        keyAlias keyProperties['keyAlias']
        keyPassword keyProperties['keyPassword']
      }
    }
    buildTypes {
      release {
        signingConfig signingConfigs.release
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
      }
    }
    

3.3 Build & Upload

cd android
./gradlew clean assembleRelease
# APK at app/build/outputs/apk/release/app-release.apk

4. iOS Release (App Store)

4.1 Bundle Identifier & Versioning

In ios/Runner/Info.plist:

<key>CFBundleIdentifier</key>
<string>com.mycompany.remitance</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<key>CFBundleVersion</key>
<string>12</string>

Match pubspec.yaml version.

4.2 Provisioning & Certificates

  1. Open ios/Runner.xcworkspace in Xcode.
  2. Select target “Runner” → Signing & Capabilities.
  3. Choose your Team, ensure automatic signing or attach your provisioning profile.

4.3 Build & Archive

flutter build ios --release

In Xcode: Product → Archive → Upload to App Store.


5. Community Contributions

5.1 Coding Standards

  • Follow lint rules in analysis_options.yaml
  • Use flutter format . before commits

5.2 Commit Messages

  • Use Conventional Commits:
    • feat: add new feature
    • fix: bug fix
    • docs: documentation only changes
  • Reference issues: fixes #123

5.3 Pull Request Process

  1. Fork the repo and create a feature branch.
  2. Add tests for new functionality.
  3. Ensure all tests pass: flutter test + flutter analyze.
  4. Open a PR against main with a clear description and linked issue.
  5. Address review feedback; squash or rebase commits if needed.