Flutter – Komplet Guide til Mobiludvikling
En praktisk, begyndervenlig guide til at bygge mobil-apps med Flutter. Skrevet på dansk med masser af kodeeksempler.
Indhold
- Hvad er Flutter?
- Hvornår skal du bruge det?
- Kom i gang
- Dart basics
- Widget-træet
- Layouts
- Navigation
- Styling
- Data
- State management
- Firebase integration
- UI widgets
- Udgivelse
- Pris
- Fordele og ulemper
- 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
- Dart-sprog: Flutter bruger Dart, et type-sikkert sprog udviklet af Google. Det minder om Java/Kotlin men med moderne features.
- Cross-platform: Skriv én gang, kør på alle platforme. Ingen separate teams til iOS og Android.
- Hot reload: Ændringer i koden vises øjeblikkeligt i simulator/emulator. Ingen genstart nødvendig.
- Widget-baseret alt: Alt i en Flutter-app er en widget — fra knapper til hele skærme.
- Ingen bridge: Flutter renderer sine egne pixels direkte (via Skia/Impeller), i modsætning til React Native som broer til native komponenter.
Hvem bruger Flutter?
- Google (Google Pay, Stadia)
- BMW
- Toyota
- ByteDance (TikTok-opsætninger)
- tusindvis af startups og indie-udviklere
2. Hvornår skal du bruge det?
Flutter er ideelt til:
- MVP'er og prototyper — du vil hurtigt vise et produkt til investorer eller brugere
- Startup-ideer — begrænset budget, behov for både iOS og Android
- Indie-apps — du er én udvikler og vil dække begge platforme
- Industri-apps — virksomheds-apps til internt brug
- Apps med custom UI — du vil have fuld kontrol over designet
Flutter er måske ikke ideelt til:
- Meget simple apps — hvis det bare er en hjemmeside i en webview, overvej PWA
- Apps der kræver avanceret hardware-adgang — kamera, Bluetooth, NFC (fungerer, men kan kræve platform-specifikt kode)
- Spil — overvej Unity eller Godot i stedet
- Hold med dedikerede iOS/Android teams — hvis budgettet er stort og appen er kompleks
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:
Flutter(af Dart Code)Dart(installeres automatisk)
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:
- Hot reload: Tryk
ri terminalen eller brug genvejen i din IDE - Hot restart: Tryk
R— nulstiller tilstand men beholder appen åben
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
- Opret en Google Play-konto (25 USD engangsbeløb)
- Generér en signeringssnøgle:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload - Opret
android/key.properties - Byg release:
flutter build appbundle - Upload til Google Play Console
- Udfyld butikssider, skærmbilleder, beskrivelse
- Indsend til gennemgang (typisk 1-3 dage)
Apple App Store
- Opret en Apple Developer-konto (99 USD/år)
- Konfigurer Xcode-projektet med dit team-ID
- Byg release:
flutter build ios --release - Åbn
ios/Runner.xcworkspacei Xcode - Archivér og upload til App Store Connect
- Udfyld butikssider
- Indsend til gennemgang (typisk 1-7 dage)
Tips til godkendelse
- Skærmbilleder i alle nødvendige størrelser
- Privatlivspolitik er obligatorisk
- App-beskrivelse skal være tydelig og ærlig
- Test grundigt på rigtige enheder
- Overhold platform-retningslinjer (ingen spam, ingen voldelig content)
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
- Backend: Brug Firebase gratis tier, Supabase, eller AppWrite
- Design: Brug Material Design komponenter (gratis)
- Ikoner: Lucide, Phosphor, eller Material Icons (gratis)
- Skærmbilleder: Lav dem med Figma (gratis)
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:
- Du vil dække begge platforme med én kodebase
- Du vil have fuld UI-kontrol
- Du er komfortabel med at lære Dart
- Du bygger en MVP eller startup-app
Vælg native hvis:
- Du har dedikerede teams til hver platform
- Appen kræver avanceret hardware-adgang
- Ydeevne er den absolutte prioritet
Vælg React Native hvis:
- Dit hold allerede kender JavaScript/React
- Du vil have native udseende og fornemmelse
- Du har brug for et stort økosystem af tredjeparts-pakker
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:
- Tilføj/opdater/slet opgaver
- Afkrysningsliste til afsluttede opgaver
- Swipe for at slette
- Statistik over antal opgaver
- Flot Material Design UI
Næste skridt at udvide
- Lokal lagring med Hive eller SharedPreferences
- Firebase Auth for brugerkonti
- Firestore for cloud-synkronisering
- Push-notifikationer for påmindelser
- Kategorier og tags
- Deadline-funktion
Opsamling
Du har nu lært:
- Flutter fundamentals: Widgets, layouts, navigation
- Dart basics: Variabler, funktioner, klasser, async
- State management: Provider og Riverpod
- Datahåndtering: HTTP, JSON, lokal lagring
- Firebase: Auth, Firestore, Storage, Messaging
- Udgivelse: Google Play og Apple App Store
- Bygget et projekt: En komplet opgave-manager-app
Næste skridt
- Flutter dokumentation
- Dart sprogets dokumentation
- FlutterFire dokumentation
- Riverpod dokumentation
- pub.dev — Pakkebutik for Flutter/Dart
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! 🚀