Bli med i Kassalapp-fellesskapet på Discord

Kassalapp API med Flutter - Komplett Guide

Innledning

Kassalapp API gir deg tilgang til Norges mest omfattende database over dagligvarepriser. Denne guiden viser deg hvordan du integrerer API-et i Flutter-applikasjoner for både iOS og Android, med støtte for strekkodeskanning, prissammenligning og butikkfinner.

Innhold

  1. Kom i gang
  2. Autentisering
  3. Oppsett med Dio
  4. Modeller og Type Safety
  5. Produktsøk
  6. Strekkodeskanning
  7. Butikklokasjoner
  8. Handleliste-funksjonalitet
  9. Feilhåndtering
  10. State Management
  11. Praktiske eksempler

Kom i gang

1. Registrer deg for API-nøkkel

Først må du registrere deg for å få en API-nøkkel:

  1. Gå til https://kassal.app/api
  2. Opprett en konto eller logg inn
  3. Generer en ny API-nøkkel
  4. Lagre nøkkelen trygt

2. Legg til nødvendige pakker

Oppdater pubspec.yaml:

yaml
name: kassalapp_flutter
description: Flutter app for Kassalapp API integration
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'
  flutter: '>=3.10.0'

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.7.0
  dio_cache_interceptor: ^3.5.0
  mobile_scanner: ^6.0.2  # Note: v7+ has breaking changes, using stable v6
  geolocator: ^13.0.1
  flutter_secure_storage: ^9.2.2
  provider: ^6.1.2
  json_annotation: ^4.9.0
  freezed_annotation: ^2.4.4  # Keeping v2 for stability
  cached_network_image: ^3.4.1
  shimmer: ^3.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.13
  json_serializable: ^6.8.0
  freezed: ^2.5.7  # Keeping v2 for stability
  flutter_lints: ^5.0.0

3. Konfigurer platform-spesifikke tillatelser

iOS (ios/Runner/Info.plist)

xml
<key>NSCameraUsageDescription</key>
<string>Appen bruker kamera for å skanne strekkoder</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Appen bruker din posisjon for å finne butikker i nærheten</string>

Android (android/app/src/main/AndroidManifest.xml)

xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />

Autentisering

Sikker lagring av API-nøkkel

dart
// lib/services/secure_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  static const _storage = FlutterSecureStorage();
  static const _apiKeyKey = 'kassalapp_api_key';

  static Future<void> saveApiKey(String apiKey) async {
    await _storage.write(key: _apiKeyKey, value: apiKey);
  }

  static Future<String?> getApiKey() async {
    return await _storage.read(key: _apiKeyKey);
  }

  static Future<void> deleteApiKey() async {
    await _storage.delete(key: _apiKeyKey);
  }
}

Oppsett med Dio

API Client konfigurasjon

dart
// lib/services/api_client.dart
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'secure_storage.dart';

class ApiClient {
  static const String _baseUrl = 'https://kassal.app/api/v1';
  late final Dio _dio;
  
  static final ApiClient _instance = ApiClient._internal();
  factory ApiClient() => _instance;
  
  ApiClient._internal() {
    _initializeDio();
  }
  
  void _initializeDio() {
    // Cache konfigurasjon
    final cacheOptions = CacheOptions(
      store: MemCacheStore(),
      policy: CachePolicy.request,
      hitCacheOnErrorExcept: [401, 403],
      maxStale: const Duration(hours: 1),
      priority: CachePriority.normal,
      keyBuilder: CacheOptions.defaultCacheKeyBuilder,
    );
    
    _dio = Dio(BaseOptions(
      baseUrl: _baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
    ));
    
    // Legg til interceptors
    _dio.interceptors.addAll([
      DioCacheInterceptor(options: cacheOptions),
      AuthInterceptor(),
      LoggingInterceptor(),
      ErrorInterceptor(),
    ]);
  }
  
  Dio get dio => _dio;
}

// Auth Interceptor
class AuthInterceptor extends Interceptor {
  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final apiKey = await SecureStorageService.getApiKey();
    if (apiKey != null) {
      options.headers['Authorization'] = 'Bearer $apiKey';
    }
    handler.next(options);
  }
}

// Logging Interceptor
class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    print('🚀 REQUEST[${options.method}] => PATH: ${options.path}');
    handler.next(options);
  }
  
  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    print('✅ RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}');
    handler.next(response);
  }
  
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    print('❌ ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}');
    handler.next(err);
  }
}

