Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
883abd8
feat: enhance UpdateRequiredScreen with improved layout and animations
diegodanielcaceres10 Oct 16, 2026
6e64750
feat: add persistAcrossBackgrounding option to biometric authentication
diegodanielcaceres10 Oct 16, 2026
f838ef0
feat: enhance transactions screen layout and loading indicators
diegodanielcaceres10 Oct 16, 2026
fa598fb
feat: enhance transaction statistics screens with improved layouts an…
diegodanielcaceres10 Oct 16, 2026
c3a6cea
feat: implement full-screen cropper page for web and enhance crop han…
diegodanielcaceres10 Oct 17, 2026
f9ec1ae
feat: add a free-form crop screen with draggable sides for Android an…
diegodanielcaceres10 Oct 17, 2026
9fd1b54
feat: enhance image cropping error handling and improve transaction f…
diegodanielcaceres10 Oct 17, 2026
75d15b1
feat: improve layout and styling of transaction transfer form for bet…
diegodanielcaceres10 Oct 17, 2026
735126b
feat: enhance profile screen layout and styling for improved user exp…
diegodanielcaceres10 Oct 17, 2026
c23f342
feat: enhance preferences screen layout and add icons for improved us…
diegodanielcaceres10 Oct 17, 2026
d852a89
feat: add large notification icon and update resource keeping for dra…
diegodanielcaceres10 Oct 17, 2026
a7886b6
build: add Firebase Crashlytics dependencies and Android Gradle plugins
diegodanielcaceres10 Oct 18, 2026
916d458
feat: add ErrorReporter abstraction with Crashlytics for Android
diegodanielcaceres10 Oct 18, 2026
9ac43e2
feat: report daily notification check failures to Crashlytics
diegodanielcaceres10 Oct 18, 2026
839a1c0
feat: report notification health problems to Crashlytics
diegodanielcaceres10 Oct 18, 2026
6c16598
feat: report unhandled errors to Crashlytics through global handlers
diegodanielcaceres10 Oct 18, 2026
36acee5
test: fix expected message in UnhandledAppError test
diegodanielcaceres10 Oct 18, 2026
e38b102
chore: update version to 2.0.0 in pubspec.yaml and navbar.html
diegodanielcaceres10 Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/firebase-distribute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ jobs:
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}

# Firebase config for Crashlytics, kept out of the repo (see README).
# Fails early when the secret is missing instead of in the Gradle build.
- name: Create google-services.json from secret
run: |
echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > android/app/google-services.json
if [ ! -s android/app/google-services.json ]; then
echo "::error::Falta el secret GOOGLE_SERVICES_JSON_BASE64 (ver README)."
exit 1
fi
env:
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}

# Genera android/key.properties (gitignored) que lee android/app/build.gradle
# para firmar el build de release con el keystore de producción.
- name: Create key.properties
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ quedar compiladas, cualquiera puede extraerlas del build.
Si compilás en CI (por ejemplo GitHub Actions), definí esas variables como
secrets/variables y generá el `.env` antes del build, o pasá cada una con
`--dart-define=NOMBRE=valor`.

### Crashlytics (Android)

Crashlytics necesita el `google-services.json` de la app Android registrada en
Firebase (`io.luma.app`). No se commitea (está en `app/android/.gitignore`) y
sin él el build de Android falla:

- **Local:** descargalo desde la consola de Firebase (Configuración del
proyecto → tu app Android) y guardalo en `app/android/app/google-services.json`.
- **CI:** guardalo codificado en base64 como secret `GOOGLE_SERVICES_JSON_BASE64`
(`base64 -w0 google-services.json` en Linux, `base64 -i google-services.json`
en macOS). El workflow de distribución lo restaura antes de compilar.
3 changes: 3 additions & 0 deletions app/android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks

# Firebase config for Crashlytics: provided locally or by CI (see README).
app/google-services.json
4 changes: 4 additions & 0 deletions app/android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
plugins {
id "com.android.application"
// Crashlytics. Needs android/app/google-services.json, which is not
// committed (see README).
id "com.google.gms.google-services"
id "com.google.firebase.crashlytics"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions app/android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ic_notification solo se referencia desde Dart (por nombre de string, ver
LocalNotificationsService), nunca desde un layout o manifest — R8 no
puede verlo y, si algún día se activa shrinkResources en el build de
release, lo eliminaría por "no usado". Este archivo le dice explícitamente
que lo conserve.
ic_notification e ic_notification_large solo se referencian desde Dart (por
nombre de string, ver LocalNotificationsService), nunca desde un layout o
manifest — R8 no puede verlos y, si algún día se activa shrinkResources en
el build de release, los eliminaría por "no usado". Este archivo le dice
explícitamente que los conserve.
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_notification" />
tools:keep="@drawable/ic_notification,@drawable/ic_notification_large" />
2 changes: 2 additions & 0 deletions app/android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.13.2" apply false
id "org.jetbrains.kotlin.android" version "2.2.21" apply false
id "com.google.gms.google-services" version "4.5.0" apply false
id "com.google.firebase.crashlytics" version "3.0.8" apply false
}

include ":app"
57 changes: 57 additions & 0 deletions app/lib/core/diagnostics/crashlytics_error_reporter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';

import 'error_reporter.dart';

/// Sends handled errors to Firebase Crashlytics.
///
/// Crashlytics is only set up for Android, so everywhere else [create]
/// returns a [NoopErrorReporter]. Non-fatal reports are written to disk and
/// uploaded the next time the app starts, not right away.
class CrashlyticsErrorReporter implements ErrorReporter {
final FirebaseCrashlytics _crashlytics;

CrashlyticsErrorReporter._(this._crashlytics);

/// One start attempt per isolate: the app and the background task each run
/// in their own isolate and need their own Firebase instance.
static Future<ErrorReporter>? _instance;

static bool get _isSupported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;

/// Starts Firebase in the current isolate, once, and returns the reporter.
/// Never throws: if Crashlytics is unavailable or fails to start, the
/// result is a [NoopErrorReporter].
static Future<ErrorReporter> create() => _instance ??= _start();

static Future<ErrorReporter> _start() async {
if (!_isSupported) return const NoopErrorReporter();

try {
// Without options, Android reads the config from google-services.json.
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
return CrashlyticsErrorReporter._(FirebaseCrashlytics.instance);
} catch (_) {
return const NoopErrorReporter();
}
}

@override
Future<void> recordError(
Object error,
StackTrace stackTrace, {
String? reason,
bool fatal = false,
}) async {
try {
await _crashlytics.recordError(
error,
stackTrace,
reason: reason,
fatal: fatal,
);
} catch (_) {}
}
}
30 changes: 30 additions & 0 deletions app/lib/core/diagnostics/error_reporter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/// Where handled errors are sent so they can be reviewed remotely.
///
/// Reporting is best effort: implementations must never throw, because a
/// problem in diagnostics must not break the feature being diagnosed.
abstract interface class ErrorReporter {
/// Reports an error that was caught, with the stack trace where it
/// happened. [reason] is a short note on what the app was doing. [fatal]
/// marks an error that nothing handled, as opposed to one the app caught
/// and dealt with.
Future<void> recordError(
Object error,
StackTrace stackTrace, {
String? reason,
bool fatal = false,
});
}

/// Discards every report. Used where Crashlytics is not available (web) and
/// when it fails to start.
class NoopErrorReporter implements ErrorReporter {
const NoopErrorReporter();

@override
Future<void> recordError(
Object error,
StackTrace stackTrace, {
String? reason,
bool fatal = false,
}) async {}
}
54 changes: 54 additions & 0 deletions app/lib/core/diagnostics/global_error_handlers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'dart:async';

import 'package:flutter/foundation.dart';

import 'error_reporter.dart';
import 'redact_error_message.dart';

/// An error that nothing in the app handled, shaped for an error report: it
/// keeps the type and message of the original error, without ids or email
/// addresses.
class UnhandledAppError implements Exception {
/// The error that reached the global handlers.
final Object cause;

const UnhandledAppError(this.cause);

@override
String toString() => 'UnhandledAppError: '
'${cause.runtimeType}: ${redactErrorMessage(cause.toString())}';
}

/// Sends the errors nothing else handled to [reporter]: the ones the Flutter
/// framework reports and the asynchronous ones that escape every zone.
///
/// The handlers that were installed before keep running afterwards, so the
/// errors still show up in the console during development.
void installGlobalErrorHandlers(ErrorReporter reporter) {
final previousFlutterHandler = FlutterError.onError;
FlutterError.onError = (details) {
unawaited(
reporter.recordError(
UnhandledAppError(details.exception),
details.stack ?? StackTrace.current,
reason: 'Flutter framework error',
fatal: true,
),
);
previousFlutterHandler?.call(details);
};

final previousPlatformHandler = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
unawaited(
reporter.recordError(
UnhandledAppError(error),
stack,
reason: 'Uncaught asynchronous error',
fatal: true,
),
);
// False leaves the error unhandled, as it was before this was installed.
return previousPlatformHandler?.call(error, stack) ?? false;
};
}
17 changes: 17 additions & 0 deletions app/lib/core/diagnostics/redact_error_message.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
final _uuid = RegExp(
r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}',
);
final _email = RegExp(r'[\w.+-]+@[\w-]+(\.[\w-]+)+');

