Initial (redacted) commit.

This commit is contained in:
mustard 2024-08-26 00:34:20 +02:00
commit 655f8a036a
368 changed files with 20949 additions and 0 deletions

View file

@ -0,0 +1,20 @@
{
"@@locale": "de",
"appTitle": "Demo-App mit Riverpod und go_router",
"ok": "Ok",
"cancel": "Abbrechen",
"yes": "Ja",
"no": "Nein",
"add": "Hinzufügen",
"save": "Speichern",
"error": "Sorry, da ist etwas schief gegangen...",
"loading": "Lade ...",
"select": "Auswahl",
"selected": "Ausgewählt",
"search": "Suche",
"counterLabel": "Du hast den Button sooft gedrückt:",
"fabMainTooltip": "erhöhen",
"placeholder": "placeholder",
"home": "Start",
"counter": "Zähler"
}

View file

@ -0,0 +1,27 @@
{
"@@locale": "en",
"appTitle": "Demo app with riverpod and go_router",
"ok": "Ok",
"cancel": "Cancel",
"yes": "Yes",
"no": "No",
"add": "Add",
"save": "Save",
"error": "We apologize for the inconvenience. This should not have happened...",
"loading": "Loading",
"select": "Select",
"selected": "Selected",
"deselect": "Deselect",
"search": "Search",
"counterLabel": "You have pushed the button this many times:",
"fabMainTooltip": "Increment",
"placeholder": "placeholder",
"requiredField": "This field is required",
"labelName": "Name",
"labelImageUrl": "Url of an image",
"delete": "Delete",
"home": "Home",
"counter": "Counter",
"details": "Details",
"noItems": "Sorry, no items."
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/routing/app_router.dart';
import 'src/utils/localization.dart';
Future<void> main() async {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final goRouter = ref.watch(goRouterProvider);
return MaterialApp.router(
routerConfig: goRouter,
debugShowCheckedModeBanner: false,
onGenerateTitle: (context) => context.loc.appTitle,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// use to find missing semantics
// showSemanticsDebugger: true,
);
}
}

View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'error_message_widget.dart';
//credits to Code with Andrea https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/src/common_widgets/async_value_widget.dart
class AsyncValueWidget<T> extends StatelessWidget {
const AsyncValueWidget({required this.value, required this.data, super.key});
final AsyncValue<T> value;
final Widget Function(T) data;
@override
Widget build(BuildContext context) {
return value.when(
data: data,
error: (error, st) => Center(child: ErrorMessageWidget(error)),
loading: () => const SizedBox(
width: 60,
height: 60,
child: Center(child: CircularProgressIndicator()),
),
);
}
}

View file

@ -0,0 +1,55 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:ri_go_demo/src/exceptions/api_exception.dart';
import '../utils/localization.dart';
import '../utils/logger.dart';
//credits to Code with Andrea https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/src/common_widgets/error_message_widget.dart
/// Simple reusable widget to show errors to the user.
class ErrorMessageWidget extends StatelessWidget {
const ErrorMessageWidget(this.error, {super.key});
/// Error object, might be a DioException.
final Object? error;
@override
Widget build(BuildContext context) {
return Text(
_pimpError(error, context.loc.error),
style:
Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.red),
);
}
String _pimpError(Object? error, String defaultStr) {
if (error == null) {
logger.d('ErrorMessageWidget - _pimpError - no error');
return defaultStr;
}
try {
final dioEx = error as DioException;
if (dioEx.response != null) {
final map = dioEx.response!.data as Map<String, dynamic>;
if (map.containsKey('detail')) {
return map['detail']! as String;
}
}
} catch (ex) {
logger.e(
'ErrorMessageWidget - _pimpError - could not extract info',
error: ex,
);
}
try {
final apiException = error as ApiException;
return 'status ${apiException.statusCode}: ${apiException.message}';
} catch (ex) {
logger.e(
'ErrorMessageWidget - _pimpError - could not extract message',
error: ex,
);
}
return defaultStr;
}
}

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
//similar from code with Andrea
class FormFieldWidget extends StatelessWidget {
const FormFieldWidget({
required this.controller,
required this.labelText,
super.key,
this.keyboardType,
this.formFieldKey,
this.required = false,
this.initialValue = '',
});
final TextEditingController controller;
final String labelText;
final TextInputType? keyboardType;
final bool required;
final Key? formFieldKey;
final String initialValue;
@override
Widget build(BuildContext context) {
return Column(
children: [
Semantics(
label: labelText,
child: TextFormField(
key: formFieldKey,
controller: controller,
decoration: InputDecoration(
labelText: labelText,
),
autocorrect: false,
textInputAction: TextInputAction.next,
keyboardType: keyboardType,
validator: (value) {
if (required && (value == null || value.isEmpty)) {
return ''; //context.loc.requiredField;
}
return null;
},
),
),
],
);
}
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
// credits to Code with Andrea, https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/src/common_widgets/primary_button.dart
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
required this.label,
required this.onPressed,
super.key,
this.isEnabled = true,
this.backgroundColor,
this.foregroundColor,
});
final VoidCallback? onPressed;
final String label;
final bool isEnabled;
final Color? backgroundColor;
final Color? foregroundColor;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
height: 48,
child: ElevatedButton(
onPressed: isEnabled ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor:
backgroundColor ?? Theme.of(context).colorScheme.primary,
foregroundColor:
foregroundColor ?? Theme.of(context).colorScheme.onPrimary,
),
child: Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
),
);
}
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../constants/breakpoint.dart';
class TwoPanelWidget extends StatelessWidget {
const TwoPanelWidget({
required this.firstPanel,
required this.secondPanel,
super.key,
});
final Widget firstPanel;
final Widget secondPanel;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final isWideScreen = screenSize.width > Breakpoint.tablet;
if (isWideScreen) {
// Display master and detail side by side
return Row(
children: [
Expanded(
flex: 2,
child: firstPanel,
),
Expanded(
flex: 2,
child: secondPanel,
),
],
);
} else {
// Display master and detail vertically
return Column(
children: [
Expanded(
flex: 2,
child: firstPanel,
),
Expanded(
flex: 2,
child: secondPanel,
),
], // Display the first item initially
);
}
}
}

View file

@ -0,0 +1,6 @@
// https://api-generator.retool.com/grQMFP/crud-demo/
abstract class Api {
static const String schema = 'https';
static const String host = 'api-generator.retool.com';
static const String path = 'grQMFP/crud-demo/';
}

View file

@ -0,0 +1,3 @@
class Breakpoint {
static const double tablet = 600;
}

View file

@ -0,0 +1,5 @@
abstract class UIConstants {
static const double minHitTargetHeight = 55;
static const double verticalItemSpace = 8;
static const double defaultPadding = 12;
}

View file

@ -0,0 +1,8 @@
class ApiException implements Exception {
ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => message;
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../utils/localization.dart';
import 'counter_screen_controller.dart';
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(counterProvider);
final controller = ref.read(counterProvider.notifier);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(context.loc.appTitle),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
context.loc.counterLabel,
),
Text(
'$state',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.increment,
tooltip: context.loc.fabMainTooltip,
child: const Icon(Icons.add),
),
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter_screen_controller.g.dart';
/// Annotating a class by `@riverpod` defines a new shared
/// state for your application,
/// accessible using the generated [counterProvider].
/// This class is both responsible for initializing the state (through the
/// [build] method)
/// and exposing ways to modify it (cf [increment]).
@riverpod
class Counter extends _$Counter {
/// Classes annotated by `@riverpod` **must** define a [build] function.
/// This function is expected to return the initial state of your
/// shared state.
/// It is totally acceptable for this function to return a [Future] or
/// [Stream] if you need to.
/// You can also freely define parameters on this method.
@override
int build() => 0;
void increment() => state++;
}

