Skip to content

.github/workflows/build-android.yml #190

@juanmanueljaimeslira55420-spec

Description

name: Build Android (Flutter)

on:
workflow_dispatch:
push:
branches: [ main, master ]

jobs:
build:
runs-on: ubuntu-latest

steps:
  - name: Checkout (repo puede estar vacío)
    uses: actions/checkout@v4

  - name: Setup Java
    uses: actions/setup-java@v4
    with:
      distribution: temurin
      java-version: 17

  - name: Setup Flutter
    uses: subosito/flutter-action@v2
    with:
      channel: stable

  - name: Crear proyecto Flutter base
    run: |
      flutter --version
      flutter create .
      rm -rf lib/*
      mkdir -p lib/shared/widgets lib/features/auth lib/features/mail/models lib/features/mail lib/features/message

  - name: Escribir archivos del esqueleto
    run: |
      cat << 'EOF' > pubspec.yaml
      name: mailhub_flutter
      description: MVP para gestionar correos multi-cuenta (Gmail y Outlook) con Flutter.
      publish_to: "none"
      version: 0.1.0+1

      environment:
        sdk: ">=3.3.0 <4.0.0"

      dependencies:
        flutter:
          sdk: flutter
        flutter_riverpod: ^2.5.1
        flutter_appauth: ^6.0.4
        dio: ^5.7.0
        intl: ^0.19.0
        url_launcher: ^6.3.0

      dev_dependencies:
        flutter_test:
          sdk: flutter
        flutter_lints: ^4.0.0

      flutter:
        uses-material-design: true
        assets:
          - assets/
      EOF

      mkdir -p .github
      cat << 'EOF' > analysis_options.yaml
      include: package:flutter_lints/flutter.yaml

      linter:
        rules:
          prefer_single_quotes: true
          always_use_package_imports: true
      EOF

      cat << 'EOF' > .env.example
      GOOGLE_CLIENT_ID=
      MS_CLIENT_ID=
      REDIRECT_URI=com.mailhub.app:/oauth2redirect
      REDIRECT_URI_MS=com.mailhub.app://oauthredirect
      BACKEND_BASE_URL=http://10.0.2.2:3000
      EOF

      cat << 'EOF' > lib/main.dart
      import 'package:flutter/material.dart';
      import 'package:mailhub_flutter/app.dart';

      void main() {
        WidgetsFlutterBinding.ensureInitialized();
        runApp(const MailHubApp());
      }
      EOF

      cat << 'EOF' > lib/app.dart
      import 'package:flutter/material.dart';
      import 'package:flutter_riverpod/flutter_riverpod.dart';
      import 'package:mailhub_flutter/features/mail/inbox_page.dart';

      class MailHubApp extends StatelessWidget {
        const MailHubApp({super.key});

        @override
        Widget build(BuildContext context) {
          return ProviderScope(
            child: MaterialApp(
              title: 'MailHub',
              theme: ThemeData(
                colorSchemeSeed: Colors.blue,
                useMaterial3: true,
              ),
              home: const InboxPage(),
            ),
          );
        }
      }
      EOF

      cat << 'EOF' > lib/shared/api_client.dart
      import 'package:dio/dio.dart';

      class ApiClient {
        ApiClient._internal();
        static final ApiClient instance = ApiClient._internal();

        final Dio dio = Dio(
          BaseOptions(
            baseUrl: const String.fromEnvironment(
              'BACKEND_BASE_URL',
              defaultValue: 'http://10.0.2.2:3000',
            ),
            connectTimeout: const Duration(seconds: 15),
            receiveTimeout: const Duration(seconds: 30),
          ),
        );
      }
      EOF

      cat << 'EOF' > lib/shared/local_db.dart
      // TODO: Implementar Drift (SQLite) para cachear mensajes offline.
      // Placeholder para no romper el build.
      class LocalDbPlaceholder {}
      EOF

      cat << 'EOF' > lib/shared/widgets/message_list_tile.dart
      import 'package:flutter/material.dart';
      import 'package:intl/intl.dart';
      import 'package:mailhub_flutter/features/mail/models/email.dart';

      class MessageListTile extends StatelessWidget {
        const MessageListTile({super.key, required this.email, this.onTap});

        final Email email;
        final VoidCallback? onTap;

        @override
        Widget build(BuildContext context) {
          final df = DateFormat('dd MMM HH:mm');
          final initials = (email.from.isNotEmpty ? email.from[0] : '?').toUpperCase();

          return ListTile(
            leading: CircleAvatar(child: Text(initials)),
            title: Text(
              email.subject,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                fontWeight: email.unread ? FontWeight.bold : FontWeight.normal,
              ),
            ),
            subtitle: Text(
              '${email.from} — ${email.snippet}',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
            trailing: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(df.format(email.date)),
                if (email.hasAttachments) const Icon(Icons.attach_file, size: 16),
              ],
            ),
            onTap: onTap,
          );
        }
      }
      EOF

      cat << 'EOF' > lib/features/auth/auth_service.dart
      import 'dart:io';
      import 'package:flutter_appauth/flutter_appauth.dart';

      /// Servicio de autenticación OAuth (Google/Microsoft) con PKCE.
      /// NOTA: Debes registrar los esquemas de URL en Android/iOS.
      /// Android: AndroidManifest.xml -> <intent-filter> con tu scheme.
      /// iOS: Info.plist -> CFBundleURLTypes.
      class AuthService {
        AuthService();

        static const _googleClientId = String.fromEnvironment(
          'GOOGLE_CLIENT_ID',
          defaultValue: 'REEMPLAZA.apps.googleusercontent.com',
        );
        static const _msClientId = String.fromEnvironment(
          'MS_CLIENT_ID',
          defaultValue: 'REEMPLAZA_MICROSOFT',
        );
        static const _googleRedirect = String.fromEnvironment(
          'REDIRECT_URI',
          defaultValue: 'com.mailhub.app:/oauth2redirect',
        );
        static const _msRedirect = String.fromEnvironment(
          'REDIRECT_URI_MS',
          defaultValue: 'com.mailhub.app://oauthredirect',
        );

        final FlutterAppAuth _appAuth = const FlutterAppAuth();

        Future<AuthorizationTokenResponse?> signInWithGoogle() async {
          return _appAuth.authorizeAndExchangeCode(
            AuthorizationTokenRequest(
              _googleClientId,
              _googleRedirect,
              serviceConfiguration: const AuthorizationServiceConfiguration(
                authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
                tokenEndpoint: 'https://oauth2.googleapis.com/token',
              ),
              scopes: [
                'openid',
                'email',
                'profile',
                'https://www.googleapis.com/auth/gmail.modify',
              ],
              preferEphemeralSession: !Platform.isAndroid,
            ),
          );
        }

        Future<AuthorizationTokenResponse?> signInWithMicrosoft() async {
          return _appAuth.authorizeAndExchangeCode(
            AuthorizationTokenRequest(
              _msClientId,
              _msRedirect,
              serviceConfiguration: const AuthorizationServiceConfiguration(
                authorizationEndpoint:
                    'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
                tokenEndpoint:
                    'https://login.microsoftonline.com/common/oauth2/v2.0/token',
              ),
              scopes: [
                'openid',
                'offline_access',
                'email',
                'profile',
                'Mail.ReadWrite',
                'Mail.Send',
              ],
              preferEphemeralSession: !Platform.isAndroid,
            ),
          );
        }
      }
      EOF

      cat << 'EOF' > lib/features/auth/auth_controller.dart
      import 'package:flutter_riverpod/flutter_riverpod.dart';
      import 'package:mailhub_flutter/features/auth/auth_service.dart';

      final authServiceProvider = Provider<AuthService>((ref) => AuthService());

      class AuthState {
        const AuthState({this.loading = false, this.error});
        final bool loading;
        final String? error;

        AuthState copyWith({bool? loading, String? error}) =>
            AuthState(loading: loading ?? this.loading, error: error);
      }

      final authControllerProvider =
          StateNotifierProvider<AuthController, AuthState>((ref) {
        final svc = ref.read(authServiceProvider);
        return AuthController(svc);
      });

      class AuthController extends StateNotifier<AuthState> {
        AuthController(this._auth) : super(const AuthState());
        final AuthService _auth;

        Future<void> signInGoogle() async {
          state = state.copyWith(loading: true, error: null);
          try {
            await _auth.signInWithGoogle();
          } catch (e) {
            state = state.copyWith(error: e.toString());
          } finally {
            state = state.copyWith(loading: false);
          }
        }

        Future<void> signInMicrosoft() async {
          state = state.copyWith(loading: true, error: null);
          try {
            await _auth.signInWithMicrosoft();
          } catch (e) {
            state = state.copyWith(error: e.toString());
          } finally {
            state = state.copyWith(loading: false);
          }
        }
      }
      EOF

      cat << 'EOF' > lib/features/mail/models/email.dart
      class Email {
        Email({
          required this.id,
          required this.from,
          required this.to,
          required this.subject,
          required this.snippet,
          required this.date,
          this.unread = false,
          this.hasAttachments = false,
        });

        final String id;
        final String from;
        final String to;
        final String subject;
        final String snippet;
        final DateTime date;
        final bool unread;
        final bool hasAttachments;
      }
      EOF

      cat << 'EOF' > lib/features/mail/mail_repository.dart
      import 'dart:async';
      import 'package:mailhub_flutter/features/mail/models/email.dart';

      abstract class MailRepository {
        Future<List<Email>> listInbox({int page = 0});
      }

      class MockMailRepository implements MailRepository {
        @override
        Future<List<Email>> listInbox({int page = 0}) async {
          await Future<void>.delayed(const Duration(milliseconds: 250));
          return List.generate(25, (i) {
            final idx = i + page * 25;
            return Email(
              id: 'msg_$idx',
              from: 'remitente$idx@acme.com',
              to: 'yo@ejemplo.com',
              subject: 'Asunto de prueba #$idx',
              snippet: 'Este es un fragmento simulado del mensaje $idx...',
              date: DateTime.now().subtract(Duration(minutes: idx * 5)),
              unread: idx % 3 == 0,
              hasAttachments: idx % 5 == 0,
            );
          });
        }
      }
      EOF

      cat << 'EOF' > lib/features/mail/inbox_controller.dart
      import 'package:flutter_riverpod/flutter_riverpod.dart';
      import 'package:mailhub_flutter/features/mail/mail_repository.dart';
      import 'package:mailhub_flutter/features/mail/models/email.dart';

      final mailRepoProvider =
          Provider<MailRepository>((ref) => MockMailRepository());

      final inboxProvider =
          StateNotifierProvider<InboxController, AsyncValue<List<Email>>>((ref) {
        final repo = ref.read(mailRepoProvider);
        return InboxController(repo);
      });

      class InboxController extends StateNotifier<AsyncValue<List<Email>>> {
        InboxController(this._repo) : super(const AsyncValue.loading()) {
          load();
        }

        final MailRepository _repo;
        int _page = 0;
        bool _isLoadingMore = false;

        Future<void> load() async {
          state = const AsyncValue.loading();
          try {
            final data = await _repo.listInbox(page: 0);
            state = AsyncValue.data(data);
            _page = 0;
          } catch (e, st) {
            state = AsyncValue.error(e, st);
          }
        }

        Future<void> loadMore() async {
          if (_isLoadingMore) return;
          _isLoadingMore = true;
          try {
            final next = await _repo.listInbox(page: _page + 1);
            state = state.whenData((value) => [...value, ...next]);
            _page += 1;
          } finally {
            _isLoadingMore = false;
          }
        }
      }
      EOF

      cat << 'EOF' > lib/features/message/message_page.dart
      import 'package:flutter/material.dart';
      import 'package:mailhub_flutter/features/mail/models/email.dart';

      class MessagePage extends StatelessWidget {
        const MessagePage({super.key, required this.email});
        final Email email;

        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(
              title: Text(
                email.subject,
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
            ),
            body: Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                'De: ${email.from}\nPara: ${email.to}\n\n'
                '${email.snippet}\n\n'
                '(Cuerpo HTML vendrá aquí cuando se integre Gmail/Graph)',
              ),
            ),
          );
        }
      }
      EOF

      cat << 'EOF' > lib/features/mail/inbox_page.dart
      import 'package:flutter/material.dart';
      import 'package:flutter_riverpod/flutter_riverpod.dart';
      import 'package:mailhub_flutter/features/auth/auth_controller.dart';
      import 'package:mailhub_flutter/features/mail/inbox_controller.dart';
      import 'package:mailhub_flutter/features/message/message_page.dart';
      import 'package:mailhub_flutter/shared/widgets/message_list_tile.dart';

      class InboxPage extends ConsumerWidget {
        const InboxPage({super.key});

        @override
        Widget build(BuildContext context, WidgetRef ref) {
          final inbox = ref.watch(inboxProvider);
          final auth = ref.watch(authControllerProvider);

          return Scaffold(
            appBar: AppBar(
              title: const Text('Bandeja unificada'),
              actions: [
                IconButton(
                  tooltip: 'Conectar Google',
                  onPressed: ref.read(authControllerProvider.notifier).signInGoogle,
                  icon: const Icon(Icons.alternate_email),
                ),
                IconButton(
                  tooltip: 'Conectar Microsoft',
                  onPressed: ref.read(authControllerProvider.notifier).signInMicrosoft,
                  icon: const Icon(Icons.mail_outline),
                ),
              ],
            ),
            body: inbox.when(
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (e, _) => Center(child: Text('Error: $e')),
              data: (items) => NotificationListener<ScrollNotification>(
                onNotification: (n) {
                  if (n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
                    ref.read(inboxProvider.notifier).loadMore();
                  }
                  return false;
                },
                child: ListView.builder(
                  itemCount: items.length,
                  itemBuilder: (context, i) => MessageListTile(
                    email: items[i],
                    onTap: () {
                      Navigator.of(context).push(MaterialPageRoute(
                        builder: (_) => MessagePage(email: items[i]),
                      ));
                    },
                  ),
                ),
              ),
            ),
            floatingActionButton: FloatingActionButton.extended(
              onPressed: () {},
              label: const Text('Nuevo'),
              icon: const Icon(Icons.edit),
            ),
            bottomNavigationBar:
                auth.loading ? const LinearProgressIndicator(minHeight: 2) : null,
          );
        }
      }
      EOF

  - name: flutter pub get
    run: flutter pub get

  - name: Build APK (release)
    run: flutter build apk --release

  - name: Publicar APK como artifact
    uses: actions/upload-artifact@v4
    with:
      name: app-release
      path: build/app/outputs/flutter-apk/app-release.apk

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions