Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in
Toggle navigation
F
Front-End
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
abdullh.alsoleman
Front-End
Commits
acf102be
Commit
acf102be
authored
Apr 28, 2017
by
Hans Muller
Committed by
GitHub
Apr 28, 2017
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
AnimatedList (#9649)
parent
67169043
Changes
3
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
550 additions
and
0 deletions
+550
-0
animated_list.dart
packages/flutter/lib/src/widgets/animated_list.dart
+373
-0
widgets.dart
packages/flutter/lib/widgets.dart
+1
-0
animated_list_test.dart
packages/flutter/test/widgets/animated_list_test.dart
+176
-0
No files found.
packages/flutter/lib/src/widgets/animated_list.dart
0 → 100644
View file @
acf102be
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import
'package:collection/collection.dart'
show
binarySearch
;
import
'package:flutter/animation.dart'
;
import
'package:flutter/foundation.dart'
;
import
'basic.dart'
;
import
'framework.dart'
;
import
'scroll_controller.dart'
;
import
'scroll_physics.dart'
;
import
'scroll_view.dart'
;
import
'ticker_provider.dart'
;
/// Signature for the builder callback used by [AnimatedList].
typedef
Widget
AnimatedListItemBuilder
(
BuildContext
context
,
int
index
,
Animation
<
double
>
animation
);
/// Signature for the builder callback used by [AnimatedList.remove].
typedef
Widget
AnimatedListRemovedItemBuilder
(
BuildContext
context
,
Animation
<
double
>
animation
);
// The default insert/remove animation duration.
const
Duration
_kDuration
=
const
Duration
(
milliseconds:
300
);
// Incoming and outgoing AnimatedList items.
class
_ActiveItem
implements
Comparable
<
_ActiveItem
>
{
_ActiveItem
.
incoming
(
this
.
controller
,
this
.
itemIndex
)
:
removedItemBuilder
=
null
;
_ActiveItem
.
outgoing
(
this
.
controller
,
this
.
itemIndex
,
this
.
removedItemBuilder
);
_ActiveItem
.
index
(
this
.
itemIndex
)
:
controller
=
null
,
removedItemBuilder
=
null
;
final
AnimationController
controller
;
final
AnimatedListRemovedItemBuilder
removedItemBuilder
;
int
itemIndex
;
@override
int
compareTo
(
_ActiveItem
other
)
=>
itemIndex
-
other
.
itemIndex
;
}
/// A scrolling container that animates items when they are inserted or removed.
///
/// This widget's [AnimatedListState] can be used to dynmically insert or remove
/// items. To refer to the [AnimatedListState] either provide a [GlobalKey] or
/// use the static [of] method from an item's input callback.
///
/// This widget is similar to one created by [ListView.builder].
class
AnimatedList
extends
StatefulWidget
{
/// Creates a scrolling container that animates items when they are inserted or removed.
AnimatedList
({
Key
key
,
@required
this
.
itemBuilder
,
this
.
initialItemCount
:
0
,
this
.
scrollDirection
:
Axis
.
vertical
,
this
.
reverse
:
false
,
this
.
controller
,
this
.
primary
,
this
.
physics
,
this
.
shrinkWrap
:
false
,
this
.
padding
,
})
:
super
(
key:
key
)
{
assert
(
itemBuilder
!=
null
);
assert
(
initialItemCount
!=
null
&&
initialItemCount
>=
0
);
}
/// Called, as needed, to build list item widgets.
///
/// List items are only built when they're scrolled into view.
///
/// The [AnimatedListItemBuilder] index parameter indicates the item's
/// posiition in the list. The value of the index parameter will be between 0 and
/// [initialItemCount] plus the total number of items that have been inserted
/// with [AnimatedListState.insertItem] and less the total number of items
/// that have been removed with [AnimatedList.removeItem].
///
/// Implementations of this callback should assume that [AnimatedList.removeItem]
/// removes an item immediately.
final
AnimatedListItemBuilder
itemBuilder
;
/// The number of items the list will start with.
///
/// The appareance of the initial items is not animated. They are
/// are created, as needed, by [itemBuilder] with an animation paramter
/// of [kAlwaysCompleteAnimation].
final
int
initialItemCount
;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final
Axis
scrollDirection
;
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if the reading direction is left-to-right and
/// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from
/// left to right when [reverse] is false and from right to left when
/// [reverse] is true.
///
/// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
final
bool
reverse
;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
final
ScrollController
controller
;
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// On iOS, this identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
///
/// Defaults to true when [scrollDirection] is [Axis.vertical] and
/// [controller] is null.
final
bool
primary
;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final
ScrollPhysics
physics
;
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// If the scroll view does not shrink wrap, then the scroll view will expand
/// to the maximum allowed size in the [scrollDirection]. If the scroll view
/// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must
/// be true.
///
/// Shrink wrapping the content of the scroll view is significantly more
/// expensive than expanding to the maximum allowed size because the content
/// can expand and contract during scrolling, which means the size of the
/// scroll view needs to be recomputed whenever the scroll position changes.
///
/// Defaults to false.
final
bool
shrinkWrap
;
/// The amount of space by which to inset the children.
final
EdgeInsets
padding
;
/// The state from the closest instance of this class that encloses the given context.
///
/// This method is typically used by [AnimatedList] item widgets that insert or
/// remove items in response to user input.
///
/// ```dart
/// AnimatedListState animatedList = AnimatedList.of(context);
/// ```
static
AnimatedListState
of
(
BuildContext
context
,
{
bool
nullOk:
false
})
{
assert
(
nullOk
!=
null
);
assert
(
context
!=
null
);
final
AnimatedListState
result
=
context
.
ancestorStateOfType
(
const
TypeMatcher
<
AnimatedListState
>());
if
(
nullOk
||
result
!=
null
)
return
result
;
throw
new
FlutterError
(
'AnimatedList.of() called with a context that does not contain a AnimatedList.
\n
'
'No AnimatedList ancestor could be found starting from the context that was passed to AnimatedList.of(). '
'This can happen when the context provided is from the same StatefulWidget that '
'built the AnimatedList. Please see the AnimatedList documentation for examples '
'of how to refer to an AnimatedListState object: '
' https://docs.flutter.io/flutter/widgets/AnimatedState-class.html
\n
'
'The context used was:
\n
'
'
$context
'
);
}
@override
AnimatedListState
createState
()
=>
new
AnimatedListState
();
}
/// The state for a scrolling container that animates items when they are
/// inserted or removed.
///
/// When an item is inserted with [insertItem] an animation begins running.
/// The animation is passed to [itemBuilder] whenever the item's widget
/// is needed.
///
/// When an item is removed with [removeItem] its animation is reversed.
/// The removed item's animation is passed to the [removeItem] builder
/// parameter.
///
/// An app that needs to insert or remove items in response to an event
/// can refer to the [AnimatedList]'s state with a global key:
///
/// ```dart
/// GlobalKey<AnimatedListState> listKey = new GlobalKey<AnimatedListState>();
/// ...
/// new AnimatedList(key: listKey, ...);
/// ...
/// listKey.currentState.insert(123);
/// ```
///
/// AnimatedList item input handlers can also refer to their [AnimatedListState]
/// with the static [of] method.
class
AnimatedListState
extends
State
<
AnimatedList
>
with
TickerProviderStateMixin
{
final
List
<
_ActiveItem
>
_incomingItems
=
<
_ActiveItem
>[];
final
List
<
_ActiveItem
>
_outgoingItems
=
<
_ActiveItem
>[];
int
_itemsCount
=
0
;
@override
void
initState
()
{
super
.
initState
();
_itemsCount
=
widget
.
initialItemCount
;
}
@override
void
dispose
()
{
for
(
_ActiveItem
item
in
_incomingItems
)
item
.
controller
.
dispose
();
for
(
_ActiveItem
item
in
_outgoingItems
)
item
.
controller
.
dispose
();
super
.
dispose
();
}
_ActiveItem
_removeActiveItemAt
(
List
<
_ActiveItem
>
items
,
int
itemIndex
)
{
final
int
i
=
binarySearch
(
items
,
new
_ActiveItem
.
index
(
itemIndex
));
return
i
==
-
1
?
null
:
items
.
removeAt
(
i
);
}
_ActiveItem
_activeItemAt
(
List
<
_ActiveItem
>
items
,
int
itemIndex
)
{
final
int
i
=
binarySearch
(
items
,
new
_ActiveItem
.
index
(
itemIndex
));
return
i
==
-
1
?
null
:
items
[
i
];
}
// The insertItem() and removeItem() index parameters are defined as if the
// removeItem() operation removed the corresponding list entry immediately.
// The entry is only actually removed from the ListView when the remove animation
// finishes. The entry is added to _outgoingItems when removeItem is called
// and removed from _outgoingItems when the remove animation finishes.
int
_indexToItemIndex
(
int
index
)
{
int
itemIndex
=
index
;
for
(
_ActiveItem
item
in
_outgoingItems
)
{
if
(
item
.
itemIndex
<=
itemIndex
)
itemIndex
+=
1
;
else
break
;
}
return
itemIndex
;
}
int
_itemIndexToIndex
(
int
itemIndex
)
{
int
index
=
itemIndex
;
for
(
_ActiveItem
item
in
_outgoingItems
)
{
assert
(
item
.
itemIndex
!=
itemIndex
);
if
(
item
.
itemIndex
<
itemIndex
)
index
-=
1
;
else
break
;
}
return
index
;
}
/// Insert an item at [index] and start an animation that will be passed
/// to [itemBuilder] when the item is visible.
///
/// This method's semantics are the same as Dart's [List.insert] method:
/// it increases the length of the list by one and shifts all items at or
/// after [index] towards the end of the list.
void
insertItem
(
int
index
,
{
Duration
duration:
_kDuration
})
{
assert
(
index
!=
null
&&
index
>=
0
);
assert
(
duration
!=
null
);
final
int
itemIndex
=
_indexToItemIndex
(
index
);
assert
(
itemIndex
>=
0
&&
itemIndex
<=
_itemsCount
);
// Increment the incoming and outgoing item indices to account
// for the insertion.
for
(
_ActiveItem
item
in
_incomingItems
)
{
if
(
item
.
itemIndex
>=
itemIndex
)
item
.
itemIndex
+=
1
;
}
for
(
_ActiveItem
item
in
_outgoingItems
)
{
if
(
item
.
itemIndex
>=
itemIndex
)
item
.
itemIndex
+=
1
;
}
final
AnimationController
controller
=
new
AnimationController
(
duration:
duration
,
vsync:
this
);
final
_ActiveItem
incomingItem
=
new
_ActiveItem
.
incoming
(
controller
,
itemIndex
);
setState
(()
{
_incomingItems
..
add
(
incomingItem
)
..
sort
();
_itemsCount
+=
1
;
});
controller
.
forward
().
then
((
Null
value
)
{
_removeActiveItemAt
(
_incomingItems
,
incomingItem
.
itemIndex
).
controller
.
dispose
();
});
}
/// Remove the item at [index] and start an animation that will be passed
/// to [builder] when the item is visible.
///
/// Items are removed immediately. After an item has been removed, its index
/// will no longer be passed to the [itemBuilder]. However the item will still
/// appear in the list for [duration] and during that time [builder] must
/// construct its widget as needed.
///
/// This method's semantics are the same as Dart's [List.remove] method:
/// it decreases the length of the list by one and shifts all items at or
/// before [index] towards the beginning of the list.
void
removeItem
(
int
index
,
AnimatedListRemovedItemBuilder
builder
,
{
Duration
duration:
_kDuration
})
{
assert
(
index
!=
null
&&
index
>=
0
);
assert
(
builder
!=
null
);
assert
(
duration
!=
null
);
final
int
itemIndex
=
_indexToItemIndex
(
index
);
assert
(
itemIndex
>=
0
&&
itemIndex
<
_itemsCount
);
assert
(
_activeItemAt
(
_outgoingItems
,
itemIndex
)
==
null
);
final
_ActiveItem
incomingItem
=
_removeActiveItemAt
(
_incomingItems
,
itemIndex
);
final
AnimationController
controller
=
incomingItem
?.
controller
??
new
AnimationController
(
duration:
duration
,
value:
1.0
,
vsync:
this
);
final
_ActiveItem
outgoingItem
=
new
_ActiveItem
.
outgoing
(
controller
,
itemIndex
,
builder
);
setState
(()
{
_outgoingItems
..
add
(
outgoingItem
)
..
sort
();
});
controller
.
reverse
().
then
((
Null
value
)
{
_removeActiveItemAt
(
_outgoingItems
,
outgoingItem
.
itemIndex
).
controller
.
dispose
();
// Decrement the incoming and outgoing item indices to account
// for the removal.
for
(
_ActiveItem
item
in
_incomingItems
)
{
if
(
item
.
itemIndex
>
outgoingItem
.
itemIndex
)
item
.
itemIndex
-=
1
;
}
for
(
_ActiveItem
item
in
_outgoingItems
)
{
if
(
item
.
itemIndex
>
outgoingItem
.
itemIndex
)
item
.
itemIndex
-=
1
;
}
setState
(()
{
_itemsCount
-=
1
;
});
});
}
Widget
_itemBuilder
(
BuildContext
context
,
int
itemIndex
)
{
final
_ActiveItem
outgoingItem
=
_activeItemAt
(
_outgoingItems
,
itemIndex
);
if
(
outgoingItem
!=
null
)
return
outgoingItem
.
removedItemBuilder
(
context
,
outgoingItem
.
controller
.
view
);
final
_ActiveItem
incomingItem
=
_activeItemAt
(
_incomingItems
,
itemIndex
);
final
Animation
<
double
>
animation
=
incomingItem
?.
controller
?.
view
??
kAlwaysCompleteAnimation
;
return
widget
.
itemBuilder
(
context
,
_itemIndexToIndex
(
itemIndex
),
animation
);
}
@override
Widget
build
(
BuildContext
context
)
{
return
new
ListView
.
builder
(
itemBuilder:
_itemBuilder
,
itemCount:
_itemsCount
,
scrollDirection:
widget
.
scrollDirection
,
reverse:
widget
.
reverse
,
controller:
widget
.
controller
,
primary:
widget
.
primary
,
physics:
widget
.
physics
,
shrinkWrap:
widget
.
shrinkWrap
,
padding:
widget
.
padding
,
);
}
}
packages/flutter/lib/widgets.dart
View file @
acf102be
...
...
@@ -10,6 +10,7 @@ library widgets;
export
'package:vector_math/vector_math_64.dart'
show
Matrix4
;
export
'src/widgets/animated_cross_fade.dart'
;
export
'src/widgets/animated_list.dart'
;
export
'src/widgets/animated_size.dart'
;
export
'src/widgets/app.dart'
;
export
'src/widgets/async.dart'
;
...
...
packages/flutter/test/widgets/animated_list_test.dart
0 → 100644
View file @
acf102be
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import
'package:flutter_test/flutter_test.dart'
;
import
'package:flutter/widgets.dart'
;
void
main
(
)
{
testWidgets
(
'AnimatedList initialItemCount'
,
(
WidgetTester
tester
)
async
{
final
Map
<
int
,
Animation
<
double
>>
animations
=
<
int
,
Animation
<
double
>>{};
await
tester
.
pumpWidget
(
new
AnimatedList
(
initialItemCount:
2
,
itemBuilder:
(
BuildContext
context
,
int
index
,
Animation
<
double
>
animation
)
{
animations
[
index
]
=
animation
;
return
new
SizedBox
(
height:
100.0
,
child:
new
Center
(
child:
new
Text
(
'item
$index
'
),
),
);
},
),
);
expect
(
find
.
text
(
'item 0'
),
findsOneWidget
);
expect
(
find
.
text
(
'item 1'
),
findsOneWidget
);
expect
(
animations
.
containsKey
(
0
),
true
);
expect
(
animations
.
containsKey
(
1
),
true
);
expect
(
animations
[
0
].
value
,
1.0
);
expect
(
animations
[
1
].
value
,
1.0
);
});
testWidgets
(
'AnimatedList insert'
,
(
WidgetTester
tester
)
async
{
final
GlobalKey
<
AnimatedListState
>
listKey
=
new
GlobalKey
<
AnimatedListState
>();
await
tester
.
pumpWidget
(
new
AnimatedList
(
key:
listKey
,
itemBuilder:
(
BuildContext
context
,
int
index
,
Animation
<
double
>
animation
)
{
return
new
SizeTransition
(
key:
new
ValueKey
<
int
>(
index
),
axis:
Axis
.
vertical
,
sizeFactor:
animation
,
child:
new
SizedBox
(
height:
100.0
,
child:
new
Center
(
child:
new
Text
(
'item
$index
'
),
),
),
);
},
),
);
double
itemHeight
(
int
index
)
=>
tester
.
getSize
(
find
.
byKey
(
new
ValueKey
<
int
>(
index
))).
height
;
double
itemTop
(
int
index
)
=>
tester
.
getTopLeft
(
find
.
byKey
(
new
ValueKey
<
int
>(
index
))).
dy
;
double
itemBottom
(
int
index
)
=>
tester
.
getBottomLeft
(
find
.
byKey
(
new
ValueKey
<
int
>(
index
))).
dy
;
listKey
.
currentState
.
insertItem
(
0
,
duration:
const
Duration
(
milliseconds:
100
));
await
tester
.
pump
();
// Newly inserted item 0's height should animate from 0 to 100
expect
(
itemHeight
(
0
),
0.0
);
await
tester
.
pump
(
const
Duration
(
milliseconds:
50
));
expect
(
itemHeight
(
0
),
50.0
);
await
tester
.
pump
(
const
Duration
(
milliseconds:
50
));
expect
(
itemHeight
(
0
),
100.0
);
// The list now contains one fully expanded item at the top:
expect
(
find
.
text
(
'item 0'
),
findsOneWidget
);
expect
(
itemTop
(
0
),
0.0
);
expect
(
itemBottom
(
0
),
100.0
);
listKey
.
currentState
.
insertItem
(
0
,
duration:
const
Duration
(
milliseconds:
100
));
listKey
.
currentState
.
insertItem
(
0
,
duration:
const
Duration
(
milliseconds:
100
));
await
tester
.
pump
();
// The height of the newly inserted items at index 0 and 1 should animate from 0 to 100.
// The height of the original item, now at index 2, should remain 100.
expect
(
itemHeight
(
0
),
0.0
);
expect
(
itemHeight
(
1
),
0.0
);
expect
(
itemHeight
(
2
),
100.0
);
await
tester
.
pump
(
const
Duration
(
milliseconds:
50
));
expect
(
itemHeight
(
0
),
50.0
);
expect
(
itemHeight
(
1
),
50.0
);
expect
(
itemHeight
(
2
),
100.0
);
await
tester
.
pump
(
const
Duration
(
milliseconds:
50
));
expect
(
itemHeight
(
0
),
100.0
);
expect
(
itemHeight
(
1
),
100.0
);
expect
(
itemHeight
(
2
),
100.0
);
// The newly inserted "item 1" and "item 2" appear above "item 0"
expect
(
find
.
text
(
'item 0'
),
findsOneWidget
);
expect
(
find
.
text
(
'item 1'
),
findsOneWidget
);
expect
(
find
.
text
(
'item 2'
),
findsOneWidget
);
expect
(
itemTop
(
0
),
0.0
);
expect
(
itemBottom
(
0
),
100.0
);
expect
(
itemTop
(
1
),
100.0
);
expect
(
itemBottom
(
1
),
200.0
);
expect
(
itemTop
(
2
),
200.0
);
expect
(
itemBottom
(
2
),
300.0
);
});
testWidgets
(
'AnimatedList remove'
,
(
WidgetTester
tester
)
async
{
final
GlobalKey
<
AnimatedListState
>
listKey
=
new
GlobalKey
<
AnimatedListState
>();
final
List
<
int
>
items
=
<
int
>[
0
,
1
,
2
];
Widget
buildItem
(
BuildContext
context
,
int
item
,
Animation
<
double
>
animation
)
{
return
new
SizeTransition
(
key:
new
ValueKey
<
int
>(
item
),
axis:
Axis
.
vertical
,
sizeFactor:
animation
,
child:
new
SizedBox
(
height:
100.0
,
child:
new
Center
(
child:
new
Text
(
'item
$item
'
),
),
),
);
}
await
tester
.
pumpWidget
(
new
AnimatedList
(
key:
listKey
,
initialItemCount:
3
,
itemBuilder:
(
BuildContext
context
,
int
index
,
Animation
<
double
>
animation
)
{
return
buildItem
(
context
,
items
[
index
],
animation
);
},
),
);
double
itemTop
(
int
index
)
=>
tester
.
getTopLeft
(
find
.
byKey
(
new
ValueKey
<
int
>(
index
))).
dy
;
double
itemBottom
(
int
index
)
=>
tester
.
getBottomLeft
(
find
.
byKey
(
new
ValueKey
<
int
>(
index
))).
dy
;
expect
(
find
.
text
(
'item 0'
),
findsOneWidget
);
expect
(
find
.
text
(
'item 1'
),
findsOneWidget
);
expect
(
find
.
text
(
'item 2'
),
findsOneWidget
);
items
.
removeAt
(
0
);
listKey
.
currentState
.
removeItem
(
0
,
(
BuildContext
context
,
Animation
<
double
>
animation
)
=>
buildItem
(
context
,
0
,
animation
),
duration:
const
Duration
(
milliseconds:
100
),
);
// Item's 0, 1, 2 at 0, 100, 200. All heights 100.
expect
(
itemTop
(
0
),
0.0
);
expect
(
itemBottom
(
0
),
100.0
);
expect
(
itemTop
(
1
),
100.0
);
expect
(
itemBottom
(
1
),
200.0
);
expect
(
itemTop
(
2
),
200.0
);
expect
(
itemBottom
(
2
),
300.0
);
// Newly removed item 0's height should animate from 100 to 0 over 100ms
// Item's 0, 1, 2 at 0, 50, 150. Item 0's height is 50.
await
tester
.
pump
();
await
tester
.
pump
(
const
Duration
(
milliseconds:
50
));
expect
(
itemTop
(
0
),
0.0
);
expect
(
itemBottom
(
0
),
50.0
);
expect
(
itemTop
(
1
),
50.0
);
expect
(
itemBottom
(
1
),
150.0
);
expect
(
itemTop
(
2
),
150.0
);
expect
(
itemBottom
(
2
),
250.0
);
// Item's 0, 1, 2 at 0, 0, 0. Item 0's height is 0.
await
tester
.
pumpAndSettle
();
expect
(
itemTop
(
0
),
0.0
);
expect
(
itemBottom
(
0
),
0.0
);
expect
(
itemTop
(
1
),
0.0
);
expect
(
itemBottom
(
1
),
100.0
);
expect
(
itemTop
(
2
),
100.0
);
expect
(
itemBottom
(
2
),
200.0
);
});
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment