Flutter Legal Localization: Terms of Service, Privacy Policies, and Compliance Documents
Legal documents require special attention when localizing Flutter apps. Privacy policies, terms of service, and compliance notices must be legally accurate, culturally appropriate, and compliant with regional regulations like GDPR, CCPA, and local consumer protection laws. This guide covers how to properly localize legal content in Flutter.
Why Legal Localization Is Different
Legal content has unique requirements:
- Legal accuracy - Translations must preserve legal meaning
- Regulatory compliance - Different regions have different laws
- Professional translation - Machine translation is insufficient
- Version control - Track which version users accepted
- Accessibility - Legal docs must be readable
- Audit trail - Record consent for compliance
Setting Up Legal Document Management
Legal Document Model
class LegalDocument {
final String id;
final String type; // 'privacy', 'terms', 'cookies', 'dpa', etc.
final String locale;
final String version;
final DateTime effectiveDate;
final DateTime? expirationDate;
final String title;
final String content; // Markdown or HTML
final List<LegalSection> sections;
final Map<String, dynamic>? metadata;
LegalDocument({
required this.id,
required this.type,
required this.locale,
required this.version,
required this.effectiveDate,
this.expirationDate,
required this.title,
required this.content,
required this.sections,
this.metadata,
});
bool get isActive {
final now = DateTime.now();
return now.isAfter(effectiveDate) &&
(expirationDate == null || now.isBefore(expirationDate!));
}
factory LegalDocument.fromJson(Map<String, dynamic> json) {
return LegalDocument(
id: json['id'] as String,
type: json['type'] as String,
locale: json['locale'] as String,
version: json['version'] as String,
effectiveDate: DateTime.parse(json['effectiveDate'] as String),
expirationDate: json['expirationDate'] != null
? DateTime.parse(json['expirationDate'] as String)
: null,
title: json['title'] as String,
content: json['content'] as String,
sections: (json['sections'] as List)
.map((s) => LegalSection.fromJson(s as Map<String, dynamic>))
.toList(),
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
}
class LegalSection {
final String id;
final String title;
final String content;
final int order;
LegalSection({
required this.id,
required this.title,
required this.content,
required this.order,
});
factory LegalSection.fromJson(Map<String, dynamic> json) {
return LegalSection(
id: json['id'] as String,
title: json['title'] as String,
content: json['content'] as String,
order: json['order'] as int,
);
}
}
Legal Document Service
class LegalDocumentService {
final String baseUrl;
final Map<String, Map<String, LegalDocument>> _cache = {};
LegalDocumentService({required this.baseUrl});
Future<LegalDocument?> getDocument({
required String type,
required String locale,
}) async {
// Check cache first
if (_cache[type]?[locale] != null) {
final cached = _cache[type]![locale]!;
if (cached.isActive) return cached;
}
try {
final response = await http.get(
Uri.parse('$baseUrl/legal/$type/$locale.json'),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
final document = LegalDocument.fromJson(json);
// Cache the document
_cache.putIfAbsent(type, () => {});
_cache[type]![locale] = document;
return document;
}
// Try fallback to English
if (locale != 'en') {
return getDocument(type: type, locale: 'en');
}
return null;
} catch (e) {
// Try to return cached version even if expired
return _cache[type]?[locale];
}
}
Future<List<LegalDocument>> getRequiredDocuments(String locale) async {
final types = ['privacy', 'terms'];
final documents = <LegalDocument>[];
for (final type in types) {
final doc = await getDocument(type: type, locale: locale);
if (doc != null) {
documents.add(doc);
}
}
return documents;
}
Future<Map<String, String>> getLatestVersions(String locale) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/legal/versions/$locale.json'),
);
if (response.statusCode == 200) {
return Map<String, String>.from(
jsonDecode(response.body) as Map,
);
}
} catch (e) {
// Return empty map on error
}
return {};
}
}
Consent Management
Tracking User Consent
class ConsentManager {
final SharedPreferences _prefs;
final LegalDocumentService _documentService;
final AnalyticsService _analytics;
ConsentManager({
required SharedPreferences prefs,
required LegalDocumentService documentService,
required AnalyticsService analytics,
}) : _prefs = prefs,
_documentService = documentService,
_analytics = analytics;
Future<ConsentStatus> getConsentStatus(String locale) async {
final acceptedVersions = _getAcceptedVersions();
final latestVersions = await _documentService.getLatestVersions(locale);
final outdated = <String>[];
final missing = <String>[];
for (final entry in latestVersions.entries) {
final type = entry.key;
final latestVersion = entry.value;
final acceptedVersion = acceptedVersions[type];
if (acceptedVersion == null) {
missing.add(type);
} else if (acceptedVersion != latestVersion) {
outdated.add(type);
}
}
return ConsentStatus(
isComplete: missing.isEmpty && outdated.isEmpty,
missingConsent: missing,
outdatedConsent: outdated,
acceptedVersions: acceptedVersions,
);
}
Map<String, String> _getAcceptedVersions() {
final json = _prefs.getString('accepted_legal_versions');
if (json == null) return {};
return Map<String, String>.from(jsonDecode(json) as Map);
}
Future<void> recordConsent({
required String documentType,
required String version,
required String locale,
}) async {
final acceptedVersions = _getAcceptedVersions();
acceptedVersions[documentType] = version;
await _prefs.setString(
'accepted_legal_versions',
jsonEncode(acceptedVersions),
);
// Record consent timestamp
await _prefs.setString(
'consent_${documentType}_timestamp',
DateTime.now().toIso8601String(),
);
// Track for analytics
_analytics.trackEvent('legal_consent', {
'document_type': documentType,
'version': version,
'locale': locale,
});
}
Future<ConsentRecord?> getConsentRecord(String documentType) async {
final acceptedVersions = _getAcceptedVersions();
final version = acceptedVersions[documentType];
if (version == null) return null;
final timestamp = _prefs.getString('consent_${documentType}_timestamp');
return ConsentRecord(
documentType: documentType,
version: version,
acceptedAt: timestamp != null ? DateTime.parse(timestamp) : null,
);
}
Future<void> revokeConsent(String documentType) async {
final acceptedVersions = _getAcceptedVersions();
acceptedVersions.remove(documentType);
await _prefs.setString(
'accepted_legal_versions',
jsonEncode(acceptedVersions),
);
_analytics.trackEvent('legal_consent_revoked', {
'document_type': documentType,
});
}
}
class ConsentStatus {
final bool isComplete;
final List<String> missingConsent;
final List<String> outdatedConsent;
final Map<String, String> acceptedVersions;
ConsentStatus({
required this.isComplete,
required this.missingConsent,
required this.outdatedConsent,
required this.acceptedVersions,
});
bool get needsAction => missingConsent.isNotEmpty || outdatedConsent.isNotEmpty;
}
class ConsentRecord {
final String documentType;
final String version;
final DateTime? acceptedAt;
ConsentRecord({
required this.documentType,
required this.version,
this.acceptedAt,
});
}
Legal Document UI
Localized Terms Screen
class LocalizedLegalScreen extends StatefulWidget {
final String documentType;
const LocalizedLegalScreen({
Key? key,
required this.documentType,
}) : super(key: key);
@override
State<LocalizedLegalScreen> createState() => _LocalizedLegalScreenState();
}
class _LocalizedLegalScreenState extends State<LocalizedLegalScreen> {
LegalDocument? _document;
bool _isLoading = true;
bool _hasScrolledToEnd = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadDocument();
_scrollController.addListener(_onScroll);
}
Future<void> _loadDocument() async {
final locale = Localizations.localeOf(context).languageCode;
final service = context.read<LegalDocumentService>();
final document = await service.getDocument(
type: widget.documentType,
locale: locale,
);
setState(() {
_document = document;
_isLoading = false;
});
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 50) {
setState(() => _hasScrolledToEnd = true);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(_getTitle(l10n)),
actions: [
// Language selector for legal docs
IconButton(
icon: Icon(Icons.language),
onPressed: _showLanguageSelector,
tooltip: l10n.changeLanguage,
),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator())
: _document == null
? _buildErrorState(l10n)
: _buildDocumentContent(),
bottomNavigationBar: _buildBottomBar(l10n),
);
}
String _getTitle(AppLocalizations l10n) {
switch (widget.documentType) {
case 'privacy':
return l10n.privacyPolicy;
case 'terms':
return l10n.termsOfService;
case 'cookies':
return l10n.cookiePolicy;
default:
return _document?.title ?? l10n.legalDocument;
}
}
Widget _buildDocumentContent() {
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Document header
_buildDocumentHeader(),
SizedBox(height: 24),
// Table of contents
if (_document!.sections.length > 3)
_buildTableOfContents(),
// Document content
_buildMarkdownContent(_document!.content),
// Sections
..._document!.sections.map(_buildSection),
// Version and date info
_buildFooter(),
SizedBox(height: 80), // Space for bottom bar
],
),
);
}
Widget _buildDocumentHeader() {
final l10n = AppLocalizations.of(context)!;
final effectiveDate = DateFormat.yMMMMd(
Localizations.localeOf(context).toString(),
).format(_document!.effectiveDate);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_document!.title,
style: Theme.of(context).textTheme.headlineMedium,
),
SizedBox(height: 8),
Text(
'${l10n.effectiveDate}: $effectiveDate',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
Text(
'${l10n.version}: ${_document!.version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
);
}
Widget _buildTableOfContents() {
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.only(bottom: 24),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.tableOfContents,
style: Theme.of(context).textTheme.titleMedium,
),
SizedBox(height: 8),
..._document!.sections.map((section) => Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: InkWell(
onTap: () => _scrollToSection(section.id),
child: Text(
'${section.order}. ${section.title}',
style: TextStyle(
color: Theme.of(context).primaryColor,
decoration: TextDecoration.underline,
),
),
),
)),
],
),
),
);
}
Widget _buildSection(LegalSection section) {
return Padding(
key: Key('section_${section.id}'),
padding: EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${section.order}. ${section.title}',
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 8),
_buildMarkdownContent(section.content),
],
),
);
}
Widget _buildMarkdownContent(String content) {
return MarkdownBody(
data: content,
styleSheet: MarkdownStyleSheet(
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.6,
),
h1: Theme.of(context).textTheme.headlineSmall,
h2: Theme.of(context).textTheme.titleLarge,
h3: Theme.of(context).textTheme.titleMedium,
listBullet: Theme.of(context).textTheme.bodyMedium,
),
onTapLink: (text, href, title) {
if (href != null) {
launchUrl(Uri.parse(href));
}
},
);
}
Widget _buildFooter() {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: EdgeInsets.only(top: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(),
SizedBox(height: 16),
Text(
l10n.legalDocumentFooter,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
SizedBox(height: 8),
Text(
'${l10n.documentId}: ${_document!.id}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
);
}
Widget _buildErrorState(AppLocalizations l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(l10n.legalDocumentLoadError),
SizedBox(height: 16),
ElevatedButton(
onPressed: _loadDocument,
child: Text(l10n.retry),
),
],
),
);
}
Widget? _buildBottomBar(AppLocalizations l10n) {
if (_document == null) return null;
return SafeArea(
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, -2),
blurRadius: 4,
color: Colors.black12,
),
],
),
child: Row(
children: [
Expanded(
child: Text(
_hasScrolledToEnd
? l10n.readyToAccept
: l10n.pleaseScrollToEnd,
style: Theme.of(context).textTheme.bodySmall,
),
),
SizedBox(width: 16),
ElevatedButton(
onPressed: _hasScrolledToEnd ? _acceptDocument : null,
child: Text(l10n.accept),
),
],
),
),
);
}
Future<void> _acceptDocument() async {
final consentManager = context.read<ConsentManager>();
final locale = Localizations.localeOf(context).languageCode;
await consentManager.recordConsent(
documentType: widget.documentType,
version: _document!.version,
locale: locale,
);
Navigator.of(context).pop(true);
}
void _showLanguageSelector() {
// Show language selection dialog
showModalBottomSheet(
context: context,
builder: (context) => LanguageSelectorSheet(
onLanguageSelected: (locale) {
Navigator.of(context).pop();
_loadDocument();
},
),
);
}
void _scrollToSection(String sectionId) {
final key = Key('section_$sectionId');
// Implementation for scrolling to section
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Regional Legal Requirements
GDPR Compliance (EU)
class GDPRComplianceHelper {
static List<String> getRequiredDocuments(String countryCode) {
// EU member states
const euCountries = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
];
if (euCountries.contains(countryCode)) {
return ['privacy', 'terms', 'cookies', 'dpa'];
}
return ['privacy', 'terms'];
}
static Map<String, String> getRequiredPrivacyFields(String locale) {
final fields = {
'data_controller': 'Required',
'data_processing_purposes': 'Required',
'legal_basis': 'Required',
'data_retention': 'Required',
'data_subject_rights': 'Required',
'dpo_contact': 'Required if applicable',
'international_transfers': 'Required if applicable',
};
// Add locale-specific requirements
switch (locale) {
case 'de':
fields['imprint'] = 'Required (Impressum)';
break;
case 'fr':
fields['cnil_registration'] = 'Required if applicable';
break;
}
return fields;
}
}
CCPA Compliance (California)
class CCPAComplianceHelper {
static bool requiresCCPA(String regionCode) {
return regionCode == 'US-CA';
}
static List<String> getRequiredElements() {
return [
'right_to_know',
'right_to_delete',
'right_to_opt_out',
'right_to_non_discrimination',
'categories_collected',
'categories_sold',
'business_purpose',
];
}
static Map<String, String> getCCPAStrings(String locale) {
if (locale == 'es') {
return {
'do_not_sell_title': 'No Vender Mi Información Personal',
'do_not_sell_description': 'Tiene derecho a optar por no vender su información personal.',
'opt_out_button': 'Optar por No Participar',
'privacy_choices': 'Sus Opciones de Privacidad',
};
}
return {
'do_not_sell_title': 'Do Not Sell My Personal Information',
'do_not_sell_description': 'You have the right to opt out of the sale of your personal information.',
'opt_out_button': 'Opt Out',
'privacy_choices': 'Your Privacy Choices',
};
}
}
Testing Legal Localization
Unit Tests
void main() {
group('LegalDocumentService', () {
late LegalDocumentService service;
setUp(() {
service = LegalDocumentService(baseUrl: 'https://api.example.com');
});
test('loads document for locale', () async {
final document = await service.getDocument(
type: 'privacy',
locale: 'en',
);
expect(document, isNotNull);
expect(document!.type, 'privacy');
expect(document.locale, 'en');
});
test('falls back to English for missing locale', () async {
final document = await service.getDocument(
type: 'privacy',
locale: 'xx', // Non-existent locale
);
expect(document, isNotNull);
expect(document!.locale, 'en');
});
});
group('ConsentManager', () {
late ConsentManager manager;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
manager = ConsentManager(
prefs: prefs,
documentService: MockLegalDocumentService(),
analytics: MockAnalyticsService(),
);
});
test('records consent correctly', () async {
await manager.recordConsent(
documentType: 'privacy',
version: '1.0',
locale: 'en',
);
final record = await manager.getConsentRecord('privacy');
expect(record, isNotNull);
expect(record!.version, '1.0');
});
test('detects outdated consent', () async {
await manager.recordConsent(
documentType: 'privacy',
version: '1.0',
locale: 'en',
);
// Mock server returns newer version
final status = await manager.getConsentStatus('en');
expect(status.outdatedConsent, contains('privacy'));
});
});
}
Best Practices
Legal Localization Checklist
- Use professional translators - Legal terms require expertise
- Track versions - Know which version users accepted
- Store consent proof - Timestamps, versions, locale
- Provide easy access - Users must be able to read anytime
- Support multiple formats - PDF download, print-friendly view
- Regular reviews - Laws change, update accordingly
- Test in all locales - Ensure formatting is correct
Common Mistakes to Avoid
- Using machine translation for legal documents
- Not tracking which version users accepted
- Missing regional legal requirements
- Hardcoding legal text in the app
- Not providing language options for legal docs
- Forgetting to update consent when terms change
Conclusion
Legal localization requires professional translation, careful version tracking, and compliance with regional regulations. By building a robust system for managing legal documents and consent, you ensure your Flutter app meets legal requirements while providing a good user experience in every market.
Related Resources
- Flutter PDF Localization - Generate localized legal PDFs
- Flutter Localization Analytics - Track consent metrics
- Flutter OTA Localization - Update legal docs remotely
- Free ARB Editor - Manage legal UI translations