View file

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'counter_screen_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$counterHash() => r'4243b34530f53accfd9014a9f0e316fe304ada3e';
/// Annotating a class by `@riverpod` defines a new shared
/// state for your application,
/// accessible using the generated [counterProvider].
/// This class is both responsible for initializing the state (through the
/// [build] method)
/// and exposing ways to modify it (cf [increment]).
///
/// Copied from [Counter].
@ProviderFor(Counter)
final counterProvider = AutoDisposeNotifierProvider<Counter, int>.internal(
Counter.new,
name: r'counterProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$counterHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Counter = AutoDisposeNotifier<int>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View file

@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../constants/api.dart';
import '../../../exceptions/api_exception.dart';
import '../../../utils/dio_provider.dart';
import '../../../utils/logger.dart';
import '../domain/person.dart';
part 'people_repository.g.dart';
class PeopleRepository {
PeopleRepository({required this.dio});
final Dio dio;
String _getUrl({int? id}) {
final url =
Uri(scheme: Api.schema, host: Api.host, path: Api.path).toString();
if (id != null) {
return '$url$id';
} else {
return url;
}
}
Future<Person> getPersonById({required int id}) async {
final url = _getUrl(id: id);
// ignore: inference_failure_on_function_invocation
final response = await dio.get<String>(url);
if (response.statusCode == 200 && response.data != null) {
final person =
Person.fromJson(json.decode(response.data!) as Map<String, Object?>);
return person;
} else {
throw ApiException(
response.statusCode ?? -1,
'getPersonById ${response.statusCode}, data=${response.data}',
);
}
}
Future<List<Person>> getPeople() async {
logger.d('people_repository.getPeople');
final url = _getUrl();
final response = await dio.get<List<dynamic>>(url);
if (response.statusCode == 200 && response.data != null) {
final dataList = response.data!;
return dataList
.map(
(personJson) => Person.fromJson(personJson as Map<String, Object?>),
)
.toList();
} else {
throw ApiException(
response.statusCode ?? -1,
'getPeople ${response.statusCode}, data=${response.data}',
);
}
}
Future<Person> updatePerson({required Person person}) async {
final url = _getUrl(id: person.id);
final response = await dio.put<String>(url, data: person.toJson());
if (response.statusCode == 200 && response.data != null) {
final personUpdated =
Person.fromJson(json.decode(response.data!) as Map<String, Object?>);
return personUpdated;
} else {
throw ApiException(
response.statusCode ?? -1,
'updateOwner ${response.statusCode}, data=${response.data}',
);
}
}
Future<bool> deletePerson(int id) async {
final url = _getUrl(id: id);
final response = await dio.delete<String>(url);
if (response.statusCode == 200) {
return true;
} else {
throw ApiException(
response.statusCode ?? -1,
'deletePerson ${response.statusCode}, data=${response.data}',
);
}
}
Future<Person> savePerson({required Person person}) async {
final url = _getUrl();
// this api uses the id if it exists, hence in case of a post
// we make sure, there is no id
final response =
await dio.post<String>(url, data: person.toJsonWithoutId());
if (response.statusCode == 201 && response.data != null) {
final newPerson =
Person.fromJson(json.decode(response.data!) as Map<String, Object?>);
return newPerson;
} else {
throw ApiException(
response.statusCode ?? -1,
'savePerson ${response.statusCode}, data=${response.data}',
);
}
}
}
@riverpod
PeopleRepository peopleRepository(PeopleRepositoryRef ref) =>
PeopleRepository(dio: ref.read(dioProvider));
@riverpod
Future<List<Person>> fetchPeople(FetchPeopleRef ref) async {
logger.d('people_repository.fetchPeople');
final repo = ref.read(peopleRepositoryProvider);
return repo.getPeople();
}
@riverpod
Future<Person> fetchPersonById(FetchPersonByIdRef ref, int id) async {
final repo = ref.read(peopleRepositoryProvider);
return repo.getPersonById(id: id);
}

View file

@ -0,0 +1,141 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'people_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$peopleRepositoryHash() => r'd0e91e6cbf45120cbcd670b6cc99fe71a9d76429';
/// See also [peopleRepository].
@ProviderFor(peopleRepository)
final peopleRepositoryProvider = AutoDisposeProvider<PeopleRepository>.internal(
peopleRepository,
name: r'peopleRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$peopleRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PeopleRepositoryRef = AutoDisposeProviderRef<PeopleRepository>;
String _$fetchPeopleHash() => r'772e01d6c483daa24de83820a021601fa93618e7';
/// See also [fetchPeople].
@ProviderFor(fetchPeople)
final fetchPeopleProvider = AutoDisposeFutureProvider<List<Person>>.internal(
fetchPeople,
name: r'fetchPeopleProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$fetchPeopleHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef FetchPeopleRef = AutoDisposeFutureProviderRef<List<Person>>;
String _$fetchPersonByIdHash() => r'ab1560261f3491819dc88719e855f1c9b973ed21';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef FetchPersonByIdRef = AutoDisposeFutureProviderRef<Person>;
/// See also [fetchPersonById].
@ProviderFor(fetchPersonById)
const fetchPersonByIdProvider = FetchPersonByIdFamily();
/// See also [fetchPersonById].
class FetchPersonByIdFamily extends Family<AsyncValue<Person>> {
/// See also [fetchPersonById].
const FetchPersonByIdFamily();
/// See also [fetchPersonById].
FetchPersonByIdProvider call(
int id,
) {
return FetchPersonByIdProvider(
id,
);
}
@override
FetchPersonByIdProvider getProviderOverride(
covariant FetchPersonByIdProvider provider,
) {
return call(
provider.id,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'fetchPersonByIdProvider';
}
/// See also [fetchPersonById].
class FetchPersonByIdProvider extends AutoDisposeFutureProvider<Person> {
/// See also [fetchPersonById].
FetchPersonByIdProvider(
this.id,
) : super.internal(
(ref) => fetchPersonById(
ref,
id,
),
from: fetchPersonByIdProvider,
name: r'fetchPersonByIdProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$fetchPersonByIdHash,
dependencies: FetchPersonByIdFamily._dependencies,
allTransitiveDependencies:
FetchPersonByIdFamily._allTransitiveDependencies,
);
final int id;
@override
bool operator ==(Object other) {
return other is FetchPersonByIdProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View file

@ -0,0 +1,37 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.freezed.dart';
part 'person.g.dart';
@freezed
class Person with _$Person {
const factory Person({
required int id,
required String name,
required String imageUrl,
}) = _Person;
factory Person.fromJson(Map<String, Object?> json) => _$PersonFromJson(json);
}
extension JsonWithoutId on Person {
String toJsonWithoutId() {
final map = toJson();
// ignore: cascade_invocations //remove returns the removed field!, cascade_invocations
map.remove('id');
return json.encode(map);
}
}
// String toJsonWithoutId(Person p) {
// var a = p.toJson();
// var b = a.remove('id');
// var c = json.encode(a);
// // final map = p.toJson().remove('id');
// // return json.encode(map);
// return c;
// }

View file

@ -0,0 +1,192 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'person.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
Person _$PersonFromJson(Map<String, dynamic> json) {
return _Person.fromJson(json);
}
/// @nodoc
mixin _$Person {
int get id => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get imageUrl => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$PersonCopyWith<Person> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $PersonCopyWith<$Res> {
factory $PersonCopyWith(Person value, $Res Function(Person) then) =
_$PersonCopyWithImpl<$Res, Person>;
@useResult
$Res call({int id, String name, String imageUrl});
}
/// @nodoc
class _$PersonCopyWithImpl<$Res, $Val extends Person>
implements $PersonCopyWith<$Res> {
_$PersonCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? imageUrl = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$_PersonCopyWith<$Res> implements $PersonCopyWith<$Res> {
factory _$$_PersonCopyWith(_$_Person value, $Res Function(_$_Person) then) =
__$$_PersonCopyWithImpl<$Res>;
@override
@useResult
$Res call({int id, String name, String imageUrl});
}
/// @nodoc
class __$$_PersonCopyWithImpl<$Res>
extends _$PersonCopyWithImpl<$Res, _$_Person>
implements _$$_PersonCopyWith<$Res> {
__$$_PersonCopyWithImpl(_$_Person _value, $Res Function(_$_Person) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? imageUrl = null,
}) {
return _then(_$_Person(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$_Person with DiagnosticableTreeMixin implements _Person {
const _$_Person(
{required this.id, required this.name, required this.imageUrl});
factory _$_Person.fromJson(Map<String, dynamic> json) =>
_$$_PersonFromJson(json);
@override
final int id;
@override
final String name;
@override
final String imageUrl;
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'Person(id: $id, name: $name, imageUrl: $imageUrl)';
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty('type', 'Person'))
..add(DiagnosticsProperty('id', id))
..add(DiagnosticsProperty('name', name))
..add(DiagnosticsProperty('imageUrl', imageUrl));
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_Person &&
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.imageUrl, imageUrl) ||
other.imageUrl == imageUrl));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, id, name, imageUrl);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_PersonCopyWith<_$_Person> get copyWith =>
__$$_PersonCopyWithImpl<_$_Person>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$_PersonToJson(
this,
);
}
}
abstract class _Person implements Person {
const factory _Person(
{required final int id,
required final String name,
required final String imageUrl}) = _$_Person;
factory _Person.fromJson(Map<String, dynamic> json) = _$_Person.fromJson;
@override
int get id;
@override
String get name;
@override
String get imageUrl;
@override
@JsonKey(ignore: true)
_$$_PersonCopyWith<_$_Person> get copyWith =>
throw _privateConstructorUsedError;
}

View file

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'person.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_Person _$$_PersonFromJson(Map<String, dynamic> json) => _$_Person(
id: json['id'] as int,
name: json['name'] as String,
imageUrl: json['imageUrl'] as String,
);
Map<String, dynamic> _$$_PersonToJson(_$_Person instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'imageUrl': instance.imageUrl,
};

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gap/gap.dart';
import '../../../common_widgets/async_value_widget.dart';
import '../../../constants/ui_constants.dart';
import '../../../utils/localization.dart';
import '../../../utils/logger.dart';
import '../data/people_repository.dart';
import '../domain/person.dart';
class DetailsScreen extends ConsumerWidget {
const DetailsScreen({super.key, this.id, this.person});
final int? id;
final Person? person;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (id == null) {
return Scaffold(
appBar: AppBar(
title: Text(context.loc.details),
),
body: Text(context.loc.error),
);
} else if (person != null) {
return DetailsScreenPlainWidget(person!);
} else {
// e.g. navigated to details by bookmark
return AsyncValueWidget<Person>(
value: ref.watch(fetchPersonByIdProvider(id!)),
data: DetailsScreenPlainWidget.new,
);
}
}
}
class DetailsScreenPlainWidget extends StatelessWidget {
const DetailsScreenPlainWidget(this.person, {super.key});
final Person person;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.loc.details),
),
body: Padding(
padding: const EdgeInsets.all(UIConstants.defaultPadding),
child: Column(
children: [
Text('hi ${person.name}'),
const Gap(UIConstants.verticalItemSpace),
if (person.imageUrl.startsWith('http'))
Expanded(
child: Image(
image: NetworkImage(person.imageUrl),
errorBuilder: (context, error, stackTrace) {
logger.d(
'DetailsScreen - image, url=${person.imageUrl}',
error: error,
stackTrace: stackTrace,
);
return Text(person.imageUrl);
},
),
)
else
Text(person.imageUrl),
],
),
),
);
}
}

View file

@ -0,0 +1,81 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../utils/logger.dart';
import '../data/people_repository.dart';
import '../domain/person.dart';
part 'edit_person_controller.g.dart';
// in some cases it might be good to store the current selected item in a
// separate provider
// final currentPersonProvider = StateProvider<Person?>((ref) {
// return null;
// });
@riverpod
class EditPersonController extends _$EditPersonController {
@override
FutureOr<Person?> build() {
ref.onDispose(
() => logger.i('EditPersonController ----- dispose controller -----'),
);
state = const AsyncData(null);
return state.value;
}
//for non string-based setters and hence input fields without own controllers
//use setters like
// void setCheckboxField(bool value) {
// state = AsyncData(state.value!.copyWith(isBossy: value));
// }
/// Show the form with the values given by [person].
void editPerson(Person person) {
state = AsyncData(person);
}
/// Show the form with empty fields, ready to create a new person.
void newPerson() {
state = const AsyncData(Person(id: -1, name: '', imageUrl: ''));
}
Future<void> delete() async {
if (state.value == null || state.value!.id < 0) {
return;
}
state = const AsyncLoading<Person?>();
try {
final repo = ref.read(peopleRepositoryProvider);
await repo.deletePerson(state.value!.id);
//this provider does not know this change, hence we need to force a
//refresh by invalidating it
ref.invalidate(fetchPeopleProvider);
state = const AsyncData(null);
} catch (error) {
state = AsyncError(error, StackTrace.current);
}
}
Future<void> save({required String name, required String imageUrl}) async {
if (name.isEmpty || state.value == null) {
return;
}
// use copywith to keep id
final editedPerson = state.value!.copyWith(name: name, imageUrl: imageUrl);
// to keep changes of the form fields, even if we may have an error
state = AsyncData(editedPerson);
state = const AsyncLoading<Person?>();
try {
final repo = ref.read(peopleRepositoryProvider);
if (editedPerson.id < 0) {
await repo.savePerson(person: editedPerson);
} else {
await repo.updatePerson(person: editedPerson);
}
ref.invalidate(fetchPeopleProvider);
state = const AsyncData(null);
} catch (error) {
state = AsyncError<Person?>(error, StackTrace.current);
}
}
}

View file

@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'edit_person_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$editPersonControllerHash() =>
r'7c656c78c7d8c60e746ec6bd7ad4e089f2f3fcac';
/// See also [EditPersonController].
@ProviderFor(EditPersonController)
final editPersonControllerProvider =
AutoDisposeAsyncNotifierProvider<EditPersonController, Person?>.internal(
EditPersonController.new,
name: r'editPersonControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$editPersonControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$EditPersonController = AutoDisposeAsyncNotifier<Person?>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gap/gap.dart';
import '../../../common_widgets/error_message_widget.dart';
import '../../../common_widgets/form_field_widget.dart';
import '../../../common_widgets/primary_button.dart';
import '../../../constants/ui_constants.dart';
import '../../../utils/localization.dart';
import 'edit_person_controller.dart';
class EditPersonForm extends ConsumerStatefulWidget {
const EditPersonForm({super.key});
@override
ConsumerState<EditPersonForm> createState() => _EditPersonFormState();
}
class _EditPersonFormState extends ConsumerState<EditPersonForm> {
final _formKey = GlobalKey<FormState>();
static const Key _nameKey = Key('name');
static const Key _imageUrlKey = Key('image_url');
final _nameController = TextEditingController();
final _imageUrlController = TextEditingController();
late final EditPersonController _controller;
@override
void initState() {
super.initState();
_controller = ref.read(editPersonControllerProvider.notifier);
}
@override
void dispose() {
_nameController.dispose();
_imageUrlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(editPersonControllerProvider);
if (state.isLoading) {
return const CircularProgressIndicator();
}
if (state.value == null) {
return Container();
} else {
_nameController.text = state.value!.name;
_imageUrlController.text = state.value!.imageUrl;
return Padding(
padding: const EdgeInsets.all(UIConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
children: [
//put here to have both visible, the error and the form
if (state.hasError) ErrorMessageWidget(state.error),
FormFieldWidget(
formFieldKey: _nameKey,
controller: _nameController,
labelText: context.loc.labelName,
required: true,
),
const Gap(UIConstants.verticalItemSpace),
FormFieldWidget(
formFieldKey: _imageUrlKey,
controller: _imageUrlController,
labelText: context.loc.labelImageUrl,
),
const Gap(UIConstants.verticalItemSpace),
Row(
children: [
PrimaryButton(
label: context.loc.delete,
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
onPressed:
state.isLoading ? null : () => {_controller.delete()},
isEnabled: state.value!.id > 0,
),
Expanded(
child: PrimaryButton(
label: context.loc.save,
onPressed: state.isLoading
? null
: () => {
if (_formKey.currentState!.validate())
{
_controller.save(
name: _nameController.text,
imageUrl: _imageUrlController.text,
),
},
},
),
),
],
),
],
),
),
);
}
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../common_widgets/async_value_widget.dart';
import '../../../common_widgets/two_panel_widget.dart';
import '../../../routing/app_router.dart';
import '../../../utils/localization.dart';
import '../data/people_repository.dart';
import '../domain/person.dart';
import 'edit_person_controller.dart';
import 'edit_person_form.dart';
class PeopleScreen extends ConsumerWidget {
const PeopleScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(context.loc.appTitle),
),
body: TwoPanelWidget(
firstPanel: RefreshIndicator(
//pull to refresh
onRefresh: () {
ref.invalidate(fetchPeopleProvider);
return ref.read(fetchPeopleProvider.future);
},
child: AsyncValueWidget<List<Person>>(
value: ref.watch(fetchPeopleProvider),
data: (people) => people.isEmpty
? Center(
child: Text(
context.loc.noItems,
style: Theme.of(context).textTheme.displaySmall,
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: people.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(people[index].name),
onTap: () {
context.goNamed(
SubRoutes.details.name,
pathParameters: {
Parameter.id.name: people[index].id.toString(),
},
extra: people[index],
);
},
trailing: IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit '.hardcoded + people[index].name,
onPressed: () {
ref
.read(
editPersonControllerProvider.notifier,
)
.editPerson(people[index]);
},
),
);
},
),
),
),
secondPanel: const EditPersonForm(),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(editPersonControllerProvider.notifier).newPerson();
},
tooltip: 'click to add a new person'.hardcoded,
child: const Icon(Icons.add),
),
);
}
}

View file

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../features/counter/presentation/counter_screen.dart';
import '../features/rest_crud_demo/domain/person.dart';
import '../features/rest_crud_demo/presentation/details_screen.dart';
import '../features/rest_crud_demo/presentation/people_screen.dart';
import 'scaffold_with_navigation.dart';
part 'app_router.g.dart';
// general ideas on navigation see https://m2.material.io/design/navigation/understanding-navigation.html#forward-navigation
// shell routes, appear in the bottom navigation
// see https://pub.dev/documentation/go_router/latest/go_router/ShellRoute-class.html
enum TopLevelDestinations { people, counter }
// GlobalKey is a factory, hence each call creates a key
//this is root, even if it navigates to people, it needs a separate key!!!
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _peopleNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: TopLevelDestinations.people.name);
final _counterNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: TopLevelDestinations.counter.name);
// other destinations, reachable from a top level destination
enum SubRoutes { details }
enum Parameter { id }
//https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart
@Riverpod(keepAlive: true)
GoRouter goRouter(GoRouterRef ref) {
return GoRouter(
initialLocation: '/${TopLevelDestinations.people.name}',
navigatorKey: _rootNavigatorKey,
debugLogDiagnostics: true,
routes: [
// Stateful navigation based on:
// https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return ScaffoldWithNavigation(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
navigatorKey: _peopleNavigatorKey,
routes: [
// base route people
GoRoute(
path: '/${TopLevelDestinations.people.name}', // path: /people
name: TopLevelDestinations.people.name,
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const PeopleScreen(),
),
routes: <RouteBase>[
// The details screen to display stacked on navigator of the
// first tab. This will cover screen A but not the application
// shell (bottom navigation bar).
GoRoute(
path: '${SubRoutes.details.name}/:${Parameter.id.name}',
name: SubRoutes.details.name,
builder: (BuildContext context, GoRouterState state) {
// alternatively use https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
final id =
int.parse(state.pathParameters[Parameter.id.name]!);
final person = _extractPersonFromExtra(state.extra);
return DetailsScreen(id: id, person: person);
},
),
],
),
],
),
StatefulShellBranch(
navigatorKey: _counterNavigatorKey,
routes: [
GoRoute(
path: '/${TopLevelDestinations.counter.name}',
name: TopLevelDestinations.counter.name,
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const CounterScreen(),
),
),
],
),
],
),
],
);
}
Person? _extractPersonFromExtra(Object? extra) {
return extra == null
? null
: extra is Person
? extra
: extra is Map // if you come back from bottom navigation, e.g. look
// at details of a person, go to counter via bottom navigation,
// use bottom navigation to go to people/home
? Person.fromJson(
extra as Map<String, Object?>,
)
: null;
}