// Error Interceptor
class ErrorInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    switch (err.response?.statusCode) {
      case 401:
        // Handle unauthorized
        _handleUnauthorized();
        break;
      case 429:
        // Handle rate limiting
        _handleRateLimiting(err);
        break;
      default:
        break;
    }
    handler.next(err);
  }
  
  void _handleUnauthorized() {
    // Navigate to login or refresh token
    SecureStorageService.deleteApiKey();
  }
  
  void _handleRateLimiting(DioException err) {
    // Implement retry logic or show user message
    print('Rate limit reached. Please wait before retrying.');
  }
}

Modeller og Type Safety

Freezed modeller for API responses

dart
// lib/models/product.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'product.freezed.dart';
part 'product.g.dart';

@freezed
class Product with _$Product {
  const factory Product({
    required int id,
    required String name,
    String? brand,
    String? vendor,
    String? description,
    String? ingredients,
    String? url,
    String? image,
    String? ean,
    required Store store,
    @JsonKey(name: 'current_price') double? currentPrice,
    @JsonKey(name: 'current_unit_price') double? currentUnitPrice,
    double? weight,
    @JsonKey(name: 'weight_unit') String? weightUnit,
    // Additional fields from actual API
    @JsonKey(name: 'view_count') int? viewCount,
    @JsonKey(name: 'price_diff') double? priceDiff,
    @JsonKey(name: 'prev_price') double? prevPrice,
    @JsonKey(name: 'last_price_checked_at') DateTime? lastPriceCheckedAt,
    @JsonKey(name: 'minimum_durability') int? minimumDurability,
    // Timestamps
    @JsonKey(name: 'created_at') DateTime? createdAt,
    @JsonKey(name: 'updated_at') DateTime? updatedAt,
    // Related data - category is actually an array
    List<Category>? category,
    List<PriceHistory>? priceHistory,
    List<Allergen>? allergens,
    List<NutritionalContent>? nutritionalContents,
    List<Label>? labels,
  }) = _Product;
  
  factory Product.fromJson(Map<String, dynamic> json) =>
      _$ProductFromJson(json);
}

@freezed
class Store with _$Store {
  const factory Store({
    required int id,
    required String name,
    String? url,
    String? logo,
  }) = _Store;
  
  factory Store.fromJson(Map<String, dynamic> json) => _$StoreFromJson(json);
}

@freezed
class PriceHistory with _$PriceHistory {
  const factory PriceHistory({
    required double price,
    required DateTime date,
  }) = _PriceHistory;
  
  factory PriceHistory.fromJson(Map<String, dynamic> json) =>
      _$PriceHistoryFromJson(json);
}

@freezed
class Category with _$Category {
  const factory Category({
    required int id,
    required String name,
    required int depth,
  }) = _Category;
  
  factory Category.fromJson(Map<String, dynamic> json) =>
      _$CategoryFromJson(json);
}

@freezed
class Allergen with _$Allergen {
  const factory Allergen({
    required String code,
    @JsonKey(name: 'display_name') required String displayName,
    required String contains,
  }) = _Allergen;
  
  factory Allergen.fromJson(Map<String, dynamic> json) =>
      _$AllergenFromJson(json);
}

@freezed
class NutritionalContent with _$NutritionalContent {
  const factory NutritionalContent({
    @JsonKey(name: 'display_name') required String displayName,
    required double amount,
    required String unit,
    required String code,
  }) = _NutritionalContent;
  
  factory NutritionalContent.fromJson(Map<String, dynamic> json) =>
      _$NutritionalContentFromJson(json);
}

@freezed
class Label with _$Label {
  const factory Label({
    required String name,
    @JsonKey(name: 'display_name') required String displayName,
    required String description,
    required String organization,
  }) = _Label;
  
  factory Label.fromJson(Map<String, dynamic> json) => _$LabelFromJson(json);
}

@freezed
class PhysicalStore with _$PhysicalStore {
  const factory PhysicalStore({
    required String id,
    required String group,
    required String name,
    required String address,
    String? phone,
    String? email,
    String? fax,
    required Position position,
    @JsonKey(name: 'opening_hours') OpeningHours? openingHours,
    required String logo,
    required String website,
    @JsonKey(name: 'detail_url') String? detailUrl,
    required Store store,
  }) = _PhysicalStore;
  
  factory PhysicalStore.fromJson(Map<String, dynamic> json) =>
      _$PhysicalStoreFromJson(json);
}

@freezed
class Position with _$Position {
  const factory Position({
    required double lat,
    required double lng,
  }) = _Position;
  
  factory Position.fromJson(Map<String, dynamic> json) =>
      _$PositionFromJson(json);
}

@freezed
class OpeningHours with _$OpeningHours {
  const factory OpeningHours({
    Map<String, DayHours>? hours,
  }) = _OpeningHours;
  
