-
Notifications
You must be signed in to change notification settings - Fork 91
Open
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
Labels
No labels