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
- Launch an Android emulator or connect a device:
flutter emulators --launch Pixel_4_API_30
- Run the app:
flutter run -d emulator-5554
4. Fastest Path: Run on iOS
- Launch the iOS Simulator:
open -a Simulator
- 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
- 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()));
- 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
}
- 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
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), ));
Calling from Repository
final success = await transactionRepository.addTransaction(myTransactionEntity);
Error Handling
- Throws on
getTransactions()
failure. - Returns
false
fromaddTransaction
if the persistence check fails.
- Throws on
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
Register in GetX (ensure GetConnect is available):
Get.put<GetConnect>(GetConnect(), permanent: true); Get.lazyPut<ExchangeRemoteDataSource>( () => ExchangeRemoteDataSourceImpl(getConnect: Get.find()), );
Inject into your repository:
Get.lazyPut<ExchangeRepository>( () => ExchangeRepositoryImpl(remoteDataSource: Get.find<ExchangeRemoteDataSource>()), );
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 overridegetLatestRates
. - 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
andCardTheme
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
- Choose a namespace:
profile.
- Add to
En.dart
:'profile.edit_button': 'Edit Profile',
- Add to other locale files (
Es.dart
, etc.). - 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
- Generate a keystore:
keytool -genkey -v -keystore ~/remitance.jks \ -alias remitance_alias -keyalg RSA -keysize 2048 -validity 10000
- Store credentials in
android/key.properties
:storePassword=<keystore_password> keyPassword=<key_password> keyAlias=remitance_alias storeFile=/Users/<you>/remitance.jks
- 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
- Open
ios/Runner.xcworkspace
in Xcode. - Select target “Runner” → Signing & Capabilities.
- 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
- Fork the repo and create a feature branch.
- Add tests for new functionality.
- Ensure all tests pass:
flutter test
+flutter analyze
. - Open a PR against
main
with a clear description and linked issue. - Address review feedback; squash or rebase commits if needed.