  factory OpeningHours.fromJson(Map<String, dynamic> json) =>
      _$OpeningHoursFromJson(json);
}

@freezed
class DayHours with _$DayHours {
  const factory DayHours({
    required String open,
    required String close,
    required bool closed,
  }) = _DayHours;
  
  factory DayHours.fromJson(Map<String, dynamic> json) =>
      _$DayHoursFromJson(json);
}

// Pagination response wrapper
@freezed
class PaginatedResponse<T> with _$PaginatedResponse<T> {
  const factory PaginatedResponse({
    required List<T> data,
    PaginationLinks? links,
    PaginationMeta? meta,
  }) = _PaginatedResponse;
  
  factory PaginatedResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object?) fromJsonT,
  ) =>
      _$PaginatedResponseFromJson(json, fromJsonT);
}

@freezed
class PaginationLinks with _$PaginationLinks {
  const factory PaginationLinks({
    String? first,
    String? last,
    String? prev,
    String? next,
  }) = _PaginationLinks;
  
  factory PaginationLinks.fromJson(Map<String, dynamic> json) =>
      _$PaginationLinksFromJson(json);
}

@freezed
class PaginationMeta with _$PaginationMeta {
  const factory PaginationMeta({
    @JsonKey(name: 'current_page') required int currentPage,
    required int from,
    @JsonKey(name: 'last_page') required int lastPage,
    @JsonKey(name: 'per_page') required int perPage,
    required int to,
    required int total,
  }) = _PaginationMeta;
  
  factory PaginationMeta.fromJson(Map<String, dynamic> json) =>
      _$PaginationMetaFromJson(json);
}

Produktsøk

Repository for produkthåndtering

dart
// lib/repositories/product_repository.dart
import 'package:dio/dio.dart';
import '../models/product.dart';
import '../services/api_client.dart';

class ProductRepository {
  final Dio _dio = ApiClient().dio;
  
  Future<PaginatedResponse<Product>> searchProducts({
    String? search,
    String? category,
    int? categoryId,
    String? brand,
    String? vendor,
    double? priceMin,
    double? priceMax,
    String? sort,
    int page = 1,
    int size = 20,
    bool unique = false,
    bool excludeWithoutEan = false,
  }) async {
    try {
      final response = await _dio.get(
        '/products',
        queryParameters: {
          if (search != null) 'search': search,
          if (category != null) 'category': category,
          if (categoryId != null) 'category_id': categoryId,
          if (brand != null) 'brand': brand,
          if (vendor != null) 'vendor': vendor,
          if (priceMin != null) 'price_min': priceMin,
          if (priceMax != null) 'price_max': priceMax,
          if (sort != null) 'sort': sort,
          'page': page,
          'size': size.clamp(1, 100),
          if (unique) 'unique': true,
          if (excludeWithoutEan) 'exclude_without_ean': true,
        },
      );
      
      return PaginatedResponse<Product>.fromJson(
        response.data,
        (json) => Product.fromJson(json as Map<String, dynamic>),
      );
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }
  
  Future<Product> getProductById(int id) async {
    try {
      final response = await _dio.get('/products/id/$id');
      return Product.fromJson(response.data['data']);
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }
  
  Future<List<Product>> getProductByEan(String ean) async {
    try {
      final response = await _dio.get('/products/ean/$ean');
      final List<dynamic> data = response.data['data'];
      return data.map((json) => Product.fromJson(json)).toList();
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }
  
  Future<Product> getProductByUrl(String url) async {
    try {
      final response = await _dio.get(
        '/products/find-by-url/single',
        queryParameters: {'url': url},
      );
      return Product.fromJson(response.data['data']);
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }
  
  Future<List<Product>> comparePricesByUrl(String url) async {
    try {
      final response = await _dio.get(
        '/products/find-by-url/compare',
        queryParameters: {'url': url},
      );
      final List<dynamic> data = response.data['data'];
      return data.map((json) => Product.fromJson(json)).toList();
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }
  
  Exception _handleError(DioException error) {
    if (error.response != null) {
      switch (error.response!.statusCode) {
        case 401:
          return Exception('Ugyldig API-nøkkel');
        case 404:
          return Exception('Produkt ikke funnet');
        case 429:
          return Exception('For mange forespørsler. Vent litt før du prøver igjen.');
        default:
          return Exception('En feil oppstod: ${error.response!.statusMessage}');
      }
    }
    return Exception('Nettverksfeil: ${error.message}');
  }
}

Søkewidget med live-søk

dart
// lib/widgets/product_search.dart
import 'dart:async';  // Added for Timer
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';
import 'product_list_item.dart';

class ProductSearch extends StatefulWidget {
  const ProductSearch({Key? key}) : super(key: key);
  
  @override
  State<ProductSearch> createState() => _ProductSearchState();
}

class _ProductSearchState extends State<ProductSearch> {
  final _searchController = TextEditingController();
  final _scrollController = ScrollController();
  Timer? _debounce;
  
  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }
  
  @override
  void dispose() {
    _debounce?.cancel();
    _searchController.dispose();
    _scrollController.dispose();
    super.dispose();
  }
  
  void _onSearchChanged(String query) {
    if (_debounce?.isActive ?? false) _debounce!.cancel();
    _debounce = Timer(const Duration(milliseconds: 500), () {
      context.read<ProductProvider>().searchProducts(query);
    });
  }
  
  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      context.read<ProductProvider>().loadMore();
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          controller: _searchController,
          onChanged: _onSearchChanged,
          decoration: InputDecoration(
            hintText: 'Søk etter produkter...',
            border: InputBorder.none,
            hintStyle: TextStyle(color: Colors.white70),
            prefixIcon: Icon(Icons.search, color: Colors.white),
            suffixIcon: _searchController.text.isNotEmpty
                ? IconButton(
                    icon: Icon(Icons.clear, color: Colors.white),
                    onPressed: () {
                      _searchController.clear();
                      _onSearchChanged('');
                    },
                  )
                : null,
          ),
          style: TextStyle(color: Colors.white),
        ),
      ),
      body: Consumer<ProductProvider>(
        builder: (context, provider, child) {
          if (provider.isLoading && provider.products.isEmpty) {
            return Center(child: CircularProgressIndicator());
          }
          
          if (provider.error != null && provider.products.isEmpty) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.error_outline, size: 48, color: Colors.red),
                  SizedBox(height: 16),
                  Text(
                    provider.error!,
                    style: TextStyle(color: Colors.red),
                    textAlign: TextAlign.center,
                  ),
                  SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => provider.retry(),
                    child: Text('Prøv igjen'),
                  ),
                ],
              ),
            );
          }
          
          if (provider.products.isEmpty && !provider.isLoading) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.search_off, size: 48, color: Colors.grey),
                  SizedBox(height: 16),
                  Text(
                    'Ingen produkter funnet',
                    style: TextStyle(color: Colors.grey[600]),
                  ),
                ],
              ),
            );
          }
          
          return RefreshIndicator(
            onRefresh: () async {
              await provider.refresh();
            },
            child: ListView.builder(
              controller: _scrollController,
              itemCount: provider.products.length + (provider.hasMore ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == provider.products.length) {
                  return Center(
                    child: Padding(
                      padding: EdgeInsets.all(16),
                      child: CircularProgressIndicator(),
                    ),
                  );
                }
                
                final product = provider.products[index];
                return ProductListItem(product: product);
              },
            ),
          );
        },
      ),
    );
  }
}

