refactored metadata loading & listening

This commit is contained in:
Thibault Deckers 2019-08-10 15:17:47 +09:00
parent ac8b6176c3
commit ea765fbdc9
15 changed files with 406 additions and 325 deletions

View file

@ -174,7 +174,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME); int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME);
for (int i = 1; i < count + 1; i++) { for (int i = 1; i < count + 1; i++) {
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i);
sb.append(" ").append(item.getValue()); sb.append(";").append(item.getValue());
} }
metadataMap.put("xmpSubjects", sb.toString()); metadataMap.put("xmpSubjects", sb.toString());
} }

View file

@ -0,0 +1,36 @@
import 'package:geocoder/model.dart';
class CatalogMetadata {
final int contentId, dateMillis;
final String xmpSubjects;
final double latitude, longitude;
Address address;
CatalogMetadata({this.contentId, this.dateMillis, this.xmpSubjects, double latitude, double longitude})
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
: this.latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
this.longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
factory CatalogMetadata.fromMap(Map map) {
return CatalogMetadata(
contentId: map['contentId'],
dateMillis: map['dateMillis'],
xmpSubjects: map['xmpSubjects'],
latitude: map['latitude'],
longitude: map['longitude'],
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'dateMillis': dateMillis,
'xmpSubjects': xmpSubjects,
'latitude': latitude,
'longitude': longitude,
};
@override
String toString() {
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}';
}
}

View file

@ -1,21 +1,27 @@
import 'package:geocoder/model.dart'; import 'package:aves/model/catalog_metadata.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
import 'package:tuple/tuple.dart';
import 'mime_types.dart'; import 'mime_types.dart';
class ImageEntry { class ImageEntry with ChangeNotifier {
String uri; final String uri;
String path; final String path;
int contentId; final int contentId;
String mimeType; final String mimeType;
int width; final int width;
int height; final int height;
int orientationDegrees; final int orientationDegrees;
int sizeBytes; final int sizeBytes;
String title; final String title;
int dateModifiedSecs; final int dateModifiedSecs;
int sourceDateTakenMillis; final int sourceDateTakenMillis;
String bucketDisplayName; final String bucketDisplayName;
int durationMillis; final int durationMillis;
CatalogMetadata catalogMetadata;
String addressLine, addressCountry;
ImageEntry({ ImageEntry({
this.uri, this.uri,
@ -74,18 +80,18 @@ class ImageEntry {
return 'ImageEntry{uri=$uri, path=$path}'; return 'ImageEntry{uri=$uri, path=$path}';
} }
// TODO TLAD add xmp subjects, address, etc.
String get searchable => title.toLowerCase();
bool get isGif => mimeType == MimeTypes.MIME_GIF; bool get isGif => mimeType == MimeTypes.MIME_GIF;
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
bool get isCataloged => catalogMetadata != null;
double get aspectRatio => height == 0 ? 1 : width / height; double get aspectRatio => height == 0 ? 1 : width / height;
int get megaPixels => (width * height / 1000000).round(); int get megaPixels => (width * height / 1000000).round();
DateTime get bestDate { DateTime get bestDate {
if (isCataloged && catalogMetadata.dateMillis > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis);
if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis); if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis);
if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
return null; return null;
@ -110,39 +116,46 @@ class ImageEntry {
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour)); String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds'; return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
} }
}
class CatalogMetadata { bool get hasGps => isCataloged && catalogMetadata.latitude != null;
final int contentId, dateMillis;
final String xmpSubjects;
final double latitude, longitude;
Address address;
CatalogMetadata({this.contentId, this.dateMillis, this.xmpSubjects, double latitude, double longitude}) bool get isLocated => addressLine != null;
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
: this.latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
this.longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
factory CatalogMetadata.fromMap(Map map) { Tuple2<double, double> get latLng => isCataloged ? Tuple2(catalogMetadata.latitude, catalogMetadata.longitude) : null;
return CatalogMetadata(
contentId: map['contentId'], List<String> get xmpSubjects => catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
dateMillis: map['dateMillis'],
xmpSubjects: map['xmpSubjects'], catalog() async {
latitude: map['latitude'], if (isCataloged) return;
longitude: map['longitude'], catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path);
); notifyListeners();
} }
Map<String, dynamic> toMap() => { locate() async {
'contentId': contentId, if (isLocated) return;
'dateMillis': dateMillis, await catalog();
'xmpSubjects': xmpSubjects, final latitude = catalogMetadata?.latitude;
'latitude': latitude, final longitude = catalogMetadata?.longitude;
'longitude': longitude, if (latitude != null && longitude != null) {
}; final coordinates = Coordinates(latitude, longitude);
try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
if (addresses != null && addresses.length > 0) {
final address = addresses.first;
addressLine = address.addressLine;
addressCountry = address.countryName;
notifyListeners();
}
} catch (e) {
debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}');
}
}
}
@override bool search(String query) {
String toString() { if (title.toLowerCase().contains(query)) return true;
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; if (catalogMetadata?.xmpSubjects?.toLowerCase()?.contains(query) ?? false) return true;
if (isLocated && addressLine.toLowerCase().contains(query)) return true;
return false;
} }
} }

