← Alle guider

Flutter – Komplet Guide til Mobiludvikling

En praktisk, begyndervenlig guide til at bygge mobil-apps med Flutter. Skrevet på dansk med masser af kodeeksempler.


Indhold

  1. Hvad er Flutter?
  2. Hvornår skal du bruge det?
  3. Kom i gang
  4. Dart basics
  5. Widget-træet
  6. Layouts
  7. Navigation
  8. Styling
  9. Data
  10. State management
  11. Firebase integration
  12. UI widgets
  13. Udgivelse
  14. Pris
  15. Fordele og ulemper
  16. Eksempel-projekt

1. Hvad er Flutter?

Flutter er Googles open-source framework til at bygge mobil-apps. Med én enkelt kodebase kan du udgive til både iOS og Android — og endda web og desktop.

Kernekoncepter

Hvem bruger Flutter?


2. Hvornår skal du bruge det?

Flutter er ideelt til:

Flutter er måske ikke ideelt til:


3. Kom i gang

Trin 1: Installér Flutter SDK

Windows:

# Hent Flutter SDK fra https://flutter.dev/docs/get-started/install/windows
    # Pak ud til f.eks. C:\flutter
    # Tilføj C:\flutter\bin til PATH

    flutter doctor
    

macOS:

# Brug Homebrew
    brew install flutter

    # Eller hent manuelt fra flutter.dev
    flutter doctor
    

flutter doctor checker at alt er sat korrekt op og fortæller dig hvad der mangler.

Trin 2: Installér en IDE

Valgmuligheder:

IDE Anbefaling
VS Code Let, hurtig, god Flutter-udvidelse
Android Studio Komplet, god til Android-emulator
IntelliJ Kraftfuld, god til større projekter

VS Code-udvidelser du skal bruge:

Trin 3: Opret dit første projekt

flutter create min_foerste_app
    cd min_foerste_app
    flutter run
    

Dette opretter en standard counter-app. Åbn den i din IDE og experimentér!

Trin 4: Kør på en enhed

# Liste tilsluttede enheder
    flutter devices

    # Kør på specifik enhet
    flutter run -d <device_id>

    # Kør på Windows
    flutter run -d windows

    # Kør på Chrome (web)
    flutter run -d chrome
    

Trin 5: Hot Reload

Når appen kører, kan du ændre kode og se resultatet øjeblikkeligt:


4. Dart basics

Før du dykker ned i Flutter, skal du kende det basale i Dart.

Variabler og typer

void main() {
      // Variabler med type-inferens
      var navn = 'Lars';           // String
      var alder = 25;              // int
      var hoejde = 1.78;           // double
      var erStuderende = true;     // bool

      // Eksplcite typer
      String efternavn = 'Nielsen';
      int antalBorne = 2;

      // Konstanter
      const pi = 3.14159;
      final maxAntal = 100; // kan ikke ændres efter initialisering

      // Null-sikkerhed (Dart 2.12+)
      String? midlertidigNavn; // kan være null
      String sikkerNavn = 'Altid sat'; // kan ikke være null

      print('Hej $navn, du er $alder år gammel');
      print('Sum: ${2 + 2}');
    }
    

Funktioner

// Simpel funktion
    String hilsen(String navn) {
      return 'Hej $navn!';
    }

    // Arrow-funktion (kort form)
    int plusEn(int x) => x + 1;

    // Valgfri parametre
    void bestil({String? mad, String? drikke, int antal = 1}) {
      print('Bestilling: $mad, $drikke x$antal');
    }

    // Named parameters (bruges meget i Flutter)
    void opretBruger({
      required String navn,    // required = skal angives
      required String email,
      int alder = 0,           // default værdi
    }) {
      print('$navn ($email), $alder år');
    }

    // Brug af funktionerne
    void main() {
      print(hilsen('Mette'));
      print(plusEn(5));
      bestil(mad: 'Pizza', drikke: 'Cola');
      opretBruger(navn: 'Peter', email: 'peter@example.com');
    }
    

Klasser

class Person {
      String navn;
      int alder;

      // Konstruktør
      Person(this.navn, this.alder);

      // Navngiven konstruktør
      Person.barn(this.navn) : alder = 0;

      // Metode
      String beskrivelse() {
        return '$navn er $alder år gammel';
      }

      // Getter
      bool get erVoksen => alder >= 18;

      // Override
      @override
      String toString() => 'Person: $navn, $alder';
    }

    // Arv
    class Studerende extends Person {
      String skole;

      Studerende(String navn, int alder, this.skole) : super(navn, alder);

      @override
      String beskrivelse() {
        return '${super.beskrivelse()} og går på $skole';
      }
    }

    void main() {
      var person = Person('Anna', 30);
      print(person.beskrivelse());
      print('Er voksen: ${person.erVoksen}');

      var studerende = Studerende('Oscar', 20, 'KU');
      print(studerende.beskrivelse());
    }
    

Async/Await

import 'dart:convert';
    import 'package:http/http.dart' as http;

    // Simpel async funktion
    Future<String> hentData() async {
      await Future.delayed(Duration(seconds: 2));
      return 'Data hentet!';
    }

    // HTTP-request
    Future<Map<String, dynamic>?> hentBruger(int id) async {
      try {
        final response = await http.get(
          Uri.parse('https://jsonplaceholder.typicode.com/users/$id'),
        );

        if (response.statusCode == 200) {
          return jsonDecode(response.body) as Map<String, dynamic>;
        }
        return null;
      } catch (e) {
        print('Fejl: $e');
        return null;
      }
    }

    // Streams (reaktiv data)
    Stream<int> taelleStream() async* {
      for (int i = 0; i < 5; i++) {
        await Future.delayed(Duration(seconds: 1));
        yield i;
      }
    }

    void main() async {
      // Brug await
      String resultat = await hentData();
      print(resultat);

      // Brug stream
      await for (int tal in taelleStream()) {
        print('Tal: $tal');
      }
    }
    

Generics og collections

void main() {
      // List
      List<String> frugter = ['æble', 'banan', 'kirsebær'];
      frugter.add('druer');
      frugter.removeWhere((f) => f.length < 5);

      // Map
      Map<String, int> priser = {
        'æble': 5,
        'banan': 3,
        'kirsebær': 12,
      };

      // Set
      Set<int> unikkeTal = {1, 2, 3, 3, 4}; // {1, 2, 3, 4}

      // Higher-order functions
      var modneFrugter = frugter.where((f) => f.length > 5).toList();
      var priserSomTekst = priser.entries.map((e) => '${e.key}: ${e.value} kr');

      print(modneFrugter);
      print(priserSomTekst);
    }

    // Generisk klasse
    class Kasse<T> {
      List<T> _items = [];

      void tilføj(T item) => _items.add(item);
      T? tag() => _items.isNotEmpty ? _items.removeLast() : null;
      int get antal => _items.length;
    }
    

5. Widget-træet

I Flutter er alt en widget. Skærmen, tekst, knapper, billeder, mellemrum — alt er widgets sammensat i et træ.

StatelessWidget

En widget uden tilstand. Den ser ens ud uanset hvad.

import 'package:flutter/material.dart';

    class MinHilsen extends StatelessWidget {
      final String navn;

      const MinHilsen({Key? key, required this.navn}) : super(key: key);

      @override
      Widget build(BuildContext context) {
        return Text(
          'Hej $navn!',
          style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
        );
      }
    }
    

StatefulWidget

En widget med tilstand der kan ændres over tid.

import 'package:flutter/material.dart';

    class TaelleKnapp extends StatefulWidget {
      const TaelleKnapp({Key? key}) : super(key: key);

      @override
      _TaelleKnappState createState() => _TaelleKnappState();
    }

    class _TaelleKnappState extends State<TaelleKnapp> {
      int _taeller = 0;

      void _oeg() {
        setState(() {
          _taeller++;
        });
      }

      @override
      Widget build(BuildContext context) {
        return Column(
          children: [
            Text('Tæller: $_taeller', style: TextStyle(fontSize: 20)),
            ElevatedButton(
              onPressed: _oeg,
              child: Text('Øg med 1'),
            ),
          ],
        );
      }
    }
    

Hvornår hvad?

Brug Når
StatelessWidget Statisk indhold, ingen interaktion
StatefulWidget Brugerinteraktion, animationer, formularer

Widget-træet i praksis

// Hver app starter med MaterialApp
    void main() {
      runApp(MyApp());
    }

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Min App',
          home: Scaffold(
            appBar: AppBar(title: Text('Forside')),
            body: Center(
              child: Text('Velkommen!'),
            ),
          ),
        );
      }
    }
    

6. Layouts

Flutter bruger flex-lignende layouts. Her er de mest brugte:

Row (vandret)

Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        Icon(Icons.star, color: Colors.yellow),
        Icon(Icons.star, color: Colors.yellow),
        Icon(Icons.star, color: Colors.yellow),
      ],
    )
    

Column (lodret)

Column(
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text('Overskrift', style: TextStyle(fontSize: 24)),
        SizedBox(height: 16),
        Text('Brødtekst'),
        SizedBox(height: 8),
        ElevatedButton(onPressed: () {}, child: Text('Klik')),
      ],
    )
    

Stack (lag-på-lag)

Stack(
      children: [
        Container(
          width: double.infinity,
          height: 200,
          color: Colors.blue,
        ),
        Positioned(
          bottom: 16,
          left: 16,
          child: Text(
            'Overlay-tekst',
            style: TextStyle(color: Colors.white, fontSize: 20),
          ),
        ),
      ],
    )
    

ListView (scrollbar liste)

ListView.builder(
      itemCount: 50,
      itemBuilder: (context, index) {
        return ListTile(
          leading: CircleAvatar(child: Text('${index + 1}')),
          title: Text('Element ${index + 1}'),
          subtitle: Text('Beskrivelse af element ${index + 1}'),
          trailing: Icon(Icons.chevron_right),
        );
      },
    )
    

GridView (gitter)

GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemCount: 12,
      itemBuilder: (context, index) {
        return Container(
          color: Colors.primaries[index % Colors.primaries.length],
          child: Center(child: Text('Boks ${index + 1}')),
        );
      },
    )
    

Container (den alsidige boks)

Container(
      width: 200,
      height: 150,
      margin: EdgeInsets.all(16),
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black26,
            blurRadius: 8,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: Text('Indeni containeren'),
    )
    

Expanded og Flexible

Row(
      children: [
        Expanded(
          flex: 2,
          child: Container(color: Colors.red, height: 50),
        ),
        SizedBox(width: 8),
        Expanded(
          flex: 1,
          child: Container(color: Colors.blue, height: 50),
        ),
      ],
    )
    

7. Navigation

Named Routes

// I MaterialApp
    MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => HomeScreen(),
        '/detaljer': (context) => DetaljerScreen(),
        '/indstillinger': (context) => IndstillingerScreen(),
      },
    )

    // Naviger til en side
    Navigator.pushNamed(context, '/detaljer');

    // Tilbage
    Navigator.pop(context);
    

Push med data

// Fra HomeScreen
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => DetaljerScreen(
          titel: 'Min besked',
          id: 42,
        ),
      ),
    );

    // Til DetaljerScreen
    class DetaljerScreen extends StatelessWidget {
      final String titel;
      final int id;

      const DetaljerScreen({required this.titel, required this.id});

      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text(titel)),
          body: Center(child: Text('ID: $id')),
        );
      }
    }
    

Tilbage med data

// I en anden skærm
    Navigator.pop(context, 'Her er mit svar');

    // Modtag svaret
    final resultat = await Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => AndenScreen()),
    );
    print('Svar: $resultat');
    

Drawer (side-menu)

Scaffold(
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: [
            DrawerHeader(
              decoration: BoxDecoration(color: Colors.blue),
              child: Text('Menu', style: TextStyle(color: Colors.white, fontSize: 24)),
            ),
            ListTile(
              leading: Icon(Icons.home),
              title: Text('Hjem'),
              onTap: () => Navigator.pop(context),
            ),
            ListTile(
              leading: Icon(Icons.settings),
              title: Text('Indstillinger'),
              onTap: () {
                Navigator.pop(context);
                Navigator.pushNamed(context, '/indstillinger');
              },
            ),
          ],
        ),
      ),
      appBar: AppBar(title: Text('Min App')),
      body: Center(child: Text('Hjem')),
    )
    

8. Styling

Themes

MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        brightness: Brightness.light,
        fontFamily: 'Roboto',
        textTheme: TextTheme(
          headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
          bodyLarge: TextStyle(fontSize: 16, height: 1.5),
        ),
        cardTheme: CardTheme(
          elevation: 4,
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
          ),
        ),
      ),
      darkTheme: ThemeData(
        primarySwatch: Colors.indigo,
        brightness: Brightness.dark,
      ),
      themeMode: ThemeMode.system, // Følg system-tema
    )
    

Custom fonts

// I pubspec.yaml
    flutter:
      fonts:
        - family: Roboto
          fonts:
            - asset: assets/fonts/Roboto-Regular.ttf
            - asset: assets/fonts/Roboto-Bold.ttf
              weight: 700
            - asset: assets/fonts/Roboto-Italic.ttf
              style: italic
    

Responsive design

class ResponsiveLayout extends StatelessWidget {
      final Widget mobile;
      final Widget tablet;
      final Widget desktop;

      const ResponsiveLayout({
        required this.mobile,
        required this.tablet,
        required this.desktop,
      });

      @override
      Widget build(BuildContext context) {
        return LayoutBuilder(
          builder: (context, constraints) {
            if (constraints.maxWidth < 600) {
              return mobile;
            } else if (constraints.maxWidth < 1200) {
              return tablet;
            } else {
              return desktop;
            }
          },
        );
      }
    }

    // Brug MediaQuery til at få skærmstørrelse
    class MinSide extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        final screenWidth = MediaQuery.of(context).size.width;
        final screenHeight = MediaQuery.of(context).size.height;

        return Container(
          width: screenWidth * 0.9,
          height: screenHeight * 0.5,
          color: Colors.blue,
        );
      }
    }
    

Indbyggede temaer og farver

// Primær farve fra tema
    final primaryColor = Theme.of(context).primaryColor;

    // Brug Material Design farver
    Container(color: Colors.blue.shade500)

    // Brug accent farve (Material 3)
    Container(color: Theme.of(context).colorScheme.secondary)
    

9. Data

HTTP-forespørgsler

import 'dart:convert';
    import 'package:http/http.dart' as http;

    class ApiClient {
      static const String baseUrl = 'https://jsonplaceholder.typicode.com';

      // GET
      static Future<List<dynamic>> hentIndlaeg() async {
        final response = await http.get(Uri.parse('$baseUrl/posts'));
        if (response.statusCode == 200) {
          return jsonDecode(response.body) as List<dynamic>;
        }
        throw Exception('Kunne ikke hente data');
      }

      // POST
      static Future<Map<String, dynamic>> opretIndlaeg(Map<String, dynamic> data) async {
        final response = await http.post(
          Uri.parse('$baseUrl/posts'),
          headers: {'Content-Type': 'application/json'},
          body: jsonEncode(data),
        );
        if (response.statusCode == 201) {
          return jsonDecode(response.body) as Map<String, dynamic>;
        }
        throw Exception('Kunne ikke oprette');
      }

      // PUT
      static Future<Map<String, dynamic>> opdaterIndlaeg(int id, Map<String, dynamic> data) async {
        final response = await http.put(
          Uri.parse('$baseUrl/posts/$id'),
          headers: {'Content-Type': 'application/json'},
          body: jsonEncode(data),
        );
        if (response.statusCode == 200) {
          return jsonDecode(response.body) as Map<String, dynamic>;
        }
        throw Exception('Kunne ikke opdatere');
      }

      // DELETE
      static Future<void> sletIndlaeg(int id) async {
        final response = await http.delete(Uri.parse('$baseUrl/posts/$id'));
        if (response.statusCode != 200) {
          throw Exception('Kunne ikke slette');
        }
      }
    }
    

JSON-parsing med modeller

class Bruger {
      final int id;
      final String navn;
      final String email;
      final String telefon;

      Bruger({
        required this.id,
        required this.navn,
        required this.email,
        required this.telefon,
      });

      // Fra JSON
      factory Bruger.fromJson(Map<String, dynamic> json) {
        return Bruger(
          id: json['id'],
          navn: json['name'],
          email: json['email'],
          telefon: json['phone'] ?? '',
        );
      }

      // Til JSON
      Map<String, dynamic> toJson() {
        return {
          'id': id,
          'name': navn,
          'email': email,
          'phone': telefon,
        };
      }
    }
    

Lokal lagring

SharedPreferences (simple nøgle-værdi-par)

import 'package:shared_preferences/shared_preferences.dart';

    class LokalLager {
      static Future<void> gemBrugerNavn(String navn) async {
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('bruger_navn', navn);
      }

      static Future<String?> hentBrugerNavn() async {
        final prefs = await SharedPreferences.getInstance();
        return prefs.getString('bruger_navn');
      }

      static Future<void> gemSenestBesoeg() async {
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('senest_besoeg', DateTime.now().toIso8601String());
      }

      static Future<void> nulstil() async {
        final prefs = await SharedPreferences.getInstance();
        await prefs.clear();
      }
    }
    

Hive (hurtigt NoSQL-database)

import 'package:hive/hive.dart';
    import 'package:hive_flutter/hive_flutter.dart';

    // Initier Hive
    await Hive.initFlutter();

    // Åbn en box (som en tabel)
    var box = await Hive.openBox('opgaver');

    // Gem data
    await box.put('opgave_1', {
      'titel': 'Køb mælk',
      'afsluttet': false,
      'oprettet': DateTime.now().toIso8601String(),
    });

    // Hent data
    var opgave = box.get('opgave_1');
    print(opgave['titel']); // 'Køb mælk'

    // Hent alle
    for (var key in box.keys) {
      var opgave = box.get(key);
      print('$key: ${opgave['titel']}');
    }

    // Slet
    await box.delete('opgave_1');

    // Tæl poster
    print('Antal opgaver: ${box.length}');
    

10. State management

State management er én af de vigtigste beslutninger i Flutter. Her er de mest populære tilgange.

Provider (simpel, god til begyndere)

import 'package:provider/provider.dart';

    // 1. Opret en model/tilstandsklasse
    class OpgaveTilstand extends ChangeNotifier {
      List<String> _opgaver = [];

      List<String> get opgaver => _opgaver;

      void tilfoej(String opgave) {
        _opgaver.add(opgave);
        notifyListeners(); // Fortæl lyttere om ændring
      }

      void fjern(int index) {
        _opgaver.removeAt(index);
        notifyListeners();
      }

      void skift(int index) {
        // Tjek om afsluttet, byt tilstand
        notifyListeners();
      }
    }

    // 2. Wrap din app med ChangeNotifierProvider
    void main() {
      runApp(
        ChangeNotifierProvider(
          create: (context) => OpgaveTilstand(),
          child: MinApp(),
        ),
      );
    }

    // 3. Brug tilstanden i widgets
    class OpgaveListe extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Consumer<OpgaveTilstand>(
          builder: (context, tilstand, child) {
            return ListView.builder(
              itemCount: tilstand.opgaver.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(tilstand.opgaver[index]),
                  trailing: IconButton(
                    icon: Icon(Icons.delete),
                    onPressed: () => tilstand.fjern(index),
                  ),
                );
              },
            );
          },
        );
      }
    }

    // 4. Tilføj en opgave
    class TilfoejOpgave extends StatelessWidget {
      final controller = TextEditingController();

      @override
      Widget build(BuildContext context) {
        return Row(
          children: [
            Expanded(
              child: TextField(controller: controller),
            ),
            ElevatedButton(
              onPressed: () {
                if (controller.text.isNotEmpty) {
                  Provider.of<OpgaveTilstand>(context, listen: false)
                      .tilfoej(controller.text);
                  controller.clear();
                }
              },
              child: Text('Tilføj'),
            ),
          ],
        );
      }
    }
    

Riverpod (moderne, mere kraftfuld)