Strekkodeskanning

Barcode Scanner implementasjon

dart
// lib/screens/barcode_scanner_screen.dart
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';
import '../models/product.dart';
import '../widgets/product_detail_sheet.dart';

class BarcodeScannerScreen extends StatefulWidget {
  const BarcodeScannerScreen({Key? key}) : super(key: key);
  
  @override
  State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}

class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
  // Updated for mobile_scanner v6 compatibility
  late MobileScannerController cameraController;
  bool _screenOpened = false;
  
  @override
  void initState() {
    super.initState();
    // Initialize controller with v6 parameters
    cameraController = MobileScannerController(
      facing: CameraFacing.back,
      torchEnabled: false,
      detectionSpeed: DetectionSpeed.normal,
      formats: [BarcodeFormat.ean13, BarcodeFormat.ean8],
    );
  }
  
  @override
  void dispose() {
    cameraController.dispose();
    super.dispose();
  }
  
  void _foundBarcode(BarcodeCapture capture) {
    final List<Barcode> barcodes = capture.barcodes;
    
    for (final barcode in barcodes) {
      if (barcode.rawValue != null && !_screenOpened) {
        final String code = barcode.rawValue!;
        
        // Validate EAN format (8 or 13 digits)
        if (RegExp(r'^\d{8}$|^\d{13}$').hasMatch(code)) {
          _screenOpened = true;
          _handleBarcodeScanned(code);
        }
      }
    }
  }
  
  void _handleBarcodeScanned(String ean) async {
    // Show loading indicator
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => Center(
        child: Card(
          child: Padding(
            padding: EdgeInsets.all(20),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                CircularProgressIndicator(),
                SizedBox(height: 16),
                Text('Søker etter produkt...'),
              ],
            ),
          ),
        ),
      ),
    );
    
    try {
      final products = await context.read<ProductProvider>().getProductByEan(ean);
      
      // Close loading dialog
      if (mounted) {
        Navigator.of(context).pop();
      }
      
      if (products.isNotEmpty) {
        _showProductDetails(products);
      } else {
        _showNoProductFound(ean);
      }
    } catch (e) {
      // Close loading dialog
      if (mounted) {
        Navigator.of(context).pop();
      }
      
      _showError(e.toString());
    }
  }
  
  void _showProductDetails(List<Product> products) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) => ProductComparisonSheet(products: products),
    ).then((_) {
      _screenOpened = false;
    });
  }
  
  void _showNoProductFound(String ean) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Produkt ikke funnet'),
        content: Text('Ingen produkter funnet for strekkode: $ean'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              _screenOpened = false;
            },
            child: Text('OK'),
          ),
        ],
      ),
    );
  }
  
  void _showError(String error) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(error),
        backgroundColor: Colors.red,
        action: SnackBarAction(
          label: 'OK',
          textColor: Colors.white,
          onPressed: () {
            _screenOpened = false;
          },
        ),
      ),
    );
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Skann strekkode'),
        actions: [
          IconButton(
            icon: Icon(cameraController.torchEnabled
                ? Icons.flash_on
                : Icons.flash_off),
            onPressed: () => cameraController.toggleTorch(),
          ),
          IconButton(
            icon: Icon(Icons.flip_camera_ios),
            onPressed: () => cameraController.switchCamera(),
          ),
        ],
      ),
      body: Stack(
        children: [
          MobileScanner(
            controller: cameraController,
            onDetect: _foundBarcode,
          ),
          _buildScannerOverlay(),
        ],
      ),
    );
  }
  
  Widget _buildScannerOverlay() {
    return Stack(
      children: [
        ColorFiltered(
          colorFilter: ColorFilter.mode(
            Colors.black.withOpacity(0.5),
            BlendMode.srcOut,
          ),
          child: Stack(
            children: [
              Container(
                decoration: BoxDecoration(
                  color: Colors.black,
                  backgroundBlendMode: BlendMode.dstOut,
                ),
              ),
              Align(
                alignment: Alignment.center,
                child: Container(
                  height: 200,
                  width: 300,
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(20),
                  ),
                ),
              ),
            ],
          ),
        ),
        Align(
          alignment: Alignment.center,
          child: Container(
            height: 200,
            width: 300,
            decoration: BoxDecoration(
              border: Border.all(
                color: Colors.white,
                width: 2,
              ),
              borderRadius: BorderRadius.circular(20),
            ),
          ),
        ),
        Positioned(
          bottom: 100,
          left: 0,
          right: 0,
          child: Text(
            'Hold kameraet over strekkoden',
            textAlign: TextAlign.center,
            style: TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ],
    );
  }
}

