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
- Kom i gang
- Autentisering
- Oppsett med Dio
- Modeller og Type Safety
- Produktsøk
- Strekkodeskanning
- Butikklokasjoner
- Handleliste-funksjonalitet
- Feilhåndtering
- State Management
- Praktiske eksempler
Kom i gang
1. Registrer deg for API-nøkkel
Først må du registrere deg for å få en API-nøkkel:
- Gå til https://kassal.app/api
- Opprett en konto eller logg inn
- Generer en ny API-nøkkel
- Lagre nøkkelen trygt
2. Legg til nødvendige pakker
Oppdater pubspec.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)
<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)
<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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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 changesfreezed: ^2.5.7- Vi holder oss til v2 for stabilitetdio: ^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.