import 'package:flutter_riverpod/flutter_riverpod.dart';

    // 1. StateNotifier med tilstand
    class OpgaveTilstand extends StateNotifier<List<Opgave>> {
      OpgaveTilstand() : super([]);

      void tilfoej(String titel) {
        state = [...state, Opgave(titel: titel, afsluttet: false)];
      }

      void skift(int index) {
        state = [
          for (int i = 0; i < state.length; i++)
            if (i == index)
              state[i].copyWith(afsluttet: !state[i].afsluttet)
            else
              state[i],
        ];
      }

      void fjern(int index) {
        state = [for (int i = 0; i < state.length; i++) if (i != index) state[i]];
      }
    }

    class Opgave {
      final String titel;
      final bool afsluttet;

      Opgave({required this.titel, this.afsluttet = false});

      Opgave copyWith({String? titel, bool? afsluttet}) {
        return Opgave(
          titel: titel ?? this.titel,
          afsluttet: afsluttet ?? this.afsluttet,
        );
      }
    }

    // 2. Opret provider
    final opgaveProvider = StateNotifierProvider<OpgaveTilstand, List<Opgave>>((ref) {
      return OpgaveTilstand();
    });

    // 3. Wrap app med ProviderScope
    void main() {
      runApp(ProviderScope(child: MinApp()));
    }

    // 4. Brug i widgets
    class OpgaveListe extends ConsumerWidget {
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final opgaver = ref.watch(opgaveProvider);

        return ListView.builder(
          itemCount: opgaver.length,
          itemBuilder: (context, index) {
            final opgave = opgaver[index];
            return CheckboxListTile(
              title: Text(
                opgave.titel,
                style: TextStyle(
                  decoration: opgave.afsluttet ? TextDecoration.lineThrough : null,
                ),
              ),
              value: opgave.afsluttet,
              onChanged: (_) => ref.read(opgaveProvider.notifier).skift(index),
            );
          },
        );
      }
    }
    

Hvornår bruge hvad?

Provider Riverpod
Lærredigt Skalerbart
God til små projekter God til alle projekter
Simpel syntaks Mere eksplicit
Fungerer medBuildContext Fungerer uden BuildContext

Anbefaling: Start med Provider, skift til Riverpod når projektet vokser.


11. Firebase integration

Firebase er Googles backend-platform. Den gratis tier er generøs til hobby-projekter.

Opsætning

# Installér FlutterFire CLI
    dart pub global activate flutterfire_cli

    # Konfigurer Firebase i dit projekt
    flutterfire configure
    

Firebase Auth (Brugerautentifikation)

import 'package:firebase_auth/firebase_auth.dart';

    class AuthService {
      final FirebaseAuth _auth = FirebaseAuth.instance;

      // Email + kode
      Future<User?> opretBruger(String email, String kode) async {
        try {
          final result = await _auth.createUserWithEmailAndPassword(
            email: email,
            password: kode,
          );
          return result.user;
        } catch (e) {
          print('Fejl: $e');
          return null;
        }
      }

      Future<User?> logInd(String email, String kode) async {
        try {
          final result = await _auth.signInWithEmailAndPassword(
            email: email,
            password: kode,
          );
          return result.user;
        } catch (e) {
          print('Fejl: $e');
          return null;
        }
      }

      Future<void> logUd() async {
        await _auth.signOut();
      }

      // Lyt på tilstandsændringer
      Stream<User?> get tilstandsStream => _auth.authStateChanges();
    }
    

Firestore (Database)

import 'package:cloud_firestore/cloud_firestore.dart';

    class FirestoreService {
      final FirebaseFirestore _db = FirebaseFirestore.instance;

      // Tilføj dokument
      Future<void> opretOpgave(String titel, String brugerId) async {
        await _db.collection('opgaver').add({
          'titel': titel,
          'brugerId': brugerId,
          'afsluttet': false,
          'oprettet': FieldValue.serverTimestamp(),
        });
      }

      // Hent dokumenter (real-time)
      Stream<QuerySnapshot> hentOpgaver(String brugerId) {
        return _db
            .collection('opgaver')
            .where('brugerId', isEqualTo: brugerId)
            .orderBy('oprettet', descending: true)
            .snapshots();
      }

      // Opdater dokument
      Future<void> opdaterOpgave(String docId, bool afsluttet) async {
        await _db.collection('opgaver').doc(docId).update({
          'afsluttet': afsluttet,
        });
      }

      // Slet dokument
      Future<void> sletOpgave(String docId) async {
        await _db.collection('opgaver').doc(docId).delete();
      }
    }
    

Firebase Storage (Filer)

import 'package:firebase_storage/firebase_storage.dart';
    import 'dart:io';

    class StorageService {
      final FirebaseStorage _storage = FirebaseStorage.instance;

      Future<String> uploadBillede(File fil, String sti) async {
        try {
          final ref = _storage.ref().child(sti);
          await ref.putFile(fil);
          final url = await ref.getDownloadURL();
          return url;
        } catch (e) {
          print('Upload-fejl: $e');
          return '';
        }
      }

      Future<void> sletFil(String sti) async {
        await _storage.ref().child(sti).delete();
      }
    }
    

Cloud Messaging (Push-notifikationer)

import 'package:firebase_messaging/firebase_messaging.dart';

    Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
      print('Modtaget baggrund: ${message.notification?.title}');
    }

    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Firebase.initializeApp();

      // Registrér baggrundshåndtering
      FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

      runApp(MinApp());
    }

    // I en widget
    class NotifikationService {
      final FirebaseMessaging _messaging = FirebaseMessaging.instance;

      Future<void> init() async {
        // Bed om tilladelse
        NotificationSettings settings = await _messaging.requestPermission(
          alert: true,
          badge: true,
          sound: true,
        );

        if (settings.authorizationStatus == AuthorizationStatus.authorized) {
          print('Tilladt');

          // Hent token
          String? token = await _messaging.getToken();
          print('FCM Token: $token');

          // Lyt på beskeder (appen åben)
          FirebaseMessaging.onMessage.listen((RemoteMessage message) {
            print('Modtaget: ${message.notification?.title}');
          });

          // Åbnet fra notifikation
          FirebaseMessaging.onMessageOpenedApp.listen((message) {
            // Naviger til relevant side
          });
        }
      }
    }
    