// Product comparison bottom sheet
class ProductComparisonSheet extends StatelessWidget {
  final List<Product> products;
  
  const ProductComparisonSheet({Key? key, required this.products})
      : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    // Find cheapest product
    final cheapest = products.reduce((a, b) =>
        (a.currentPrice ?? double.infinity) <
                (b.currentPrice ?? double.infinity)
            ? a
            : b);
    
    return DraggableScrollableSheet(
      initialChildSize: 0.7,
      minChildSize: 0.5,
      maxChildSize: 0.95,
      builder: (context, scrollController) {
        return Container(
          decoration: BoxDecoration(
            color: Theme.of(context).scaffoldBackgroundColor,
            borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
          ),
          child: Column(
            children: [
              Container(
                margin: EdgeInsets.symmetric(vertical: 8),
                height: 4,
                width: 40,
                decoration: BoxDecoration(
                  color: Colors.grey[300],
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
              Padding(
                padding: EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      products.first.name,
                      style: Theme.of(context).textTheme.headlineSmall,
                    ),
                    if (products.first.brand != null)
                      Text(
                        products.first.brand!,
                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                              color: Colors.grey[600],
                            ),
                      ),
                    SizedBox(height: 8),
                    Row(
                      children: [
                        Icon(Icons.savings, size: 16, color: Colors.green),
                        SizedBox(width: 4),
                        Text(
                          'Billigste: ${cheapest.currentPrice?.toStringAsFixed(2) ?? "N/A"} kr hos ${cheapest.store.name}',
                          style: TextStyle(
                            color: Colors.green,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              Divider(),
              Expanded(
                child: ListView.builder(
                  controller: scrollController,
                  itemCount: products.length,
                  itemBuilder: (context, index) {
                    final product = products[index];
                    final isCheapest = product.id == cheapest.id;
                    
                    return ListTile(
                      leading: CircleAvatar(
                        backgroundImage: product.store.logo != null
                            ? NetworkImage(product.store.logo!)
                            : null,
                        child: product.store.logo == null
                            ? Text(product.store.name.substring(0, 1))
                            : null,
                      ),
                      title: Text(product.store.name),
                      subtitle: product.currentPrice != null
                          ? Text('${product.currentPrice!.toStringAsFixed(2)} kr')
                          : Text('Pris ikke tilgjengelig'),
                      trailing: isCheapest
                          ? Chip(
                              label: Text('Billigst'),
                              backgroundColor: Colors.green[100],
                            )
                          : product.currentPrice != null
                              ? Text(
                                  '+${(product.currentPrice! - cheapest.currentPrice!).toStringAsFixed(2)} kr',
                                  style: TextStyle(color: Colors.grey),
                                )
                              : null,
                      onTap: () {
                        // Navigate to product detail
                        Navigator.push(
                          context,
                          MaterialPageRoute(
                            builder: (context) =>
                                ProductDetailScreen(product: product),
                          ),
                        );
                      },
                    );
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

Butikklokasjoner

Store locator med kart

dart
// lib/screens/store_locator_screen.dart
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../providers/store_provider.dart';
import '../models/physical_store.dart';

class StoreLocatorScreen extends StatefulWidget {
  const StoreLocatorScreen({Key? key}) : super(key: key);
  
  @override
  State<StoreLocatorScreen> createState() => _StoreLocatorScreenState();
}

class _StoreLocatorScreenState extends State<StoreLocatorScreen> {
  Position? _currentPosition;
  bool _isLoadingLocation = false;
  
  @override
  void initState() {
    super.initState();
    _getCurrentLocation();
  }
  
  Future<void> _getCurrentLocation() async {
    setState(() {
      _isLoadingLocation = true;
    });
    
    try {
      // Check permissions
      LocationPermission permission = await Geolocator.checkPermission();
      
      if (permission == LocationPermission.denied) {
        permission = await Geolocator.requestPermission();
        
        if (permission == LocationPermission.denied) {
          throw Exception('Posisjonstilgang nektet');
        }
      }
      
      if (permission == LocationPermission.deniedForever) {
        throw Exception('Posisjonstilgang permanent nektet');
      }
      
      // Get current position
      final position = await Geolocator.getCurrentPosition(
        desiredAccuracy: LocationAccuracy.high,
      );
      
      setState(() {
        _currentPosition = position;
        _isLoadingLocation = false;
      });
      
      // Load nearby stores
      if (mounted) {
        context.read<StoreProvider>().loadNearbyStores(
              position.latitude,
              position.longitude,
              radius: 5,
            );
      }
    } catch (e) {
      setState(() {
        _isLoadingLocation = false;
      });
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Kunne ikke hente posisjon: ${e.toString()}'),
          backgroundColor: Colors.red,
        ),
      );
    }
  }
  
  double _calculateDistance(PhysicalStore store) {
    if (_currentPosition == null) return 0;
    
    return Geolocator.distanceBetween(
          _currentPosition!.latitude,
          _currentPosition!.longitude,
          store.position.lat,
          store.position.lng,
        ) /
        1000; // Convert to kilometers
  }
  
  bool _isStoreOpen(PhysicalStore store) {
    final now = DateTime.now();
    final dayName = _getDayName(now.weekday);
    
    if (store.openingHours?.hours?[dayName] == null) return false;
    
    final dayHours = store.openingHours!.hours![dayName]!;
    
    if (dayHours.closed) return false;
    
    final openTime = _parseTime(dayHours.open);
    final closeTime = _parseTime(dayHours.close);
    final currentTime = TimeOfDay.now();
    
    return _isTimeBetween(currentTime, openTime, closeTime);
  }
  
  String _getDayName(int weekday) {
    switch (weekday) {
      case 1:
        return 'monday';
      case 2:
        return 'tuesday';
      case 3:
        return 'wednesday';
      case 4:
        return 'thursday';
      case 5:
        return 'friday';
      case 6:
        return 'saturday';
      case 7:
        return 'sunday';
      default:
        return 'monday';
    }
  }
  
  TimeOfDay _parseTime(String time) {
    final parts = time.split(':');
    return TimeOfDay(
      hour: int.parse(parts[0]),
      minute: int.parse(parts[1]),
    );
  }
  
  bool _isTimeBetween(TimeOfDay current, TimeOfDay start, TimeOfDay end) {
    final currentMinutes = current.hour * 60 + current.minute;
    final startMinutes = start.hour * 60 + start.minute;
    final endMinutes = end.hour * 60 + end.minute;
    
    return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Butikker i nærheten'),
        actions: [
          IconButton(
            icon: Icon(Icons.filter_list),
            onPressed: () {
              _showFilterDialog();
            },
          ),
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: _getCurrentLocation,
          ),
        ],
      ),
      body: Consumer<StoreProvider>(
        builder: (context, provider, child) {
          if (_isLoadingLocation) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text('Henter din posisjon...'),
                ],
              ),
            );
          }
          
          if (provider.isLoading && provider.stores.isEmpty) {
            return Center(child: CircularProgressIndicator());
          }
          
          if (provider.error != null && provider.stores.isEmpty) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.error_outline, size: 48, color: Colors.red),
                  SizedBox(height: 16),
                  Text(
                    provider.error!,
                    style: TextStyle(color: Colors.red),
                    textAlign: TextAlign.center,
                  ),
                  SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => _getCurrentLocation(),
                    child: Text('Prøv igjen'),
                  ),
                ],
              ),
            );
          }
          
          if (provider.stores.isEmpty && !provider.isLoading) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.store, size: 48, color: Colors.grey),
                  SizedBox(height: 16),
                  Text(
                    'Ingen butikker funnet',
                    style: TextStyle(color: Colors.grey[600]),
                  ),
                ],
              ),
            );
          }
          
          // Sort stores by distance
          final sortedStores = List<PhysicalStore>.from(provider.stores);
          if (_currentPosition != null) {
            sortedStores.sort((a, b) {
              final distanceA = _calculateDistance(a);
              final distanceB = _calculateDistance(b);
              return distanceA.compareTo(distanceB);
            });
          }
          
          return RefreshIndicator(
            onRefresh: () async {
              await _getCurrentLocation();
            },
            child: ListView.builder(
              itemCount: sortedStores.length,
              itemBuilder: (context, index) {
                final store = sortedStores[index];
                final distance = _calculateDistance(store);
                final isOpen = _isStoreOpen(store);
                
                return Card(
                  margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  child: ListTile(
                    leading: CircleAvatar(
                      backgroundImage: NetworkImage(store.logo),
                      backgroundColor: Colors.white,
                    ),
                    title: Text(store.name),
                    subtitle: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(store.address),
                        SizedBox(height: 4),
                        Row(
                          children: [
                            if (_currentPosition != null) ...[
                              Icon(Icons.navigation, size: 14),
                              SizedBox(width: 4),
                              Text(
                                '${distance.toStringAsFixed(1)} km',
                                style: TextStyle(fontSize: 12),
                              ),
                              SizedBox(width: 16),
                            ],
                            Container(
                              padding: EdgeInsets.symmetric(
                                horizontal: 8,
                                vertical: 2,
                              ),
                              decoration: BoxDecoration(
                                color: isOpen ? Colors.green : Colors.red,
                                borderRadius: BorderRadius.circular(12),
                              ),
                              child: Text(
                                isOpen ? 'Åpen' : 'Stengt',
                                style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 12,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                    trailing: IconButton(
                      icon: Icon(Icons.directions),
                      onPressed: () {
                        // Open in maps
                        _openInMaps(store);
                      },
                    ),
                    onTap: () {
                      _showStoreDetails(store);
                    },
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }
  
  void _showFilterDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Filtrer butikker'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // Add filter options here
            CheckboxListTile(
              title: Text('Kun åpne butikker'),
              value: false,
              onChanged: (value) {},
            ),
            CheckboxListTile(
              title: Text('REMA 1000'),
              value: true,
              onChanged: (value) {},
            ),
            CheckboxListTile(
              title: Text('Kiwi'),
              value: true,
              onChanged: (value) {},
            ),
            CheckboxListTile(
              title: Text('Meny'),
              value: true,
              onChanged: (value) {},
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('Avbryt'),
          ),
          ElevatedButton(
            onPressed: () {
              Navigator.pop(context);
              // Apply filters
            },
            child: Text('Bruk'),
          ),
        ],
      ),
    );
  }
  
  void _showStoreDetails(PhysicalStore store) {
    // Show store details in bottom sheet
  }
  
  void _openInMaps(PhysicalStore store) {
    // Open store location in external maps app
  }
}

Handleliste-funksjonalitet

Shopping list implementation

dart
// lib/providers/shopping_list_provider.dart
import 'package:flutter/foundation.dart';
import '../models/shopping_list.dart';
import '../repositories/shopping_list_repository.dart';

class ShoppingListProvider extends ChangeNotifier {
  final ShoppingListRepository _repository = ShoppingListRepository();
  
  List<ShoppingList> _shoppingLists = [];
  ShoppingList? _currentList;
  bool _isLoading = false;
  String? _error;
  
  List<ShoppingList> get shoppingLists => _shoppingLists;
  ShoppingList? get currentList => _currentList;
  bool get isLoading => _isLoading;
  String? get error => _error;
  
  Future<void> loadShoppingLists() async {
    _isLoading = true;
    _error = null;
    notifyListeners();
    
    try {
      _shoppingLists = await _repository.getShoppingLists();
    } catch (e) {
      _error = e.toString();
    }
    
    _isLoading = false;
    notifyListeners();
  }
  
  Future<void> createShoppingList(String name) async {
    try {
      final newList = await _repository.createShoppingList(name);
      _shoppingLists.insert(0, newList);
      _currentList = newList;
      notifyListeners();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
      rethrow;
    }
  }
  
  Future<void> addItemToList(String productId, {int quantity = 1}) async {
    if (_currentList == null) {
      throw Exception('Ingen handleliste valgt');
    }
    
    try {
      final item = await _repository.addItemToList(
        _currentList!.id,
        productId,
        quantity: quantity,
      );
      
      _currentList = _currentList!.copyWith(
        items: [..._currentList!.items, item],
      );
      
      notifyListeners();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
      rethrow;
    }
  }
  
  Future<void> removeItemFromList(String itemId) async {
    if (_currentList == null) {
      throw Exception('Ingen handleliste valgt');
    }
    
    try {
      await _repository.removeItemFromList(_currentList!.id, itemId);
      
      _currentList = _currentList!.copyWith(
        items: _currentList!.items.where((i) => i.id != itemId).toList(),
      );
      
      notifyListeners();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
      rethrow;
    }
  }
  
  Future<void> updateItemQuantity(String itemId, int quantity) async {
    if (_currentList == null) {
      throw Exception('Ingen handleliste valgt');
    }
    
    try {
      await _repository.updateItemQuantity(
        _currentList!.id,
        itemId,
        quantity,
      );
      
      final updatedItems = _currentList!.items.map((item) {
        if (item.id == itemId) {
          return item.copyWith(quantity: quantity);
        }
        return item;
      }).toList();
      
      _currentList = _currentList!.copyWith(items: updatedItems);
      notifyListeners();
    } catch (e) {
      _error = e.toString();
      notifyListeners();
      rethrow;
    }
  }
  
  Map<String, double> calculateStorePrices() {
    if (_currentList == null) return {};
    
    final storePrices = <String, double>{};
    
    for (final item in _currentList!.items) {
      if (item.product != null && item.product!.currentPrice != null) {
        final storeName = item.product!.store.name;
        final price = item.product!.currentPrice! * item.quantity;
        
        storePrices[storeName] = (storePrices[storeName] ?? 0) + price;
      }
    }
    
    return storePrices;
  }
  
  String? findCheapestStore() {
    final prices = calculateStorePrices();
    
    if (prices.isEmpty) return null;
    
    String? cheapestStore;
    double? lowestPrice;
    
    prices.forEach((store, price) {
      if (lowestPrice == null || price < lowestPrice!) {
        cheapestStore = store;
        lowestPrice = price;
      }
    });
    
    return cheapestStore;
  }
}

State Management

Provider setup

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ProductProvider()),
        ChangeNotifierProvider(create: (_) => StoreProvider()),
        ChangeNotifierProvider(create: (_) => ShoppingListProvider()),
      ],
      child: MaterialApp(
        title: 'Kassalapp Flutter',
        theme: ThemeData(
          primarySwatch: Colors.red,
          useMaterial3: true,
        ),
        home: HomeScreen(),
      ),
    );
  }
}

Avslutning

Denne guiden har vist deg hvordan du integrerer Kassalapp API i Flutter-applikasjoner. Med eksemplene og kodesnuttene kan du nå:

  • Søke etter produkter og sammenligne priser
  • Implementere strekkodeskanning med kamera
  • Finne nærmeste butikker basert på GPS
  • Bygge smarte handlelister
  • Håndtere feil og state management profesjonelt

For mer informasjon og oppdatert dokumentasjon, besøk https://kassal.app/api.

Pakkekompatibilitet

Viktig: Denne guiden bruker følgende stabile versjoner:

  • mobile_scanner: ^6.0.2 - Vi bruker v6 da v7+ har breaking changes
  • freezed: ^2.5.7 - Vi holder oss til v2 for stabilitet
  • dio: ^5.7.0 - Siste stabile versjon

Hvis du ønsker å oppgradere til nyere versjoner, sjekk changelog for breaking changes:

Nyttige ressurser

Lisens og bruksvilkår

Husk å følge Kassalapp sine bruksvilkår og respekter rate limits. For kommersiell bruk eller høyere rate limits, kontakt Kassalapp for enterprise-løsninger.