View file

@ -1,4 +1,4 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/catalog_metadata.dart';
import 'package:aves/model/metadata_storage_service.dart'; import 'package:aves/model/metadata_storage_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';

View file

@ -1,4 +1,4 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/catalog_metadata.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';

View file

@ -51,7 +51,7 @@ class ImageSearchDelegate extends SearchDelegate<ImageEntry> {
return SizedBox.shrink(); return SizedBox.shrink();
} }
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
final matches = entries.where((entry) => entry.searchable.contains(lowerQuery)).toList(); final matches = entries.where((entry) => entry.search(lowerQuery)).toList();
if (matches.isEmpty) { if (matches.isEmpty) {
return Center( return Center(
child: Text( child: Text(

View file

@ -1,4 +1,4 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/catalog_metadata.dart';
import 'package:aves/model/metadata_storage_service.dart'; import 'package:aves/model/metadata_storage_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -2,7 +2,7 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay_bottom.dart'; import 'package:aves/widgets/fullscreen/overlay_bottom.dart';
import 'package:aves/widgets/fullscreen/overlay_top.dart'; import 'package:aves/widgets/fullscreen/overlay_top.dart';
import 'package:aves/widgets/fullscreen/video.dart'; import 'package:aves/widgets/fullscreen/video.dart';

View file

@ -0,0 +1,126 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:aves/widgets/fullscreen/info/metadata_section.dart';
import 'package:aves/widgets/fullscreen/info/xmp_section.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class InfoPage extends StatefulWidget {
final ImageEntry entry;
const InfoPage({this.entry});
@override
State<StatefulWidget> createState() => InfoPageState();
}
class InfoPageState extends State<InfoPage> {
bool _scrollStartFromTop = false;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
entry.locate();
}
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = '${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}';
final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '' : ' (${entry.megaPixels} MP)'}';
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: () => BackUpNotification().dispatch(context),
tooltip: 'Back to image',
),
title: Text('Info'),
),
body: NotificationListener(
onNotification: handleTopScroll,
child: ListView(
padding: EdgeInsets.all(8.0),
children: [
InfoRow('Title', entry.title),
InfoRow('Date', dateText),
if (entry.isVideo) InfoRow('Duration', entry.durationText),
InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)),
InfoRow('Path', entry.path),
LocationSection(entry: entry),
XmpTagSection(entry: entry),
MetadataSection(entry: entry),
],
),
),
);
}
bool handleTopScroll(Notification notification) {
if (notification is ScrollNotification) {
if (notification is ScrollStartNotification) {
final metrics = notification.metrics;
_scrollStartFromTop = metrics.pixels == metrics.minScrollExtent;
}
if (_scrollStartFromTop) {
if (notification is ScrollEndNotification) {
_scrollStartFromTop = false;
} else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) {
BackUpNotification().dispatch(context);
_scrollStartFromTop = false;
}
}
}
}
return false;
}
}
class SectionRow extends StatelessWidget {
final String title;
const SectionRow(this.title);
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Divider(color: Colors.white70)),
Padding(
padding: EdgeInsets.all(16.0),
child: Text(title, style: TextStyle(fontSize: 20)),
),
Expanded(child: Divider(color: Colors.white70)),
],
);
}
}
class InfoRow extends StatelessWidget {
final String label, value;
const InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(text: '$label ', style: TextStyle(color: Colors.white70)),
TextSpan(text: value),
],
),
),
);
}
}
class BackUpNotification extends Notification {}