/// Makes an error message safer to send off the device: ids and email
/// addresses are replaced and the text is cut at [maxLength].
///
/// This is a best-effort filter for the usual leaks, not a guarantee that the
/// message holds no personal data.
String redactErrorMessage(String message, {int maxLength = 300}) {
final redacted =
message.replaceAll(_uuid, '<id>').replaceAll(_email, '<email>');
return redacted.length <= maxLength
? redacted
: '${redacted.substring(0, maxLength)}…';
}
92 changes: 48 additions & 44 deletions app/lib/core/utils/image_crop_picker.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import 'dart:typed_data';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';

import '../../app/theme/app_colors.dart';
import '../widgets/crop_preview_dialog.dart';
import '../widgets/themed_cropper_dialog.dart';
import '../widgets/image_crop_screen.dart';
import '../widgets/themed_cropper_page.dart';

/// An image already cropped by the user, ready to be sent for scanning.
typedef PickedImage = ({Uint8List bytes, String mimeType});
Expand Down Expand Up @@ -127,50 +128,53 @@ Future<T?> pickAndCropImage<T>(
// The picker is async; the screen may be gone by now.
if (!context.mounted) return null;

final webSide = webCropperSideFor(MediaQuery.sizeOf(context));
final cropped = await ImageCropper().cropImage(
sourcePath: sourcePath,
compressFormat: ImageCompressFormat.jpg,
compressQuality: imageQuality,
// Web keeps cropper.js. Android and iOS use our own editor, because the
// native croppers only let the user drag from the corners.
if (kIsWeb) {
return _cropOnWeb(
context,
sourcePath,
quality: imageQuality,
maxWidth: maxWidth,
);
}

final bytes = await XFile(sourcePath).readAsBytes();
if (!context.mounted) return null;
return showImageCropScreen(
context,
bytes: bytes,
quality: imageQuality,
maxWidth: maxWidth,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'Recortar',
toolbarColor: AppColors.authBackgroundTop,
toolbarWidgetColor: AppColors.authTextPrimary,
backgroundColor: AppColors.authBackgroundTop,
activeControlsWidgetColor: AppColors.authAccent,
// Dark app: light icons on the status and navigation bars.
statusBarLight: false,
navBarLight: false,
dimmedLayerColor:
AppColors.authBackgroundTop.withValues(alpha: 0.7),
cropFrameColor: AppColors.authAccent,
cropGridColor: AppColors.authTextPrimary.withValues(alpha: 0.5),
initAspectRatio: CropAspectRatioPreset.original,
lockAspectRatio: false,
),
IOSUiSettings(
title: 'Recortar',
doneButtonTitle: 'Listo',
cancelButtonTitle: 'Cancelar',
),
WebUiSettings(
context: context,
size: CropperSize(width: webSide.floor(), height: webSide.floor()),
customDialogBuilder: (cropper, initCropper, crop, rotate, scale) =>
ThemedCropperDialog(
cropper: cropper,
initCropper: initCropper,
crop: crop,
rotate: rotate,
scale: scale,
cropperSide: webSide.floorToDouble(),
),
),
],
);
return cropped?.readAsBytes();
},
);
}

Future<Uint8List?> _cropOnWeb(
BuildContext context,
String sourcePath, {
required int quality,
int? maxWidth,
}) async {
final cropped = await ImageCropper().cropImage(
sourcePath: sourcePath,
compressFormat: ImageCompressFormat.jpg,
compressQuality: quality,
maxWidth: maxWidth,
uiSettings: [
WebUiSettings(
context: context,
// Full-screen page instead of a small dialog: the cropper uses all the
// space available, which matters on a phone browser.
presentStyle: WebPresentStyle.page,
customRouteBuilder: ThemedCropperPage.route,
// Dragging the image moves it; the crop box is resized from its
// handles. With the default mode a touch outside the box starts a new
// box, which is easy to trigger by accident on a phone.
dragMode: WebDragMode.move,
),
],
);
return cropped?.readAsBytes();
}
Loading
Loading