From 6b634beb5ff902f5ab827f4c5b9c411becdf0291 Mon Sep 17 00:00:00 2001 From: Kevin Grandon Date: Tue, 25 Mar 2014 09:44:50 -0700 Subject: [PATCH] Bug 987753 - Land a vertical homescreen in test_apps r=vingtetun --- Makefile | 2 +- build/config/phone/apps-engineering.list | 1 + test_apps/home2/index.html | 22 ++ test_apps/home2/js/.jshintrc | 6 + test_apps/home2/js/app.js | 197 +++++++++++++++ test_apps/home2/js/divider.js | 58 +++++ test_apps/home2/js/dragdrop.js | 231 ++++++++++++++++++ test_apps/home2/js/icon.js | 134 ++++++++++ test_apps/home2/js/zoom.js | 126 ++++++++++ .../home2/locales/homescreen.en-US.properties | 0 test_apps/home2/locales/locales.ini | 1 + test_apps/home2/manifest.webapp | 22 ++ test_apps/home2/style/css/app.css | 60 +++++ test_apps/home2/style/icons/blank.png | Bin 0 -> 2120 bytes 14 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 test_apps/home2/index.html create mode 100644 test_apps/home2/js/.jshintrc create mode 100644 test_apps/home2/js/app.js create mode 100644 test_apps/home2/js/divider.js create mode 100644 test_apps/home2/js/dragdrop.js create mode 100644 test_apps/home2/js/icon.js create mode 100644 test_apps/home2/js/zoom.js create mode 100644 test_apps/home2/locales/homescreen.en-US.properties create mode 100644 test_apps/home2/locales/locales.ini create mode 100644 test_apps/home2/manifest.webapp create mode 100644 test_apps/home2/style/css/app.css create mode 100644 test_apps/home2/style/icons/blank.png diff --git a/Makefile b/Makefile index ab10ea85cd34..e929f9f66795 100644 --- a/Makefile +++ b/Makefile @@ -899,7 +899,7 @@ ifdef APP JSHINTED_PATH = apps/$(APP) GJSLINTED_PATH = $(shell grep "^apps/$(APP)" build/jshint/xfail.list | ( while read file ; do test -f "$$file" && echo $$file ; done ) ) else - JSHINTED_PATH = apps shared build/test/unit + JSHINTED_PATH = apps shared build/test/unit test_apps/home2 GJSLINTED_PATH = $(shell ( while read file ; do test -f "$$file" && echo $$file ; done ) < build/jshint/xfail.list ) endif endif diff --git a/build/config/phone/apps-engineering.list b/build/config/phone/apps-engineering.list index 26027c796494..60006999c9bf 100644 --- a/build/config/phone/apps-engineering.list +++ b/build/config/phone/apps-engineering.list @@ -6,6 +6,7 @@ test_apps/bookmarks-reader test_apps/ds-test test_apps/demo-keyboard test_apps/geoloc +test_apps/home2 test_apps/membuster test_apps/music2 test_apps/test-ime diff --git a/test_apps/home2/index.html b/test_apps/home2/index.html new file mode 100644 index 000000000000..1fe41641b9ae --- /dev/null +++ b/test_apps/home2/index.html @@ -0,0 +1,22 @@ + + + + + + FirefoxOS Homescreen + + + + + + + + + + + + + +
+ + diff --git a/test_apps/home2/js/.jshintrc b/test_apps/home2/js/.jshintrc new file mode 100644 index 000000000000..fd33e5ab7783 --- /dev/null +++ b/test_apps/home2/js/.jshintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../../.jshintrc", + "predef": [ + "app" + ] +} diff --git a/test_apps/home2/js/app.js b/test_apps/home2/js/app.js new file mode 100644 index 000000000000..ff7fd2423f92 --- /dev/null +++ b/test_apps/home2/js/app.js @@ -0,0 +1,197 @@ +'use strict'; +/* global Divider */ +/* global DragDrop */ +/* global Icon */ +/* global Zoom */ + +(function(exports) { + + // For now we inject a divider every few icons for testing. + var tempDivideEvery = 6; + var tempCurrent = 0; + + // Hidden manifest roles that we do not show + const HIDDEN_ROLES = ['system', 'keyboard', 'homescreen', 'search']; + + function App() { + this.zoom = new Zoom(); + this.dragdrop = new DragDrop(); + + this.container = document.getElementById('icons'); + this.iconLaunch = this.clickIcon.bind(this); + } + + App.prototype = { + + /** + * List of all application icons. + * Maps an icon identifier to an icon object. + */ + icons: {}, + + /** + * Lists of all displayed objects in the homescreen. + * Includes app icons, dividers, and bookmarks. + */ + items: [], + + /** + * Fetch all icons and render them. + */ + init: function() { + navigator.mozApps.mgmt.getAll().onsuccess = function(event) { + event.target.result.forEach(this.makeIcons.bind(this)); + this.render(); + this.start(); + }.bind(this); + }, + + start: function() { + this.container.addEventListener('click', this.iconLaunch); + }, + + stop: function() { + this.container.removeEventListener('click', this.iconLaunch); + }, + + /** + * Creates icons for an app based on hidden roles and entry points. + */ + makeIcons: function(app) { + if (HIDDEN_ROLES.indexOf(app.manifest.role) !== -1) { + return; + } + + function eachIcon(icon) { + /* jshint validthis:true */ + + // If there is no icon entry, do not push it onto items. + if (!icon.icon) { + return; + } + + // FIXME: Remove after we have real divider insertion/remembering. + tempCurrent++; + if (tempCurrent >= tempDivideEvery) { + this.items.push(new Divider()); + tempCurrent = 0; + } + + this.items.push(icon); + this.icons[icon.identifier] = icon; + } + + if (app.manifest.entry_points) { + for (var i in app.manifest.entry_points) { + eachIcon.call(this, new Icon(app, i)); + } + } else { + eachIcon.call(this, new Icon(app)); + } + }, + + /** + * Scrubs the list of items, removing empty sections. + */ + cleanItems: function() { + var appCount = 0; + var toRemove = []; + + this.items.forEach(function(item, idx) { + if (item instanceof Divider) { + if (appCount === 0) { + toRemove.push(idx); + } + appCount = 0; + } else { + appCount++; + } + }, this); + + toRemove.reverse(); + toRemove.forEach(function(idx) { + var removed = this.items.splice(idx, 1)[0]; + removed.remove(); + }, this); + + // There should always be a divider at the end, it's hidden in CSS when + // not in edit mode. + var lastItem = this.items[this.items.length - 1]; + if (!(lastItem instanceof Divider)) { + this.items.push(new Divider()); + } + }, + + /** + * Renders all icons. + * Positions app icons and dividers accoriding to available space + * on the grid. + */ + render: function() { + + app.cleanItems(); + + // Reset offset steps + this.zoom.offsetY = 0; + + // Grid render coordinates + var x = 0; + var y = 0; + + /** + * Steps the y-axis. + * @param {Object} item + */ + function step(item) { + app.zoom.stepYAxis(item.pixelHeight); + + x = 0; + y++; + } + + this.items.forEach(function(item, idx) { + + // If the item would go over the boundary before rendering, + // step the y-axis. + if (x > 0 && item.gridWidth > 1 && + x + item.gridWidth >= this.zoom.perRow) { + // Step the y-axis by the size of the last row. + // For now we just check the height of the last item. + var lastItem = this.items[idx - 1]; + step(lastItem); + } + + item.render({ + x: x, + y: y + }, idx); + + // Increment the x-step by the sizing of the item. + // If we go over the current boundary, reset it, and step the y-axis. + x += item.gridWidth; + if (x >= this.zoom.perRow) { + step(item); + } + }, this); + }, + + /** + * Launches an app. + */ + clickIcon: function(e) { + var container = e.target; + var identifier = container.dataset.identifier; + var icon = this.icons[identifier]; + + if (!icon) { + return; + } + + icon.launch(); + } + }; + + exports.app = new App(); + exports.app.init(); + +}(window)); diff --git a/test_apps/home2/js/divider.js b/test_apps/home2/js/divider.js new file mode 100644 index 000000000000..818bf885ef47 --- /dev/null +++ b/test_apps/home2/js/divider.js @@ -0,0 +1,58 @@ +'use strict'; + +(function() { + // Icon container + var container = document.getElementById('icons'); + + /** + * Represents a single divider on the homepage. + */ + function Divider() {} + + Divider.prototype = { + + x: 0, + y: 0, + + /** + * Height in pixels of each divider. + */ + pixelHeight: 70, + + /** + * Width in grid units for each divider. + */ + gridWidth: 4, + + scale: 1, + + /** + * Renders the icon to the container. + * @param {Object} coordinates Grid coordinates to render to. + * @param {Number} itemIndex The index of the items list of this item. + */ + render: function(coordinates, itemIndex) { + // Generate the content if we need to + if (!this.divider) { + var divider = document.createElement('div'); + divider.className = 'divider'; + this.divider = divider; + + container.appendChild(divider); + } + + var y = app.zoom.offsetY; + this.divider.style.transform = 'translate(0 ,' + y + 'px)'; + + this.itemIndex = itemIndex; + this.y = y; + }, + + remove: function() { + this.divider.parentNode.removeChild(this.divider); + } + }; + + window.Divider = Divider; + +}()); diff --git a/test_apps/home2/js/dragdrop.js b/test_apps/home2/js/dragdrop.js new file mode 100644 index 000000000000..55528e5fc8c0 --- /dev/null +++ b/test_apps/home2/js/dragdrop.js @@ -0,0 +1,231 @@ +'use strict'; + +(function(exports) { + + const activateDelay = 600; + + const activeScaleAdjust = 0.4; + + var container = document.getElementById('icons'); + + function DragDrop() { + container.addEventListener('touchstart', this); + container.addEventListener('touchmove', this); + container.addEventListener('touchend', this); + } + + DragDrop.prototype = { + + /** + * The current touchmove target. + * @type {DomElement} + */ + target: null, + + /** + * Begins the drag/drop interaction. + * Enlarges the icon. + * Sets additional data to make the touchmove handler faster. + */ + begin: function(e) { + if (!this.target || !this.icon) { + return; + } + + // Stop icon launching while we are in active state + app.stop(); + + this.active = true; + container.classList.add('edit-mode'); + this.target.classList.add('active'); + + // Testing with some extra offset (20) + this.xAdjust = app.zoom.gridItemHeight / 2 + 20; + this.yAdjust = app.zoom.gridItemWidth / 2 + 20; + + // Make the icon larger + this.icon.transform( + e.touches[0].pageX - this.xAdjust, + e.touches[0].pageY - this.yAdjust, + this.icon.scale + activeScaleAdjust); + }, + + /** + * Scrolls the page if needed. + * The page is scrolled via javascript if an icon is being moved, + * and is within a percentage of a page edge. + * @param {Object} e A touch object from a touchmove event. + */ + scrollIfNeeded: function() { + var scrollStep = 2; + + var touch = this.currentTouch; + if (!touch) { + this.isScrolling = false; + return; + } + + function doScroll(amount) { + /* jshint validthis:true */ + this.isScrolling = true; + document.documentElement.scrollTop += amount; + exports.requestAnimationFrame(this.scrollIfNeeded.bind(this)); + touch.pageY += amount; + this.positionIcon(touch.pageX, touch.pageY); + } + + var docScroll = document.documentElement.scrollTop; + if (touch.pageY - docScroll > window.innerHeight - 50) { + doScroll.call(this, scrollStep); + } else if (touch.pageY > 0 && touch.pageY - docScroll < 50) { + doScroll.call(this, 0 - scrollStep); + } else { + this.isScrolling = false; + } + }, + + /** + * Positions an icon on the grid. + * @param {Integer} pageX + * @param {Integer} posY + */ + positionIcon: function(pageX, pageY) { + this.icon.transform( + pageX - this.xAdjust, + pageY - this.yAdjust, + this.icon.scale + activeScaleAdjust); + + // Reposition in the icons array if necessary. + // Find the icon with the closest X/Y position of the move, + // and insert ours before it. + // Todo: this could be more efficient with a binary search. + var leastDistance; + var foundIndex; + for (var i = 0, iLen = app.items.length; i < iLen; i++) { + var item = app.items[i]; + var distance = Math.sqrt( + (pageX - item.x) * (pageX - item.x) + + (pageY - item.y) * (pageY - item.y)); + if (!leastDistance || distance < leastDistance) { + leastDistance = distance; + foundIndex = i; + } + } + + // Insert at the found position + var myIndex = this.icon.itemIndex; + if (foundIndex !== myIndex) { + this.icon.noRender = true; + app.items.splice(foundIndex, 0, app.items.splice(myIndex, 1)[0]); + app.render(); + } + }, + + /** + * General event handler. + */ + handleEvent: function(e) { + var touch; + + switch(e.type) { + case 'touchstart': + // If we get a second touch, cancel everything. + if (e.touches.length > 1) { + clearTimeout(this.timeout); + return; + } + + touch = e.touches[0]; + this.startTouch = { + pageX: touch.pageX, + pageY: touch.pageY + }; + + this.target = touch.target; + + var identifier = this.target.dataset.identifier; + this.icon = app.icons[identifier]; + + if (!this.icon) { + return; + } + + this.timeout = setTimeout(this.begin.bind(this, e), + activateDelay); + + break; + case 'touchmove': + if (!this.startTouch) { + return; + } + + // If we have an activate timeout, and our finger has moved past + // some threshold, cancel it. + touch = e.touches[0]; + var distance = Math.sqrt( + (touch.pageX - this.startTouch.pageX) * + (touch.pageX - this.startTouch.pageX) + + (touch.pageY - this.startTouch.pageY) * + (touch.pageY - this.startTouch.pageY)); + + if (!this.active && this.timeout && distance > 20) { + clearTimeout(this.timeout); + return; + } + + if (!this.active || !this.icon) { + return; + } + + e.stopImmediatePropagation(); + e.preventDefault(); + + this.currentTouch = { + pageX: touch.pageX, + pageY: touch.pageY + }; + + this.positionIcon(touch.pageX, touch.pageY); + + if (!this.isScrolling) { + this.scrollIfNeeded(); + } + + break; + case 'touchend': + clearTimeout(this.timeout); + + if (!this.active) { + return; + } + + // Ensure the app is not launched + e.stopImmediatePropagation(); + e.preventDefault(); + + this.currentTouch = null; + this.active = false; + container.classList.remove('edit-mode'); + + delete this.icon.noRender; + this.icon = null; + + if (this.target) { + this.target.classList.remove('active'); + } + app.render(); + + this.target = null; + + setTimeout(function nextTick() { + app.start(); + }); + + break; + } + } + }; + + exports.DragDrop = DragDrop; + +}(window)); diff --git a/test_apps/home2/js/icon.js b/test_apps/home2/js/icon.js new file mode 100644 index 000000000000..78197c0bd7d2 --- /dev/null +++ b/test_apps/home2/js/icon.js @@ -0,0 +1,134 @@ +'use strict'; + +(function(exports) { + // Icon container + var container = document.getElementById('icons'); + + /** + * Represents a single app icon on the homepage. + */ + function Icon(app, entryPoint) { + this.app = app; + this.entryPoint = entryPoint; + } + + Icon.prototype = { + + /** + * Returns the height in pixels of each icon. + */ + get pixelHeight() { + return app.zoom.gridItemHeight; + }, + + /** + * Width in grid units for each icon. + */ + gridWidth: 1, + + get name() { + var name = this.descriptor.name; + var userLang = document.documentElement.lang; + + if (name[userLang]) { + return name[userLang]; + } + return name; + }, + + get icon() { + if (!this.descriptor.icons) { + return ''; + } + + var lastIcon = 0; + for (var i in this.descriptor.icons) { + if (i > lastIcon) { + lastIcon = i; + } + } + return this.descriptor.icons[lastIcon]; + }, + + get descriptor() { + if (this.entryPoint) { + return this.app.manifest.entry_points[this.entryPoint]; + } + return this.app.manifest; + }, + + get identifier() { + var identifier = [this.app.origin]; + + if (this.entryPoint) { + identifier.push(this.entryPoint); + } else { + identifier.push(0); + } + + return identifier.join('-'); + }, + + /** + * Renders the icon to the container. + * @param {Object} coordinates Grid coordinates to render to. + * @param {Number} itemIndex The index of the items list of this item. + */ + render: function(coordinates, itemIndex) { + var x = coordinates.x * app.zoom.gridItemWidth; + var y = app.zoom.offsetY; + + // Generate the tile if we need to + if (!this.tile) { + var tile = document.createElement('div'); + tile.className = 'icon'; + tile.dataset.identifier = this.identifier; + tile.style.backgroundImage = 'url(' + this.app.origin + this.icon + ')'; + + var nameEl = document.createElement('span'); + nameEl.className = 'title'; + nameEl.textContent = this.name; + tile.appendChild(nameEl); + + this.tile = tile; + + container.appendChild(tile); + } + + this.itemIndex = itemIndex; + this.x = x; + this.y = y; + this.scale = app.zoom.percent; + + // Avoid rendering the icon during a drag to prevent jumpiness + if (this.noRender) { + return; + } + + this.transform(x, y, app.zoom.percent); + }, + + /** + * Positions and scales an icon. + */ + transform: function(x, y, scale) { + scale = scale || 1; + this.tile.style.transform = + 'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')'; + }, + + /** + * Launches the application for this icon. + */ + launch: function() { + if (this.entryPoint) { + this.app.launch(this.entryPoint); + } else { + this.app.launch(); + } + } + }; + + exports.Icon = Icon; + +}(window)); diff --git a/test_apps/home2/js/zoom.js b/test_apps/home2/js/zoom.js new file mode 100644 index 000000000000..75b9bde517df --- /dev/null +++ b/test_apps/home2/js/zoom.js @@ -0,0 +1,126 @@ +'use strict'; + +(function(exports) { + + const maxIconsPerCol = 4; + + const maxIconsPerRow = 4; + + const minIconsPerRow = 3; + + const windowHeight = window.innerHeight; + + const windowWidth = window.innerWidth; + + function Zoom() { + this.touches = 0; + this.zoomStartTouches = []; + + window.addEventListener('touchstart', this); + window.addEventListener('touchmove', this); + } + + Zoom.prototype = { + + perRow: minIconsPerRow, + + minIconsPerRow: minIconsPerRow, + + maxIconsPerRow: maxIconsPerRow, + + _offsetY: 0, + + _percent: minIconsPerRow / minIconsPerRow, + + get percent() { + return this._percent; + }, + + set percent(value) { + + // Reset the y-offset because we will re-render everything anyway. + this._offsetY = 0; + + this._percent = value; + this.perRow = maxIconsPerRow + minIconsPerRow - maxIconsPerRow * value; + }, + + /** + * The height of each grid item. + * This number changes based on current zoom level. + */ + get gridItemHeight() { + return windowHeight / maxIconsPerCol * this.percent; + }, + + /** + * The width of each grid item. + * This number changes based on current zoom level. + */ + get gridItemWidth() { + return windowWidth / this.perRow; + }, + + /** + * Gets the current offset of the Y-axis for the current zoom level. + * This value is updated by calling zoom.stepYAxis. For example, each + * group of three icons, or a divider, should increment this value. + * The value is reset and recalculated when the zoom level changes. + */ + get offsetY() { + return this._offsetY; + }, + + set offsetY(value) { + this._offsetY = value; + }, + + /** + * After we render a row we need to store the current position of the y-axis + */ + stepYAxis: function(value) { + this._offsetY += value; + }, + + /** + * General Event Handler + */ + handleEvent: function(e) { + + if (e.type === 'touchend') { + console.log('touchend: ', e.touches.length); + } + + if (!e.touches || e.touches.length !== 2) { + return; + } + + // Sort touches by ascending pageX position. + var touches = [e.touches[0], e.touches[1]].sort(function(a, b) { + return a.pageX - b.pageX; + }); + + switch(e.type) { + case 'touchstart': + this.zoomStartTouches = touches; + break; + case 'touchmove': + if (this.perRow < maxIconsPerRow && + touches[1].pageX < this.zoomStartTouches[1].pageX) { + this.percent = 0.75; + app.render(); + } else if (this.perRow > minIconsPerRow && + touches[1].pageX > this.zoomStartTouches[1].pageX) { + this.percent = 1; + app.render(); + } + + break; + } + } + + }; + + exports.Zoom = Zoom; + +}(window)); diff --git a/test_apps/home2/locales/homescreen.en-US.properties b/test_apps/home2/locales/homescreen.en-US.properties new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test_apps/home2/locales/locales.ini b/test_apps/home2/locales/locales.ini new file mode 100644 index 000000000000..c7dd5f0afffa --- /dev/null +++ b/test_apps/home2/locales/locales.ini @@ -0,0 +1 @@ +@import url(homescreen.en-US.properties) diff --git a/test_apps/home2/manifest.webapp b/test_apps/home2/manifest.webapp new file mode 100644 index 000000000000..1e936798093f --- /dev/null +++ b/test_apps/home2/manifest.webapp @@ -0,0 +1,22 @@ +{ + "name": "homescreen2", + "description": "A FirefoxOS vertical homepage.", + "launch_path": "/index.html", + "type": "certified", + "role": "homescreen", + "developer": { + "name": "The Gaia Team", + "url": "https://github.com/mozilla-b2g/gaia" + }, + "permissions": { + "webapps-manage": {}, + "storage": {} + }, + "default_locale": "en-US", + "icons": { + "60": "/style/icons/blank.png" + }, + "orientation": "portrait-primary", + "activities": {}, + "messages": [] +} diff --git a/test_apps/home2/style/css/app.css b/test_apps/home2/style/css/app.css new file mode 100644 index 000000000000..758d1773fbfb --- /dev/null +++ b/test_apps/home2/style/css/app.css @@ -0,0 +1,60 @@ +html { + font-size: 10px; +} + +html, body, .icons { + height: 100%; + padding: 0; + margin: 0; +} + +.icons { + overflow-y: scroll; + overflow-x: hidden; + width: 100%; + height: 100%; + will-change: scroll-position; +} + +.icon { + position: absolute; + transform-origin: 0 0; + will-change: transform; + display: inline-block; + width: calc(100% / 3); + height: 9rem; + text-align: center; + background: no-repeat center; + background-size: 8rem; + transition: transform 500ms ease; +} + +.icon .title { + display: block; + margin-top: 9rem; + pointer-events: none; + color: #fff; + font-size: 1.2rem; + text-shadow: 0 0 0.5rem rgba(0,0,0,0.5); +} + +.icon.active { + transition: none; +} + +.divider { + position: absolute; + height: 0.4rem; + margin: 2.3rem 1rem; + background: rgba(255, 255, 255, 0.3); + width: calc(100% - 2rem); + transition: transform 500ms ease; +} + +.divider:last-child { + visibility: hidden; +} + +.edit-mode .divider:last-child { + visibility: visible; +} diff --git a/test_apps/home2/style/icons/blank.png b/test_apps/home2/style/icons/blank.png new file mode 100644 index 0000000000000000000000000000000000000000..953508a97ada901bb9dd2609cd967c4077dd09bb GIT binary patch literal 2120 zcmchZ`9IYA9>+i5SN)qEFJP;As!x^Hxzfdz|jL<*nPGy-ETABXV(c@QfW^dT=p% z!bbU8$lkQeKd;N=f~HT}O0G)36i-DKzeP zX}+HLC+p>>*nI7oAt@F9BaUb8!89(plx;yFffYz}x$D=gN=&MvLs>>mShkYHXB(d*M;#%>Vl1+-+T&`GF)%$jTK8oXP6#~X269C>?t@HeM8 z5{@fU(7pgbvii$F1Uf*%GGU9_Ym zgLr;igUH1J*R?Lbu~zbZpO|d@bJQmYRjMZ>I)Vr!$5XCe(FV8qR3GvN&hFCCZp!&# z`bZLDysg|s@G^h}sxT}sX^kk+e`3HY{qcK5IKRy+Pi{BMW*&uWx7DIYYtWpvg(hR~ zjsd)*gCmt(vQBoGmb-80fk8t|nM(2~CTtn$OC#BE$JEK5-s*8dLIex`M)s_b3-Yx= z>NBmZQP{6u-Ezlq?m^_-x^-H5aC=!O%UYFD4UXM`q8Q6< zn0Tc7JL2^eM&HhqQcEm`P-~);*BI*6fai(xXNLNs%dum zaBJ2}H4YgfOi5BX4vXy6Q__V@qAqwOX=Mw!g`x*{kg=&kr=j?T$NA!cOa_Y-KKJ4J z&C3X@vNPGYh?R%MLa{;*BX*j`!|)UeT;xruI)PFPUdsg*RTZj-5c$L^E&8`mWp3%>5#l z8fDkKpE-Cf$-&|2B<9-9@!B)#ZezUB3S_{F!2S*rm(IRkTv}95pcv%^ZrV?-_$U9K zBIjpW>~T|)$I^-i?wh5r4En~oe{UQ3HY;gvGPr5nVm|qWaR3rJ%CA^u&CKaeP>TnZ zTAe#=VHfkATyaeSv^{7L^8F+)8e*w(NkAF#KcHBV5X8U%h5tW(6R1!o{J**9_}%Y= z=0K&-kAV{_m9qV&q`+X)tj>H$!+7KPs@T$FKugDyJc(^c8RQuDiHXPh`+J`&QM#=L zckV>~%d>9*k?#A;Wz!a&vrM<;pbEjz!Cvtj!`XoChYvB`Y@IFEUiEfG1LnlkxrwfE zk2l)%#XoR~CkM#ys;#-Z41&?1TW>otQtE+D>B9-Gu!ddD{UdtwkL;*ikMI8}=KY1) zKK0Y&jlk)-dc__DF<9=M$L6TaPi>3T$mz%1wQI_zSmrmJ?-t?5?e+Htw{#7FSepVu zfzuMACEBC8@4&3f?pDg(H^gk|HK3)LF|vnQInf|0!-t17EMq~;+T`0Y5>^YZ{@DWf zjdB7=wR)q?&nCSj>R(+Gs?99 z;971;^kjmzpi+i1)z_WSOqr$$;Nh@@Z`OA3eN|4iyevXyWZ=KsUMb6pj|6?&VMX5a z%pjf6zBgTVlqkvr*l4>;my zlRAisgLwvD&R1^$aOCFiHTh;;=p*2L0A`SKHGOGeHq70o6AkkuAw@p-(5xVXubPzD zQ}aNx3SpCXDgFCMK0XTSK`g8#)+ecO>nEruuhyZ>Zfr0Xo>w!Z70O;amB`h$ zeQDB(+T6*)BdBDKS|4fad0+B6G!uzBRn4_6^|FP|-C&z?H-qc(z6s@(Fq?0N z5*h)=#oI$A{qLmww6??SOZt?nh>IT_GRx~MT6OlxDscU7Qorjl)mHm`rKZ3HrlK<# zFma!3Ygs$nJdTPi>5>YZ4~vnkpPg9*{zP)n?Nv$g=V@kNh`&0n>VD4DE&2Nga5?Jc J_>{^>`7h6RsKWpN literal 0 HcmV?d00001