GameSense Tracker was a comprehensive gaming statistics tracker built with Flutter, featuring advanced support for Tom Clancy's Rainbow Six® Siege. The app was previously available on the iOS app store but has been discontinued due to restrictions and limitations imposed by Ubisoft on their API services.
🎉 Success Metrics: Despite its architectural shortcomings, GameSense generated over 300,000 unique downloads before its takedown in 2025, demonstrating strong user engagement and market demand.
📅 Historical Context: This code was initially written in 2022 before I had any university or work experience and was done completely self-taught, hence many of the architectural issues and bad practices documented below. It represents my early learning journey in Flutter development.
This repository is now public as the project is legacy and serves as a code portfolio/reference for Flutter development patterns and techniques - both what to do and what not to do.
As a legacy project, GameSense has several architectural and development shortcomings that would benefit from refactoring in a modern implementation:
- Lack of separation of concerns: Business logic is tightly coupled with UI components
- No proper state management: Heavy reliance on
setState()instead of proper state management solutions (Bloc, Provider, Riverpod) - Mixed responsibilities: UI widgets handle data fetching, business logic, and presentation
- No dependency injection: Hard dependencies make testing and modularity difficult
- Large widget files: Many widgets are hundreds of lines long and handle multiple responsibilities
- Inconsistent error handling: Mix of different error handling patterns throughout the codebase
- Debug code in production: Excessive debug print statements and logging
- Hardcoded values: Magic numbers and strings scattered throughout the code
- Inconsistent naming: Mix of camelCase and other naming conventions
- No repository pattern: Direct API calls from UI components
- Poor data caching: Limited offline support and data persistence strategy
- No data validation: Insufficient input validation and sanitization
- Mixed data sources: Firebase, REST APIs, and local storage handled inconsistently
- Minimal test coverage: Very few unit tests, integration tests, or widget tests
- Untestable code: Tight coupling makes unit testing difficult
- No mocking: Direct dependencies on external services without abstraction
- Large commits: Poor version control practices with large, mixed-purpose commits
- Inconsistent code style: No enforced code formatting or linting rules
- No documentation: Limited inline documentation and architectural decision records
- Security concerns: Potential exposure of sensitive data in debug logs
- Excessive rebuilds: Poor widget optimization leading to unnecessary rebuilds
- Memory leaks: Potential memory leaks from unclosed streams and listeners
- Large bundle size: Unused dependencies and assets increasing app size
- Poor list performance: Inefficient list rendering for large datasets
- Implement Clean Architecture with proper layer separation
- Use a robust state management solution (Bloc, Riverpod)
- Add comprehensive testing at all levels
- Implement proper dependency injection
- Use repository pattern for data access
- Add proper error handling and logging
- Implement offline-first architecture
- Use code generation for models and routing
- Add proper CI/CD pipeline with automated testing
- Implement proper security practices
This project serves as a learning example of how not to structure a large Flutter application, while still demonstrating functional implementation of complex features like in-app purchases, camera integration, and real-time data processing.
It's important to note that this represents early self-taught development work from 2022 and should be viewed as a snapshot of learning progression rather than current best practices.
- 📊 Real-time Statistics: Track detailed player statistics and performance metrics
- 📱 Camera Integration: Use device camera to capture and analyze game scoreboards
- 🎯 Operator Recommendations: AI-powered operator ban suggestions based on opponent data
- 👥 Social Features: Save friends to favorites and track their progress
- 📈 Advanced Analytics: View K/D ratios, ranks, win percentages, and seasonal stats
- 🏆 Leaderboards: Global and friend-based leaderboards
- 📱 Cross-Platform: Available for both iOS and Android
- 💰 In-App Purchases: Premium features and customization options
- 🎮 Multi-Game Support: Extensible architecture for additional games
This repository contains placeholder values for security reasons.
You must configure your own credentials before the app will function. See the setup instructions below.
- Framework: Flutter
- Backend: Firebase (Database, Authentication, Crashlytics)
- APIs: Custom game statistics APIs
- Payments: In-App Purchases
- Advertising: Google AdMob
- Authentication: Firebase Auth with anonymous sign-in
- Storage: Shared Preferences + Secure Storage
- Image Processing: Camera integration for scoreboard analysis
The app follows a modular architecture with clear separation of concerns:
lib/main.dart- App initialization and configurationlib/configs/- Configuration management and constantslib/utilities/- Shared utilities and serviceslib/rainbow_six_siege/- Game-specific implementationslib/alert_views/- Reusable UI componentslib/main_menu/- Main navigation and UIlib/settings/- User preferences and configuration
- Flutter SDK (latest stable version)
- Android Studio / Xcode for platform-specific development
- Firebase project with required services enabled
- Valid signing certificates for app store deployment
-
Clone the repository:
git clone https://github.com/your-username/GameSense-Flutter.git cd GameSense-Flutter -
Install dependencies:
flutter pub get
-
Configure environment variables:
- Copy
.env.exampleto.env - Fill in your actual API keys and configuration values (see Configuration below)
- Copy
-
Set up Firebase:
- Replace placeholder values in
lib/firebase_options.dartwith your Firebase project configuration - Add
google-services.json(Android) andGoogleService-Info.plist(iOS) from your Firebase console
- Replace placeholder values in
-
Run the app:
flutter run
lib/
├── main.dart # App entry point
├── firebase_options.dart # Firebase configuration
├── configs/ # App configuration
│ ├── config.dart # Main config class
│ ├── enums.dart # App-wide enumerations
│ └── operator_categories.dart # Game-specific categories
├── utilities/ # Shared utilities
│ ├── api/ # API communication
│ ├── firebase/ # Firebase services
│ ├── in_app_purchases/ # Payment handling
│ ├── storage/ # Data persistence
│ └── ui/ # UI components
├── rainbow_six_siege/ # Game-specific features
│ ├── stats/ # Statistics views
│ └── *.dart # Game screens
├── main_menu/ # Main navigation
├── settings/ # App settings
└── alert_views/ # Modal dialogs
- Secure receipt validation with JWT encryption
- Premium subscription management
- Consumable purchases (verification badges)
- Purchase restoration functionality
- Google AdMob integration
- Smart ad timing and frequency control
- Premium user ad removal
- Banner and interstitial ad support
- Real-time scoreboard image capture
- Image processing for stat extraction
- Privacy-conscious image handling
- JWT-based API authentication
- Encrypted local storage for sensitive data
- Secure HTTP communication
- Firebase security rules compliance
Create a .env file in the project root using .env.example as a template. Key variables include:
# JWT Secret Key (generate a secure random string)
JWT_SECRET_KEY=your-jwt-secret-key-here
# API Endpoints
DOMAIN_API_EPOCH=https://your-api-domain.com/epoch
DOMAIN_API_RECEIPT_VERIFIER=https://your-api-domain.com/verify-receipt
DOMAIN_TERMS_CONDITIONS=https://your-domain.com/terms
DOMAIN_PRIVACY_POLICY=https://your-domain.com/privacy
# In-App Purchase Configuration
IAP_PREMIUM=your.premium.product.id
IAP_CHECKMARK_BADGE=your.badge.product.id
KEY_IAP_HTTP=your-iap-http-encryption-key
KEY_IAP_STORAGE=your-iap-storage-encryption-key
KEY_UUID_STORAGE=your-uuid-storage-encryption-key
# Google AdMob (replace with your actual Ad Unit IDs)
ADS_TESTING=false
ADS_IOS_BANNER=your-ios-banner-ad-unit-id
ADS_ANDROID_BANNER=your-android-banner-ad-unit-id
ADS_IOS_INTERSTITIAL=your-ios-interstitial-ad-unit-id
ADS_ANDROID_INTERSTITIAL=your-android-interstitial-ad-unit-id
# Ad timing and frequency
ADS_MINIMUM_INTERVAL=300
ADS_ADDITIONAL_TIME_MAXIMUM=120
ADS_PROBABILITY_NUMERATOR=1
ADS_PROBABILITY_DENOMINATOR=3The lib/firebase_options.dart file contains placeholder values. Replace them with your actual Firebase project configuration from the Firebase Console.
.env file to version control.
- Banner ads must display beneath loading screens.
- Block ads can appear on loading screens.
- A single ad placement should only be manually loaded once.
- Avoid calling
setState()unless it's absolutely crucial for updating the UI.
In order to be able to use the same adUnitId across multiple screens, and avoid unnecessary ad loading, we need to cache the instances of each screen's banner ad(s). The cache is stored in Ads._cachedAds as:
static final Map<String, BannerAd> _cachedAds = {};where each key follows the format of $adUnitId_$screenKey, and each value is a BannerAd instance. The screenKey is unique to each instance of each screen. It is not re-generated on setState().
Banner ads can be integrated as sticky or natural placements.
- Sticky: Stays fixed at the bottom of the screen and ignores
Widgets behind it, placed in aStack. - Natural: Can be placed in a
Columnand will move with content, can also be placed in aColumnwhere the preceding child or children is/are wrapped in anExpandedwidget, meaning the body content will not be covered, but the banner ad will still stay at the bottom.
- Import the
ads.dartfile from'package:GameSense/extensions/ui/ads/ads.dart'
import 'package:GameSense/extensions/ui/ads/ads.dart';- Define
keyfor the private State class that you want to place the ad in. - Declare a placeholder for an
AsyncMemoizerfor each ad placement. If placing a banner ad and a block ad, for example, twoAsyncMemoizers are needed. - Declare a
Future<Widget>?placeholder to pair with eachAsyncMemoizer. This will be used to store a single instance of the eachFutureto minimize instantiation. - Inside the
initState()method, givekeya value of a newGlobalKey; give_bannerMemoizerand_blockMemoizervalues of newAsyncMemoizers. - Ensure that you have declared a
boolvalue calledhasPremiumin yourbuild()method. This wil be used to determine whether ads should be displayed. - (OPTIONAL) If there are alert Widgets that overlay on top of banner ads, the banner ads should temporarily be hidden while the overlay is shown. In your private State class, declare
bool overlayWidgetShown = false;.
class _MainState extends State<Main> {
// AdMob
late final GlobalKey key;
late final AsyncMemoizer _bannerMemoizer;
late final AsyncMemoizer _blockMemoizer;
Future<Widget>? buildBannerAd;
Future<Widget>? buildBlockAd;
// Whether any overlay widget is shown.
bool overlayWidgetShown = false;
@override
void initState() {
// AdMob
key = GlobalKey();
_bannerMemoizer = AsyncMemoizer();
_blockMemoizer = AsyncMemoizer();
super.initState();
}
}- Inside your state class, declare the following functions. Use one or both as needed:
Future<Widget> _buildBannerAd({required double width}) async =>
await _bannerMemoizer.runOnce(() async {
return await Ads.banner(width: width, key: key).widget();
});
Future<Widget> _buildBlockAd({required double width}) async =>
await _blockMemoizer.runOnce(() async {
return await Ads.block(key: key).widget();
});-
In your
build()method, declarewidthwith the value of the screen's width andheightwith the value of the screen's height. -
Give your
Futureplaceholders values respectively. Use??=to give them a new value only if they arenull. -
(OPTIONAL) If there are alert Widgets that overlay on top of banner ads, the banner ads should temporarily be hidden while the overlay is shown. Give
overlayWidgetShowna value of each individualisShownvariable for each overlay Widget separated by 'or' statements (||).- Eg.
overlayWidgetShown = _errorWidgetShwon || _purchaseWidgetShown
- Eg.
final double width = MediaQuery.of(context).size.width;
final double height = MediaQuery.of(context).size.height;
// AdMob
buildBannerAd ??= _buildBannerAd(width: width);
buildBlockAd ??= _buildBlockAd(width: width);
// Show/hide banner ads when overlay widgets are shown/hidden.
overlayWidgetShown = CONDITIONAL_STATEMENT_HERE;- Create a
Stackin your body. - Add your main body
Widget(s) as the first child or children. - Add the following so your
Stacklooks something like this:
Stack(
children: [
// Your main body Widget(s).
...,
// If the user doesn't have Premium, and should see ads.
if (!hasPremium && !overlayWidgetShown)
SizedBox(
// Gives the banner ad the ability to stick to the bottom of the screen.
height: height,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// The Spacer will force the banner to stay at the bottom.
const Spacer(),
FutureBuilder<Widget>(
// Your previously defined "buildBannerAd" instance.
future: buildBannerAd,
builder: (_, snapshot) {
return snapshot.hasData
// Shows the banner ad.
? snapshot.data!
// Shows nothing when the ad is not loaded.
: Ads.banner(width: width, key: key)
.defaultWidget();
}
)
]
)
)
]
)- Create a
Columnin your body. - Add your main body
Widget(s) as the first child or children. OPTIONAL: Wrap one of thoseWidgets with anExpanded(child: ...)to force the natural banner ad to stay at the bottom. - Add the following so your
Columnlooks something like this:
Column(
children: [
// Your expanded body widget. Other widgets can be above or below this too.
Expanded(
child: ...
),
// If the user doesn't have Premium, and should see ads.
if (!hasPremium && !overlayWidgetShown)
FutureBuilder<Widget>(
// Your previously defined "buildBannerAd" instance.
future: buildBannerAd,
builder: (_, snapshot) {
return snapshot.hasData
// Shows the banner ad.
? snapshot.data!
// Shows nothing when the ad is not loaded.
: Ads.banner(width: width, key: key)
// Specify styling for the placeholder
.defaultWidget(
backgroundColor: MyColors.r6grey,
textColor: MyColors.r6light
);
}
)
]
)Block ads should avoid having Widgets on either side of them. Block ads should be placed in Columns.
- Create a
Columnin your body. - Add your main body
Widget(s) as the first child or children. - Add the following so your
Columnlooks something like this:
Column(
children: [
// Your main body Widget(s).
...,
// If the user doesn't have Premium, and should see ads.
if (!hasPremium && !overlayWidgetShown)
FutureBuilder<Widget>(
// Your previously defined "buildBlockAd" instance.
future: buildBlockAd,
builder: (_, snapshot) {
return snapshot.hasData
// Shows the banner ad.
? snapshot.data!
// Shows nothing when the ad is not loaded.
: const SizedBox();
}
)
]
)- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project implements several security measures:
- Environment-based configuration management
- Encrypted data storage
- Secure API communication
- Production-ready authentication flows
If you discover any security vulnerabilities, please report them responsibly.
This project is licensed for viewing purposes only.
All code and assets © 2020–2025 Jarren Morris.
See the LICENSE file for full details.
- Built with Flutter and Firebase
- Rainbow Six Siege statistics integration
- Google AdMob for monetization
- Various open-source Flutter packages
This project is not affiliated with or endorsed by Ubisoft Entertainment. Tom Clancy's Rainbow Six® Siege is a trademark of Ubisoft Entertainment.