Firebase gratis tier (2024-priser)

Tjeneste Gratis inkluderet
Firestore 1 GB lager, 50.000 læsninger/dag
Storage 5 GB, 1 GB download/dag
Auth 10.000 oprettelser/måned
Hosting 10 GB lager, 360 MB/dag
Cloud Messaging Ubegrænset

12. UI widgets

AppBar

AppBar(
      title: Text('Min App'),
      leading: IconButton(
        icon: Icon(Icons.menu),
        onPressed: () => Scaffold.of(context).openDrawer(),
      ),
      actions: [
        IconButton(
          icon: Icon(Icons.search),
          onPressed: () {},
        ),
        IconButton(
          icon: Icon(Icons.notifications),
          onPressed: () {},
        ),
      ],
      elevation: 4,
      backgroundColor: Colors.indigo,
      foregroundColor: Colors.white,
    )
    

Cards

Card(
      elevation: 4,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Titel', style: Theme.of(context).textTheme.headlineSmall),
            SizedBox(height: 8),
            Text('Brødtekst her...'),
            SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                TextButton(onPressed: () {}, child: Text('ANNULLER')),
                ElevatedButton(onPressed: () {}, child: Text('OK')),
              ],
            ),
          ],
        ),
      ),
    )
    

Forms

class MinForm extends StatefulWidget {
      @override
      _MinFormState createState() => _MinFormState();
    }

    class _MinFormState extends State<MinForm> {
      final _formKey = GlobalKey<FormState>();
      final _navnController = TextEditingController();
      final _emailController = TextEditingController();

      @override
      Widget build(BuildContext context) {
        return Form(
          key: _formKey,
          child: Column(
            children: [
              TextFormField(
                controller: _navnController,
                decoration: InputDecoration(
                  labelText: 'Navn',
                  hintText: 'Indtast dit navn',
                  prefixIcon: Icon(Icons.person),
                  border: OutlineInputBorder(),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Indtast venligst et navn';
                  }
                  return null;
                },
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: InputDecoration(
                  labelText: 'E-mail',
                  hintText: 'navn@example.com',
                  prefixIcon: Icon(Icons.email),
                  border: OutlineInputBorder(),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Indtast venligst en e-mail';
                  }
                  if (!value.contains('@')) {
                    return 'Ugyldig e-mail';
                  }
                  return null;
                },
              ),
              SizedBox(height: 24),
              ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    print('Navn: ${_navnController.text}');
                    print('E-mail: ${_emailController.text}');
                  }
                },
                child: Text('Indsend'),
              ),
            ],
          ),
        );
      }
    }
    

Knapper

// ElevatedButton (primær)
    ElevatedButton(
      onPressed: () {},
      child: Text('Klik her'),
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.blue,
        foregroundColor: Colors.white,
        padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      ),
    )

    // TextButton (sekundær)
    TextButton(
      onPressed: () {},
      child: Text('Annuller'),
    )

    // OutlinedButton
    OutlinedButton(
      onPressed: () {},
      child: Text('Mere info'),
      style: OutlinedButton.styleFrom(
        side: BorderSide(color: Colors.blue),
      ),
    )

    // IconButton
    IconButton(
      icon: Icon(Icons.favorite),
      color: Colors.red,
      onPressed: () {},
    )

    // Floating Action Button
    FloatingActionButton(
      onPressed: () {},
      child: Icon(Icons.add),
      backgroundColor: Colors.blue,
    )
    

Snackbars

ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('Handling gennemført!'),
        action: SnackBarAction(
          label: 'Fortryd',
          onPressed: () {
            print('Fortrydt');
          },
        ),
        duration: Duration(seconds: 3),
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      ),
    );
    

Dialogs

// Simpel bekræftelsesdialog
    Future<bool> bekræftDialog(BuildContext context) async {
      final result = await showDialog<bool>(
        context: context,
        builder: (context) => AlertDialog(
          title: Text('Bekræft'),
          content: Text('Er du sikker på at du vil slette?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context, false),
              child: Text('Annuller'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pop(context, true),
              style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
              child: Text('Slet'),
            ),
          ],
        ),
      );
      return result ?? false;
    }

    // Brug
    void sletElement() async {
      final bekræft = await bekræftDialog(context);
      if (bekræft) {
        // Slet elementet
      }
    }
    

13. UdgiVELSE

Google Play Store

  1. Opret en Google Play-konto (25 USD engangsbeløb)
  2. Generér en signeringssnøgle:
    keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
        
  3. Opret android/key.properties
  4. Byg release:
    flutter build appbundle
        
  5. Upload til Google Play Console
  6. Udfyld butikssider, skærmbilleder, beskrivelse
  7. Indsend til gennemgang (typisk 1-3 dage)

Apple App Store

  1. Opret en Apple Developer-konto (99 USD/år)
  2. Konfigurer Xcode-projektet med dit team-ID
  3. Byg release:
    flutter build ios --release
        
  4. Åbn ios/Runner.xcworkspace i Xcode
  5. Archivér og upload til App Store Connect
  6. Udfyld butikssider
  7. Indsend til gennemgang (typisk 1-7 dage)

