Creare un'App Secret Santa con Flutter

Guida completa per developer (codice completo e free su Github)

Creare un'App Secret Santa con Flutter - scarica la guida

Il Natale incontra l’Open Source

Il periodo natalizio è sinonimo di condivisione, e quale modo migliore per celebrarlo se non attraverso un progetto open source che unisce tecnologia e tradizione?

In questa guida completa, vi accompagneremo nella creazione di un'applicazione Secret Santa utilizzando Flutter, il framework cross-platform di Google che sta rivoluzionando lo sviluppo mobile.

Questo progetto non è solo un tutorial: è un esempio concreto di come un'idea semplice possa trasformarsi in un'applicazione completa e funzionale, pronta per essere utilizzata nella vita reale. E la cosa migliore? Tutto il codice è disponibile su GitHub, perché in Unitiva crediamo fermamente nella filosofia open source e nella condivisione della conoscenza.

Perché Flutter per un'app Secret Santa?

Prima di immergerci nel codice, è importante capire perché Flutter rappresenta la scelta ideale per questo progetto. La capacità di scrivere una singola codebase che funziona perfettamente su iOS, Android e web è solo l'inizio. Flutter offre un ecosistema ricco di package, performance native e un'esperienza di sviluppo straordinaria grazie all'hot reload.

Per un'applicazione come Secret Santa, dove l'interfaccia utente gioca un ruolo cruciale nell'esperienza dell'utente, il sistema di widget di Flutter ci permette di creare design accattivanti e responsive con relativa facilità. Ma non è solo questione di estetica: la gestione dello stato, la validazione dei dati e l'integrazione di funzionalità complesse come l'esportazione PDF/CSV trovano in Flutter un terreno fertile.

Architettura del progetto: clean e scalabile

Per garantire manutenibilità, testabilità e scalabilità futura, l'applicazione è stata progettata seguendo i principi della Clean Architecture, separando le responsabilità in tre layer fondamentali:

  • data,
  • domain,
  • presentation.

Questa scelta non è casuale: un'architettura pulita garantisce manutenibilità, testabilità e la possibilità di scalare il progetto in futuro senza dover riscrivere interi moduli.

Il layer di presentazione gestisce tutto ciò che l'utente vede e con cui interagisce. Qui troviamo i widget, le pagine e i cubit per la gestione dello stato. Il layer di dominio contiene la logica di business pura, indipendente da framework e librerie esterne. Infine, il layer data si occupa di come i dati vengono recuperati, salvati ed elaborati.

Questa separazione ci permette, ad esempio, di cambiare completamente il sistema di persistenza dei dati (passando da file locali a un database cloud) senza dover toccare una sola riga di codice nella UI o nella logica di business.

Struttura delle cartelle

La struttura delle cartelle segue le convenzioni consolidate nella community Flutter, facilitando l'onboarding di nuovi contributor.

lib/

├── main.dart

├── core/

│   ├── dependency_injection/

│   ├── routes/

│   └── utils/

└── features/

    └── home/

        ├── data/

        │   ├── models/           # Modelli per la persistenza (e.g., User)

        ├── domain/

        │   └── usecases/         # Logica di business pura

        └── presentation/

            ├── cubit/            # Gestione dello stato (BLoC/Cubit)

            ├── pages/            # Pagine e schermate

            └── widgets/          # Componenti riutilizzabili della UI
    

Il pattern BLoC: gestione dello stato intelligente

Per la gestione dello stato abbiamo scelto il pattern BLoC (Business Logic Component), nella sua variante semplificata: il Cubit. Questa scelta deriva dalla natura relativamente semplice dell'applicazione, dove non abbiamo bisogno della complessità completa di un BLoC tradizionale con eventi e stati.

Il Cubit ci permette di mantenere separata la logica di business dalla UI, rendendo il codice più testabile e manutenibile. Ogni stato rappresenta una condizione specifica dell'applicazione: iniziale, creazione gruppo, errore. Questa chiarezza nella definizione degli stati rende il flusso dell'applicazione prevedibile e facile da debuggare.

Guida passo passo all'implementazione

Configurazione iniziale

Crea il progetto Flutter e aggiungi le dipendenze:

flutter create secret_santa

cd secret_santa

flutter pub get
    

Aggiungi le dipendenze essenziali nel pubspec.yaml:

dependencies:

  flutter:

    sdk: flutter

  flutter_localizations:

    sdk: flutter

  easy_localization: ^3.0.7  # Internazionalizzazione

  flutter_bloc: ^9.1.0       # Gestione stato

  file_picker: ^8.1.2        # Importazione CSV

  pdf: ^3.10.7               # Generazione PDF

  printing: ^5.12.0          # Condivisione e stampa PDF

  share_plus: ^10.0.2        # Condivisione CSV

  # ... altre dipendenze come get_it, font_awesome_flutter, ecc.

flutter:

  uses-material-design: true

  assets:

    - assets/images/

    - assets/translations/
    

Modello Dati base

Il modello User è il fondamento su cui costruiamo l'intera applicazione. La semplicità è la chiave: name ed email sono tutto ciò che serve per identificare un partecipante. Tuttavia, questa semplicità nasconde decisioni progettuali importanti.

Abbiamo incluso metodi per la serializzazione JSON (toJson e fromJson) che permetteranno in futuro di salvare e recuperare facilmente i dati, sia localmente che da API remote. Il getter displayName fornisce un punto di estensione: in futuro potremmo voler mostrare nickname personalizzati o formattare il nome in modi diversi, e questo metodo ci offre un unico punto di controllo.

La validazione dell'email è cruciale: un indirizzo email malformato potrebbe causare problemi quando implementeremo funzionalità di notifica via email. Per questo motivo, utilizziamo una regex robusta che verifica la presenza dei componenti essenziali: parte locale, chiocciola e dominio.

lib/features/home/data/models/user_model.dart

class User {

  final String name;

  final String email;

  User({required this.name, required this.email});

  String get displayName => name;

  Map<String, dynamic> toJson() => {

    'name': name,

    'email': email,

  };

  factory User.fromJson(Map<String, dynamic> json) => User(

    name: json['name'],

    email: json['email'],

  );

}
    

Gestione dello stato (BLoC/Cubit)

lib/features/home/presentation/cubit/home_state.dart

import 'package:flutter/foundation.dart';

@immutable

sealed class HomeState {}

final class HomeInitial extends HomeState {}

final class HomeCreatingGroup extends HomeState {}

final class HomeLoaded extends HomeState {}

final class HomeError extends HomeState {

  final String message;

  HomeError(this.message);

}
    

lib/features/home/presentation/cubit/home_cubit.dart

import 'package:flutter_bloc/flutter_bloc.dart';

import 'home_state.dart';

class HomeCubit extends Cubit<HomeState> {

  HomeCubit() : super(HomeInitial());

  void startCreatingGroup() {

    emit(HomeCreatingGroup());

  }

  void backToInitial() {

    emit(HomeInitial());

  }

  // Aggiungere qui la logica per aggiungere utenti, generare assegnazioni, ecc.

}
    

Funzionalità principali e logica di business

Aggiunta utenti e validazione email

La validazione dell'email è cruciale per prevenire problemi con future funzionalità di notifica via email. Utilizziamo una robusta regex per verificare la presenza dei componenti essenziali: parte locale, chiocciola e dominio.

void _addUser() {

  // ... (omitted boilerplate for controllers and unfocus)

  

  // Validazione email con regex

  final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');

  if (!emailRegex.hasMatch(email)) {

    ScaffoldMessenger.of(context).showSnackBar(

      SnackBar(content: Text('Inserisci un indirizzo email valido')), // Messaggio localizzato

    );

    return;

  }

  

  setState(() {

    users.add(User(name: name, email: email));

    // ... (clear controllers)

  });

}
    

Importazione da CSV

L'importazione da CSV, gestita tramite il package file_picker, è fondamentale per organizzare un Secret Santa aziendale con molti dipendenti. Il parsing è robusto: salta l'header, verifica che nome ed email non siano vuoti ed è tollerante verso file "sporchi" con spazi extra.

/// Importa i dati utente da un file CSV selezionato dall'utente.

/// La funzione:

/// 1. Apre il selettore di file.

/// 2. Verifica che il file selezionato sia un CSV.

/// 3. Legge il contenuto del file.

/// 4. Parsa ogni riga (saltando l'intestazione) e crea oggetti User.

/// 5. Aggiorna lo stato.

void _importFromCSV() async {

  // 1. Apri il selettore di file per permettere all'utente di scegliere un file

  FilePickerResult? result = await FilePicker.platform.pickFiles(

    type: FileType.any, // Potresti voler usare FileType.custom con ['csv'] se FilePicker lo supporta

  );

  if (result != null) {

    final file = result.files.first;

    // 2. Verifica che il file selezionato sia un CSV

    if (!file.name.toLowerCase().endsWith('.csv')) {

      _showSnackBar(

        message: tr('messages.select_csv_file'),

        isError: true,

      );

      return;

    }

    String content;

    // 3. Leggi il contenuto del file

    if (file.bytes != null) {

      // Lettura da memoria (es. su Web)

      content = String.fromCharCodes(file.bytes!);

    } else if (file.path != null) {

      // Lettura da percorso file (es. su Mobile/Desktop)

      try {

        final fileObj = File(file.path!);

        content = await fileObj.readAsString();

      } catch (e) {

        _showSnackBar(

          message: tr('messages.unable_to_read_file'),

          isError: true,

        );

        return;

      }

    } else {

      // Caso in cui non è possibile accedere né ai bytes né al path

      _showSnackBar(

        message: tr('messages.unable_to_read_file'),

        isError: true,

      );

      return;

    }

    // 4. Parsa e processa il contenuto del CSV

    final lines = content.split('\n');

    // Itera su tutte le righe, saltando la prima (presupposta come intestazione)

    for (var line in lines.skip(1)) {

      // Rimuovi eventuali spazi bianchi all'inizio/fine della riga

      final cleanLine = line.trim();

      if (cleanLine.isEmpty) continue; // Salta righe vuote

      // Assumiamo che i campi siano separati da virgole (CSV standard)

      final parts = cleanLine.split(',');

      // Controlla che ci siano almeno 2 parti (Nome e Email)

      if (parts.length >= 2) {

        final name = parts[0].trim();

        final email = parts[1].trim();

        // Verifica che i campi nome ed email non siano vuoti

        if (name.isNotEmpty && email.isNotEmpty) {

          // Aggiungi il nuovo utente alla lista

          users.add(User(name: name, email: email));

        }

      }

      // Le righe con meno di 2 campi validi vengono semplicemente ignorate.

    }

  }

  // 5. Aggiorna l'interfaccia utente

  setState(() {});

}

/// Funzione helper per mostrare la SnackBar in modo pulito.

void _showSnackBar({required String message, bool isError = false}) {

  ScaffoldMessenger.of(context).showSnackBar(

    SnackBar(

      backgroundColor: isError ? AppColors.red[500] : AppColors.green[500],

      content: Text(message),

    ),

  );

}

// Nota: Assicurati che 'User', 'users', 'tr', 'AppColors' e 'context'

// siano definiti e accessibili nel contesto della classe State.
    

Algoritmo di assegnazione: matematica e casualità

Il cuore dell'applicazione è l'algoritmo che genera le assegnazioni, garantendo che nessuno regali a se stesso e che tutti ricevano un regalo.

La soluzione è elegante:

  1. Mescoliamo la lista degli utenti con shuffle() (algoritmo Fisher-Yates per casualità uniforme).
  2. Assegniamo in modo circolare: la persona in posizione $i$ regala alla persona in posizione $i+1$, e l'ultima regala alla prima grazie all'operatore modulo (%).

Questo approccio garantisce che la struttura risultante sia sempre un ciclo Hamiltoniano.

void _generateAssignments() {

  // Validazione preventiva: almeno due partecipanti

  if (users.length < 2) { 

    ScaffoldMessenger.of(context).showSnackBar(

      SnackBar(content: Text('Almeno 2 utenti necessari')),

    );

    return;

  }

  

  // 1. Mescola la lista

  final shuffled = List<User>.from(users)..shuffle();

  assignments.clear();

  

  // 2. Assegna in modo circolare

  for (int i = 0; i < shuffled.length; i++) {

    // La persona in posizione 'i' regala a quella in posizione '(i + 1) % lunghezza'

    assignments[shuffled[i]] = shuffled[(i + 1) % shuffled.length]; 

  }

  

  setState(() {});

  ScaffoldMessenger.of(context).showSnackBar(

    SnackBar(content: Text('Assegnazioni generate con successo!')),

  );

}
    

Esportazione PDF

L'esportazione PDF trasforma le assegnazioni in un documento condivisibile e stampabile. Utilizziamo il package pdf per costruire il documento e printing per la condivisione multi-piattaforma.

import 'package:pdf/pdf.dart';

import 'package:pdf/widgets.dart' as pw;

import 'package:printing/printing.dart';

// ... (omitted boilerplate and checks)

/// Genera un documento PDF contenente le assegnazioni di Secret Santa

/// e lo condivide tramite la funzione di stampa/condivisione del dispositivo.

Future<void> _generatePDF() async {

  // 1. Verifica preliminare: Se non ci sono assegnazioni, mostra un errore e termina.

  if (assignments.isEmpty) {

    ScaffoldMessenger.of(context).showSnackBar(

      SnackBar(

        backgroundColor: AppColors.red[500],

        content: Text(tr('group.no_assignments_to_export')),

      ),

    );

    return;

  }

  // 2. Imposta lo stato per indicare l'inizio della generazione (es. mostra un loader)

  setState(() {

    isGeneratingPDF = true;

  });

  // Crea un nuovo documento PDF

  final pdf = pw.Document();

  // --- 3. Logica di Paginazione ---

  const int itemsPerPage = 20;

  List<List<MapEntry<User, User>>> chunks = [];

  // Converte la mappa in una lista di entry per poterla dividere in blocchi

  List<MapEntry<User, User>> entries = assignments.entries.toList();

  // Suddivide la lista in blocchi di 'itemsPerPage' elementi

  for (int i = 0; i < entries.length; i += itemsPerPage) {

    // Calcola l'indice finale, assicurandosi di non superare la fine della lista

    int endIndex = i + itemsPerPage > entries.length ? entries.length : i + itemsPerPage;

    chunks.add(entries.sublist(i, endIndex));

  }

  // ---------------------------------

  // 4. Cicla sui blocchi (chunks) per creare le pagine PDF

  for (int i = 0; i < chunks.length; i++) {

    pdf.addPage(

      pw.Page(

        build: (pw.Context context) {

          return pw.Column(

            crossAxisAlignment: pw.CrossAxisAlignment.start,

            children: [

              // Aggiunge l'intestazione grande solo alla prima pagina (i == 0)

              if (i == 0) ...[

                pw.Text(

                  tr(

                    'group.assignments_secret_santa',

                    namedArgs: {'groupName': groupName},

                  ),

                  style: pw.TextStyle(

                    fontSize: 24,

                    fontWeight: pw.FontWeight.bold,

                  ),

                ),

                pw.SizedBox(height: 20),

              ],

              // Mappa gli elementi del blocco corrente nel corpo del PDF

              ...chunks[i].map(

                (entry) => pw.Text(

                  // Formato: Donatore -> Ricevente

                  '${entry.key.displayName} -> ${entry.value.displayName}',

                  style: pw.TextStyle(fontSize: 16),

                ),

              ),

            ],

          );

        },

      ),

    );

  }

  // 5. Salva e condivide il PDF

  await Printing.sharePdf(

    bytes: await pdf.save(), // Ottiene i byte del documento salvato

    filename: 'assegnazioni_secret_santa.pdf',

  );

  // 6. Imposta lo stato per indicare la fine della generazione

  setState(() {

    isGeneratingPDF = false;

  });

}
    

Esportazione CSV

L'esportazione CSV degli utenti serve un proposito diverso dal PDF: permette l'interoperabilità con altri sistemi. Gli utenti possono importare la lista in Excel, Google Sheets o altri tool per ulteriori elaborazioni.

import 'package:share_plus/share_plus.dart';

Future<void> _exportToCSV() async {

  if (users.isEmpty) {

    ScaffoldMessenger.of(context).showSnackBar(

      SnackBar(content: Text('Nessun utente da esportare')),

    );

    return;

  }

  // Costruisci il contenuto CSV

  String csv = 'Name,Email\n'; // Header nella prima riga

  for (var user in users) {

    csv += '${user.name},${user.email}\n';

  }

  

  // Condividi il file CSV usando share_plus

  await Share.share(csv, subject: 'Lista utenti Secret Santa');

}
    

Internazionalizzazione (i18n): struttura i18n con easy_localization

Per supportare il multilingua, abbiamo implementato l'internazionalizzazione fin dall'inizio usando easy_localization, un package che semplifica enormemente la gestione delle traduzioni.

I file JSON per ogni lingua contengono coppie chiave-valore annidate, seguendo la struttura logica dell'applicazione.

L'uso di chiavi semantiche (come "group.create_group" invece di "button_text_1") rende il codice self-documenting. Quando un developer legge context.tr('group.add_user'), capisce immediatamente che si riferisce al bottone per aggiungere un utente nella sezione gruppo.

main.dart Setup:

import 'package:easy_localization/easy_localization.dart';

void main() async {

  WidgetsFlutterBinding.ensureInitialized();

  await EasyLocalization.ensureInitialized();

  

  runApp(

    EasyLocalization(

      supportedLocales: [Locale('en'), Locale('it')],

      path: 'assets/translations',

      fallbackLocale: Locale('en'), // Inglese come fallback

      child: MyApp(),

    ),

  );

}
    

Esempio di file di traduzione (assets/translations/it.json):

{

  "app_name": "Secret Santa",

  "welcome": "Il mistero dei regali segreti inizia ora!",

  "group": {

    "group_name": "Nome del gruppo",

    "add_users": "Aggiungi Utenti",

    "generate_assignments": "Genera assegnazioni",

    "generate_pdf": "Genera PDF"

  },

  "messages": {

    "invalid_email": "Inserisci un indirizzo email valido"

  }

}
    

Styling e UI/UX

L'interfaccia utente è stata progettata a partire dal design system di unitiva.

Esempio di Pulsante con Gradient:

Container(

  decoration: BoxDecoration(

    gradient: LinearGradient(

      begin: Alignment.centerLeft,

      end: Alignment.centerRight,

      colors: const [

        Color(0xFF654AD2),  // Viola

        Color(0xFF2173CF),  // Blu

        Color(0xFF247CCC),  // Blu chiaro

        Color(0xFF36B1BD),  // Turchese

      ],

      stops: const [0.0, 0.4, 0.49, 1.0],

    ),

    borderRadius: BorderRadius.circular(12),

    border: Border.all(color: Colors.white, width: 2), // Bordo bianco per contrasto

  ),

  child: ElevatedButton(

    style: ElevatedButton.styleFrom(

      backgroundColor: Colors.transparent,

      shadowColor: Colors.transparent,

      padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),

    ),

    onPressed: () {},

    child: Text(

      'Pulsante con Gradient',

      style: TextStyle(color: Colors.white),

    ),

  ),

)
    

Feedback utente: SnackBar e stati di caricamento

Ogni azione dell'utente riceve un feedback immediato. Quando un utente viene aggiunto, una SnackBar conferma l'operazione. Quando le assegnazioni vengono generate, un messaggio di successo appare brevemente.

Per operazioni più lunghe come la generazione PDF, implementiamo uno stato di loading visibile. Un flag booleano isGeneratingPDF controlla la visibilità di un indicatore di progresso, impedendo all'utente di cliccare ripetutamente il bottone e potenzialmente creare duplicati o sovraccaricare il sistema.


Best practice e pattern

Dependency Injection: preparare il terreno

Anche se l'applicazione attuale non usa dependency injection complessa, abbiamo strutturato il progetto per supportarla facilmente. La cartella core/dependency_injection è pronta per ospitare la configurazione di un service locator come GetIt.

Questo approccio forward-thinking significa che quando l'app crescerà e avremo bisogno di gestire database, API o servizi complessi, la struttura sarà già pronta. Aggiungere dependency injection retrofit sarà una questione di configurare i provider, non di ristrutturare l'intera applicazione.

Gestione errori: robustezza

Ogni operazione che può fallire è wrappata in logica di error handling. La lettura di file CSV potrebbe fallire se il file è corrotto, la generazione PDF se manca memoria, la condivisione se l'utente annulla il dialog.

Per questo motivo, usiamo try-catch dove appropriato e verifichiamo sempre i risultati prima di procedere. L'obiettivo è un'applicazione che degrada gracefully: se qualcosa va storto, l'utente riceve un messaggio chiaro, non un crash misterioso.

Preparazione per i test

Anche se in questo tutorial non implementiamo test estesi, l'architettura scelta rende il testing straightforward. I Cubit sono facili da testare in isolamento: basta emettere azioni e verificare gli stati risultanti.

Il layer di domain, essendo puro Dart senza dipendenze da Flutter, è particolarmente adatto a unit testing. I modelli con metodi toJson/fromJson possono essere testati verificando la corretta serializzazione/deserializzazione.

La UI può essere testata con widget test che verificano la presenza di elementi, il comportamento al tap, e il rendering corretto in diversi stati. L'uso di BLoC facilita questo perché possiamo mockare facilmente i Cubit.

Build multi-piattaforma

Uno dei vantaggi principali di Flutter è la capacità di buildare per multiple piattaforme da una singola codebase. Per Android, il comando flutter build apk genera un APK installabile. Per iOS, flutter build ipa crea l'archivio per l'App Store.

La build web è altrettanto semplice: flutter build web genera una cartella con HTML, JavaScript e asset che possono essere deployati su qualsiasi hosting statico. Questo significa che la stessa app può essere distribuita come app nativa mobile e come web app responsive.

Alcune considerazioni 

Nel caso vogliate estendere l'app aggiungendo backend services (ad esempio per inviare email di notifica o sincronizzare dati), dovrete gestire API keys e secrets. Flutter non ha un built-in secret manager, ma ci sono diverse strategie: variabili d'ambiente, file di configurazione gitignored, o servizi come Firebase Remote Config.

Per progetti open source è cruciale non committare mai secrets nel repository. Usate file .env gitignored o, ancora meglio, servizi esterni per la gestione delle chiavi sensibili.

Estensioni e Roadmap Future

Il progetto è strutturato per future estensioni:

  • Persistenza dati locale: aggiungere il salvataggio dei gruppi con Hive per non perdere i dati alla chiusura dell'app.
  • Backend e sincronizzazione: per trasformare l'app da single-user a collaborative, un backend come Firebase Firestore permetterebbe agli organizzatori di invitare partecipanti e sincronizzare le informazioni in real-time.
  • Wishlist: implementare un sistema di preferenze per ogni partecipante, visibile solo al proprio Secret Santa.
  • Regole avanzate di matching: supportare regole complesse come l'esclusione di coppie o famiglie dall'assegnazione.

Il valore dell’Open Source

Questo progetto dimostra come un'idea apparentemente semplice possa trasformarsi in un'applicazione completa e professionale. Abbiamo coperto praticamente ogni aspetto dello sviluppo Flutter moderno: architettura pulita, gestione dello stato, UI responsive, file handling, generazione documenti, e internazionalizzazione.

Ma la vera bellezza di questo tutorial è che non si ferma qui. Il codice completo è disponibile su GitHub, pronto per essere clonato, studiato, modificato ed esteso. Questa è l'essenza dell'open source: imparare dal codice degli altri e contribuire con le proprie migliorie.

Contribuisci al progetto

Hai idee per migliorare l'app? Hai trovato un bug? Vuoi aggiungere una nuova feature? Le pull request sono benvenute! Questo è un progetto community-driven, e ogni contributo, grande o piccolo, è prezioso.

Anche se non sei pronto per contribuire con codice, puoi comunque aiutare: usa l'app, segnala problemi, proponi idee, o semplicemente metti una stella su GitHub per supportare il progetto.

Link e risorse:

Buon coding e buone feste! 🎅🎄🎁

Autoreadmin
Potrebbero interessarti...
back to top icon