View file

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_router.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$goRouterHash() => r'caf38f1ae54f10aea6450bd6fc62dae849543827';
/// See also [goRouter].
@ProviderFor(goRouter)
final goRouterProvider = Provider<GoRouter>.internal(
goRouter,
name: r'goRouterProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$goRouterHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef GoRouterRef = ProviderRef<GoRouter>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View file

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../constants/breakpoint.dart';
import '../utils/localization.dart';
// see https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart
// and https://github.com/bizz84/tmdb_movie_app_riverpod/blob/main/lib/src/routing/scaffold_with_nested_navigation.dart
class NavigationItem {
NavigationItem({required this.icon, required this.selectedIcon});
final IconData icon;
final IconData selectedIcon;
}
final _navigationList = (
people: NavigationItem(icon: Icons.home_outlined, selectedIcon: Icons.home),
counter: NavigationItem(
icon: Icons.plus_one_outlined,
selectedIcon: Icons.plus_one,
),
);
class ScaffoldWithNavigation extends StatelessWidget {
const ScaffoldWithNavigation({
required this.navigationShell,
Key? key,
}) : super(key: key ?? const ValueKey('ScaffoldWithNavigation'));
final StatefulNavigationShell navigationShell;
void _goBranch(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
if (size.width < Breakpoint.tablet) {
return ScaffoldWithNavigationBar(
body: navigationShell,
currentIndex: navigationShell.currentIndex,
onDestinationSelected: _goBranch,
);
} else {
return ScaffoldWithNavigationRail(
body: navigationShell,
currentIndex: navigationShell.currentIndex,
onDestinationSelected: _goBranch,
);
}
}
}
class ScaffoldWithNavigationBar extends StatelessWidget {
const ScaffoldWithNavigationBar({
required this.body,
required this.currentIndex,
required this.onDestinationSelected,
super.key,
});
final Widget body;
final int currentIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
destinations: [
NavigationDestination(
icon: Icon(_navigationList.people.icon),
selectedIcon: Icon(_navigationList.people.selectedIcon),
label: context.loc.home,
),
NavigationDestination(
icon: Icon(_navigationList.counter.icon),
selectedIcon: Icon(_navigationList.counter.selectedIcon),
label: context.loc.counter,
),
],
onDestinationSelected: onDestinationSelected,
),
);
}
}
class ScaffoldWithNavigationRail extends StatelessWidget {
const ScaffoldWithNavigationRail({
required this.body,
required this.currentIndex,
required this.onDestinationSelected,
super.key,
});
final Widget body;
final int currentIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
labelType: NavigationRailLabelType.all,
destinations: <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(_navigationList.people.icon),
selectedIcon: Icon(_navigationList.people.selectedIcon),
label: Text(context.loc.home),
),
NavigationRailDestination(
icon: Icon(_navigationList.counter.icon),
selectedIcon: Icon(_navigationList.counter.selectedIcon),
label: Text(context.loc.counter),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
// This is the main content.
Expanded(
child: body,
),
],
),
);
}
}