View file

@ -0,0 +1,69 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class LocationSection extends AnimatedWidget {
final ImageEntry entry;
const LocationSection({Key key, this.entry}) : super(key: key, listenable: entry);
@override
Widget build(BuildContext context) {
return !entry.hasGps
? SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionRow('Location'),
ImageMap(markerId: entry.path, latLng: LatLng(entry.latLng.item1, entry.latLng.item2)),
if (entry.isLocated)
Padding(
padding: EdgeInsets.only(top: 8),
child: InfoRow('Address', entry.addressLine),
),
],
);
}
}
class ImageMap extends StatefulWidget {
final String markerId;
final LatLng latLng;
const ImageMap({Key key, this.markerId, this.latLng}) : super(key: key);
@override
State<StatefulWidget> createState() => ImageMapState();
}
class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return SizedBox(
height: 200,
child: ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(16),
),
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: widget.latLng,
zoom: 12,
),
markers: [
Marker(
markerId: MarkerId(widget.markerId),
icon: BitmapDescriptor.defaultMarker,
position: widget.latLng,
)
].toSet(),
),
),
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,69 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
class MetadataSection extends StatefulWidget {
final ImageEntry entry;
const MetadataSection({this.entry});
@override
State<StatefulWidget> createState() => MetadataSectionState();
}
class MetadataSectionState extends State<MetadataSection> {
Future<Map> _metadataLoader;
@override
void initState() {
super.initState();
initMetadataLoader();
}
@override
void didUpdateWidget(MetadataSection oldWidget) {
super.didUpdateWidget(oldWidget);
initMetadataLoader();
}
initMetadataLoader() async {
_metadataLoader = MetadataService.getAllMetadata(widget.entry.path);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _metadataLoader,
builder: (futureContext, AsyncSnapshot<Map> snapshot) {
if (snapshot.hasError) return Text(snapshot.error);
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final metadataMap = snapshot.data.cast<String, Map>();
final directoryNames = metadataMap.keys.toList()..sort();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionRow('Metadata'),
...directoryNames.expand(
(directoryName) {
final directory = metadataMap[directoryName];
final tagKeys = directory.keys.toList()..sort();
return [
if (directoryName.isNotEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Text(directoryName, style: TextStyle(fontSize: 18)),
),
...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])),
SizedBox(height: 16),
];
},
)
],
);
},
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
class XmpTagSection extends AnimatedWidget {
final ImageEntry entry;
const XmpTagSection({Key key, this.entry}) : super(key: key, listenable: entry);
@override
Widget build(BuildContext context) {
final tags = entry.xmpSubjects;
return tags.isEmpty
? SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionRow('XMP Tags'),
Wrap(
children: tags
.map((tag) => Padding(
padding: EdgeInsets.symmetric(horizontal: 4.0),
child: Chip(
backgroundColor: Colors.indigo,
label: Text(tag),
),
))
.toList(),
),
],
);
}
}

View file

@ -1,273 +0,0 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:intl/intl.dart';
class InfoPage extends StatefulWidget {
final ImageEntry entry;
const InfoPage({this.entry});
@override
State<StatefulWidget> createState() => InfoPageState();
}
class InfoPageState extends State<InfoPage> {
Future<Map> _metadataLoader;
Future<CatalogMetadata> _catalogLoader;
bool _scrollStartFromTop = false;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
initMetadataLoader();
}
@override
void didUpdateWidget(InfoPage oldWidget) {
super.didUpdateWidget(oldWidget);
initMetadataLoader();
}
initMetadataLoader() {
_catalogLoader = MetadataService.getCatalogMetadata(entry.contentId, entry.path).then(addAddressToMetadata);
_metadataLoader = MetadataService.getAllMetadata(entry.path);
}
Future<CatalogMetadata> addAddressToMetadata(metadata) async {
if (metadata == null) return null;
final latitude = metadata.latitude;
final longitude = metadata.longitude;
if (latitude != null && longitude != null) {
final coordinates = Coordinates(latitude, longitude);
try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
if (addresses != null && addresses.length > 0) {
metadata.address = addresses.first;
}
} catch (e) {
debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}');
}
}
return metadata;
}
@override
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = '${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}';
final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '' : ' (${entry.megaPixels} MP)'}';
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: () => BackUpNotification().dispatch(context),
tooltip: 'Back to image',
),
title: Text('Info'),
),
body: NotificationListener(
onNotification: (notification) {
if (notification is ScrollNotification) {
if (notification is ScrollStartNotification) {
final metrics = notification.metrics;
_scrollStartFromTop = metrics.pixels == metrics.minScrollExtent;
}
if (_scrollStartFromTop) {
if (notification is ScrollEndNotification) {
_scrollStartFromTop = false;
} else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) {
BackUpNotification().dispatch(context);
_scrollStartFromTop = false;
}
}
}
}
return false;
},
child: ListView(
padding: EdgeInsets.all(8.0),
children: [
InfoRow('Title', entry.title),
InfoRow('Date', dateText),
if (entry.isVideo) InfoRow('Duration', entry.durationText),
InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)),
InfoRow('Path', entry.path),
FutureBuilder(
future: _catalogLoader,
builder: (futureContext, AsyncSnapshot<CatalogMetadata> snapshot) {
if (snapshot.hasError) return Text(snapshot.error);
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final metadata = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildLocationSection(metadata?.latitude, metadata?.longitude, metadata?.address),
..._buildTagSection(metadata?.xmpSubjects),
],
);
},
),
FutureBuilder(
future: _metadataLoader,
builder: (futureContext, AsyncSnapshot<Map> snapshot) {
if (snapshot.hasError) return Text(snapshot.error);
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final metadataMap = snapshot.data.cast<String, Map>();
final directoryNames = metadataMap.keys.toList()..sort();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionRow('Metadata'),
...directoryNames.expand(
(directoryName) {
final directory = metadataMap[directoryName];
final tagKeys = directory.keys.toList()..sort();
return [
if (directoryName.isNotEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Text(directoryName, style: TextStyle(fontSize: 18)),
),
...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])),
SizedBox(height: 16),
];
},
)
],
);
},
),
],
),
),
);
}
List<Widget> _buildLocationSection(double latitude, double longitude, Address address) {
if (latitude == null || longitude == null) return [];
return [
SectionRow('Location'),
ImageMap(markerId: entry.path, latLng: LatLng(latitude, longitude)),
if (address != null)
Padding(
padding: EdgeInsets.only(top: 8),
child: InfoRow('Address', address.addressLine),
),
];
}
List<Widget> _buildTagSection(String xmpSubjects) {
if (xmpSubjects == null) return [];
return [
SectionRow('XMP Tags'),
Wrap(
children: xmpSubjects
.split(' ')
.where((word) => word.isNotEmpty)
.map((word) => Padding(
padding: EdgeInsets.symmetric(horizontal: 4.0),
child: Chip(
backgroundColor: Colors.indigo,
label: Text(word),
),
))
.toList(),
),
];
}
}
class ImageMap extends StatefulWidget {
final String markerId;
final LatLng latLng;
const ImageMap({Key key, this.markerId, this.latLng}) : super(key: key);
@override
State<StatefulWidget> createState() => ImageMapState();
}
class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return SizedBox(
height: 200,
child: ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(16),
),
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: widget.latLng,
zoom: 12,
),
markers: [
Marker(
markerId: MarkerId(widget.markerId),
icon: BitmapDescriptor.defaultMarker,
position: widget.latLng,
)
].toSet(),
),
),
);
}
@override
bool get wantKeepAlive => true;
}
class SectionRow extends StatelessWidget {
final String title;
const SectionRow(this.title);
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Divider(color: Colors.white70)),
Padding(
padding: EdgeInsets.all(16.0),
child: Text(title, style: TextStyle(fontSize: 20)),
),
Expanded(child: Divider(color: Colors.white70)),
],
);
}
}
class InfoRow extends StatelessWidget {
final String label, value;
const InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(text: '$label ', style: TextStyle(color: Colors.white70)),
TextSpan(text: value),
],
),
),
);
}
}
class BackUpNotification extends Notification {}

View file

@ -205,6 +205,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
tuple:
dependency: "direct main"
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View file

@ -30,6 +30,7 @@ dependencies:
screen: screen:
sqflite: sqflite:
transparent_image: transparent_image:
tuple:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: