Initial commit: TimeReg Flutter app med Firebase backend

- Opprettet Flutter-prosjekt med alle nødvendige avhengigheter
- Implementert datamodeller (User, TimeRegistration, TariffProfile, Deviation, AuditLog)
- Implementert tjenester (AuthService, TimeService)
- Implementert Riverpod providers for state management
- Opprettet autentiseringsskjermer (login, signup, reset password)
- Opprettet hjemmeskjerm med timer-funksjonalitet
- Opprettet placeholder-skjermer for historikk, rapporter og profil
- Lagt til norsk dokumentasjon i README
This commit is contained in:
steinhelge
2025-11-24 20:52:27 +01:00
commit c829f78984
148 changed files with 8462 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'screens/auth/login_screen.dart';
import 'screens/home/home_screen.dart';
import 'providers/auth_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialiser Firebase
await Firebase.initializeApp();
// Initialiser Hive for offline støtte
await Hive.initFlutter();
runApp(
const ProviderScope(
child: TimeRegApp(),
),
);
}
class TimeRegApp extends ConsumerWidget {
const TimeRegApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authStateProvider);
return MaterialApp(
title: 'TimeReg',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
themeMode: ThemeMode.system,
home: authState.when(
data: (user) {
if (user != null) {
return const HomeScreen();
}
return const LoginScreen();
},
loading: () => const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
),
error: (error, stack) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Feil: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Prøv å laste på nytt
},
child: const Text('Prøv igjen'),
),
],
),
),
),
),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum AuditAction {
create,
update,
delete,
}
enum EntityType {
timeRegistration,
user,
tariffProfile,
}
class AuditLog {
final String id;
final String userId;
final String organizationId;
final AuditAction action;
final EntityType entityType;
final String entityId;
final Map<String, dynamic>? before;
final Map<String, dynamic>? after;
final DateTime timestamp;
final String? ipAddress;
AuditLog({
required this.id,
required this.userId,
required this.organizationId,
required this.action,
required this.entityType,
required this.entityId,
this.before,
this.after,
required this.timestamp,
this.ipAddress,
});
factory AuditLog.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return AuditLog(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
action: _parseAction(data['action']),
entityType: _parseEntityType(data['entityType']),
entityId: data['entityId'] ?? '',
before: data['changes']?['before'],
after: data['changes']?['after'],
timestamp: (data['timestamp'] as Timestamp).toDate(),
ipAddress: data['ipAddress'],
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'action': action.name,
'entityType': entityType.name,
'entityId': entityId,
'changes': {
'before': before,
'after': after,
},
'timestamp': Timestamp.fromDate(timestamp),
'ipAddress': ipAddress,
};
}
static AuditAction _parseAction(String? actionString) {
switch (actionString) {
case 'update':
return AuditAction.update;
case 'delete':
return AuditAction.delete;
default:
return AuditAction.create;
}
}
static EntityType _parseEntityType(String? typeString) {
switch (typeString) {
case 'user':
return EntityType.user;
case 'tariffProfile':
return EntityType.tariffProfile;
default:
return EntityType.timeRegistration;
}
}
String get actionDisplayName {
switch (action) {
case AuditAction.create:
return 'Opprettet';
case AuditAction.update:
return 'Endret';
case AuditAction.delete:
return 'Slettet';
}
}
String get entityTypeDisplayName {
switch (entityType) {
case EntityType.timeRegistration:
return 'Timeregistrering';
case EntityType.user:
return 'Bruker';
case EntityType.tariffProfile:
return 'Tariffprofil';
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum DeviationType {
dailyMax,
weeklyMax,
dailyRest,
weeklyRest,
averageExceeded,
nightWork,
}
enum DeviationSeverity {
warning,
violation,
}
class DeviationMetadata {
final double actualValue;
final double limitValue;
final String? period;
DeviationMetadata({
required this.actualValue,
required this.limitValue,
this.period,
});
factory DeviationMetadata.fromMap(Map<String, dynamic> map) {
return DeviationMetadata(
actualValue: (map['actualValue'] ?? 0).toDouble(),
limitValue: (map['limitValue'] ?? 0).toDouble(),
period: map['period'],
);
}
Map<String, dynamic> toMap() {
return {
'actualValue': actualValue,
'limitValue': limitValue,
'period': period,
};
}
}
class Deviation {
final String id;
final String userId;
final String organizationId;
final String timeRegistrationId;
final DeviationType type;
final DeviationSeverity severity;
final String description;
final DateTime detectedAt;
final DateTime? acknowledgedAt;
final String? acknowledgedBy;
final DeviationMetadata metadata;
Deviation({
required this.id,
required this.userId,
required this.organizationId,
required this.timeRegistrationId,
required this.type,
required this.severity,
required this.description,
required this.detectedAt,
this.acknowledgedAt,
this.acknowledgedBy,
required this.metadata,
});
factory Deviation.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return Deviation(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
timeRegistrationId: data['timeRegistrationId'] ?? '',
type: _parseType(data['type']),
severity: _parseSeverity(data['severity']),
description: data['description'] ?? '',
detectedAt: (data['detectedAt'] as Timestamp).toDate(),
acknowledgedAt: data['acknowledgedAt'] != null
? (data['acknowledgedAt'] as Timestamp).toDate()
: null,
acknowledgedBy: data['acknowledgedBy'],
metadata: DeviationMetadata.fromMap(data['metadata'] ?? {}),
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'timeRegistrationId': timeRegistrationId,
'type': type.name,
'severity': severity.name,
'description': description,
'detectedAt': Timestamp.fromDate(detectedAt),
'acknowledgedAt':
acknowledgedAt != null ? Timestamp.fromDate(acknowledgedAt!) : null,
'acknowledgedBy': acknowledgedBy,
'metadata': metadata.toMap(),
};
}
static DeviationType _parseType(String? typeString) {
switch (typeString) {
case 'dailyMax':
return DeviationType.dailyMax;
case 'weeklyMax':
return DeviationType.weeklyMax;
case 'dailyRest':
return DeviationType.dailyRest;
case 'weeklyRest':
return DeviationType.weeklyRest;
case 'averageExceeded':
return DeviationType.averageExceeded;
case 'nightWork':
return DeviationType.nightWork;
default:
return DeviationType.dailyMax;
}
}
static DeviationSeverity _parseSeverity(String? severityString) {
switch (severityString) {
case 'violation':
return DeviationSeverity.violation;
default:
return DeviationSeverity.warning;
}
}
String get typeDisplayName {
switch (type) {
case DeviationType.dailyMax:
return 'Maks daglig arbeidstid';
case DeviationType.weeklyMax:
return 'Maks ukentlig arbeidstid';
case DeviationType.dailyRest:
return 'Daglig hviletid';
case DeviationType.weeklyRest:
return 'Ukentlig hviletid';
case DeviationType.averageExceeded:
return 'Gjennomsnittsberegning overskredet';
case DeviationType.nightWork:
return 'Nattarbeid';
}
}
bool get isAcknowledged => acknowledgedAt != null;
Deviation copyWith({
String? userId,
String? organizationId,
String? timeRegistrationId,
DeviationType? type,
DeviationSeverity? severity,
String? description,
DateTime? detectedAt,
DateTime? acknowledgedAt,
String? acknowledgedBy,
DeviationMetadata? metadata,
}) {
return Deviation(
id: id,
userId: userId ?? this.userId,
organizationId: organizationId ?? this.organizationId,
timeRegistrationId: timeRegistrationId ?? this.timeRegistrationId,
type: type ?? this.type,
severity: severity ?? this.severity,
description: description ?? this.description,
detectedAt: detectedAt ?? this.detectedAt,
acknowledgedAt: acknowledgedAt ?? this.acknowledgedAt,
acknowledgedBy: acknowledgedBy ?? this.acknowledgedBy,
metadata: metadata ?? this.metadata,
);
}
}
+195
View File
@@ -0,0 +1,195 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class NightWorkRules {
final int startHour; // e.g., 21 for 9 PM
final int endHour; // e.g., 6 for 6 AM
final int maxNightHours;
NightWorkRules({
required this.startHour,
required this.endHour,
required this.maxNightHours,
});
factory NightWorkRules.fromMap(Map<String, dynamic> map) {
return NightWorkRules(
startHour: map['startHour'] ?? 21,
endHour: map['endHour'] ?? 6,
maxNightHours: map['maxNightHours'] ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
'startHour': startHour,
'endHour': endHour,
'maxNightHours': maxNightHours,
};
}
}
class TariffRules {
final int maxDailyHours;
final int maxWeeklyHours;
final int minDailyRest; // in minutes
final int minWeeklyRest; // in minutes
final bool useAverageCalculation;
final int? averagePeriodWeeks;
final int? maxAverageHours;
final NightWorkRules? nightWorkRules;
TariffRules({
required this.maxDailyHours,
required this.maxWeeklyHours,
required this.minDailyRest,
required this.minWeeklyRest,
this.useAverageCalculation = false,
this.averagePeriodWeeks,
this.maxAverageHours,
this.nightWorkRules,
});
factory TariffRules.fromMap(Map<String, dynamic> map) {
return TariffRules(
maxDailyHours: map['maxDailyHours'] ?? 9,
maxWeeklyHours: map['maxWeeklyHours'] ?? 40,
minDailyRest: map['minDailyRest'] ?? 660, // 11 hours
minWeeklyRest: map['minWeeklyRest'] ?? 2100, // 35 hours
useAverageCalculation: map['useAverageCalculation'] ?? false,
averagePeriodWeeks: map['averagePeriodWeeks'],
maxAverageHours: map['maxAverageHours'],
nightWorkRules: map['nightWorkRules'] != null
? NightWorkRules.fromMap(map['nightWorkRules'])
: null,
);
}
Map<String, dynamic> toMap() {
return {
'maxDailyHours': maxDailyHours,
'maxWeeklyHours': maxWeeklyHours,
'minDailyRest': minDailyRest,
'minWeeklyRest': minWeeklyRest,
'useAverageCalculation': useAverageCalculation,
'averagePeriodWeeks': averagePeriodWeeks,
'maxAverageHours': maxAverageHours,
'nightWorkRules': nightWorkRules?.toMap(),
};
}
}
class TariffProfile {
final String id;
final String name;
final String description;
final String organizationId;
final TariffRules rules;
final bool isDefault;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
TariffProfile({
required this.id,
required this.name,
required this.description,
required this.organizationId,
required this.rules,
this.isDefault = false,
this.isActive = true,
required this.createdAt,
required this.updatedAt,
});
factory TariffProfile.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return TariffProfile(
id: doc.id,
name: data['name'] ?? '',
description: data['description'] ?? '',
organizationId: data['organizationId'] ?? '',
rules: TariffRules.fromMap(data['rules'] ?? {}),
isDefault: data['isDefault'] ?? false,
isActive: data['isActive'] ?? true,
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'name': name,
'description': description,
'organizationId': organizationId,
'rules': rules.toMap(),
'isDefault': isDefault,
'isActive': isActive,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
TariffProfile copyWith({
String? name,
String? description,
String? organizationId,
TariffRules? rules,
bool? isDefault,
bool? isActive,
DateTime? updatedAt,
}) {
return TariffProfile(
id: id,
name: name ?? this.name,
description: description ?? this.description,
organizationId: organizationId ?? this.organizationId,
rules: rules ?? this.rules,
isDefault: isDefault ?? this.isDefault,
isActive: isActive ?? this.isActive,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Predefined profiles
static TariffProfile createDefaultAML(String organizationId) {
return TariffProfile(
id: 'default_aml',
name: 'Ingen tariffavtale (AML Standard)',
description: 'Standard arbeidsmiljøloven uten tariffavtale',
organizationId: organizationId,
rules: TariffRules(
maxDailyHours: 9,
maxWeeklyHours: 40,
minDailyRest: 660, // 11 hours
minWeeklyRest: 2100, // 35 hours
),
isDefault: true,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
static TariffProfile createTariffA(String organizationId) {
return TariffProfile(
id: 'tariff_a',
name: 'Tariffavtale A',
description: 'Tariffavtale med utvidet arbeidstid og gjennomsnittsberegning',
organizationId: organizationId,
rules: TariffRules(
maxDailyHours: 10,
maxWeeklyHours: 48,
minDailyRest: 660, // 11 hours
minWeeklyRest: 2100, // 35 hours
useAverageCalculation: true,
averagePeriodWeeks: 8,
maxAverageHours: 48,
),
isDefault: false,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum RegistrationType {
ordinary,
overtime,
oncall,
travel,
}
enum SyncStatus {
synced,
pending,
conflict,
}
class TimeBreak {
final DateTime startTime;
final DateTime endTime;
final int duration; // in minutes
TimeBreak({
required this.startTime,
required this.endTime,
required this.duration,
});
factory TimeBreak.fromMap(Map<String, dynamic> map) {
return TimeBreak(
startTime: (map['startTime'] as Timestamp).toDate(),
endTime: (map['endTime'] as Timestamp).toDate(),
duration: map['duration'] ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
'startTime': Timestamp.fromDate(startTime),
'endTime': Timestamp.fromDate(endTime),
'duration': duration,
};
}
}
class TimeRegistration {
final String id;
final String userId;
final String organizationId;
final DateTime startTime;
final DateTime? endTime;
final int duration; // in minutes
final RegistrationType type;
final String? projectId;
final String? customerId;
final String? comment;
final List<TimeBreak> breaks;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
final String? lastModifiedBy;
final List<String> deviations;
final bool isManual;
final SyncStatus syncStatus;
TimeRegistration({
required this.id,
required this.userId,
required this.organizationId,
required this.startTime,
this.endTime,
required this.duration,
this.type = RegistrationType.ordinary,
this.projectId,
this.customerId,
this.comment,
this.breaks = const [],
required this.createdAt,
required this.updatedAt,
required this.createdBy,
this.lastModifiedBy,
this.deviations = const [],
this.isManual = false,
this.syncStatus = SyncStatus.synced,
});
factory TimeRegistration.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return TimeRegistration(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
startTime: (data['startTime'] as Timestamp).toDate(),
endTime: data['endTime'] != null
? (data['endTime'] as Timestamp).toDate()
: null,
duration: data['duration'] ?? 0,
type: _parseType(data['type']),
projectId: data['projectId'],
customerId: data['customerId'],
comment: data['comment'],
breaks: (data['breaks'] as List<dynamic>?)
?.map((b) => TimeBreak.fromMap(b as Map<String, dynamic>))
.toList() ??
[],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
createdBy: data['createdBy'] ?? '',
lastModifiedBy: data['lastModifiedBy'],
deviations: List<String>.from(data['deviations'] ?? []),
isManual: data['isManual'] ?? false,
syncStatus: _parseSyncStatus(data['syncStatus']),
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'startTime': Timestamp.fromDate(startTime),
'endTime': endTime != null ? Timestamp.fromDate(endTime!) : null,
'duration': duration,
'type': type.name,
'projectId': projectId,
'customerId': customerId,
'comment': comment,
'breaks': breaks.map((b) => b.toMap()).toList(),
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'createdBy': createdBy,
'lastModifiedBy': lastModifiedBy,
'deviations': deviations,
'isManual': isManual,
'syncStatus': syncStatus.name,
};
}
static RegistrationType _parseType(String? typeString) {
switch (typeString) {
case 'overtime':
return RegistrationType.overtime;
case 'oncall':
return RegistrationType.oncall;
case 'travel':
return RegistrationType.travel;
default:
return RegistrationType.ordinary;
}
}
static SyncStatus _parseSyncStatus(String? statusString) {
switch (statusString) {
case 'pending':
return SyncStatus.pending;
case 'conflict':
return SyncStatus.conflict;
default:
return SyncStatus.synced;
}
}
int get totalBreakMinutes {
return breaks.fold(0, (sum, b) => sum + b.duration);
}
int get netWorkMinutes {
return duration - totalBreakMinutes;
}
bool get isActive {
return endTime == null;
}
TimeRegistration copyWith({
String? userId,
String? organizationId,
DateTime? startTime,
DateTime? endTime,
int? duration,
RegistrationType? type,
String? projectId,
String? customerId,
String? comment,
List<TimeBreak>? breaks,
DateTime? updatedAt,
String? lastModifiedBy,
List<String>? deviations,
bool? isManual,
SyncStatus? syncStatus,
}) {
return TimeRegistration(
id: id,
userId: userId ?? this.userId,
organizationId: organizationId ?? this.organizationId,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
duration: duration ?? this.duration,
type: type ?? this.type,
projectId: projectId ?? this.projectId,
customerId: customerId ?? this.customerId,
comment: comment ?? this.comment,
breaks: breaks ?? this.breaks,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy,
lastModifiedBy: lastModifiedBy ?? this.lastModifiedBy,
deviations: deviations ?? this.deviations,
isManual: isManual ?? this.isManual,
syncStatus: syncStatus ?? this.syncStatus,
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum UserRole {
employee,
admin,
systemAdmin,
}
class UserModel {
final String uid;
final String email;
final String displayName;
final UserRole role;
final String tariffProfileId;
final String organizationId;
final String? departmentId;
final DateTime createdAt;
final DateTime updatedAt;
final bool isActive;
final UserPreferences preferences;
UserModel({
required this.uid,
required this.email,
required this.displayName,
required this.role,
required this.tariffProfileId,
required this.organizationId,
this.departmentId,
required this.createdAt,
required this.updatedAt,
this.isActive = true,
required this.preferences,
});
factory UserModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return UserModel(
uid: doc.id,
email: data['email'] ?? '',
displayName: data['displayName'] ?? '',
role: _parseRole(data['role']),
tariffProfileId: data['tariffProfileId'] ?? '',
organizationId: data['organizationId'] ?? '',
departmentId: data['departmentId'],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
isActive: data['isActive'] ?? true,
preferences: UserPreferences.fromMap(data['preferences'] ?? {}),
);
}
Map<String, dynamic> toFirestore() {
return {
'email': email,
'displayName': displayName,
'role': role.name,
'tariffProfileId': tariffProfileId,
'organizationId': organizationId,
'departmentId': departmentId,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'isActive': isActive,
'preferences': preferences.toMap(),
};
}
static UserRole _parseRole(String? roleString) {
switch (roleString) {
case 'admin':
return UserRole.admin;
case 'systemAdmin':
return UserRole.systemAdmin;
default:
return UserRole.employee;
}
}
UserModel copyWith({
String? email,
String? displayName,
UserRole? role,
String? tariffProfileId,
String? organizationId,
String? departmentId,
DateTime? updatedAt,
bool? isActive,
UserPreferences? preferences,
}) {
return UserModel(
uid: uid,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
role: role ?? this.role,
tariffProfileId: tariffProfileId ?? this.tariffProfileId,
organizationId: organizationId ?? this.organizationId,
departmentId: departmentId ?? this.departmentId,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isActive: isActive ?? this.isActive,
preferences: preferences ?? this.preferences,
);
}
}
class UserPreferences {
final bool notificationsEnabled;
final bool emailNotifications;
final bool pushNotifications;
UserPreferences({
this.notificationsEnabled = true,
this.emailNotifications = true,
this.pushNotifications = true,
});
factory UserPreferences.fromMap(Map<String, dynamic> map) {
return UserPreferences(
notificationsEnabled: map['notificationsEnabled'] ?? true,
emailNotifications: map['emailNotifications'] ?? true,
pushNotifications: map['pushNotifications'] ?? true,
);
}
Map<String, dynamic> toMap() {
return {
'notificationsEnabled': notificationsEnabled,
'emailNotifications': emailNotifications,
'pushNotifications': pushNotifications,
};
}
UserPreferences copyWith({
bool? notificationsEnabled,
bool? emailNotifications,
bool? pushNotifications,
}) {
return UserPreferences(
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
emailNotifications: emailNotifications ?? this.emailNotifications,
pushNotifications: pushNotifications ?? this.pushNotifications,
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../services/auth_service.dart';
import '../models/user_model.dart';
// Auth service provider
final authServiceProvider = Provider<AuthService>((ref) => AuthService());
// Auth state provider - lytter til Firebase Auth state changes
final authStateProvider = StreamProvider<User?>((ref) {
final authService = ref.watch(authServiceProvider);
return authService.authStateChanges;
});
// Current user provider
final currentUserProvider = Provider<User?>((ref) {
final authState = ref.watch(authStateProvider);
return authState.when(
data: (user) => user,
loading: () => null,
error: (_, __) => null,
);
});
// User data provider - henter brukerdata fra Firestore
final userDataProvider = StreamProvider<UserModel?>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Stream.value(null);
}
final authService = ref.watch(authServiceProvider);
return authService.userDataStream(user.uid);
});
// Login provider
final loginProvider = Provider<Future<UserCredential> Function({
required String email,
required String password,
})>((ref) {
final authService = ref.watch(authServiceProvider);
return ({required String email, required String password}) {
return authService.signInWithEmail(email: email, password: password);
};
});
// Sign up provider
final signUpProvider = Provider<Future<UserCredential> Function({
required String email,
required String password,
required String displayName,
required String organizationId,
String? departmentId,
})>((ref) {
final authService = ref.watch(authServiceProvider);
return ({
required String email,
required String password,
required String displayName,
required String organizationId,
String? departmentId,
}) {
return authService.signUpWithEmail(
email: email,
password: password,
displayName: displayName,
organizationId: organizationId,
departmentId: departmentId,
);
};
});
// Sign out provider
final signOutProvider = Provider<Future<void> Function()>((ref) {
final authService = ref.watch(authServiceProvider);
return () => authService.signOut();
});
// Reset password provider
final resetPasswordProvider = Provider<Future<void> Function(String)>((ref) {
final authService = ref.watch(authServiceProvider);
return (String email) => authService.resetPassword(email);
});
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/time_service.dart';
import '../models/time_registration.dart';
import 'auth_provider.dart';
// Time service provider
final timeServiceProvider = Provider<TimeService>((ref) => TimeService());
// Active registration provider - henter pågående timeregistrering
final activeRegistrationProvider = FutureProvider<TimeRegistration?>((ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) return null;
final timeService = ref.watch(timeServiceProvider);
return timeService.getActiveRegistration(user.uid);
});
// Today's registrations provider
final todayRegistrationsProvider = StreamProvider<List<TimeRegistration>>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Stream.value([]);
}
final timeService = ref.watch(timeServiceProvider);
final today = DateTime.now();
final startOfDay = DateTime(today.year, today.month, today.day);
final endOfDay = DateTime(today.year, today.month, today.day, 23, 59, 59);
return timeService.registrationsStream(
userId: user.uid,
startDate: startOfDay,
endDate: endOfDay,
);
});
// Registrations for date range provider
final registrationsProvider = FutureProvider.family<List<TimeRegistration>, DateRange>(
(ref, dateRange) async {
final user = ref.watch(currentUserProvider);
if (user == null) return [];
final timeService = ref.watch(timeServiceProvider);
return timeService.getRegistrations(
userId: user.uid,
startDate: dateRange.start,
endDate: dateRange.end,
);
},
);
// Total hours for period provider
final totalHoursProvider = FutureProvider.family<Map<String, int>, DateRange>(
(ref, dateRange) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return {'total': 0, 'ordinary': 0, 'overtime': 0};
}
final timeService = ref.watch(timeServiceProvider);
return timeService.calculateTotalHours(
userId: user.uid,
startDate: dateRange.start,
endDate: dateRange.end,
);
},
);
// Helper class for date ranges
class DateRange {
final DateTime start;
final DateTime end;
DateRange({required this.start, required this.end});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DateRange &&
runtimeType == other.runtimeType &&
start == other.start &&
end == other.end;
@override
int get hashCode => start.hashCode ^ end.hashCode;
}
+202
View File
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import 'signup_screen.dart';
import 'reset_password_screen.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final login = ref.read(loginProvider);
await login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
// Navigation håndteres automatisk av authStateProvider i main.dart
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo/Ikon
Icon(
Icons.access_time_rounded,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
// Tittel
Text(
'TimeReg',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Registrer arbeidstid med AML-kontroll',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// E-post felt
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-post',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn e-post';
}
if (!value.contains('@')) {
return 'Vennligst skriv inn en gyldig e-post';
}
return null;
},
),
const SizedBox(height: 16),
// Passord felt
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Passord',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn passord';
}
return null;
},
),
const SizedBox(height: 8),
// Glemt passord
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ResetPasswordScreen(),
),
);
},
child: const Text('Glemt passord?'),
),
),
const SizedBox(height: 24),
// Logg inn knapp
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Logg inn'),
),
const SizedBox(height: 16),
// Opprett konto
OutlinedButton(
onPressed: _isLoading
? null
: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SignUpScreen(),
),
);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Opprett konto'),
),
],
),
),
),
),
),
);
}
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
class ResetPasswordScreen extends ConsumerStatefulWidget {
const ResetPasswordScreen({super.key});
@override
ConsumerState<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _handleResetPassword() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final resetPassword = ref.read(resetPasswordProvider);
await resetPassword(_emailController.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('E-post for tilbakestilling av passord er sendt'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tilbakestill passord'),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Skriv inn e-postadressen din, så sender vi deg en lenke for å tilbakestille passordet.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-post',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn e-post';
}
if (!value.contains('@')) {
return 'Vennligst skriv inn en gyldig e-post';
}
return null;
},
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleResetPassword,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Send tilbakestillingslenke'),
),
],
),
),
),
),
);
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@override
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _organizationIdController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_organizationIdController.dispose();
super.dispose();
}
Future<void> _handleSignUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final signUp = ref.read(signUpProvider);
await signUp(
email: _emailController.text.trim(),
password: _passwordController.text,
displayName: _nameController.text.trim(),
organizationId: _organizationIdController.text.trim(),
);
if (mounted) {
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Opprett konto'),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Navn
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Fullt navn',
prefixIcon: Icon(Icons.person_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn navn';
}
return null;
},
),
const SizedBox(height: 16),
// E-post
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-post',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn e-post';
}
if (!value.contains('@')) {
return 'Vennligst skriv inn en gyldig e-post';
}
return null;
},
),
const SizedBox(height: 16),
// Organisasjons-ID
TextFormField(
controller: _organizationIdController,
decoration: const InputDecoration(
labelText: 'Organisasjons-ID',
prefixIcon: Icon(Icons.business_outlined),
helperText: 'Kontakt administrator for organisasjons-ID',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn organisasjons-ID';
}
return null;
},
),
const SizedBox(height: 16),
// Passord
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Passord',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst skriv inn passord';
}
if (value.length < 6) {
return 'Passordet må være minst 6 tegn';
}
return null;
},
),
const SizedBox(height: 16),
// Bekreft passord
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: 'Bekreft passord',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vennligst bekreft passord';
}
if (value != _passwordController.text) {
return 'Passordene matcher ikke';
}
return null;
},
),
const SizedBox(height: 32),
// Opprett konto knapp
FilledButton(
onPressed: _isLoading ? null : _handleSignUp,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Opprett konto'),
),
],
),
),
),
),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class HistoryScreen extends StatelessWidget {
const HistoryScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Historikk'),
),
body: const Center(
child: Text('Historikk kommer her'),
),
);
}
}
+311
View File
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../providers/auth_provider.dart';
import '../../providers/time_provider.dart';
import '../../services/time_service.dart';
import '../../models/time_registration.dart';
import '../history/history_screen.dart';
import '../reports/reports_screen.dart';
import '../profile/profile_screen.dart';
import '../../widgets/timer_widget.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final List<Widget> screens = [
const HomeTab(),
const HistoryScreen(),
const ReportsScreen(),
const ProfileScreen(),
];
return Scaffold(
body: screens[_selectedIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Hjem',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historikk',
),
NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Rapporter',
),
NavigationDestination(
icon: Icon(Icons.person_outlined),
selectedIcon: Icon(Icons.person),
label: 'Profil',
),
],
),
);
}
}
class HomeTab extends ConsumerWidget {
const HomeTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userData = ref.watch(userDataProvider);
final todayRegistrations = ref.watch(todayRegistrationsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('TimeReg'),
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: Implementer varsler
},
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Velkomst
userData.when(
data: (user) => user != null
? Text(
'Hei, ${user.displayName}!',
style: Theme.of(context).textTheme.headlineSmall,
)
: const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
const SizedBox(height: 8),
Text(
DateFormat('EEEE d. MMMM yyyy', 'nb_NO').format(DateTime.now()),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 24),
// Timer widget
const TimerWidget(),
const SizedBox(height: 24),
// Dagens registreringer
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'I dag',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
todayRegistrations.when(
data: (registrations) {
final totalMinutes = registrations
.where((r) => r.endTime != null)
.fold<int>(0, (sum, r) => sum + r.netWorkMinutes);
final hours = totalMinutes ~/ 60;
final minutes = totalMinutes % 60;
return Text(
'${hours}t ${minutes}m',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
);
},
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
error: (_, __) => const Text('--'),
),
],
),
const SizedBox(height: 16),
todayRegistrations.when(
data: (registrations) {
if (registrations.isEmpty) {
return const Text('Ingen registreringer i dag');
}
return Column(
children: registrations.map((reg) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
_getIconForType(reg.type),
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'${DateFormat('HH:mm').format(reg.startTime)} - ${reg.endTime != null ? DateFormat('HH:mm').format(reg.endTime!) : 'Pågår'}',
),
subtitle: reg.comment != null
? Text(reg.comment!)
: null,
trailing: reg.endTime != null
? Text(
'${reg.netWorkMinutes ~/ 60}t ${reg.netWorkMinutes % 60}m',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
)
: const Icon(Icons.play_arrow, color: Colors.green),
);
}).toList(),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, _) => Text('Feil: $error'),
),
],
),
),
),
const SizedBox(height: 16),
// Ukens oversikt
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Denne uken',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_WeekSummary(),
],
),
),
),
],
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// TODO: Implementer manuell registrering
},
icon: const Icon(Icons.add),
label: const Text('Manuell registrering'),
),
);
}
IconData _getIconForType(RegistrationType type) {
switch (type) {
case RegistrationType.ordinary:
return Icons.work_outline;
case RegistrationType.overtime:
return Icons.access_time_filled;
case RegistrationType.oncall:
return Icons.phone_in_talk;
case RegistrationType.travel:
return Icons.directions_car;
}
}
}
class _WeekSummary extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final now = DateTime.now();
final startOfWeek = now.subtract(Duration(days: now.weekday - 1));
final endOfWeek = startOfWeek.add(const Duration(days: 6, hours: 23, minutes: 59));
final dateRange = DateRange(start: startOfWeek, end: endOfWeek);
final totalHours = ref.watch(totalHoursProvider(dateRange));
return totalHours.when(
data: (hours) {
final totalMinutes = hours['total'] ?? 0;
final ordinaryMinutes = hours['ordinary'] ?? 0;
final overtimeMinutes = hours['overtime'] ?? 0;
return Column(
children: [
_SummaryRow(
label: 'Total arbeidstid',
value: '${totalMinutes ~/ 60}t ${totalMinutes % 60}m',
),
const SizedBox(height: 8),
_SummaryRow(
label: 'Ordinær tid',
value: '${ordinaryMinutes ~/ 60}t ${ordinaryMinutes % 60}m',
),
const SizedBox(height: 8),
_SummaryRow(
label: 'Overtid',
value: '${overtimeMinutes ~/ 60}t ${overtimeMinutes % 60}m',
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Text('Feil: $error'),
);
}
}
class _SummaryRow extends StatelessWidget {
final String label;
final String value;
const _SummaryRow({
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userData = ref.watch(userDataProvider);
final signOut = ref.read(signOutProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Profil'),
),
body: userData.when(
data: (user) {
if (user == null) {
return const Center(child: Text('Ingen brukerdata'));
}
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
CircleAvatar(
radius: 40,
child: Text(
user.displayName.isNotEmpty
? user.displayName[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 32),
),
),
const SizedBox(height: 16),
Text(
user.displayName,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
user.email,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 8),
Chip(
label: Text(
user.role.name == 'employee'
? 'Ansatt'
: user.role.name == 'admin'
? 'Administrator'
: 'Systemadministrator',
),
),
],
),
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.business),
title: const Text('Organisasjon'),
subtitle: Text(user.organizationId),
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logg ut'),
onTap: () async {
await signOut();
},
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Feil: $error')),
),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class ReportsScreen extends StatelessWidget {
const ReportsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Rapporter'),
),
body: const Center(
child: Text('Rapporter kommer her'),
),
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/user_model.dart';
class AuthService {
final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
// Hent nåværende bruker
User? get currentUser => _auth.currentUser;
// Stream av autentiseringstilstand
Stream<User?> get authStateChanges => _auth.authStateChanges();
// Registrer ny bruker med e-post og passord
Future<UserCredential> signUpWithEmail({
required String email,
required String password,
required String displayName,
required String organizationId,
String? departmentId,
}) async {
try {
// Opprett bruker i Firebase Auth
final userCredential = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
// Oppdater displayName
await userCredential.user?.updateDisplayName(displayName);
// Opprett brukerdata i Firestore
await _createUserDocument(
uid: userCredential.user!.uid,
email: email,
displayName: displayName,
organizationId: organizationId,
departmentId: departmentId,
);
return userCredential;
} on FirebaseAuthException catch (e) {
throw _handleAuthException(e);
}
}
// Logg inn med e-post og passord
Future<UserCredential> signInWithEmail({
required String email,
required String password,
}) async {
try {
return await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
throw _handleAuthException(e);
}
}
// Tilbakestill passord
Future<void> resetPassword(String email) async {
try {
await _auth.sendPasswordResetEmail(email: email);
} on FirebaseAuthException catch (e) {
throw _handleAuthException(e);
}
}
// Logg ut
Future<void> signOut() async {
await _auth.signOut();
}
// Hent brukerdata fra Firestore
Future<UserModel?> getUserData(String uid) async {
try {
final doc = await _firestore.collection('users').doc(uid).get();
if (doc.exists) {
return UserModel.fromFirestore(doc);
}
return null;
} catch (e) {
throw Exception('Kunne ikke hente brukerdata: $e');
}
}
// Stream av brukerdata
Stream<UserModel?> userDataStream(String uid) {
return _firestore
.collection('users')
.doc(uid)
.snapshots()
.map((doc) => doc.exists ? UserModel.fromFirestore(doc) : null);
}
// Opprett brukerdokument i Firestore
Future<void> _createUserDocument({
required String uid,
required String email,
required String displayName,
required String organizationId,
String? departmentId,
}) async {
// Hent standard tariffprofil for organisasjonen
final defaultProfile = await _getDefaultTariffProfile(organizationId);
final userData = UserModel(
uid: uid,
email: email,
displayName: displayName,
role: UserRole.employee,
tariffProfileId: defaultProfile,
organizationId: organizationId,
departmentId: departmentId,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
preferences: UserPreferences(),
);
await _firestore.collection('users').doc(uid).set(userData.toFirestore());
}
// Hent standard tariffprofil for organisasjon
Future<String> _getDefaultTariffProfile(String organizationId) async {
final query = await _firestore
.collection('tariff_profiles')
.where('organizationId', isEqualTo: organizationId)
.where('isDefault', isEqualTo: true)
.limit(1)
.get();
if (query.docs.isNotEmpty) {
return query.docs.first.id;
}
// Hvis ingen standard profil finnes, returner en placeholder
return 'default_aml';
}
// Håndter Firebase Auth exceptions
String _handleAuthException(FirebaseAuthException e) {
switch (e.code) {
case 'weak-password':
return 'Passordet er for svakt';
case 'email-already-in-use':
return 'E-postadressen er allerede i bruk';
case 'invalid-email':
return 'Ugyldig e-postadresse';
case 'user-not-found':
return 'Ingen bruker funnet med denne e-postadressen';
case 'wrong-password':
return 'Feil passord';
case 'user-disabled':
return 'Denne brukerkontoen er deaktivert';
case 'too-many-requests':
return 'For mange forsøk. Prøv igjen senere';
case 'operation-not-allowed':
return 'Denne operasjonen er ikke tillatt';
default:
return 'En feil oppstod: ${e.message}';
}
}
}
+322
View File
@@ -0,0 +1,322 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:uuid/uuid.dart';
import '../models/time_registration.dart';
class TimeService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final Uuid _uuid = const Uuid();
// Start ny timeregistrering (stempling)
Future<String> startTimer({
required String userId,
required String organizationId,
RegistrationType type = RegistrationType.ordinary,
String? projectId,
String? customerId,
String? comment,
}) async {
try {
final registration = TimeRegistration(
id: _uuid.v4(),
userId: userId,
organizationId: organizationId,
startTime: DateTime.now(),
endTime: null, // Ikke avsluttet ennå
duration: 0,
type: type,
projectId: projectId,
customerId: customerId,
comment: comment,
breaks: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
createdBy: userId,
isManual: false,
);
await _firestore
.collection('time_registrations')
.doc(registration.id)
.set(registration.toFirestore());
return registration.id;
} catch (e) {
throw Exception('Kunne ikke starte timer: $e');
}
}
// Stopp pågående timeregistrering
Future<void> stopTimer(String registrationId, String userId) async {
try {
final doc = await _firestore
.collection('time_registrations')
.doc(registrationId)
.get();
if (!doc.exists) {
throw Exception('Timeregistrering ikke funnet');
}
final registration = TimeRegistration.fromFirestore(doc);
final endTime = DateTime.now();
final duration = endTime.difference(registration.startTime).inMinutes;
await _firestore
.collection('time_registrations')
.doc(registrationId)
.update({
'endTime': Timestamp.fromDate(endTime),
'duration': duration,
'updatedAt': Timestamp.fromDate(DateTime.now()),
'lastModifiedBy': userId,
});
} catch (e) {
throw Exception('Kunne ikke stoppe timer: $e');
}
}
// Opprett manuell timeregistrering
Future<String> createManualEntry({
required String userId,
required String organizationId,
required DateTime startTime,
required DateTime endTime,
RegistrationType type = RegistrationType.ordinary,
List<TimeBreak> breaks = const [],
String? projectId,
String? customerId,
String? comment,
}) async {
try {
final duration = endTime.difference(startTime).inMinutes;
final registration = TimeRegistration(
id: _uuid.v4(),
userId: userId,
organizationId: organizationId,
startTime: startTime,
endTime: endTime,
duration: duration,
type: type,
projectId: projectId,
customerId: customerId,
comment: comment,
breaks: breaks,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
createdBy: userId,
isManual: true,
);
await _firestore
.collection('time_registrations')
.doc(registration.id)
.set(registration.toFirestore());
return registration.id;
} catch (e) {
throw Exception('Kunne ikke opprette timeregistrering: $e');
}
}
// Oppdater eksisterende timeregistrering
Future<void> updateRegistration({
required String registrationId,
required String userId,
DateTime? startTime,
DateTime? endTime,
RegistrationType? type,
List<TimeBreak>? breaks,
String? projectId,
String? customerId,
String? comment,
}) async {
try {
final doc = await _firestore
.collection('time_registrations')
.doc(registrationId)
.get();
if (!doc.exists) {
throw Exception('Timeregistrering ikke funnet');
}
final registration = TimeRegistration.fromFirestore(doc);
final Map<String, dynamic> updates = {
'updatedAt': Timestamp.fromDate(DateTime.now()),
'lastModifiedBy': userId,
};
if (startTime != null) {
updates['startTime'] = Timestamp.fromDate(startTime);
}
if (endTime != null) {
updates['endTime'] = Timestamp.fromDate(endTime);
}
if (type != null) {
updates['type'] = type.name;
}
if (breaks != null) {
updates['breaks'] = breaks.map((b) => b.toMap()).toList();
}
if (projectId != null) {
updates['projectId'] = projectId;
}
if (customerId != null) {
updates['customerId'] = customerId;
}
if (comment != null) {
updates['comment'] = comment;
}
// Beregn ny varighet hvis start eller slutt er endret
final newStartTime = startTime ?? registration.startTime;
final newEndTime = endTime ?? registration.endTime;
if (newEndTime != null) {
updates['duration'] = newEndTime.difference(newStartTime).inMinutes;
}
await _firestore
.collection('time_registrations')
.doc(registrationId)
.update(updates);
} catch (e) {
throw Exception('Kunne ikke oppdatere timeregistrering: $e');
}
}
// Slett timeregistrering
Future<void> deleteRegistration(String registrationId) async {
try {
await _firestore
.collection('time_registrations')
.doc(registrationId)
.delete();
} catch (e) {
throw Exception('Kunne ikke slette timeregistrering: $e');
}
}
// Hent alle timeregistreringer for bruker i en periode
Future<List<TimeRegistration>> getRegistrations({
required String userId,
DateTime? startDate,
DateTime? endDate,
}) async {
try {
Query query = _firestore
.collection('time_registrations')
.where('userId', isEqualTo: userId);
if (startDate != null) {
query = query.where('startTime',
isGreaterThanOrEqualTo: Timestamp.fromDate(startDate));
}
if (endDate != null) {
query = query.where('startTime',
isLessThanOrEqualTo: Timestamp.fromDate(endDate));
}
final snapshot = await query.orderBy('startTime', descending: true).get();
return snapshot.docs
.map((doc) => TimeRegistration.fromFirestore(doc))
.toList();
} catch (e) {
throw Exception('Kunne ikke hente timeregistreringer: $e');
}
}
// Hent timeregistreringer for en spesifikk dag
Future<List<TimeRegistration>> getRegistrationsByDay({
required String userId,
required DateTime date,
}) async {
final startOfDay = DateTime(date.year, date.month, date.day);
final endOfDay = DateTime(date.year, date.month, date.day, 23, 59, 59);
return getRegistrations(
userId: userId,
startDate: startOfDay,
endDate: endOfDay,
);
}
// Hent pågående timeregistrering
Future<TimeRegistration?> getActiveRegistration(String userId) async {
try {
final snapshot = await _firestore
.collection('time_registrations')
.where('userId', isEqualTo: userId)
.where('endTime', isNull: true)
.limit(1)
.get();
if (snapshot.docs.isNotEmpty) {
return TimeRegistration.fromFirestore(snapshot.docs.first);
}
return null;
} catch (e) {
throw Exception('Kunne ikke hente aktiv timeregistrering: $e');
}
}
// Stream av timeregistreringer for bruker
Stream<List<TimeRegistration>> registrationsStream({
required String userId,
DateTime? startDate,
DateTime? endDate,
}) {
Query query = _firestore
.collection('time_registrations')
.where('userId', isEqualTo: userId);
if (startDate != null) {
query = query.where('startTime',
isGreaterThanOrEqualTo: Timestamp.fromDate(startDate));
}
if (endDate != null) {
query = query.where('startTime',
isLessThanOrEqualTo: Timestamp.fromDate(endDate));
}
return query.orderBy('startTime', descending: true).snapshots().map(
(snapshot) => snapshot.docs
.map((doc) => TimeRegistration.fromFirestore(doc))
.toList(),
);
}
// Beregn total arbeidstid for en periode
Future<Map<String, int>> calculateTotalHours({
required String userId,
required DateTime startDate,
required DateTime endDate,
}) async {
final registrations = await getRegistrations(
userId: userId,
startDate: startDate,
endDate: endDate,
);
int totalMinutes = 0;
int ordinaryMinutes = 0;
int overtimeMinutes = 0;
for (final reg in registrations) {
if (reg.endTime != null) {
totalMinutes += reg.netWorkMinutes;
if (reg.type == RegistrationType.ordinary) {
ordinaryMinutes += reg.netWorkMinutes;
} else if (reg.type == RegistrationType.overtime) {
overtimeMinutes += reg.netWorkMinutes;
}
}
}
return {
'total': totalMinutes,
'ordinary': ordinaryMinutes,
'overtime': overtimeMinutes,
};
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../providers/time_provider.dart';
import '../services/time_service.dart';
class TimerWidget extends ConsumerStatefulWidget {
const TimerWidget({super.key});
@override
ConsumerState<TimerWidget> createState() => _TimerWidgetState();
}
class _TimerWidgetState extends ConsumerState<TimerWidget> {
Timer? _timer;
Duration _elapsed = Duration.zero;
String? _activeRegistrationId;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer(DateTime startTime, String registrationId) {
_activeRegistrationId = registrationId;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {
_elapsed = DateTime.now().difference(startTime);
});
}
});
}
void _stopTimer() {
_timer?.cancel();
_activeRegistrationId = null;
setState(() {
_elapsed = Duration.zero;
});
}
Future<void> _handleStartStop() async {
final user = ref.read(currentUserProvider);
final userData = await ref.read(userDataProvider.future);
if (user == null || userData == null) return;
final timeService = ref.read(timeServiceProvider);
final activeReg = await ref.read(activeRegistrationProvider.future);
try {
if (activeReg != null) {
// Stopp timer
await timeService.stopTimer(activeReg.id, user.uid);
_stopTimer();
ref.invalidate(activeRegistrationProvider);
ref.invalidate(todayRegistrationsProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Timer stoppet'),
backgroundColor: Colors.green,
),
);
}
} else {
// Start timer
final registrationId = await timeService.startTimer(
userId: user.uid,
organizationId: userData.organizationId,
);
_startTimer(DateTime.now(), registrationId);
ref.invalidate(activeRegistrationProvider);
ref.invalidate(todayRegistrationsProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Timer startet'),
backgroundColor: Colors.green,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Feil: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final activeReg = ref.watch(activeRegistrationProvider);
return activeReg.when(
data: (registration) {
if (registration != null && _activeRegistrationId == null) {
// Start timer hvis det finnes en aktiv registrering
WidgetsBinding.instance.addPostFrameCallback((_) {
_startTimer(registration.startTime, registration.id);
});
}
final isActive = registration != null;
final hours = _elapsed.inHours;
final minutes = _elapsed.inMinutes.remainder(60);
final seconds = _elapsed.inSeconds.remainder(60);
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
isActive ? 'Timer kjører' : 'Klar til å starte',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
// Timer display
Container(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
decoration: BoxDecoration(
color: isActive
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
),
const SizedBox(height: 24),
// Start/Stop button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _handleStartStop,
icon: Icon(isActive ? Icons.stop : Icons.play_arrow),
label: Text(isActive ? 'Stopp' : 'Start'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: isActive
? Colors.red
: Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
);
},
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('Feil: $error'),
),
),
);
}
}