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:
+116
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user