View file

@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'dio_provider.g.dart';
@riverpod
Dio dio(DioRef ref) {
final dio = Dio();
dio.interceptors.add(
PrettyDioLogger(
requestHeader: true,
responseBody: false,
responseHeader: true,
),
);
return dio;
}

View file

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$dioHash() => r'50bc684111bbcb930ccaac200da3a7ad761e689b';
/// See also [dio].
@ProviderFor(dio)
final dioProvider = AutoDisposeProvider<Dio>.internal(
dio,
name: r'dioProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$dioHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef DioRef = AutoDisposeProviderRef<Dio>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View file

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/// A simple placeholder that can be used to search all the hardcoded strings
/// in the code (useful to identify strings that need to be localized).
/// thanks to https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/src/localization/string_hardcoded.dart
extension StringHardcoded on String {
String get hardcoded => this;
}
//thanks to https://codewithandrea.com/articles/flutter-localization-build-context-extension/
extension LocalizedBuildContext on BuildContext {
AppLocalizations get loc => AppLocalizations.of(this);
}

View file

@ -0,0 +1,13 @@
import 'package:logger/logger.dart';
const Level loggerLevel = Level.trace;
/// Logger for the app.
Logger logger = Logger(
printer: PrettyPrinter(
methodCount: 1,
errorMethodCount: 5,
lineLength: 90,
),
level: loggerLevel,
);