Tips til godkendelse


14. Pris

Hvad er gratis?

Pris
Flutter SDK Gratis
Dart Gratis
VS Code / Android Studio Gratis
Firebase gratis tier Gratis (med begrænsninger)
Open source pakker Gratis

Hvad koster penge?

Pris
Google Play-konto 25 USD (engangsbeløb)
Apple Developer-konto 99 USD/år
Firebase (over gratis tier) Fra $25/måned
Backend-server (egen) Fra $5/måned
Design-assetser (ikoner, billeder) Fra $0-50
Custom skrifttyper Fra $0-100
App Store skærmbilleder Gratis (lav dem selv)

Budget-venlige alternativer


15. Fordele og ulemper

Flutter vs Native (Swift/Kotlin)

Flutter Native
Kodebase Én til begge platforme Separate til iOS og Android
UI-kontrol Fuldstændig (custom rendering) Fuld (native komponenter)
Ydeevne Meget god (næsten native) Bedst mulig
Adgang til hardware God (plugins til det meste) Bedst (direkte adgang)
Indlæringskurve Middel Høj (to sprog)
Udviklingshastighed Hurtigere Langsommere
Community Voksende Stort

Flutter vs React Native

Flutter React Native
Sprog Dart JavaScript/TypeScript
Ydeevne Hurtigere (eget renderer) God (native broer)
UI Custom (materiale-lignende) Native komponenter
Hot reload Ja Ja
Ekosystem Voksende Stort
Indlæringskurve Dart er let at lære JS er kendt af mange
Meta-frameworks Bare, Riverpod Expo, Next.js

Samlet anbefaling

Vælg Flutter hvis:

Vælg native hvis:

Vælg React Native hvis:


16. Eksempel-projekt: Opgave-manager

Bygger vi sammen en simpel opgave-manager-app med alle de koncepter vi har lært.

Trin 1: Opret projekt

flutter create opgave_manager
    cd opgave_manager
    flutter pub add provider hive hive_flutter
    

Trin 2: Modeller

// lib/models/opgave.dart
    class Opgave {
      String id;
      String titel;
      String beskrivelse;
      bool afsluttet;
      DateTime oprettet;

      Opgave({
        required this.id,
        required this.titel,
        this.beskrivelse = '',
        this.afsluttet = false,
        DateTime? oprettet,
      }) : oprettet = oprettet ?? DateTime.now();

      Map<String, dynamic> toMap() {
        return {
          'id': id,
          'titel': titel,
          'beskrivelse': beskrivelse,
          'afsluttet': afsluttet,
          'oprettet': oprettet.toIso8601String(),
        };
      }

      factory Opgave.fromMap(Map<String, dynamic> map) {
        return Opgave(
          id: map['id'],
          titel: map['titel'],
          beskrivelse: map['beskrivelse'] ?? '',
          afsluttet: map['afsluttet'] ?? false,
          oprettet: DateTime.parse(map['oprettet']),
        );
      }
    }
    

Trin 3: Tilstandshåndtering

// lib/providers/opgave_provider.dart
    import 'package:flutter/foundation.dart';
    import '../models/opgave.dart';
    import 'package:uuid/uuid.dart';

    class OpgaveProvider extends ChangeNotifier {
      List<Opgave> _opgaver = [];
      final _uuid = Uuid();

      List<Opgave> get opgaver => _opgaver;
      List<Opgave> get afsluttede => _opgaver.where((o) => o.afsluttet).toList();
      List<Opgave> get uafsluttede => _opgaver.where((o) => !o.afsluttet).toList();
      int get antalAfsluttede => afsluttede.length;
      int get antalUafsluttede => uafsluttede.length;

      void tilfoej(String titel, [String beskrivelse = '']) {
        _opgaver.add(Opgave(
          id: _uuid.v4(),
          titel: titel,
          beskrivelse: beskrivelse,
        ));
        notifyListeners();
      }

      void skift(String id) {
        final index = _opgaver.indexWhere((o) => o.id == id);
        if (index != -1) {
          _opgaver[index].afsluttet = !_opgaver[index].afsluttet;
          notifyListeners();
        }
      }

      void slet(String id) {
        _opgaver.removeWhere((o) => o.id == id);
        notifyListeners();
      }

      void opdater(String id, String nyTitel, String nyBeskrivelse) {
        final index = _opgaver.indexWhere((o) => o.id == id);
        if (index != -1) {
          _opgaver[index].titel = nyTitel;
          _opgaver[index].beskrivelse = nyBeskrivelse;
          notifyListeners();
        }
      }
    }
    

Trin 4: Hovedskærm

// lib/screens/home_screen.dart
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import '../providers/opgave_provider.dart';
    import 'tilfoej_screen.dart';

    class HomeScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Opgave Manager'),
            actions: [
              Consumer<OpgaveProvider>(
                builder: (context, provider, child) {
                  return Padding(
                    padding: EdgeInsets.only(right: 16),
                    child: Center(
                      child: Text(
                        '${provider.antalUafsluttede} tilbage',
                        style: TextStyle(fontSize: 16),
                      ),
                    ),
                  );
                },
              ),
            ],
          ),
          body: Consumer<OpgaveProvider>(
            builder: (context, provider, child) {
              if (provider.opgaver.isEmpty) {
                return Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.task_alt, size: 64, color: Colors.grey),
                      SizedBox(height: 16),
                      Text('Ingen opgaver endnu'),
                      SizedBox(height: 8),
                      Text('Tryk + for at tilføje en opgave'),
                    ],
                  ),
                );
              }

              return ListView.builder(
                itemCount: provider.opgaver.length,
                itemBuilder: (context, index) {
                  final opgave = provider.opgaver[index];
                  return OpgaveKort(opgave: opgave);
                },
              );
            },
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => TilfoejScreen()),
              );
            },
            child: Icon(Icons.add),
          ),
        );
      }
    }

    class OpgaveKort extends StatelessWidget {
      final Opgave opgave;

      const OpgaveKort({required this.opgave});

      @override
      Widget build(BuildContext context) {
        return Dismissible(
          key: Key(opgave.id),
          direction: DismissDirection.endToStart,
          background: Container(
            color: Colors.red,
            alignment: Alignment.centerRight,
            padding: EdgeInsets.only(right: 16),
            child: Icon(Icons.delete, color: Colors.white),
          ),
          onDismissed: (_) {
            Provider.of<OpgaveProvider>(context, listen: false).slet(opgave.id);
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('${opgave.titel} slettet')),
            );
          },
          child: Card(
            margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
            child: ListTile(
              leading: Checkbox(
                value: opgave.afsluttet,
                onChanged: (_) {
                  Provider.of<OpgaveProvider>(context, listen: false)
                      .skift(opgave.id);
                },
              ),
              title: Text(
                opgave.titel,
                style: TextStyle(
                  decoration: opgave.afsluttet
                      ? TextDecoration.lineThrough
                      : TextDecoration.none,
                  color: opgave.afsluttet ? Colors.grey : Colors.black,
                ),
              ),
              subtitle: opgave.beskrivelse.isNotEmpty
                  ? Text(opgave.beskrivelse)
                  : null,
            ),
          ),
        );
      }
    }
    

Trin 5: Tilføj opgave-skærm

// lib/screens/tilfoej_screen.dart
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import '../providers/opgave_provider.dart';

    class TilfoejScreen extends StatefulWidget {
      @override
      _TilfoejScreenState createState() => _TilfoejScreenState();
    }

    class _TilfoejScreenState extends State<TilfoejScreen> {
      final _formKey = GlobalKey<FormState>();
      final _titelController = TextEditingController();
      final _beskrivelseController = TextEditingController();

      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text('Tilføj opgave')),
          body: Padding(
            padding: EdgeInsets.all(16),
            child: Form(
              key: _formKey,
              child: Column(
                children: [
                  TextFormField(
                    controller: _titelController,
                    decoration: InputDecoration(
                      labelText: 'Titel',
                      hintText: 'Hvad skal der gøres?',
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Indtast venligst en titel';
                      }
                      return null;
                    },
                  ),
                  SizedBox(height: 16),
                  TextFormField(
                    controller: _beskrivelseController,
                    decoration: InputDecoration(
                      labelText: 'Beskrivelse (valgfri)',
                      hintText: 'Tilføj flere detaljer...',
                      border: OutlineInputBorder(),
                    ),
                    maxLines: 3,
                  ),
                  SizedBox(height: 24),
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: () {
                        if (_formKey.currentState!.validate()) {
                          Provider.of<OpgaveProvider>(context, listen: false)
                              .tilfoej(
                            _titelController.text,
                            _beskrivelseController.text,
                          );
                          Navigator.pop(context);
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(content: Text('Opgave tilføjet!')),
                          );
                        }
                      },
                      child: Text('Tilføj opgave'),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    

Trin 6: main.dart

// lib/main.dart
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import 'providers/opgave_provider.dart';
    import 'screens/home_screen.dart';

    void main() {
      runApp(MyApp());
    }

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return ChangeNotifierProvider(
          create: (context) => OpgaveProvider(),
          child: MaterialApp(
            title: 'Opgave Manager',
            debugShowCheckedModeBanner: false,
            theme: ThemeData(
              primarySwatch: Colors.indigo,
              useMaterial3: true,
              cardTheme: CardTheme(
                elevation: 2,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(8),
                ),
              ),
            ),
            home: HomeScreen(),
          ),
        );
      }
    }
    

Kør projektet

flutter run
    

Du har nu en fungerende opgave-manager-app med:

Næste skridt at udvide

  1. Lokal lagring med Hive eller SharedPreferences
  2. Firebase Auth for brugerkonti
  3. Firestore for cloud-synkronisering
  4. Push-notifikationer for påmindelser
  5. Kategorier og tags
  6. Deadline-funktion

Opsamling

Du har nu lært:

Næste skridt

Nyttige pakker

Pakke Formål
provider Simpel state management
flutter_riverpod Moderne state management
http HTTP-forespørgsler
hive Lokal NoSQL-database
shared_preferences Simple nøgle-værdi-par
firebase_core Firebase-basis
cloud_firestore Firestore-database
firebase_auth Brugerautentifikation
go_router Moderne routing
cached_network_image Billed-caching
lottie Animationer
url_launcher Åbn URLs og apps
image_picker Vælg billeder
permission_handler Håndter tilladelser

Denne guide er skrevet til begyndere og let øvede Flutter-udviklere. God kodelyst! 🚀